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>
203 lines
8.2 KiB
Swift
203 lines
8.2 KiB
Swift
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Foundation
|
|
|
|
// MARK: - ExternalMCPManager
|
|
|
|
@Observable
|
|
@MainActor
|
|
final class ExternalMCPManager {
|
|
nonisolated static let shared = ExternalMCPManager()
|
|
|
|
private(set) var clientStates: [UUID: MCPClientState] = [:]
|
|
private(set) var cachedToolSchemas: [Tool] = []
|
|
|
|
// Keep server config alongside client so we can access slug without await
|
|
private var clients: [UUID: ExternalMCPClient] = [:]
|
|
private var serverConfigs: [UUID: ExternalMCPServer] = [:]
|
|
private var restartTasks: [UUID: Task<Void, Never>] = [:]
|
|
private var restartAttempts: [UUID: Int] = [:]
|
|
|
|
private nonisolated init() {}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func startAll() {
|
|
for server in SettingsService.shared.externalMCPServers where server.isEnabled {
|
|
startClient(for: server)
|
|
}
|
|
}
|
|
|
|
func stopAll() {
|
|
for client in clients.values { client.stop() }
|
|
clients.removeAll()
|
|
serverConfigs.removeAll()
|
|
clientStates.removeAll()
|
|
cachedToolSchemas.removeAll()
|
|
for task in restartTasks.values { task.cancel() }
|
|
restartTasks.removeAll()
|
|
restartAttempts.removeAll()
|
|
}
|
|
|
|
func reconfigure(servers: [ExternalMCPServer]) {
|
|
let activeIds = Set(servers.filter { $0.isEnabled }.map { $0.id })
|
|
for id in clients.keys where !activeIds.contains(id) {
|
|
clients[id]?.stop()
|
|
clients.removeValue(forKey: id)
|
|
serverConfigs.removeValue(forKey: id)
|
|
clientStates.removeValue(forKey: id)
|
|
restartTasks[id]?.cancel()
|
|
restartTasks.removeValue(forKey: id)
|
|
restartAttempts.removeValue(forKey: id)
|
|
removeCachedSchemas(for: id)
|
|
}
|
|
for server in servers where server.isEnabled && clients[server.id] == nil {
|
|
startClient(for: server)
|
|
}
|
|
}
|
|
|
|
private func startClient(for server: ExternalMCPServer) {
|
|
// Stop any existing client for this ID before creating a new one
|
|
clients[server.id]?.stop()
|
|
let client = ExternalMCPClient(server: server, stateDelegate: self)
|
|
clients[server.id] = client
|
|
serverConfigs[server.id] = server
|
|
clientStates[server.id] = .connecting
|
|
Task {
|
|
do {
|
|
try await client.start()
|
|
} catch MCPClientError.processLaunchFailed(let msg) {
|
|
// Process never started — termination handler won't fire, so manually trigger crashed
|
|
Log.extMcp.error("Failed to launch '\(server.name)': \(msg)")
|
|
clientDidChangeState(id: server.id, state: .crashed)
|
|
} catch {
|
|
// Handshake/other failure — proc.terminate() was called in start(), termination
|
|
// handler will fire and set .crashed, which drives the restart from one place only.
|
|
Log.extMcp.warning("'\(server.name)' start failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleRestart(for server: ExternalMCPServer, attempt: Int) {
|
|
let delays: [Double] = [5, 15, 30]
|
|
let delay = delays[min(attempt - 1, delays.count - 1)]
|
|
Log.extMcp.warning("MCP server '\(server.name)' crashed — restarting in \(Int(delay))s (attempt \(attempt)/3)")
|
|
|
|
restartTasks[server.id]?.cancel()
|
|
let id = server.id
|
|
restartTasks[id] = Task { [weak self, id] in
|
|
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
|
guard !Task.isCancelled, let self,
|
|
self.clients[id] != nil,
|
|
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled })
|
|
else { return }
|
|
// startClient is the single place that creates and launches clients.
|
|
// It handles processLaunchFailed by calling clientDidChangeState(.crashed),
|
|
// and all other failures let the termination handler drive the .crashed callback.
|
|
self.startClient(for: server)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tool Schema Integration (synchronous)
|
|
|
|
func getToolSchemas() -> [Tool] { cachedToolSchemas }
|
|
|
|
func isExternalTool(_ name: String) -> Bool {
|
|
cachedToolSchemas.contains { $0.function.name == name }
|
|
}
|
|
|
|
// MARK: - Tool Execution
|
|
|
|
func executeTool(name: String, argumentsJSON: String) async -> [String: Any] {
|
|
for (id, client) in clients {
|
|
guard clientStates[id] == .ready,
|
|
let server = serverConfigs[id] else { continue }
|
|
let prefix = "\(server.slug)_"
|
|
if name.hasPrefix(prefix) {
|
|
let originalName = String(name.dropFirst(prefix.count))
|
|
return await client.callTool(originalName: originalName, argumentsJSON: argumentsJSON)
|
|
}
|
|
}
|
|
return ["error": "No external MCP server found for tool: \(name)"]
|
|
}
|
|
|
|
// MARK: - Schema Cache
|
|
|
|
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
|
|
removeCachedSchemas(for: server.id, slug: server.slug)
|
|
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
|
|
cachedToolSchemas.append(contentsOf: prefixed)
|
|
Log.extMcp.info("[\(server.name)] cached \(prefixed.count) tools: \(prefixed.map { $0.function.name }.joined(separator: ", "))")
|
|
}
|
|
|
|
private func removeCachedSchemas(for id: UUID) {
|
|
guard let server = serverConfigs[id] else { return }
|
|
removeCachedSchemas(for: id, slug: server.slug)
|
|
}
|
|
|
|
private func removeCachedSchemas(for id: UUID, slug: String) {
|
|
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
|
|
}
|
|
|
|
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
|
|
Tool(
|
|
type: "function",
|
|
function: Tool.Function(
|
|
name: "\(server.slug)_\(def.name)",
|
|
description: "[\(server.name)] \(def.description ?? "")",
|
|
parameters: convertInputSchema(def.inputSchema)
|
|
)
|
|
)
|
|
}
|
|
|
|
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
|
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
|
for (key, prop) in schema.properties ?? [:] {
|
|
let normalized: String
|
|
switch prop.type ?? "string" {
|
|
case "integer": normalized = "number"
|
|
case "string", "number", "boolean", "array", "object": normalized = prop.type!
|
|
default: normalized = "string"
|
|
}
|
|
var items: Tool.Function.Parameters.Property.Items? = nil
|
|
if normalized == "array", let t = prop.items?.type { items = .init(type: t) }
|
|
properties[key] = Tool.Function.Parameters.Property(
|
|
type: normalized,
|
|
description: prop.description ?? "",
|
|
enum: prop.enum,
|
|
items: items
|
|
)
|
|
}
|
|
return Tool.Function.Parameters(type: "object", properties: properties, required: schema.required)
|
|
}
|
|
}
|
|
|
|
// MARK: - ExternalMCPStateDelegate
|
|
|
|
extension ExternalMCPManager: ExternalMCPStateDelegate {
|
|
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer) {
|
|
clientStates[id] = .ready
|
|
restartAttempts.removeValue(forKey: id)
|
|
rebuildCache(for: server, tools: tools)
|
|
}
|
|
|
|
func clientDidChangeState(id: UUID, state: MCPClientState) {
|
|
clientStates[id] = state
|
|
if case .crashed = state,
|
|
let server = serverConfigs[id],
|
|
SettingsService.shared.externalMCPServers.contains(where: { $0.id == id && $0.isEnabled }) {
|
|
removeCachedSchemas(for: id, slug: server.slug)
|
|
let attempt = (restartAttempts[id] ?? 0) + 1
|
|
guard attempt <= 3 else {
|
|
Log.extMcp.error("MCP server '\(server.name)' gave up after 3 restart attempts")
|
|
clientStates[id] = .error("Maximum restart attempts reached")
|
|
restartAttempts.removeValue(forKey: id)
|
|
return
|
|
}
|
|
restartAttempts[id] = attempt
|
|
scheduleRestart(for: server, attempt: attempt)
|
|
}
|
|
}
|
|
}
|