// SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen import Foundation // MARK: - Server Configuration struct ExternalMCPServer: Codable, Identifiable, Sendable { var id: UUID var name: String var command: String var args: [String] var isEnabled: Bool var timeout: TimeInterval var createdAt: Date init(id: UUID = UUID(), name: String, command: String, args: [String] = [], isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date()) { self.id = id self.name = name self.command = command self.args = args self.isEnabled = isEnabled self.timeout = timeout self.createdAt = createdAt } var slug: String { Self.makeSlug(from: name) } static func makeSlug(from name: String) -> String { let s = name .lowercased() .components(separatedBy: CharacterSet.alphanumerics.inverted) .filter { !$0.isEmpty } .joined(separator: "_") return s.isEmpty ? "ext" : s } /// Splits a raw arguments string into tokens, respecting single/double-quoted /// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`) /// survive intact instead of being split on every space. static func parseArguments(_ input: String) -> [String] { var args: [String] = [] var current = "" var inSingleQuotes = false var inDoubleQuotes = false for char in input { if char == "'" && !inDoubleQuotes { inSingleQuotes.toggle() } else if char == "\"" && !inSingleQuotes { inDoubleQuotes.toggle() } else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes { if !current.isEmpty { args.append(current) current = "" } } else { current.append(char) } } if !current.isEmpty { args.append(current) } return args } static let reservedSlugs: Set = [ "anytype", "paperless", "calendar", "reminders", "contacts", "location", "maps", "bash", "web", "read", "write", "list", "search", "edit", "delete", "create", "move", "copy", "spawn" ] var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) } } // MARK: - Client State enum MCPClientState: Equatable { case idle case connecting case ready case error(String) case crashed case stopped } // MARK: - State Delegate (all callbacks on MainActor) @MainActor protocol ExternalMCPStateDelegate: AnyObject { func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) func clientDidChangeState(id: UUID, state: MCPClientState) } // MARK: - Client Errors enum MCPClientError: LocalizedError { case notConnected case invalidResponse(String) case timeout case processLaunchFailed(String) case handshakeFailed(String) case writeFailed var errorDescription: String? { switch self { case .notConnected: return "MCP server is not connected" case .invalidResponse(let s): return "Invalid MCP response: \(s)" case .timeout: return "MCP request timed out" case .processLaunchFailed(let s): return "Failed to launch MCP server: \(s)" case .handshakeFailed(let s): return "MCP handshake failed: \(s)" case .writeFailed: return "Failed to write to MCP server stdin" } } } // MARK: - MCP Protocol Types struct MCPInitializeResult: Decodable { let protocolVersion: String let capabilities: MCPCapabilities let serverInfo: MCPServerInfo? } struct MCPCapabilities: Decodable { let tools: MCPToolsCapability? struct MCPToolsCapability: Decodable { let listChanged: Bool? } } struct MCPServerInfo: Decodable { let name: String let version: String? } struct MCPToolsListResult: Decodable { let tools: [MCPToolDefinition] let nextCursor: String? } struct MCPToolDefinition: Decodable { let name: String let description: String? let inputSchema: MCPInputSchema } struct MCPInputSchema: Decodable { let type: String let properties: [String: MCPPropertySchema]? let required: [String]? } struct MCPPropertySchema: Decodable { let type: String? let description: String? let `enum`: [String]? let items: MCPItemsSchema? struct MCPItemsSchema: Decodable { let type: String? } } struct MCPToolCallResult: Decodable { let content: [MCPContent] let isError: Bool? } struct MCPContent: Decodable { let type: String let text: String? let data: String? let mimeType: String? let uri: String? }