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,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?
|
||||
}
|
||||
Reference in New Issue
Block a user