// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import Foundation // MARK: - Server Configuration /// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`; /// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum /// with associated values — simpler `Codable` and simpler settings-JSON storage, at the cost of /// each server config carrying some always-unused fields for its transport. nonisolated enum MCPTransportKind: String, Codable, Sendable, CaseIterable { case stdio case http } nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable, Equatable { var id: UUID var name: String var transportKind: MCPTransportKind var command: String var args: [String] var env: [String: String] var url: String var bearerToken: String var headers: [String: String] var isEnabled: Bool var timeout: TimeInterval var createdAt: Date init( id: UUID = UUID(), name: String, transportKind: MCPTransportKind = .stdio, command: String = "", args: [String] = [], env: [String: String] = [:], url: String = "", bearerToken: String = "", headers: [String: String] = [:], isEnabled: Bool = true, timeout: TimeInterval = 30, createdAt: Date = Date() ) { self.id = id self.name = name self.transportKind = transportKind self.command = command self.args = args self.env = env self.url = url self.bearerToken = bearerToken self.headers = headers self.isEnabled = isEnabled self.timeout = timeout self.createdAt = createdAt } enum CodingKeys: String, CodingKey { case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt } /// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers` /// existed (plain stdio-only configs) still decode — those keys default rather than fail. init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) id = try c.decode(UUID.self, forKey: .id) name = try c.decode(String.self, forKey: .name) command = try c.decode(String.self, forKey: .command) args = try c.decode([String].self, forKey: .args) isEnabled = try c.decode(Bool.self, forKey: .isEnabled) timeout = try c.decode(TimeInterval.self, forKey: .timeout) createdAt = try c.decode(Date.self, forKey: .createdAt) transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:] url = try c.decodeIfPresent(String.self, forKey: .url) ?? "" bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? "" headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:] } 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) } /// Returns a copy with only `isEnabled` flipped. Exists so toggling a server in Settings can't /// silently drop fields — see the fixed bug in `SettingsService.toggleExternalMCPServer`, which /// used to rebuild the struct from a subset of fields and lose `transportKind`/`env`/`url`/ /// `bearerToken`/`headers` on every toggle. func withEnabledToggled() -> ExternalMCPServer { var copy = self copy.isEnabled.toggle() return copy } } // 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 case invalidConfiguration(String) /// The stdio server's `command` couldn't be located anywhere on PATH (inherited + the common /// install directories `LoginShellEnvironment` checks). Deliberately distinct from /// `.processLaunchFailed`: this is a permanent, static condition — no amount of /// restart-with-backoff will ever fix a binary that isn't there — so callers route it straight /// to a clear `.error` state instead of the crash/retry loop. See `ExternalMCPClient.start()`. case commandNotFound(String) 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" case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)" case .commandNotFound(let s): return "Command not found: \(s)" } } } // 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? } // Plain DTOs read from ExternalMCPManager.convertToolDefinition/convertInputSchema — both // `nonisolated static func` (for direct unit testing) — so these must stay `nonisolated` too, // same reasoning as `Tool` in AIProvider.swift. nonisolated struct MCPToolDefinition: Decodable { let name: String let description: String? let inputSchema: MCPInputSchema } nonisolated struct MCPInputSchema: Decodable { let type: String let properties: [String: MCPPropertySchema]? let required: [String]? } nonisolated struct MCPPropertySchema: Decodable { let type: String? let description: String? let `enum`: [String]? let items: MCPItemsSchema? nonisolated 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? }