ExternalMCPManager.reconfigure() only started brand-new clients — a server ID that already had a client (in ANY state, including .crashed) was silently skipped even when its settings had just changed. Editing a server in Settings and clicking Save persisted correctly but never reached the live connection, which just kept running with its old (often broken) config until the next app launch. Rune hit this directly editing Obsidian's URL/token after the toggle-fields bug corrupted it. Added ExternalMCPServer: Equatable so reconfigure can detect a changed config for a still-enabled server and restart it fresh (extracted the restart-attempt-reset logic already used by retryClient into a shared restartFresh helper).
238 lines
9.5 KiB
Swift
238 lines
9.5 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")
|
|
}
|
|
}
|
|
|
|
@Suite("ExternalMCPServer.Equatable")
|
|
struct ExternalMCPServerEquatableTests {
|
|
|
|
@Test("Two servers with identical fields are equal")
|
|
func identicalServersAreEqual() {
|
|
let id = UUID()
|
|
let createdAt = Date()
|
|
let a = ExternalMCPServer(id: id, name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "tok", timeout: 30, createdAt: createdAt)
|
|
let b = ExternalMCPServer(id: id, name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "tok", timeout: 30, createdAt: createdAt)
|
|
#expect(a == b)
|
|
}
|
|
|
|
@Test("A changed URL/bearerToken makes servers unequal — this is what ExternalMCPManager.reconfigure uses to detect an edit")
|
|
func editedFieldsMakeServersUnequal() {
|
|
let id = UUID()
|
|
let createdAt = Date()
|
|
let original = ExternalMCPServer(id: id, name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "old-token", timeout: 30, createdAt: createdAt)
|
|
let edited = ExternalMCPServer(id: id, name: "Obsidian", transportKind: .http, url: "http://127.0.0.1:27123/mcp/", bearerToken: "new-token", timeout: 30, createdAt: createdAt)
|
|
#expect(original != edited)
|
|
}
|
|
}
|