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> {
|
||||
|
||||
@@ -153,4 +153,5 @@ enum Log {
|
||||
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
|
||||
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
|
||||
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
|
||||
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
|
||||
}
|
||||
|
||||
@@ -801,6 +801,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
let bashActive = settings.bashEnabled
|
||||
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
|
||||
let researchAgentsActive = settings.agentsEnabled
|
||||
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
|
||||
// Dedicated images API path (OpenRouter /images endpoint — separate from chat completions)
|
||||
if selectedModel?.capabilities.usesImagesAPI == true,
|
||||
let orProvider = provider as? OpenRouterProvider {
|
||||
@@ -809,7 +810,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
}
|
||||
|
||||
let modelSupportTools = selectedModel?.capabilities.tools ?? false
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
if modelSupportTools && (anytypeActive || bashActive || personalDataActive || researchAgentsActive || externalMCPActive || (mcpActive && !mcp.allowedFolders.isEmpty)) {
|
||||
generateAIResponseWithTools(provider: provider, modelId: modelId)
|
||||
return
|
||||
}
|
||||
@@ -1360,6 +1361,12 @@ Don't narrate future actions ("Let me...") - just use the tools.
|
||||
systemParts.append("You have access to the user's Anytype knowledge base through tool calls (anytype_* tools). You can search across all spaces, list spaces, get objects, and create or update notes, tasks, and pages. Use these tools proactively when the user asks about their notes, tasks, or knowledge base.")
|
||||
}
|
||||
|
||||
let activeExternalServers = settings.externalMCPServers.filter { $0.isEnabled }
|
||||
if !activeExternalServers.isEmpty {
|
||||
let names = activeExternalServers.map { $0.name }.joined(separator: ", ")
|
||||
systemParts.append("You have access to external tools from MCP servers: \(names). Their tools are prefixed with the server slug (e.g. safari_navigate_to_url). Use them proactively when the user's request relates to what those servers provide.")
|
||||
}
|
||||
|
||||
var systemContent = systemParts.joined(separator: "\n\n")
|
||||
|
||||
// Append the complete system prompt (default + custom)
|
||||
|
||||
@@ -75,6 +75,14 @@ struct SettingsView: View {
|
||||
// Default model picker state
|
||||
@State private var showDefaultModelPicker = false
|
||||
|
||||
// External MCP Servers state
|
||||
@State private var showAddExternalMCPServer = false
|
||||
@State private var newMCPServerName = ""
|
||||
@State private var newMCPServerCommand = ""
|
||||
@State private var newMCPServerArgs = ""
|
||||
@State private var newMCPServerTimeout: Double = 30
|
||||
private var externalMCPManager = ExternalMCPManager.shared
|
||||
|
||||
// Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
|
||||
@State private var calendarAccessState = EventKitService.shared.calendarAccessState
|
||||
@State private var remindersAccessState = EventKitService.shared.reminderAccessState
|
||||
@@ -817,6 +825,10 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: External MCP Servers
|
||||
Divider()
|
||||
externalMCPSection
|
||||
|
||||
// MARK: Personal Data
|
||||
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
|
||||
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2
|
||||
@@ -917,6 +929,206 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - External MCP Servers Section
|
||||
|
||||
@ViewBuilder
|
||||
private var externalMCPSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.purple)
|
||||
Text("External MCP Servers")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
}
|
||||
Text("Connect any stdio MCP server (e.g. safaridriver --mcp) to give the AI access to its tools. Tool names are prefixed with the server slug.")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.bottom, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
sectionHeader("Configured Servers")
|
||||
formSection {
|
||||
if settingsService.externalMCPServers.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "server.rack")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No external servers configured")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Add a server below, e.g.: safaridriver --mcp")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 20)
|
||||
} else {
|
||||
ForEach(settingsService.externalMCPServers) { server in
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(mcpStatusColor(externalMCPManager.clientStates[server.id]))
|
||||
.frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(server.name)
|
||||
.font(.system(size: 14))
|
||||
Text(([server.command] + server.args).joined(separator: " "))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
Text(mcpStatusLabel(externalMCPManager.clientStates[server.id]))
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("", isOn: Binding(
|
||||
get: { server.isEnabled },
|
||||
set: { _ in settingsService.toggleExternalMCPServer(id: server.id) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
.labelsHidden()
|
||||
Button {
|
||||
settingsService.deleteExternalMCPServer(id: server.id)
|
||||
} label: {
|
||||
Image(systemName: "trash.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
if server.id != settingsService.externalMCPServers.last?.id {
|
||||
rowDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
newMCPServerName = ""
|
||||
newMCPServerCommand = ""
|
||||
newMCPServerArgs = ""
|
||||
newMCPServerTimeout = 30
|
||||
showAddExternalMCPServer = true
|
||||
} label: {
|
||||
Label("Add Server…", systemImage: "plus")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 7)
|
||||
.background(Color.purple)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.sheet(isPresented: $showAddExternalMCPServer) {
|
||||
addExternalMCPServerSheet
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var addExternalMCPServerSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Text("Add External MCP Server")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
formSection {
|
||||
row("Name") {
|
||||
TextField("Safari", text: $newMCPServerName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 240)
|
||||
}
|
||||
rowDivider()
|
||||
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")
|
||||
}
|
||||
rowDivider()
|
||||
row("Timeout") {
|
||||
HStack(spacing: 8) {
|
||||
Stepper("", value: $newMCPServerTimeout, in: 5...120, step: 5)
|
||||
.labelsHidden()
|
||||
Text("\(Int(newMCPServerTimeout))s")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 32, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !newMCPServerName.isEmpty {
|
||||
let slug = ExternalMCPServer.makeSlug(from: newMCPServerName)
|
||||
HStack(spacing: 6) {
|
||||
Text("Tool prefix:")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(slug)_")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if ExternalMCPServer.reservedSlugs.contains(slug) {
|
||||
Text("'\(slug)' is a reserved prefix. Choose a different name.")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { showAddExternalMCPServer = false }
|
||||
Spacer()
|
||||
Button("Add") {
|
||||
let args = ExternalMCPServer.parseArguments(newMCPServerArgs)
|
||||
let server = ExternalMCPServer(
|
||||
name: newMCPServerName,
|
||||
command: newMCPServerCommand,
|
||||
args: args,
|
||||
timeout: newMCPServerTimeout
|
||||
)
|
||||
settingsService.addExternalMCPServer(server)
|
||||
showAddExternalMCPServer = false
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(newMCPServerName.isEmpty || newMCPServerCommand.isEmpty ||
|
||||
ExternalMCPServer.reservedSlugs.contains(ExternalMCPServer.makeSlug(from: newMCPServerName)))
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(minWidth: 460, minHeight: 320)
|
||||
}
|
||||
|
||||
private func mcpStatusColor(_ state: MCPClientState?) -> Color {
|
||||
switch state {
|
||||
case .ready: return .green
|
||||
case .connecting: return .orange
|
||||
case .error, .crashed: return .red
|
||||
default: return Color(nsColor: .tertiaryLabelColor)
|
||||
}
|
||||
}
|
||||
|
||||
private func mcpStatusLabel(_ state: MCPClientState?) -> LocalizedStringKey {
|
||||
switch state {
|
||||
case .ready: return "Connected"
|
||||
case .connecting: return "Connecting…"
|
||||
case .error: return "Error"
|
||||
case .crashed: return "Crashed"
|
||||
default: return "Not started"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Appearance Tab
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
+4
-1
@@ -37,6 +37,9 @@ struct oAIApp: App {
|
||||
// Start email handler on app launch
|
||||
EmailHandlerService.shared.start()
|
||||
|
||||
// Start external MCP servers
|
||||
Task { @MainActor in ExternalMCPManager.shared.startAll() }
|
||||
|
||||
// Sync Git changes on app launch (pull + import)
|
||||
Task {
|
||||
await GitSyncService.shared.syncOnStartup()
|
||||
@@ -56,7 +59,7 @@ struct oAIApp: App {
|
||||
}
|
||||
#if os(macOS)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
// Trigger auto-save on app quit
|
||||
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
|
||||
Task {
|
||||
await chatViewModel.onAppWillTerminate()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user