Add external MCP server support (stdio JSON-RPC)
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>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user