Files
oai-swift/oAI/Services/ExternalMCPModels.swift
T
rune 017a0dbd9d Parse a pasted JSON args array correctly instead of mangling each token
Real incident: pasting gethomepage.dev's example args array (JSON,
quotes/commas/brackets and all) into the plain "Arguments" field
produced tokens like "mcp-remote," with the comma baked in, which
crashed npx with EINVALIDTAGNAME on the literal package name
"mcp-remote,". The existing char-by-char tokenizer treats quote
characters as its own quoting mechanism and consumes them, so a JSON
array's per-item quotes never get stripped and commas outside them
become part of the token.

parseArguments now tries decoding a well-formed JSON array of strings
first (only when the whole trimmed input is bracket-wrapped valid
JSON), falling back to the original shell-style tokenizer otherwise —
so pasting a server's args straight from its JSON config now works.
Tooltip and help doc updated; a partial paste (e.g. missing the opening
bracket) still isn't valid JSON and falls back as before, documented as
a known limitation rather than silently guessed at.
2026-08-26 14:26:45 +02:00

264 lines
9.8 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, Equatable {
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.
/// If `input` (trimmed) is a syntactically valid JSON array of strings, decodes and returns it
/// directly instead of falling through to the shell-style tokenizer below. MCP server configs
/// are almost always distributed as JSON, and pasting an `"args": [...]` array's value straight
/// into this single-line field is a very natural mistake — without this, the shell tokenizer
/// treats each `"…",` as one token complete with its trailing comma (and the JSON array's own
/// quote characters get consumed as its OWN quoting mechanism, not stripped), producing mangled
/// tokens like `mcp-remote,` that break whatever actually consumes them. Real incident: pasting
/// gethomepage.dev's example `args` array this way produced exactly that and crashed `npx` with
/// `EINVALIDTAGNAME` on the literal package name `"mcp-remote,"`.
nonisolated static func parseArgumentsAsJSONArray(_ input: String) -> [String]? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.hasPrefix("["), trimmed.hasSuffix("]"),
let data = trimmed.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode([String].self, from: data)
}
static func parseArguments(_ input: String) -> [String] {
if let jsonArgs = parseArgumentsAsJSONArray(input) { return jsonArgs }
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?
}