Add external MCP server support (stdio JSON-RPC)
Lets the AI connect to any external stdio MCP server (e.g. safaridriver --mcp) configured in Settings, with tools auto-discovered and prefixed by server slug. Includes crash detection with backoff restart (5s/15s/30s) and a Settings UI to add/enable/disable/remove servers. Fixes the temp-dir allowlist in MCPService.isPathAllowed to also match /tmp and /private/tmp (not just NSTemporaryDirectory(), which resolves to a different per-user Darwin temp dir) so the MCP file tools can actually read files external servers and image generation write there. Also switches the Add Server sheet's argument parsing to a quote-aware tokenizer so args containing spaces survive intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
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.
|
||||
@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 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] = []
|
||||
|
||||
init(server: ExternalMCPServer, stateDelegate: (any ExternalMCPStateDelegate)?) {
|
||||
self.server = server
|
||||
self.stateDelegate = stateDelegate
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
func start() async throws {
|
||||
guard state == .idle || state == .stopped || state == .crashed else { return }
|
||||
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)
|
||||
}
|
||||
|
||||
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": "oAI", "version": "1.0"] as [String: Any]
|
||||
])
|
||||
try sendNotification(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()
|
||||
throw error
|
||||
}
|
||||
|
||||
state = .ready
|
||||
stateDelegate?.clientDidBecomeReady(id: server.id, tools: discoveredTools, server: server)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// 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: - 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
|
||||
}
|
||||
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] {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// 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 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.
|
||||
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 { 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)_") }
|
||||
}
|
||||
|
||||
private 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)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func convertInputSchema(_ schema: MCPInputSchema) -> Tool.Function.Parameters {
|
||||
var properties: [String: Tool.Function.Parameters.Property] = [:]
|
||||
for (key, prop) in schema.properties ?? [:] {
|
||||
let normalized: String
|
||||
switch prop.type ?? "string" {
|
||||
case "integer": normalized = "number"
|
||||
case "string", "number", "boolean", "array", "object": normalized = prop.type!
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Copyright (C) 2026 Rune Olsen
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Server Configuration
|
||||
|
||||
struct ExternalMCPServer: Codable, Identifiable, Sendable {
|
||||
var id: UUID
|
||||
var name: String
|
||||
var command: String
|
||||
var args: [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()) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.isEnabled = isEnabled
|
||||
self.timeout = timeout
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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?
|
||||
}
|
||||
@@ -98,6 +98,17 @@ class MCPService {
|
||||
|
||||
func isPathAllowed(_ path: String) -> Bool {
|
||||
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath
|
||||
// Always allow the system temp directory — external MCP servers and tools write
|
||||
// intermediate data there (e.g. Safari MCP page-content files, generated images).
|
||||
// Check both NSTemporaryDirectory() (per-user Darwin temp dir) and /tmp (what this
|
||||
// codebase's own temp files actually use, e.g. ChatViewModel's /tmp/oai_generated_*
|
||||
// and ExternalMCPClient's /tmp/oai_mcp_* — they resolve to different directories.
|
||||
let tmpCandidates = [
|
||||
(NSTemporaryDirectory() as NSString).standardizingPath,
|
||||
"/tmp",
|
||||
"/private/tmp"
|
||||
]
|
||||
if tmpCandidates.contains(where: { resolved.hasPrefix($0) }) { return true }
|
||||
return allowedFolders.contains { resolved.hasPrefix($0) }
|
||||
}
|
||||
|
||||
@@ -310,6 +321,9 @@ class MCPService {
|
||||
))
|
||||
}
|
||||
|
||||
// Add tools from external MCP servers (stdio JSON-RPC protocol)
|
||||
tools.append(contentsOf: ExternalMCPManager.shared.getToolSchemas())
|
||||
|
||||
return tools
|
||||
}
|
||||
|
||||
@@ -472,6 +486,10 @@ class MCPService {
|
||||
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
|
||||
|
||||
default:
|
||||
// Route to external MCP servers (stdio JSON-RPC)
|
||||
if ExternalMCPManager.shared.isExternalTool(name) {
|
||||
return await ExternalMCPManager.shared.executeTool(name: name, argumentsJSON: arguments)
|
||||
}
|
||||
// Route anytype_* tools to AnytypeMCPService
|
||||
if name.hasPrefix("anytype_") {
|
||||
return await anytypeService.executeTool(name: name, arguments: arguments)
|
||||
|
||||
@@ -461,6 +461,48 @@ class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External MCP Servers
|
||||
|
||||
var externalMCPServers: [ExternalMCPServer] {
|
||||
get {
|
||||
guard let json = cache["externalMCPServers"],
|
||||
let data = json.data(using: .utf8) else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .iso8601
|
||||
return (try? decoder.decode([ExternalMCPServer].self, from: data)) ?? []
|
||||
}
|
||||
set {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .iso8601
|
||||
if let data = try? encoder.encode(newValue),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
cache["externalMCPServers"] = json
|
||||
DatabaseService.shared.setSetting(key: "externalMCPServers", value: json)
|
||||
}
|
||||
Task { @MainActor in ExternalMCPManager.shared.reconfigure(servers: newValue) }
|
||||
}
|
||||
}
|
||||
|
||||
func addExternalMCPServer(_ server: ExternalMCPServer) {
|
||||
externalMCPServers = externalMCPServers + [server]
|
||||
}
|
||||
|
||||
func updateExternalMCPServer(_ server: ExternalMCPServer) {
|
||||
externalMCPServers = externalMCPServers.map { $0.id == server.id ? server : $0 }
|
||||
}
|
||||
|
||||
func deleteExternalMCPServer(id: UUID) {
|
||||
externalMCPServers = externalMCPServers.filter { $0.id != id }
|
||||
}
|
||||
|
||||
func toggleExternalMCPServer(id: UUID) {
|
||||
externalMCPServers = externalMCPServers.map { s in
|
||||
s.id == id ? ExternalMCPServer(id: s.id, name: s.name, command: s.command,
|
||||
args: s.args, isEnabled: !s.isEnabled,
|
||||
timeout: s.timeout, createdAt: s.createdAt) : s
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Favorite Models
|
||||
|
||||
var favoriteModelIds: Set<String> {
|
||||
|
||||
Reference in New Issue
Block a user