Files
oai-swift/oAI/Services/ExternalMCPModels.swift
T
rune 57b3477903 Fix External MCP server bugs; add npx/Node.js detection and install help
Root-caused two real issues Rune hit with Obsidian/Homepage external MCP
servers:

1. Toggling a server's enable switch silently wiped transportKind/env/
   url/bearerToken/headers back to stdio defaults (only id/name/command/
   args/isEnabled/timeout/createdAt were preserved) — almost certainly
   how Obsidian's config got corrupted into an empty-command stdio entry
   despite never being edited directly. Fixed via
   ExternalMCPServer.withEnabledToggled(), which flips only isEnabled.

2. npx (installed via Homebrew) was invisible to Confab because GUI apps
   only inherit launchd's minimal PATH, not the Terminal PATH. Tried
   spawning the user's login shell to ask for its real PATH — this
   caused two real hangs in one session (first an -ilc pipe deadlock,
   then a waitUntilExit()/CFRunLoop reentrancy issue even after fixing
   that) and was abandoned entirely in favor of LoginShellEnvironment:
   deterministic, subprocess-free directory probing (Homebrew, MacPorts,
   Volta, nvm's alias file) that can't hang by construction.

Also added:
- Edit capability for existing External MCP servers (previously only
  Add/Toggle/Delete) — the second thing Rune explicitly asked for, and
  the way to fix a corrupted entry like Obsidian's without deleting it.
- MCPClientError.commandNotFound: a stdio server's command is checked
  against PATH up front in StdioMCPTransport.prepare() and fails
  immediately with a clear reason instead of cycling through 3 rounds of
  crash/restart backoff (5s/15s/30s) for a permanently-missing binary.
- A "Get Node.js" button appears when this happens, opening a sheet with
  a copyable `brew install node`, a one-click install (via
  NodeInstallHelper, using the terminationHandler/readabilityHandler
  pattern already proven safe elsewhere in this file — deliberately not
  waitUntilExit()), or a nodejs.org link if Homebrew isn't present.
- ExternalMCPManager.retryClient(id:) to manually retry after fixing the
  underlying cause.
- Help book: new "Servers That Use npx" section, updated Server Status
  section, updated Settings blurb.

37 new/changed tests covering the toggle fix, PATH probing, the
commandNotFound fast-fail path, and missing-command detection — full
suite (374 tests) passes clean.
2026-08-26 14:02:17 +02:00

246 lines
8.5 KiB
Swift

// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Foundation
// MARK: - Server Configuration
/// Which wire protocol an `ExternalMCPServer` uses. `.stdio` fields are `command`/`args`/`env`;
/// `.http` fields are `url`/`bearerToken`/`headers`. Kept as one flat struct rather than an enum
/// with associated values — simpler `Codable` and simpler settings-JSON storage, at the cost of
/// each server config carrying some always-unused fields for its transport.
nonisolated enum MCPTransportKind: String, Codable, Sendable, CaseIterable {
case stdio
case http
}
nonisolated struct ExternalMCPServer: Codable, Identifiable, Sendable {
var id: UUID
var name: String
var transportKind: MCPTransportKind
var command: String
var args: [String]
var env: [String: String]
var url: String
var bearerToken: String
var headers: [String: String]
var isEnabled: Bool
var timeout: TimeInterval
var createdAt: Date
init(
id: UUID = UUID(),
name: String,
transportKind: MCPTransportKind = .stdio,
command: String = "",
args: [String] = [],
env: [String: String] = [:],
url: String = "",
bearerToken: String = "",
headers: [String: String] = [:],
isEnabled: Bool = true,
timeout: TimeInterval = 30,
createdAt: Date = Date()
) {
self.id = id
self.name = name
self.transportKind = transportKind
self.command = command
self.args = args
self.env = env
self.url = url
self.bearerToken = bearerToken
self.headers = headers
self.isEnabled = isEnabled
self.timeout = timeout
self.createdAt = createdAt
}
enum CodingKeys: String, CodingKey {
case id, name, transportKind, command, args, env, url, bearerToken, headers, isEnabled, timeout, createdAt
}
/// Custom decoding so servers saved before `transportKind`/`env`/`url`/`bearerToken`/`headers`
/// existed (plain stdio-only configs) still decode — those keys default rather than fail.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(UUID.self, forKey: .id)
name = try c.decode(String.self, forKey: .name)
command = try c.decode(String.self, forKey: .command)
args = try c.decode([String].self, forKey: .args)
isEnabled = try c.decode(Bool.self, forKey: .isEnabled)
timeout = try c.decode(TimeInterval.self, forKey: .timeout)
createdAt = try c.decode(Date.self, forKey: .createdAt)
transportKind = try c.decodeIfPresent(MCPTransportKind.self, forKey: .transportKind) ?? .stdio
env = try c.decodeIfPresent([String: String].self, forKey: .env) ?? [:]
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
bearerToken = try c.decodeIfPresent(String.self, forKey: .bearerToken) ?? ""
headers = try c.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
}
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) }
/// Returns a copy with only `isEnabled` flipped. Exists so toggling a server in Settings can't
/// silently drop fields — see the fixed bug in `SettingsService.toggleExternalMCPServer`, which
/// used to rebuild the struct from a subset of fields and lose `transportKind`/`env`/`url`/
/// `bearerToken`/`headers` on every toggle.
func withEnabledToggled() -> ExternalMCPServer {
var copy = self
copy.isEnabled.toggle()
return copy
}
}
// 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
case invalidConfiguration(String)
/// The stdio server's `command` couldn't be located anywhere on PATH (inherited + the common
/// install directories `LoginShellEnvironment` checks). Deliberately distinct from
/// `.processLaunchFailed`: this is a permanent, static condition — no amount of
/// restart-with-backoff will ever fix a binary that isn't there — so callers route it straight
/// to a clear `.error` state instead of the crash/retry loop. See `ExternalMCPClient.start()`.
case commandNotFound(String)
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"
case .invalidConfiguration(let s): return "Invalid MCP server configuration: \(s)"
case .commandNotFound(let s): return "Command not found: \(s)"
}
}
}
// 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?
}
// Plain DTOs read from ExternalMCPManager.convertToolDefinition/convertInputSchema — both
// `nonisolated static func` (for direct unit testing) — so these must stay `nonisolated` too,
// same reasoning as `Tool` in AIProvider.swift.
nonisolated struct MCPToolDefinition: Decodable {
let name: String
let description: String?
let inputSchema: MCPInputSchema
}
nonisolated struct MCPInputSchema: Decodable {
let type: String
let properties: [String: MCPPropertySchema]?
let required: [String]?
}
nonisolated struct MCPPropertySchema: Decodable {
let type: String?
let description: String?
let `enum`: [String]?
let items: MCPItemsSchema?
nonisolated 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?
}