Files
oai-swift/oAITests/ExternalMCPModelsTests.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

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