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.
132 lines
4.9 KiB
Swift
132 lines
4.9 KiB
Swift
//
|
|
// MCPTransportTests.swift
|
|
// ConfabTests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Confab
|
|
|
|
@Suite("MCPTransportSupport.extractResult")
|
|
struct MCPTransportSupportTests {
|
|
|
|
@Test("Extracts the result payload from a successful JSON-RPC response")
|
|
func extractsResult() throws {
|
|
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "result": ["tools": []]]
|
|
let data = try MCPTransportSupport.extractResult(from: json)
|
|
let decoded = try JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
#expect(decoded?["tools"] != nil)
|
|
}
|
|
|
|
@Test("Throws with the server's message when the response is a JSON-RPC error")
|
|
func throwsOnJSONRPCError() {
|
|
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1, "error": ["code": -32601, "message": "Method not found"]]
|
|
do {
|
|
_ = try MCPTransportSupport.extractResult(from: json)
|
|
Issue.record("Expected extractResult to throw")
|
|
} catch let error as MCPClientError {
|
|
switch error {
|
|
case .invalidResponse(let message): #expect(message == "Method not found")
|
|
default: Issue.record("Expected .invalidResponse, got \(error)")
|
|
}
|
|
} catch {
|
|
Issue.record("Expected MCPClientError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test("Throws when the response has neither result nor error")
|
|
func throwsOnMissingResult() {
|
|
let json: [String: Any] = ["jsonrpc": "2.0", "id": 1]
|
|
do {
|
|
_ = try MCPTransportSupport.extractResult(from: json)
|
|
Issue.record("Expected extractResult to throw")
|
|
} catch let error as MCPClientError {
|
|
switch error {
|
|
case .invalidResponse(let message): #expect(message == "Missing result field")
|
|
default: Issue.record("Expected .invalidResponse, got \(error)")
|
|
}
|
|
} catch {
|
|
Issue.record("Expected MCPClientError, got \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suite("HTTPMCPTransport.parseResponseBody")
|
|
struct HTTPMCPTransportParseTests {
|
|
|
|
@Test("Parses a plain application/json response body directly")
|
|
func parsesPlainJSON() throws {
|
|
let body = Data(#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#.utf8)
|
|
let json = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 7)
|
|
#expect(json["id"] as? Int == 7)
|
|
}
|
|
|
|
@Test("Parses a text/event-stream body, finding the data: line matching the expected id")
|
|
func parsesSSEMatchingId() throws {
|
|
let sse = """
|
|
event: message
|
|
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
|
|
|
|
"""
|
|
let json = try HTTPMCPTransport.parseResponseBody(
|
|
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
|
)
|
|
#expect(json["id"] as? Int == 7)
|
|
}
|
|
|
|
@Test("Skips unrelated server-sent messages before the matching response, per the Streamable HTTP spec")
|
|
func skipsUnrelatedMessagesInSSE() throws {
|
|
// Spec: "The server MAY send JSON-RPC requests and notifications before sending the
|
|
// JSON-RPC response." Simulated here as an unrelated id=99 message before the real id=7 one.
|
|
let sse = """
|
|
data: {"jsonrpc":"2.0","id":99,"method":"unrelated/notification"}
|
|
|
|
data: {"jsonrpc":"2.0","id":7,"result":{"ok":true}}
|
|
|
|
"""
|
|
let json = try HTTPMCPTransport.parseResponseBody(
|
|
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
|
)
|
|
#expect(json["id"] as? Int == 7)
|
|
}
|
|
|
|
@Test("Throws when no SSE data: line matches the expected id")
|
|
func throwsWhenNoMatchInSSE() {
|
|
let sse = """
|
|
data: {"jsonrpc":"2.0","id":99,"result":{}}
|
|
|
|
"""
|
|
do {
|
|
_ = try HTTPMCPTransport.parseResponseBody(
|
|
Data(sse.utf8), contentType: "text/event-stream", expectedId: 7
|
|
)
|
|
Issue.record("Expected parseResponseBody to throw")
|
|
} catch let error as MCPClientError {
|
|
switch error {
|
|
case .invalidResponse: break
|
|
default: Issue.record("Expected .invalidResponse, got \(error)")
|
|
}
|
|
} catch {
|
|
Issue.record("Expected MCPClientError, got \(error)")
|
|
}
|
|
}
|
|
|
|
@Test("Throws on malformed JSON in a plain application/json body")
|
|
func throwsOnMalformedJSON() {
|
|
let body = Data("not json".utf8)
|
|
do {
|
|
_ = try HTTPMCPTransport.parseResponseBody(body, contentType: "application/json", expectedId: 1)
|
|
Issue.record("Expected parseResponseBody to throw")
|
|
} catch let error as MCPClientError {
|
|
switch error {
|
|
case .invalidResponse: break
|
|
default: Issue.record("Expected .invalidResponse, got \(error)")
|
|
}
|
|
} catch {
|
|
Issue.record("Expected MCPClientError, got \(error)")
|
|
}
|
|
}
|
|
}
|