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.
226 lines
7.2 KiB
Swift
226 lines
7.2 KiB
Swift
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
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,
|
|
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 {
|
|
let s = name
|
|
.lowercased()
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: "_")
|
|
return s.isEmpty ? "ext" : s
|
|
}
|
|
|
|
/// Splits a raw arguments string into tokens, respecting single/double-quoted
|
|
/// segments so arguments containing spaces (e.g. `--root "/Users/x/My Documents"`)
|
|
/// survive intact instead of being split on every space.
|
|
static func parseArguments(_ input: String) -> [String] {
|
|
var args: [String] = []
|
|
var current = ""
|
|
var inSingleQuotes = false
|
|
var inDoubleQuotes = false
|
|
|
|
for char in input {
|
|
if char == "'" && !inDoubleQuotes {
|
|
inSingleQuotes.toggle()
|
|
} else if char == "\"" && !inSingleQuotes {
|
|
inDoubleQuotes.toggle()
|
|
} else if char.isWhitespace && !inSingleQuotes && !inDoubleQuotes {
|
|
if !current.isEmpty {
|
|
args.append(current)
|
|
current = ""
|
|
}
|
|
} else {
|
|
current.append(char)
|
|
}
|
|
}
|
|
if !current.isEmpty { args.append(current) }
|
|
return args
|
|
}
|
|
|
|
static let reservedSlugs: Set<String> = [
|
|
"anytype", "paperless", "calendar", "reminders",
|
|
"contacts", "location", "maps", "bash", "web", "read", "write",
|
|
"list", "search", "edit", "delete", "create", "move", "copy", "spawn"
|
|
]
|
|
|
|
var isSlugReserved: Bool { Self.reservedSlugs.contains(slug) }
|
|
}
|
|
|
|
// MARK: - Client State
|
|
|
|
enum MCPClientState: Equatable {
|
|
case idle
|
|
case connecting
|
|
case ready
|
|
case error(String)
|
|
case crashed
|
|
case stopped
|
|
}
|
|
|
|
// MARK: - State Delegate (all callbacks on MainActor)
|
|
|
|
@MainActor
|
|
protocol ExternalMCPStateDelegate: AnyObject {
|
|
func clientDidBecomeReady(id: UUID, tools: [MCPToolDefinition], server: ExternalMCPServer)
|
|
func clientDidChangeState(id: UUID, state: MCPClientState)
|
|
}
|
|
|
|
// MARK: - Client Errors
|
|
|
|
enum MCPClientError: LocalizedError {
|
|
case notConnected
|
|
case invalidResponse(String)
|
|
case timeout
|
|
case processLaunchFailed(String)
|
|
case handshakeFailed(String)
|
|
case writeFailed
|
|
case invalidConfiguration(String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .notConnected: return "MCP server is not connected"
|
|
case .invalidResponse(let s): return "Invalid MCP response: \(s)"
|
|
case .timeout: return "MCP request timed out"
|
|
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)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - MCP Protocol Types
|
|
|
|
struct MCPInitializeResult: Decodable {
|
|
let protocolVersion: String
|
|
let capabilities: MCPCapabilities
|
|
let serverInfo: MCPServerInfo?
|
|
}
|
|
|
|
struct MCPCapabilities: Decodable {
|
|
let tools: MCPToolsCapability?
|
|
struct MCPToolsCapability: Decodable { let listChanged: Bool? }
|
|
}
|
|
|
|
struct MCPServerInfo: Decodable {
|
|
let name: String
|
|
let version: String?
|
|
}
|
|
|
|
struct MCPToolsListResult: Decodable {
|
|
let tools: [MCPToolDefinition]
|
|
let nextCursor: String?
|
|
}
|
|
|
|
struct MCPToolDefinition: Decodable {
|
|
let name: String
|
|
let description: String?
|
|
let inputSchema: MCPInputSchema
|
|
}
|
|
|
|
struct MCPInputSchema: Decodable {
|
|
let type: String
|
|
let properties: [String: MCPPropertySchema]?
|
|
let required: [String]?
|
|
}
|
|
|
|
struct MCPPropertySchema: Decodable {
|
|
let type: String?
|
|
let description: String?
|
|
let `enum`: [String]?
|
|
let items: MCPItemsSchema?
|
|
struct MCPItemsSchema: Decodable { let type: String? }
|
|
}
|
|
|
|
struct MCPToolCallResult: Decodable {
|
|
let content: [MCPContent]
|
|
let isError: Bool?
|
|
}
|
|
|
|
struct MCPContent: Decodable {
|
|
let type: String
|
|
let text: String?
|
|
let data: String?
|
|
let mimeType: String?
|
|
let uri: String?
|
|
}
|