External MCP Servers previously only spoke stdio (spawn a local command + args). Adds: - env vars for stdio servers (merged into the subprocess environment, not embedded in the args string), with a masked key-value editor - a native Streamable HTTP transport (URL + Bearer token + custom headers), so HTTP-based MCP servers like Obsidian's Local REST API plugin connect directly without needing npx/Node.js as a bridge Introduces an MCPTransport abstraction (stdio/HTTP) so ExternalMCPClient stays transport-agnostic — mirrors how Provider.swift already abstracts AI backends in this codebase. Also fixes a real crash found via live testing against Obsidian: convertInputSchema force-unwrapped a tool parameter's `type`, which isn't required by JSON Schema — Obsidian's plugin was the first real server to send a parameter without one. Live-verified end to end (vault search/read/write/edit) before this commit, per the project's standing rule to hold external-service-dependent changes until they're actually confirmed working, not just compiling and passing tests.
106 lines
3.9 KiB
Swift
106 lines
3.9 KiB
Swift
//
|
|
// ExternalMCPModelsTests.swift
|
|
// ConfabTests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@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"])
|
|
}
|
|
}
|