Files
oai-swift/oAITests/ExternalMCPModelsTests.swift
T
rune 41f0b8f083 Fix Edit/Save not applying to an already-connected External MCP server
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).
2026-08-26 14:12:28 +02:00

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)
}
}