Files
oai-swift/oAITests/ExternalMCPModelsTests.swift
T
rune 25028e3405 Add env-var support and native HTTP transport for External MCP Servers
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.
2026-08-13 16:05:42 +02:00

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