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.
161 lines
6.9 KiB
Swift
161 lines
6.9 KiB
Swift
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Foundation
|
|
|
|
// MARK: - ExternalMCPClient
|
|
|
|
/// Owns one MCP server connection's lifecycle and JSON-RPC message framing. Delivery (stdio
|
|
/// subprocess vs Streamable HTTP) is delegated to a `MCPTransport` — this class only builds
|
|
/// envelopes, decodes typed results, and tracks connection state.
|
|
/// All state is MainActor-isolated (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor
|
|
/// project setting).
|
|
@MainActor
|
|
final class ExternalMCPClient {
|
|
let server: ExternalMCPServer
|
|
weak var stateDelegate: (any ExternalMCPStateDelegate)?
|
|
|
|
private let transport: any MCPTransport
|
|
private var nextRequestId: Int = 1
|
|
|
|
private(set) var state: MCPClientState = .idle
|
|
private(set) var discoveredTools: [MCPToolDefinition] = []
|
|
|
|
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
|
|
self.server = server
|
|
self.stateDelegate = stateDelegate
|
|
let stdioTransport: StdioMCPTransport?
|
|
switch server.transportKind {
|
|
case .stdio:
|
|
let t = StdioMCPTransport(server: server)
|
|
stdioTransport = t
|
|
self.transport = t
|
|
case .http:
|
|
stdioTransport = nil
|
|
self.transport = HTTPMCPTransport(server: server)
|
|
}
|
|
// `self` is only safe to capture once every stored property above has a value —
|
|
// wire the crash callback here, after `init` would otherwise be considered complete.
|
|
stdioTransport?.onTerminated = { [weak self] in
|
|
self?.handleTransportTerminatedUnexpectedly()
|
|
}
|
|
}
|
|
|
|
/// Called by a stdio transport whose subprocess died on its own — as opposed to a
|
|
/// deliberate `stop()` call, or a failure already handled inline within `start()`.
|
|
/// No HTTP equivalent: a Streamable HTTP connection has no persistent process to crash;
|
|
/// its failures surface per-request instead (handled in `start()`/`callTool()` directly).
|
|
private func handleTransportTerminatedUnexpectedly() {
|
|
guard state != .stopped else { return }
|
|
state = .crashed
|
|
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
|
}
|
|
|
|
// MARK: - Lifecycle
|
|
|
|
func start() async throws {
|
|
guard state == .idle || state == .stopped || state == .crashed else { return }
|
|
state = .connecting
|
|
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
|
|
|
|
do {
|
|
try await transport.prepare()
|
|
|
|
let _: MCPInitializeResult = try await timedRequest(seconds: 15, method: "initialize", params: [
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": [:] as [String: Any],
|
|
"clientInfo": ["name": "Confab", "version": "1.0"] as [String: Any]
|
|
])
|
|
try await transport.sendNotification(["jsonrpc": "2.0", "method": "notifications/initialized"])
|
|
|
|
let toolsResult: MCPToolsListResult = try await timedRequest(seconds: 15, method: "tools/list", params: nil)
|
|
discoveredTools = toolsResult.tools
|
|
} catch {
|
|
// Uniformly route every start() failure (bad config, launch failure, handshake
|
|
// failure — for either transport) through .crashed, not .error, so
|
|
// ExternalMCPManager's restart-with-backoff drives from exactly one place.
|
|
// (Previously, stdio relied on the subprocess's termination handler firing
|
|
// asynchronously to reach .crashed; that path doesn't exist for HTTP, so failures
|
|
// there would otherwise get stuck at .error with no retry.)
|
|
state = .crashed
|
|
transport.stop()
|
|
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
|
|
throw error
|
|
}
|
|
|
|
state = .ready
|
|
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
|
|
}
|
|
|
|
func stop() {
|
|
state = .stopped
|
|
transport.stop()
|
|
}
|
|
|
|
// MARK: - Tool Execution
|
|
|
|
func callTool(originalName: String, argumentsJSON: String) async -> [String: Any] {
|
|
guard state == .ready else {
|
|
return ["error": "MCP server '\(server.name)' is not connected"]
|
|
}
|
|
guard let argData = argumentsJSON.data(using: .utf8),
|
|
let argsDict = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
|
|
return ["error": "Invalid arguments JSON for tool \(originalName)"]
|
|
}
|
|
do {
|
|
let result: MCPToolCallResult = try await timedRequest(
|
|
seconds: server.timeout,
|
|
method: "tools/call",
|
|
params: ["name": originalName, "arguments": argsDict]
|
|
)
|
|
return convertMCPResult(result)
|
|
} catch MCPClientError.timeout {
|
|
return ["error": "MCP server '\(server.name)' timed out after \(Int(server.timeout))s"]
|
|
} catch {
|
|
return ["error": "MCP call '\(originalName)' failed: \(error.localizedDescription)"]
|
|
}
|
|
}
|
|
|
|
// MARK: - JSON-RPC
|
|
|
|
private func timedRequest<T: Decodable>(seconds: Double, method: String, params: [String: Any]?) async throws -> T {
|
|
let id = nextRequestId
|
|
nextRequestId += 1
|
|
var message: [String: Any] = ["jsonrpc": "2.0", "method": method, "id": id]
|
|
if let params { message["params"] = params }
|
|
let resultData = try await transport.sendRequest(message, id: id, timeoutSeconds: seconds)
|
|
return try JSONDecoder().decode(T.self, from: resultData)
|
|
}
|
|
|
|
// MARK: - Result conversion
|
|
|
|
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
|
|
let isError = result.isError ?? false
|
|
var parts: [String] = []
|
|
for content in result.content {
|
|
switch content.type {
|
|
case "text":
|
|
if let text = content.text { parts.append(text) }
|
|
case "image":
|
|
if let base64 = content.data, let imageData = Data(base64Encoded: base64) {
|
|
parts.append("[Image saved to: \(writeTempImage(imageData, mimeType: content.mimeType))]")
|
|
}
|
|
case "resource":
|
|
if let text = content.text { parts.append(text) }
|
|
else if let uri = content.uri { parts.append("[Resource: \(uri)]") }
|
|
default:
|
|
if let text = content.text { parts.append(text) }
|
|
}
|
|
}
|
|
let combined = parts.joined(separator: "\n")
|
|
return isError ? ["error": combined.isEmpty ? "Tool returned an error" : combined] : ["output": combined]
|
|
}
|
|
|
|
private func writeTempImage(_ data: Data, mimeType: String?) -> String {
|
|
let ext = mimeType?.contains("png") == true ? "png" : "jpg"
|
|
let path = "/tmp/oai_mcp_\(Int(Date().timeIntervalSince1970 * 1000)).\(ext)"
|
|
try? data.write(to: URL(fileURLWithPath: path))
|
|
return path
|
|
}
|
|
}
|