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
+212
View File
@@ -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