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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user