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