e6f965ff19
Lets the AI connect to any external stdio MCP server (e.g. safaridriver --mcp) configured in Settings, with tools auto-discovered and prefixed by server slug. Includes crash detection with backoff restart (5s/15s/30s) and a Settings UI to add/enable/disable/remove servers. Fixes the temp-dir allowlist in MCPService.isPathAllowed to also match /tmp and /private/tmp (not just NSTemporaryDirectory(), which resolves to a different per-user Darwin temp dir) so the MCP file tools can actually read files external servers and image generation write there. Also switches the Add Server sheet's argument parsing to a quote-aware tokenizer so args containing spaces survive intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
4.7 KiB
Swift
171 lines
4.7 KiB
Swift
// 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<String> = [
|
|
"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?
|
|
}
|