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.
216 lines
8.3 KiB
Swift
216 lines
8.3 KiB
Swift
//
|
|
// ExternalMCPModelsTests.swift
|
|
// ConfabTests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
import SwiftUI
|
|
@testable import Confab
|
|
|
|
@Suite("ExternalMCPServer Codable")
|
|
struct ExternalMCPServerCodableTests {
|
|
|
|
private static func makeCoders() -> (JSONEncoder, JSONDecoder) {
|
|
let encoder = JSONEncoder()
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
let decoder = JSONDecoder()
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
return (encoder, decoder)
|
|
}
|
|
|
|
@Test("Decodes old-shape JSON (saved before env/HTTP fields existed) with safe defaults")
|
|
func decodesOldShapeJSON() throws {
|
|
// Exactly the 7 keys ExternalMCPServer had before transportKind/env/url/bearerToken/headers
|
|
// were added — this is what's actually sitting in existing users' settings DB right now.
|
|
let oldJSON = """
|
|
{
|
|
"id": "00000000-0000-0000-0000-000000000001",
|
|
"name": "Safari",
|
|
"command": "safaridriver",
|
|
"args": ["--mcp"],
|
|
"isEnabled": true,
|
|
"timeout": 30,
|
|
"createdAt": "2026-01-01T00:00:00Z"
|
|
}
|
|
"""
|
|
let (_, decoder) = Self.makeCoders()
|
|
let server = try decoder.decode(ExternalMCPServer.self, from: Data(oldJSON.utf8))
|
|
|
|
#expect(server.name == "Safari")
|
|
#expect(server.command == "safaridriver")
|
|
#expect(server.args == ["--mcp"])
|
|
#expect(server.transportKind == .stdio)
|
|
#expect(server.env.isEmpty)
|
|
#expect(server.url.isEmpty)
|
|
#expect(server.bearerToken.isEmpty)
|
|
#expect(server.headers.isEmpty)
|
|
}
|
|
|
|
@Test("An array of old-shape servers (the real settings-JSON shape) decodes without dropping any")
|
|
func decodesOldShapeArray() throws {
|
|
let oldJSON = """
|
|
[
|
|
{"id": "00000000-0000-0000-0000-000000000001", "name": "Safari", "command": "safaridriver",
|
|
"args": ["--mcp"], "isEnabled": true, "timeout": 30, "createdAt": "2026-01-01T00:00:00Z"},
|
|
{"id": "00000000-0000-0000-0000-000000000002", "name": "Other", "command": "some-tool",
|
|
"args": [], "isEnabled": false, "timeout": 15, "createdAt": "2026-02-01T00:00:00Z"}
|
|
]
|
|
"""
|
|
let (_, decoder) = Self.makeCoders()
|
|
let servers = try decoder.decode([ExternalMCPServer].self, from: Data(oldJSON.utf8))
|
|
#expect(servers.count == 2)
|
|
}
|
|
|
|
@Test("Round-trips a full HTTP-transport server through encode/decode")
|
|
func roundTripsHTTPServer() throws {
|
|
let original = ExternalMCPServer(
|
|
name: "Obsidian",
|
|
transportKind: .http,
|
|
url: "http://127.0.0.1:27123/mcp/",
|
|
bearerToken: "secret-token",
|
|
headers: ["X-Extra": "value"],
|
|
timeout: 45
|
|
)
|
|
let (encoder, decoder) = Self.makeCoders()
|
|
let data = try encoder.encode(original)
|
|
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
|
|
|
|
#expect(decoded.id == original.id)
|
|
#expect(decoded.transportKind == .http)
|
|
#expect(decoded.url == "http://127.0.0.1:27123/mcp/")
|
|
#expect(decoded.bearerToken == "secret-token")
|
|
#expect(decoded.headers == ["X-Extra": "value"])
|
|
}
|
|
|
|
@Test("Round-trips a stdio-transport server with env vars")
|
|
func roundTripsStdioServerWithEnv() throws {
|
|
let original = ExternalMCPServer(
|
|
name: "Custom",
|
|
command: "npx",
|
|
args: ["-y", "some-tool"],
|
|
env: ["API_KEY": "abc123"],
|
|
timeout: 30
|
|
)
|
|
let (encoder, decoder) = Self.makeCoders()
|
|
let data = try encoder.encode(original)
|
|
let decoded = try decoder.decode(ExternalMCPServer.self, from: data)
|
|
|
|
#expect(decoded.transportKind == .stdio)
|
|
#expect(decoded.env == ["API_KEY": "abc123"])
|
|
#expect(decoded.command == "npx")
|
|
#expect(decoded.args == ["-y", "some-tool"])
|
|
}
|
|
}
|
|
|
|
@Suite("ExternalMCPServer.withEnabledToggled")
|
|
struct ExternalMCPServerToggleTests {
|
|
|
|
@Test("Flips isEnabled and leaves every other field untouched, including HTTP-only fields")
|
|
func toggleFlipsOnlyIsEnabledForHTTPServer() {
|
|
let original = ExternalMCPServer(
|
|
name: "Obsidian",
|
|
transportKind: .http,
|
|
url: "http://127.0.0.1:27123/mcp/",
|
|
bearerToken: "secret-token",
|
|
headers: ["X-Custom": "value"],
|
|
isEnabled: true,
|
|
timeout: 45
|
|
)
|
|
let toggled = original.withEnabledToggled()
|
|
|
|
#expect(toggled.isEnabled == false)
|
|
#expect(toggled.id == original.id)
|
|
#expect(toggled.transportKind == .http)
|
|
#expect(toggled.url == original.url)
|
|
#expect(toggled.bearerToken == original.bearerToken)
|
|
#expect(toggled.headers == original.headers)
|
|
#expect(toggled.timeout == original.timeout)
|
|
#expect(toggled.createdAt == original.createdAt)
|
|
}
|
|
|
|
@Test("Flips isEnabled and leaves env vars untouched for a stdio server")
|
|
func toggleFlipsOnlyIsEnabledForStdioServer() {
|
|
let original = ExternalMCPServer(
|
|
name: "Homepage",
|
|
command: "npx",
|
|
args: ["-y", "mcp-remote"],
|
|
env: ["HOMEPAGE_API_KEY": "abc"],
|
|
isEnabled: false,
|
|
timeout: 30
|
|
)
|
|
let toggled = original.withEnabledToggled()
|
|
|
|
#expect(toggled.isEnabled == true)
|
|
#expect(toggled.command == "npx")
|
|
#expect(toggled.args == ["-y", "mcp-remote"])
|
|
#expect(toggled.env == ["HOMEPAGE_API_KEY": "abc"])
|
|
}
|
|
|
|
@Test("Toggling twice returns to the original isEnabled value")
|
|
func doubleToggleRoundTrips() {
|
|
let original = ExternalMCPServer(name: "Safari", command: "safaridriver", args: ["--mcp"])
|
|
let backAgain = original.withEnabledToggled().withEnabledToggled()
|
|
#expect(backAgain.isEnabled == original.isEnabled)
|
|
}
|
|
}
|
|
|
|
@Suite("SettingsView.argsToDisplayString round-trips through ExternalMCPServer.parseArguments")
|
|
struct ArgsDisplayStringRoundTripTests {
|
|
|
|
@Test("Simple space-separated args round-trip unchanged")
|
|
func simpleArgsRoundTrip() {
|
|
let args = ["-y", "mcp-remote", "http://localhost:3000/api/mcp"]
|
|
let displayed = SettingsView.argsToDisplayString(args)
|
|
#expect(displayed == "-y mcp-remote http://localhost:3000/api/mcp")
|
|
#expect(ExternalMCPServer.parseArguments(displayed) == args)
|
|
}
|
|
|
|
@Test("An arg containing a space is quoted so re-parsing keeps it as one token")
|
|
func argWithSpaceIsQuoted() {
|
|
let args = ["--header", "X-Homepage-Api-Key: some value"]
|
|
let displayed = SettingsView.argsToDisplayString(args)
|
|
#expect(displayed == "--header \"X-Homepage-Api-Key: some value\"")
|
|
#expect(ExternalMCPServer.parseArguments(displayed) == args)
|
|
}
|
|
|
|
@Test("Empty args array displays and re-parses as empty")
|
|
func emptyArgsRoundTrip() {
|
|
#expect(SettingsView.argsToDisplayString([]).isEmpty)
|
|
#expect(ExternalMCPServer.parseArguments(SettingsView.argsToDisplayString([])).isEmpty)
|
|
}
|
|
}
|
|
|
|
@Suite("SettingsView.missingCommand")
|
|
struct MissingCommandDetectionTests {
|
|
|
|
@Test("Extracts the command name from a commandNotFound-shaped error state")
|
|
func extractsCommandName() {
|
|
let state: MCPClientState = .error("Command not found: npx")
|
|
#expect(SettingsView.missingCommand(from: state) == "npx")
|
|
}
|
|
|
|
@Test("Returns nil for an unrelated error message")
|
|
func nilForUnrelatedError() {
|
|
let state: MCPClientState = .error("Maximum restart attempts reached")
|
|
#expect(SettingsView.missingCommand(from: state) == nil)
|
|
}
|
|
|
|
@Test("Returns nil for non-error states")
|
|
func nilForNonErrorStates() {
|
|
#expect(SettingsView.missingCommand(from: .ready) == nil)
|
|
#expect(SettingsView.missingCommand(from: .crashed) == nil)
|
|
#expect(SettingsView.missingCommand(from: .connecting) == nil)
|
|
#expect(SettingsView.missingCommand(from: nil) == nil)
|
|
}
|
|
|
|
@Test("Matches MCPClientError.commandNotFound's real errorDescription text exactly")
|
|
func matchesRealErrorDescription() {
|
|
let error = MCPClientError.commandNotFound("npx")
|
|
let state: MCPClientState = .error(error.errorDescription ?? "")
|
|
#expect(SettingsView.missingCommand(from: state) == "npx")
|
|
}
|
|
}
|