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.
290 lines
12 KiB
Swift
290 lines
12 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)
|
|
}
|
|
}
|
|
|
|
@Suite("ExternalMCPServer.parseArguments handles a pasted JSON array")
|
|
struct ParseArgumentsJSONArrayTests {
|
|
|
|
@Test("A well-formed JSON array of strings decodes directly, without shell-tokenizer mangling")
|
|
func fullJSONArrayParsesCleanly() {
|
|
let input = """
|
|
["-y", "mcp-remote", "https://h.rune.pm/api/mcp", "--header", "X-Homepage-MCP-Token: abc123"]
|
|
"""
|
|
#expect(ExternalMCPServer.parseArguments(input) == [
|
|
"-y", "mcp-remote", "https://h.rune.pm/api/mcp", "--header", "X-Homepage-MCP-Token: abc123"
|
|
])
|
|
}
|
|
|
|
@Test("A multi-line JSON array (as most docs format it) also parses cleanly")
|
|
func multilineJSONArrayParsesCleanly() {
|
|
let input = """
|
|
[
|
|
"-y",
|
|
"mcp-remote",
|
|
"https://h.rune.pm/api/mcp"
|
|
]
|
|
"""
|
|
#expect(ExternalMCPServer.parseArguments(input) == ["-y", "mcp-remote", "https://h.rune.pm/api/mcp"])
|
|
}
|
|
|
|
@Test("A partial paste missing the opening bracket is NOT treated as JSON — falls back to the shell tokenizer (documents the known limitation, doesn't silently guess)")
|
|
func partialPasteMissingOpenBracketFallsBackToShellParsing() {
|
|
// Real incident shape: Rune pasted from "-y" through the closing ']' but missed the '['.
|
|
let input = #""-y", "mcp-remote", "https://h.rune.pm/api/mcp"]"#
|
|
#expect(ExternalMCPServer.parseArgumentsAsJSONArray(input) == nil)
|
|
// The shell tokenizer still runs on it — each token ends up with its trailing comma
|
|
// attached, which is exactly the real bug this whole feature exists to prevent when the
|
|
// full array IS pasted; this test just documents that a *partial* paste isn't rescued.
|
|
#expect(ExternalMCPServer.parseArguments(input) == ["-y,", "mcp-remote,", "https://h.rune.pm/api/mcp]"])
|
|
}
|
|
|
|
@Test("A plain shell-style string (no brackets) is unaffected — still uses the original tokenizer")
|
|
func plainShellStyleStringUnaffected() {
|
|
#expect(ExternalMCPServer.parseArguments(#"-y mcp-remote --header "X-Token: abc""#) == [
|
|
"-y", "mcp-remote", "--header", "X-Token: abc"
|
|
])
|
|
}
|
|
|
|
@Test("Malformed JSON that merely looks bracketed doesn't crash — falls back to shell parsing")
|
|
func malformedBracketedInputFallsBack() {
|
|
let input = "[not, valid, json]"
|
|
#expect(ExternalMCPServer.parseArgumentsAsJSONArray(input) == nil)
|
|
// Doesn't throw or crash; still produces *something* via the fallback tokenizer.
|
|
#expect(ExternalMCPServer.parseArguments(input).isEmpty == false)
|
|
}
|
|
}
|