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

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