External MCP Servers previously only spoke stdio (spawn a local command + args). Adds: - env vars for stdio servers (merged into the subprocess environment, not embedded in the args string), with a masked key-value editor - a native Streamable HTTP transport (URL + Bearer token + custom headers), so HTTP-based MCP servers like Obsidian's Local REST API plugin connect directly without needing npx/Node.js as a bridge Introduces an MCPTransport abstraction (stdio/HTTP) so ExternalMCPClient stays transport-agnostic — mirrors how Provider.swift already abstracts AI backends in this codebase. Also fixes a real crash found via live testing against Obsidian: convertInputSchema force-unwrapped a tool parameter's `type`, which isn't required by JSON Schema — Obsidian's plugin was the first real server to send a parameter without one. Live-verified end to end (vault search/read/write/edit) before this commit, per the project's standing rule to hold external-service-dependent changes until they're actually confirmed working, not just compiling and passing tests.
205 lines
8.5 KiB
Swift
205 lines
8.5 KiB
Swift
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// 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 {
|
|
// start() already notified stateDelegate with .crashed (uniformly, for both
|
|
// transports and every failure kind) before throwing — restart-with-backoff is
|
|
// already scheduled via clientDidChangeState. Nothing further to do but log.
|
|
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 { Self.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)_") }
|
|
}
|
|
|
|
nonisolated static 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)
|
|
)
|
|
)
|
|
}
|
|
|
|
/// A schema property with no `"type"` at all is valid JSON Schema (e.g. an `enum`-only or
|
|
/// composed property) — not every MCP server's tool schemas set it, so this must not assume
|
|
/// it's present. (Found via a real crash: Obsidian's Local REST API plugin sends at least one
|
|
/// tool parameter with no `type`, which a `prop.type!` force-unwrap here used to crash on.)
|
|
nonisolated static func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
|
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
|
for (key, prop) in schema.properties ?? [:] {
|
|
let effectiveType = prop.type ?? "string"
|
|
let normalized: String
|
|
switch effectiveType {
|
|
case "integer": normalized = "number"
|
|
case "string", "number", "boolean", "array", "object": normalized = effectiveType
|
|
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)
|
|
}
|
|
}
|
|
}
|