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:
2026-07-14 08:01:00 +02:00
parent f2949cea3b
commit e6f965ff19
9 changed files with 937 additions and 2 deletions
+280
View File
@@ -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
}
}
+202
View File
@@ -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)
}
}
}
+170
View File
@@ -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?
}
+18
View File
@@ -98,6 +98,17 @@ class MCPService {
func isPathAllowed(_ path: String) -> Bool { func isPathAllowed(_ path: String) -> Bool {
let resolved = ((path as NSString).expandingTildeInPath as NSString).standardizingPath 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) } 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 return tools
} }
@@ -472,6 +486,10 @@ class MCPService {
return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary) return await executePersonalDataAction(toolName: name, argumentsJSON: arguments, summary: summary)
default: 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 // Route anytype_* tools to AnytypeMCPService
if name.hasPrefix("anytype_") { if name.hasPrefix("anytype_") {
return await anytypeService.executeTool(name: name, arguments: arguments) return await anytypeService.executeTool(name: name, arguments: arguments)
+42
View File
@@ -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 // MARK: - Favorite Models
var favoriteModelIds: Set<String> { var favoriteModelIds: Set<String> {
+1
View File
@@ -153,4 +153,5 @@ enum Log {
nonisolated static let search = AppLogger(subsystem: subsystem, category: "search") nonisolated static let search = AppLogger(subsystem: subsystem, category: "search")
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui") nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general") nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
} }
+8 -1
View File
@@ -801,6 +801,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let bashActive = settings.bashEnabled let bashActive = settings.bashEnabled
let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled let personalDataActive = settings.calendarEnabled || settings.remindersEnabled || settings.contactsEnabled || settings.locationMapsEnabled
let researchAgentsActive = settings.agentsEnabled let researchAgentsActive = settings.agentsEnabled
let externalMCPActive = !settings.externalMCPServers.filter { $0.isEnabled }.isEmpty
// Dedicated images API path (OpenRouter /images endpoint separate from chat completions) // Dedicated images API path (OpenRouter /images endpoint separate from chat completions)
if selectedModel?.capabilities.usesImagesAPI == true, if selectedModel?.capabilities.usesImagesAPI == true,
let orProvider = provider as? OpenRouterProvider { 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 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) generateAIResponseWithTools(provider: provider, modelId: modelId)
return 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.") 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") var systemContent = systemParts.joined(separator: "\n\n")
// Append the complete system prompt (default + custom) // Append the complete system prompt (default + custom)
+212
View File
@@ -75,6 +75,14 @@ struct SettingsView: View {
// Default model picker state // Default model picker state
@State private var showDefaultModelPicker = false @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) // Personal Data state (Calendar/Reminders/Contacts/Location/Maps)
@State private var calendarAccessState = EventKitService.shared.calendarAccessState @State private var calendarAccessState = EventKitService.shared.calendarAccessState
@State private var remindersAccessState = EventKitService.shared.reminderAccessState @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 // MARK: Personal Data
// isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug). // isHiddenPendingAppleFix hides the entire section (macOS 27 beta TCC bug).
// isContactsHiddenPendingAppleFix hides just the Contacts row (still broken in beta 2 // 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 // MARK: - Appearance Tab
@ViewBuilder @ViewBuilder
+4 -1
View File
@@ -37,6 +37,9 @@ struct oAIApp: App {
// Start email handler on app launch // Start email handler on app launch
EmailHandlerService.shared.start() EmailHandlerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
// Sync Git changes on app launch (pull + import) // Sync Git changes on app launch (pull + import)
Task { Task {
await GitSyncService.shared.syncOnStartup() await GitSyncService.shared.syncOnStartup()
@@ -56,7 +59,7 @@ struct oAIApp: App {
} }
#if os(macOS) #if os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
// Trigger auto-save on app quit Task { @MainActor in ExternalMCPManager.shared.stopAll() }
Task { Task {
await chatViewModel.onAppWillTerminate() await chatViewModel.onAppWillTerminate()
} }