Files
oai-swift/oAI/Services/MCPTransport.swift
T
rune 25028e3405 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.
2026-08-13 16:05:42 +02:00

322 lines
13 KiB
Swift

// 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
}
}