Add env-var support and native HTTP transport for External MCP Servers

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.
This commit is contained in:
2026-08-13 16:05:42 +02:00
parent 30e18f92a5
commit 25028e3405
9 changed files with 1219 additions and 198 deletions
+340
View File
@@ -1,6 +1,346 @@
{
"sourceLanguage" : "en",
"strings" : {
"Remote (HTTP)" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fjern (HTTP)"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remote (HTTP)"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Distant (HTTP)"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ekstern (HTTP)"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Fjärr (HTTP)"
}
}
}
},
"Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL." : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kommando: et lokalt program Confab selv starter. Fjern (HTTP): en allerede kørende MCP-server, der kan nås via URL."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Befehl: ein lokales Programm, das Confab selbst startet. Remote (HTTP): ein bereits laufender MCP-Server, der über eine URL erreichbar ist."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Commande : un programme local que Confab lance lui-même. Distant (HTTP) : un serveur MCP déjà en cours d'exécution, accessible par URL."
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kommando: et lokalt program Confab selv starter. Ekstern (HTTP): en allerede kjørende MCP-server som kan nås via URL."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kommando: ett lokalt program som Confab själv startar. Fjärr (HTTP): en redan igångkörd MCP-server som nås via URL."
}
}
}
},
"Type" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Typ"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Typ"
}
}
}
},
"Bearer Token" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bearer-token"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bearer-Token"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Jeton Bearer"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bearer-token"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Bearer-token"
}
}
}
},
"Environment Variables" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Miljøvariabler"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Umgebungsvariablen"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Variables d'environnement"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Miljøvariabler"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Miljövariabler"
}
}
}
},
"Extra Headers" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ekstra headers"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zusätzliche Header"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "En-têtes supplémentaires"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ekstra headers"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Extra headers"
}
}
}
},
"Optional" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Valgfri"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Optional"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Facultatif"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Valgfritt"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Valfritt"
}
}
}
},
"Key" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nøgle"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schlüssel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Clé"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nøkkel"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Nyckel"
}
}
}
},
"Value" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Værdi"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wert"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Valeur"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Verdi"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Värde"
}
}
}
},
"None configured" : {
"localizations" : {
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen konfigureret"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine konfiguriert"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Aucun configuré"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ingen konfigurert"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "Inga konfigurerade"
}
}
}
},
"By Provider" : {
"localizations" : {
"da" : {
+44 -164
View File
@@ -5,22 +5,18 @@ import Foundation
// MARK: - ExternalMCPClient
/// Manages one MCP stdio server process. All state is MainActor-isolated
/// (consistent with SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor project setting).
/// Background I/O runs in Task.detached; state mutations hop back to MainActor.
/// 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 var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private let transport: any MCPTransport
private var nextRequestId: Int = 1
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
private(set) var state: MCPClientState = .idle
private(set) var discoveredTools: [MCPToolDefinition] = []
@@ -28,6 +24,31 @@ final class ExternalMCPClient {
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
@@ -37,54 +58,28 @@ final class ExternalMCPClient {
state = .connecting
stateDelegate?.clientDidChangeState(id: server.id, state: .connecting)
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
proc.environment = ProcessInfo.processInfo.environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
try await transport.prepare()
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
do {
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 sendNotification(method: "notifications/initialized")
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 {
state = .error(error.localizedDescription)
stateDelegate?.clientDidChangeState(id: server.id, state: .error(error.localizedDescription))
proc.terminate()
// 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
}
@@ -94,14 +89,7 @@ final class ExternalMCPClient {
func stop() {
state = .stopped
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
transport.stop()
}
// MARK: - Tool Execution
@@ -128,125 +116,17 @@ final class ExternalMCPClient {
}
}
// MARK: - I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: - Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
if let err = json["error"] as? [String: Any] {
cont.resume(throwing: MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error"))
} else if let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result) {
cont.resume(returning: resultData)
} else {
cont.resume(throwing: MCPClientError.invalidResponse("Missing result field"))
}
}
// MARK: - JSON-RPC
/// Send a JSON-RPC request with a per-call timeout. The timeout fires a cancellation
/// directly into the pending-calls table rather than using a task group (which would
/// pass the generic T through a @Sendable closure and trigger an isolated-conformance warning).
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 }
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
// Await response data, then decode on MainActor
let resultData: Data = try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
let resultData = try await transport.sendRequest(message, id: id, timeoutSeconds: seconds)
return try JSONDecoder().decode(T.self, from: resultData)
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func sendNotification(method: String) throws {
try writeJSON(["jsonrpc": "2.0", "method": method])
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
// MARK: - Process termination
private func handleProcessTerminated() {
guard state != .stopped else { return }
state = .crashed
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
stateDelegate?.clientDidChangeState(id: server.id, state: .crashed)
}
// MARK: - Result conversion
private func convertMCPResult(_ result: MCPToolCallResult) -> [String: Any] {
+13 -11
View File
@@ -67,13 +67,10 @@ final class ExternalMCPManager {
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.
// 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)")
}
}
@@ -126,7 +123,7 @@ final class ExternalMCPManager {
private func rebuildCache(for server: ExternalMCPServer, tools: [MCPToolDefinition]) {
removeCachedSchemas(for: server.id, slug: server.slug)
let prefixed = tools.compactMap { convertToolDefinition($0, server: server) }
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: ", "))")
}
@@ -140,7 +137,7 @@ final class ExternalMCPManager {
cachedToolSchemas.removeAll { $0.function.name.hasPrefix("\(slug)_") }
}
private func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
nonisolated static func convertToolDefinition(_ def: MCPToolDefinition, server: ExternalMCPServer) -> Tool? {
Tool(
type: "function",
function: Tool.Function(
@@ -151,13 +148,18 @@ final class ExternalMCPManager {
)
}
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
/// 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 prop.type ?? "string" {
switch effectiveType {
case "integer": normalized = "number"
case "string", "number", "boolean", "array", "object": normalized = prop.type!
case "string", "number", "boolean", "array", "object": normalized = effectiveType
default: normalized = "string"
}
var items: Tool.Function.Parameters.Property.Items? = nil
+57 -2
View File
@@ -5,26 +5,79 @@ import Foundation
// MARK: - Server Configuration
/// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`;
/// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum
/// with associated values simpler `Codable` and simpler settings-JSON storage, at the cost of
/// each server config carrying some always-unused fields for its transport.
enum MCPTransportKind: String, Codable, Sendable, CaseIterable {
case stdio
case http
}
struct ExternalMCPServer: Codable, Identifiable, Sendable {
var id: UUID
var name: String
var transportKind: MCPTransportKind
var command: String
var args: [String]
var env: [String: String]
var url: String
var bearerToken: String
var headers: [String: 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()) {
init(
id: UUID = UUID(),
name: String,
transportKind: MCPTransportKind = .stdio,
command: String = "",
args: [String] = [],
env: [String: String] = [:],
url: String = "",
bearerToken: String = "",
headers: [String: String] = [:],
isEnabled: Bool = true,
timeout: TimeInterval = 30,
createdAt: Date = Date()
) {
self.id = id
self.name = name
self.transportKind = transportKind
self.command = command
self.args = args
self.env = env
self.url = url
self.bearerToken = bearerToken
self.headers = headers
self.isEnabled = isEnabled
self.timeout = timeout
self.createdAt = createdAt
}
enum CodingKeys: String, CodingKey {
case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt
}
/// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers`
/// existed (plain stdio-only configs) still decode those keys default rather than fail.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
command = try c.decode(String.self, forKey: .command)
args = try c.decode([String].self, forKey: .args)
isEnabled = try c.decode(Bool.self, forKey: .isEnabled)
timeout = try c.decode(TimeInterval.self, forKey: .timeout)
createdAt = try c.decode(Date.self, forKey: .createdAt)
transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio
env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:]
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? ""
headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
}
var slug: String { Self.makeSlug(from: name) }
static func makeSlug(from name: String) -> String {
@@ -100,6 +153,7 @@ enum MCPClientError: LocalizedError {
case processLaunchFailed(String)
case handshakeFailed(String)
case writeFailed
case invalidConfiguration(String)
var errorDescription: String? {
switch self {
@@ -109,6 +163,7 @@ enum MCPClientError: LocalizedError {
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"
case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)"
}
}
}
+321
View File
@@ -0,0 +1,321 @@
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Transport abstraction
/// One MCP server connection's wire protocol. `ExternalMCPClient` builds JSON-RPC envelopes and
/// decodes typed results; the transport only owns *delivery* how the bytes get to the server
/// and back. A long-lived subprocess pipe for stdio, discrete HTTP requests for Streamable HTTP.
@MainActor
protocol MCPTransport: AnyObject {
/// Prepares the transport for use launches the subprocess for stdio; a no-op for HTTP,
/// since there's no persistent connection to establish ahead of the first request.
func prepare() async throws
/// Sends a JSON-RPC *request* (`message` includes `"id"`) and returns its `result` payload.
/// Throws `MCPClientError.invalidResponse` if the server returned a JSON-RPC error, or
/// `.timeout` if `timeoutSeconds` elapses first.
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data
/// Sends a JSON-RPC *notification* (no `"id"`, no response expected).
func sendNotification(_ message: [String: Any]) async throws
/// Tears down the transport terminates the subprocess / drops HTTP session state.
func stop()
}
enum MCPTransportSupport {
/// Extracts the `result` payload from a decoded top-level JSON-RPC response object, shared
/// by every transport so error/result semantics stay identical regardless of wire protocol.
static func extractResult(from json: [String: Any]) throws -> Data {
if let err = json["error"] as? [String: Any] {
throw MCPClientError.invalidResponse(err["message"] as? String ?? "Unknown error")
}
guard let result = json["result"],
let resultData = try? JSONSerialization.data(withJSONObject: result)
else {
throw MCPClientError.invalidResponse("Missing result field")
}
return resultData
}
}
// MARK: - Stdio Transport
/// Launches the server as a subprocess and speaks newline-delimited JSON-RPC over its
/// stdin/stdout, exactly as the original (pre-transport-split) `ExternalMCPClient` did.
@MainActor
final class StdioMCPTransport: MCPTransport {
private let server: ExternalMCPServer
/// Set by the owner after construction (not an init parameter) the owner (`ExternalMCPClient`)
/// needs to capture `self` weakly to wire this up, which Swift only allows once `self` is
/// fully initialized, i.e. after its own `init` has finished assigning all stored properties.
var onTerminated: (() -> Void)?
private var process: Process?
private var stdinHandle: FileHandle?
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var pendingCalls: [Int: CheckedContinuation<Data, Error>] = [:]
private var lineBuffer = Data()
init(server: ExternalMCPServer) {
self.server = server
}
func prepare() async throws {
let proc = Process()
if server.command.hasPrefix("/") {
proc.executableURL = URL(fileURLWithPath: server.command)
proc.arguments = server.args
} else {
proc.executableURL = URL(fileURLWithPath: "/usr/bin/env")
proc.arguments = [server.command] + server.args
}
// Inherited environment first (so PATH etc. still resolves e.g. `npx` needs PATH to
// find node), then layer the user-configured vars over it so they can override.
var environment = ProcessInfo.processInfo.environment
for (key, value) in server.env { environment[key] = value }
proc.environment = environment
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
proc.standardInput = stdinPipe
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
proc.terminationHandler = { [weak self] _ in
Task { @MainActor [weak self] in self?.handleProcessTerminated() }
}
do {
try proc.run()
} catch {
throw MCPClientError.processLaunchFailed(error.localizedDescription)
}
process = proc
stdinHandle = stdinPipe.fileHandleForWriting
startReadLoop(pipe: stdoutPipe)
startStderrLoop(pipe: stderrPipe)
}
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
try writeJSON(message)
// Schedule timeout: cancels the specific pending call by ID
let timeoutId = id
Task { [weak self, timeoutId] in
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
self?.cancelPendingCall(id: timeoutId, with: MCPClientError.timeout)
}
return try await withCheckedThrowingContinuation { cont in
pendingCalls[id] = cont
}
}
func sendNotification(_ message: [String: Any]) async throws {
try writeJSON(message)
}
func stop() {
readTask?.cancel()
stderrTask?.cancel()
process?.terminate()
process = nil
stdinHandle = nil
lineBuffer = Data()
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
}
// MARK: I/O Loops (detached from MainActor)
private func startReadLoop(pipe: Pipe) {
readTask = Task.detached { [weak self] in
let handle = pipe.fileHandleForReading
while true {
let data = handle.availableData
if data.isEmpty { break }
await self?.receiveData(data)
}
}
}
private func startStderrLoop(pipe: Pipe) {
let name = server.name
stderrTask = Task.detached {
let handle = pipe.fileHandleForReading
var buf = Data()
while true {
let data = handle.availableData
if data.isEmpty { break }
buf.append(data)
while let idx = buf.firstIndex(of: UInt8(ascii: "\n")) {
let line = String(data: buf[buf.startIndex..<idx], encoding: .utf8) ?? ""
buf = Data(buf[buf.index(after: idx)...])
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
Log.extMcp.warning("[\(name)] \(line)")
}
}
}
}
}
// MARK: Data Processing (MainActor)
private func receiveData(_ data: Data) {
lineBuffer.append(data)
while let idx = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(lineBuffer[lineBuffer.startIndex..<idx])
lineBuffer = Data(lineBuffer[lineBuffer.index(after: idx)...])
processLine(lineData)
}
}
private func processLine(_ data: Data) {
guard !data.isEmpty,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let id = json["id"] as? Int,
let cont = pendingCalls.removeValue(forKey: id) else { return }
do {
let resultData = try MCPTransportSupport.extractResult(from: json)
cont.resume(returning: resultData)
} catch {
cont.resume(throwing: error)
}
}
private func cancelPendingCall(id: Int, with error: Error) {
pendingCalls.removeValue(forKey: id)?.resume(throwing: error)
}
private func writeJSON(_ message: [String: Any]) throws {
guard let handle = stdinHandle, process?.isRunning == true else {
throw MCPClientError.writeFailed
}
guard let data = try? JSONSerialization.data(withJSONObject: message),
let line = String(data: data, encoding: .utf8) else {
throw MCPClientError.writeFailed
}
do {
try handle.write(contentsOf: Data((line + "\n").utf8))
} catch {
throw MCPClientError.writeFailed
}
}
private func handleProcessTerminated() {
for (_, cont) in pendingCalls { cont.resume(throwing: MCPClientError.notConnected) }
pendingCalls.removeAll()
onTerminated?()
}
}
// MARK: - HTTP (Streamable HTTP) Transport
/// Speaks MCP's Streamable HTTP transport (spec revision 2025-06-18): every JSON-RPC message is
/// its own HTTP POST to the server's single MCP endpoint. The server may answer with a plain
/// `application/json` body, or open a `text/event-stream` (SSE) response this transport
/// supports both, but (since Confab only needs request/response tool calls, not server-initiated
/// push) does not open a standalone listening GET stream for unsolicited server messages.
@MainActor
final class HTTPMCPTransport: MCPTransport {
private let server: ExternalMCPServer
private let urlSession: URLSession
private var sessionId: String?
init(server: ExternalMCPServer, urlSession: URLSession = .shared) {
self.server = server
self.urlSession = urlSession
}
func prepare() async throws {
guard URL(string: server.url) != nil else {
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
}
// No persistent connection to establish ahead of time the first request (`initialize`)
// both opens the session and confirms the server is reachable.
}
func sendRequest(_ message: [String: Any], id: Int, timeoutSeconds: Double) async throws -> Data {
let request = try buildRequest(for: message, timeoutSeconds: timeoutSeconds)
let (data, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw MCPClientError.invalidResponse("Non-HTTP response")
}
if let newSessionId = http.value(forHTTPHeaderField: "Mcp-Session-Id") {
sessionId = newSessionId
}
guard (200...299).contains(http.statusCode) else {
throw MCPClientError.invalidResponse("HTTP \(http.statusCode)")
}
let json = try Self.parseResponseBody(
data, contentType: http.value(forHTTPHeaderField: "Content-Type"), expectedId: id
)
return try MCPTransportSupport.extractResult(from: json)
}
func sendNotification(_ message: [String: Any]) async throws {
// Notifications carry no "id", so per spec the server responds 202 Accepted, no body.
let request = try buildRequest(for: message, timeoutSeconds: 15)
let (_, response) = try await urlSession.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw MCPClientError.invalidResponse("Notification not accepted")
}
}
func stop() {
sessionId = nil
}
private func buildRequest(for message: [String: Any], timeoutSeconds: Double) throws -> URLRequest {
guard let url = URL(string: server.url) else {
throw MCPClientError.invalidConfiguration("Invalid server URL: \(server.url)")
}
var request = URLRequest(url: url, timeoutInterval: timeoutSeconds)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("2025-06-18", forHTTPHeaderField: "MCP-Protocol-Version")
if !server.bearerToken.isEmpty {
request.setValue("Bearer \(server.bearerToken)", forHTTPHeaderField: "Authorization")
}
for (key, value) in server.headers {
request.setValue(value, forHTTPHeaderField: key)
}
if let sessionId {
request.setValue(sessionId, forHTTPHeaderField: "Mcp-Session-Id")
}
request.httpBody = try JSONSerialization.data(withJSONObject: message)
return request
}
/// Parses either a direct `application/json` body, or an SSE (`text/event-stream`) body
/// scanning its `data:` lines for the JSON-RPC message whose `id` matches `expectedId` (the
/// server may send unrelated requests/notifications on the same stream first, per spec).
nonisolated static func parseResponseBody(_ data: Data, contentType: String?, expectedId: Int) throws -> [String: Any] {
if contentType?.contains("text/event-stream") == true {
guard let text = String(data: data, encoding: .utf8) else {
throw MCPClientError.invalidResponse("Non-UTF8 SSE body")
}
for line in text.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("data:") else { continue }
let payload = trimmed.dropFirst("data:".count).trimmingCharacters(in: .whitespaces)
guard let payloadData = payload.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: payloadData) as? [String: Any],
let id = json["id"] as? Int, id == expectedId
else { continue }
return json
}
throw MCPClientError.invalidResponse("No matching response in SSE stream")
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MCPClientError.invalidResponse("Malformed JSON response body")
}
return json
}
}
+143 -21
View File
@@ -25,6 +25,13 @@ import SwiftUI
import UniformTypeIdentifiers
import FoundationModels
/// One editable row in the External MCP Servers "add" sheet's env var / custom header lists.
private struct MCPKeyValuePair: Identifiable {
let id = UUID()
var key: String = ""
var value: String = ""
}
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
@@ -77,8 +84,13 @@ struct SettingsView: View {
// External MCP Servers state
@State private var showAddExternalMCPServer = false
@State private var newMCPServerName = ""
@State private var newMCPServerTransportKind: MCPTransportKind = .stdio
@State private var newMCPServerCommand = ""
@State private var newMCPServerArgs = ""
@State private var newMCPServerEnvPairs: [MCPKeyValuePair] = []
@State private var newMCPServerURL = ""
@State private var newMCPServerBearerToken = ""
@State private var newMCPServerHeaderPairs: [MCPKeyValuePair] = []
@State private var newMCPServerTimeout: Double = 30
private var externalMCPManager = ExternalMCPManager.shared
@@ -1049,7 +1061,7 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
VStack(alignment: .leading, spacing: 2) {
Text(server.name)
.font(.system(size: 14))
Text(([server.command] + server.args).joined(separator: " "))
Text(server.transportKind == .http ? server.url : ([server.command] + server.args).joined(separator: " "))
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -1084,8 +1096,13 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Button {
newMCPServerName = ""
newMCPServerTransportKind = .stdio
newMCPServerCommand = ""
newMCPServerArgs = ""
newMCPServerEnvPairs = []
newMCPServerURL = ""
newMCPServerBearerToken = ""
newMCPServerHeaderPairs = []
newMCPServerTimeout = 30
showAddExternalMCPServer = true
} label: {
@@ -1272,19 +1289,47 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
.frame(width: 240)
}
rowDivider()
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
row("Type") {
Picker("", selection: $newMCPServerTransportKind) {
Text("Command").tag(MCPTransportKind.stdio)
Text("Remote (HTTP)").tag(MCPTransportKind.http)
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(width: 240)
.help("Command: a local program Confab launches itself. Remote (HTTP): an already-running MCP server reachable by URL.")
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
switch newMCPServerTransportKind {
case .stdio:
row("Command") {
TextField("safaridriver", text: $newMCPServerCommand)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Arguments") {
TextField("--mcp", text: $newMCPServerArgs)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
.help("Space-separated arguments")
}
case .http:
row("Server URL") {
TextField("http://127.0.0.1:27123/mcp/", text: $newMCPServerURL)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
rowDivider()
row("Bearer Token") {
SecureField("Optional", text: $newMCPServerBearerToken)
.textFieldStyle(.roundedBorder)
.font(.system(size: 13, design: .monospaced))
.frame(width: 240)
}
}
rowDivider()
row("Timeout") {
@@ -1299,6 +1344,15 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
formSection {
switch newMCPServerTransportKind {
case .stdio:
mcpKeyValueEditor(title: "Environment Variables", pairs: $newMCPServerEnvPairs)
case .http:
mcpKeyValueEditor(title: "Extra Headers", pairs: $newMCPServerHeaderPairs)
}
}
if !newMCPServerName.isEmpty {
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
HStack(spacing: 6) {
@@ -1320,25 +1374,93 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Button("Cancel") { showAddExternalMCPServer = false }
Spacer()
Button("Add") {
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
let server = ExternalMCPServer(
name: newMCPServerName,
command: newMCPServerCommand,
args: args,
timeout: newMCPServerTimeout
)
let server: ExternalMCPServer
switch newMCPServerTransportKind {
case .stdio:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .stdio,
command: newMCPServerCommand,
args: ExternalMCPServer.parseArguments(newMCPServerArgs),
env: mcpDictionary(from: newMCPServerEnvPairs),
timeout: newMCPServerTimeout
)
case .http:
server = ExternalMCPServer(
name: newMCPServerName,
transportKind: .http,
url: newMCPServerURL,
bearerToken: newMCPServerBearerToken,
headers: mcpDictionary(from: newMCPServerHeaderPairs),
timeout: newMCPServerTimeout
)
}
settingsService.addExternalMCPServer(server)
showAddExternalMCPServer = false
}
.buttonStyle(.borderedProminent)
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
.disabled(isAddExternalMCPServerDisabled)
}
}
.padding(24)
.frame(minWidth: 460, minHeight: 320)
}
private var isAddExternalMCPServerDisabled: Bool {
if newMCPServerName.isEmpty { return true }
if ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)) { return true }
switch newMCPServerTransportKind {
case .stdio: return newMCPServerCommand.isEmpty
case .http: return newMCPServerURL.isEmpty
}
}
private func mcpDictionary(from pairs: [MCPKeyValuePair]) -> [String: String] {
Dictionary(uniqueKeysWithValues: pairs.filter { !$0.key.isEmpty }.map { ($0.key, $0.value) })
}
@ViewBuilder
private func mcpKeyValueEditor(title: LocalizedStringKey, pairs: Binding<[MCPKeyValuePair]>) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
Spacer()
Button {
pairs.wrappedValue.append(MCPKeyValuePair())
} label: {
Image(systemName: "plus.circle.fill")
}
.buttonStyle(.plain)
}
ForEach(pairs) { $pair in
HStack(spacing: 6) {
TextField("Key", text: $pair.key)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
SecureField("Value", text: $pair.value)
.textFieldStyle(.roundedBorder)
.font(.system(size: 12, design: .monospaced))
Button {
pairs.wrappedValue.removeAll { $0.id == pair.id }
} label: {
Image(systemName: "minus.circle.fill")
.foregroundStyle(.red)
}
.buttonStyle(.plain)
}
}
if pairs.wrappedValue.isEmpty {
Text("None configured")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
switch state {
case .ready: return .green
@@ -0,0 +1,65 @@
//
// ExternalMCPManagerConversionTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ExternalMCPManager.convertInputSchema")
struct ExternalMCPManagerConversionTests {
/// Regression test for a real crash: Obsidian's Local REST API plugin's MCP tool schemas
/// include at least one property with no "type" key at all valid JSON Schema (e.g. an
/// enum-only or composed property) which a `prop.type!` force-unwrap used to crash on the
/// moment a live HTTP MCP server's tools/list response reached this code.
@Test("A property with no type at all defaults to string instead of crashing")
func propertyWithNoTypeDefaultsToString() {
let prop = MCPPropertySchema(type: nil, description: "no explicit type", enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["mode": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["mode"]?.type == "string")
#expect(parameters.properties["mode"]?.description == "no explicit type")
}
@Test("integer is normalized to number")
func integerNormalizesToNumber() {
let prop = MCPPropertySchema(type: "integer", description: nil, enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["count": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["count"]?.type == "number")
}
@Test("An unrecognized type string falls back to string")
func unrecognizedTypeFallsBackToString() {
let prop = MCPPropertySchema(type: "something-unusual", description: nil, enum: nil, items: nil)
let schema = MCPInputSchema(type: "object", properties: ["weird": prop], required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
#expect(parameters.properties["weird"]?.type == "string")
}
@Test("Recognized types (string/number/boolean/array/object) pass through unchanged")
func recognizedTypesPassThrough() {
let types = ["string", "number", "boolean", "array", "object"]
var properties: [String: MCPPropertySchema] = [:]
for t in types {
properties[t] = MCPPropertySchema(type: t, description: nil, enum: nil, items: nil)
}
let schema = MCPInputSchema(type: "object", properties: properties, required: nil)
let parameters = ExternalMCPManager.convertInputSchema(schema)
for t in types {
#expect(parameters.properties[t]?.type == t)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
//
// ExternalMCPModelsTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ExternalMCPServer Codable")
struct ExternalMCPServerCodableTests {
private static func makeCoders() -> (JSONEncoder, JSONDecoder) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (encoder, decoder)
}
@Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults")
func decodesOldShapeJSON() throws {
// Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers
// were added this is what's actually sitting in existing users' settings DB right now.
let oldJSON = """
{
"id": "00000000-0000-0000-0000-000000000001",
"name": "Safari",
"command": "safaridriver",
"args": ["--mcp"],
"isEnabled": true,
"timeout": 30,
"createdAt": "2026-01-01T00:00:00Z"
}
"""
let (_, decoder) = Self.makeCoders()
let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8))
#expect(server.name == "Safari")
#expect(server.command == "safaridriver")
#expect(server.args == ["--mcp"])
#expect(server.transportKind == .stdio)
#expect(server.env.isEmpty)
#expect(server.url.isEmpty)
#expect(server.bearerToken.isEmpty)
#expect(server.headers.isEmpty)
}
@Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any")
func decodesOldShapeArray() throws {
let oldJSON = """
[
{"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver",
"args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"},
{"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool",
"args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"}
]
"""
let (_, decoder) = Self.makeCoders()
let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8))
#expect(servers.count == 2)
}
@Test("Round-trips a full HTTP-transport server through encode/decode")
func roundTripsHTTPServer() throws {
let original = ExternalMCPServer(
name: "Obsidian",
transportKind: .http,
url: "http://127.0.0.1:27123/mcp/",
bearerToken: "secret-token",
headers: ["X-Extra": "value"],
timeout: 45
)
let (encoder, decoder) = Self.makeCoders()
let data = try encoder.encode(original)
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
#expect(decoded.id == original.id)
#expect(decoded.transportKind == .http)
#expect(decoded.url == "http://127.0.0.1:27123/mcp/")
#expect(decoded.bearerToken == "secret-token")
#expect(decoded.headers == ["X-Extra": "value"])
}
@Test("Round-trips a stdio-transport server with env vars")
func roundTripsStdioServerWithEnv() throws {
let original = ExternalMCPServer(
name: "Custom",
command: "npx",
args: ["-y", "some-tool"],
env: ["API_KEY": "abc123"],
timeout: 30
)
let (encoder, decoder) = Self.makeCoders()
let data = try encoder.encode(original)
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
#expect(decoded.transportKind == .stdio)
#expect(decoded.env == ["API_KEY": "abc123"])
#expect(decoded.command == "npx")
#expect(decoded.args == ["-y", "some-tool"])
}
}
+131
View File
@@ -0,0 +1,131 @@
//
// MCPTransportTests.swift
// ConfabTests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("MCPTransportSupport.extractResult")
struct MCPTransportSupportTests {
@Test("Extracts the result payload from a successful JSON-RPC response")
func extractsResult() throws {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "result": ["tools": []]]
let data = try MCPTransportSupport.extractResult(from: json)
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
#expect(decoded?["tools"] != nil)
}
@Test("Throws with the server's message when the response is a JSON-RPC error")
func throwsOnJSONRPCError() {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "error": ["code": -32601, "message": "Method not found"]]
do {
_ = try MCPTransportSupport.extractResult(from: json)
Issue.record("Expected extractResult to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse(let message): #expect(message == "Method not found")
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
@Test("Throws when the response has neither result nor error")
func throwsOnMissingResult() {
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1]
do {
_ = try MCPTransportSupport.extractResult(from: json)
Issue.record("Expected extractResult to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse(let message): #expect(message == "Missing result field")
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
}
@Suite("HTTPMCPTransport.parseResponseBody")
struct HTTPMCPTransportParseTests {
@Test("Parses a plain application/json response body directly")
func parsesPlainJSON() throws {
let body = Data(#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#.utf8)
let json = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 7)
#expect(json["id"] as? Int == 7)
}
@Test("Parses a text/event-stream body, finding the data: line matching the expected id")
func parsesSSEMatchingId() throws {
let sse = """
event: message
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
"""
let json = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
#expect(json["id"] as? Int == 7)
}
@Test("Skips unrelated server-sent messages before the matching response, per the Streamable HTTP spec")
func skipsUnrelatedMessagesInSSE() throws {
// Spec: "The server MAY send JSON-RPC requests and notifications before sending the
// JSON-RPC response." Simulated here as an unrelated id=99 message before the real id=7 one.
let sse = """
data: {"jsonrpc":"2.0","id":99,"method":"unrelated/notification"}
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
"""
let json = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
#expect(json["id"] as? Int == 7)
}
@Test("Throws when no SSE data: line matches the expected id")
func throwsWhenNoMatchInSSE() {
let sse = """
data: {"jsonrpc":"2.0","id":99,"result":{}}
"""
do {
_ = try HTTPMCPTransport.parseResponseBody(
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
)
Issue.record("Expected parseResponseBody to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse: break
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
@Test("Throws on malformed JSON in a plain application/json body")
func throwsOnMalformedJSON() {
let body = Data("not json".utf8)
do {
_ = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 1)
Issue.record("Expected parseResponseBody to throw")
} catch let error as MCPClientError {
switch error {
case .invalidResponse: break
default: Issue.record("Expected .invalidResponse, got \(error)")
}
} catch {
Issue.record("Expected MCPClientError, got \(error)")
}
}
}