Add streamable HTTP transport for clients (#70)
* Rename file to Transport.swift * Rename file to StdioTransportTests.swift * Add .index-build/ to .gitignore * Implement HTTP client transport * Add test coverage for streamable HTTP client transport * Conditionally import FoundationNetworking * Conditionalize streaming for unsupported platforms * Import FoundationNetworking in tests * Add 5 minute timeout to CI jobs * Remove debug logging * Formatting * Skip SSE tests on platforms that don't support streaming
This commit is contained in:
parent
4143f35b41
commit
d583151871
2 changed files with 726 additions and 0 deletions
419
Tests/MCPTests/HTTPClientTransportTests.swift
Normal file
419
Tests/MCPTests/HTTPClientTransportTests.swift
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
@preconcurrency import Foundation
|
||||
import Logging
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
#if canImport(FoundationNetworking)
|
||||
import FoundationNetworking
|
||||
#endif
|
||||
|
||||
#if swift(>=6.1)
|
||||
|
||||
// MARK: - Test trait
|
||||
|
||||
/// A test trait that automatically manages the mock URL protocol handler for HTTP client transport tests.
|
||||
struct HTTPClientTransportTestSetupTrait: TestTrait, TestScoping {
|
||||
func provideScope(
|
||||
for test: Test, testCase: Test.Case?,
|
||||
performing function: @Sendable () async throws -> Void
|
||||
) async throws {
|
||||
// Clear handler before test
|
||||
await MockURLProtocol.requestHandlerStorage.clearHandler()
|
||||
|
||||
// Execute the test
|
||||
try await function()
|
||||
|
||||
// Clear handler after test
|
||||
await MockURLProtocol.requestHandlerStorage.clearHandler()
|
||||
}
|
||||
}
|
||||
|
||||
extension Trait where Self == HTTPClientTransportTestSetupTrait {
|
||||
static var httpClientTransportSetup: Self { Self() }
|
||||
}
|
||||
|
||||
// MARK: - Mock Handler Registry Actor
|
||||
|
||||
actor RequestHandlerStorage {
|
||||
private var requestHandler:
|
||||
(@Sendable (URLRequest) async throws -> (HTTPURLResponse, Data))?
|
||||
|
||||
func setHandler(
|
||||
_ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data)
|
||||
) async {
|
||||
requestHandler = handler
|
||||
}
|
||||
|
||||
func clearHandler() async {
|
||||
requestHandler = nil
|
||||
}
|
||||
|
||||
func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) {
|
||||
guard let handler = requestHandler else {
|
||||
throw MockURLProtocolError.noRequestHandler
|
||||
}
|
||||
return try await handler(request)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
extension URLRequest {
|
||||
fileprivate func readBody() -> Data? {
|
||||
if let httpBodyData = self.httpBody {
|
||||
return httpBodyData
|
||||
}
|
||||
|
||||
guard let bodyStream = self.httpBodyStream else { return nil }
|
||||
bodyStream.open()
|
||||
defer { bodyStream.close() }
|
||||
|
||||
let bufferSize: Int = 4096
|
||||
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
|
||||
defer { buffer.deallocate() }
|
||||
|
||||
var data = Data()
|
||||
while bodyStream.hasBytesAvailable {
|
||||
let bytesRead = bodyStream.read(buffer, maxLength: bufferSize)
|
||||
data.append(buffer, count: bytesRead)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock URL Protocol
|
||||
|
||||
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let requestHandlerStorage = RequestHandlerStorage()
|
||||
|
||||
static func setHandler(
|
||||
_ handler: @Sendable @escaping (URLRequest) async throws -> (HTTPURLResponse, Data)
|
||||
) async {
|
||||
await requestHandlerStorage.setHandler { request in
|
||||
try await handler(request)
|
||||
}
|
||||
}
|
||||
|
||||
func executeHandler(for request: URLRequest) async throws -> (HTTPURLResponse, Data) {
|
||||
return try await Self.requestHandlerStorage.executeHandler(for: request)
|
||||
}
|
||||
|
||||
override class func canInit(with request: URLRequest) -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
|
||||
return request
|
||||
}
|
||||
|
||||
override func startLoading() {
|
||||
Task {
|
||||
do {
|
||||
let (response, data) = try await self.executeHandler(for: request)
|
||||
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch {
|
||||
client?.urlProtocol(self, didFailWithError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override func stopLoading() {}
|
||||
}
|
||||
|
||||
enum MockURLProtocolError: Swift.Error {
|
||||
case noRequestHandler
|
||||
case invalidURL
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
@Suite("HTTP Client Transport Tests", .serialized)
|
||||
struct HTTPClientTransportTests {
|
||||
let testEndpoint = URL(string: "http://localhost:8080/test")!
|
||||
|
||||
@Test("Connect and Disconnect", .httpClientTransportSetup)
|
||||
func testConnectAndDisconnect() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
|
||||
|
||||
try await transport.connect()
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Send and Receive JSON Response", .httpClientTransportSetup)
|
||||
func testSendAndReceiveJSON() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
let messageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(using: .utf8)!
|
||||
let responseData = #"{"jsonrpc":"2.0","result":{},"id":1}"#.data(using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.url == testEndpoint)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.readBody() == messageData)
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
#expect(
|
||||
request.value(forHTTPHeaderField: "Accept")
|
||||
== "application/json, text/event-stream"
|
||||
)
|
||||
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (response, responseData)
|
||||
}
|
||||
|
||||
try await transport.send(messageData)
|
||||
|
||||
let stream = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
let receivedData = try await iterator.next()
|
||||
|
||||
#expect(receivedData == responseData)
|
||||
}
|
||||
|
||||
@Test("Send and Receive Session ID", .httpClientTransportSetup)
|
||||
func testSendAndReceiveSessionID() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
let messageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(using: .utf8)!
|
||||
let newSessionID = "session-12345"
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == nil)
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": newSessionID,
|
||||
])!
|
||||
return (response, Data())
|
||||
}
|
||||
|
||||
try await transport.send(messageData)
|
||||
|
||||
let storedSessionID = await transport.sessionID
|
||||
#expect(storedSessionID == newSessionID)
|
||||
}
|
||||
|
||||
@Test("Send With Existing Session ID", .httpClientTransportSetup)
|
||||
func testSendWithExistingSessionID() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration, streaming: false, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
let initialSessionID = "existing-session-abc"
|
||||
let firstMessageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(
|
||||
using: .utf8)!
|
||||
let secondMessageData = #"{"jsonrpc":"2.0","method":"ping","id":2}"#.data(
|
||||
using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.readBody() == firstMessageData)
|
||||
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == nil)
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": initialSessionID,
|
||||
])!
|
||||
return (response, Data())
|
||||
}
|
||||
try await transport.send(firstMessageData)
|
||||
#expect(await transport.sessionID == initialSessionID)
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.readBody() == secondMessageData)
|
||||
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == initialSessionID)
|
||||
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "application/json"])!
|
||||
return (response, Data())
|
||||
}
|
||||
try await transport.send(secondMessageData)
|
||||
|
||||
#expect(await transport.sessionID == initialSessionID)
|
||||
}
|
||||
|
||||
@Test("HTTP 404 Not Found Error", .httpClientTransportSetup)
|
||||
func testHTTPNotFoundError() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration)
|
||||
try await transport.connect()
|
||||
|
||||
let messageData = #"{"jsonrpc":"2.0","method":"test","id":3}"#.data(using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||
return (response, Data("Not Found".utf8))
|
||||
}
|
||||
|
||||
do {
|
||||
try await transport.send(messageData)
|
||||
Issue.record("Expected send to throw an error for 404")
|
||||
} catch let error as MCPError {
|
||||
guard case .internalError(let message) = error else {
|
||||
Issue.record("Expected MCPError.internalError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
#expect(message?.contains("Endpoint not found") ?? false)
|
||||
} catch {
|
||||
Issue.record("Expected MCPError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@Test("HTTP 500 Server Error", .httpClientTransportSetup)
|
||||
func testHTTPServerError() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration)
|
||||
try await transport.connect()
|
||||
|
||||
let messageData = #"{"jsonrpc":"2.0","method":"test","id":4}"#.data(using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 500, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||
return (response, Data("Server Error".utf8))
|
||||
}
|
||||
|
||||
do {
|
||||
try await transport.send(messageData)
|
||||
Issue.record("Expected send to throw an error for 500")
|
||||
} catch let error as MCPError {
|
||||
guard case .internalError(let message) = error else {
|
||||
Issue.record("Expected MCPError.internalError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
#expect(message?.contains("HTTP error: 500") ?? false)
|
||||
} catch {
|
||||
Issue.record("Expected MCPError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Session Expired Error (404 with Session ID)", .httpClientTransportSetup)
|
||||
func testSessionExpiredError() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration)
|
||||
try await transport.connect()
|
||||
|
||||
let initialSessionID = "expired-session-xyz"
|
||||
let firstMessageData = #"{"jsonrpc":"2.0","method":"initialize","id":1}"#.data(
|
||||
using: .utf8)!
|
||||
let secondMessageData = #"{"jsonrpc":"2.0","method":"ping","id":2}"#.data(
|
||||
using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: [
|
||||
"Content-Type": "application/json",
|
||||
"Mcp-Session-Id": initialSessionID,
|
||||
])!
|
||||
return (response, Data())
|
||||
}
|
||||
try await transport.send(firstMessageData)
|
||||
#expect(await transport.sessionID == initialSessionID)
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.value(forHTTPHeaderField: "Mcp-Session-Id") == initialSessionID)
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 404, httpVersion: "HTTP/1.1", headerFields: nil)!
|
||||
return (response, Data("Not Found".utf8))
|
||||
}
|
||||
|
||||
do {
|
||||
try await transport.send(secondMessageData)
|
||||
Issue.record("Expected send to throw session expired error")
|
||||
} catch let error as MCPError {
|
||||
guard case .internalError(let message) = error else {
|
||||
Issue.record("Expected MCPError.internalError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
#expect(message?.contains("Session expired") ?? false)
|
||||
#expect(await transport.sessionID == nil)
|
||||
} catch {
|
||||
Issue.record("Expected MCPError, got \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Skip SSE tests on platforms that don't support streaming
|
||||
#if !canImport(FoundationNetworking)
|
||||
@Test("Receive Server-Sent Event (SSE)", .httpClientTransportSetup)
|
||||
func testReceiveSSE() async throws {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.protocolClasses = [MockURLProtocol.self]
|
||||
|
||||
let transport = HTTPClientTransport(
|
||||
endpoint: testEndpoint, configuration: configuration, streaming: true,
|
||||
logger: nil)
|
||||
|
||||
let eventString = "id: event1\ndata: {\"key\":\"value\"}\n\n"
|
||||
let sseEventData = eventString.data(using: .utf8)!
|
||||
|
||||
await MockURLProtocol.requestHandlerStorage.setHandler {
|
||||
[testEndpoint] (request: URLRequest) in
|
||||
#expect(request.url == testEndpoint)
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Accept") == "text/event-stream")
|
||||
let response = HTTPURLResponse(
|
||||
url: testEndpoint, statusCode: 200, httpVersion: "HTTP/1.1",
|
||||
headerFields: ["Content-Type": "text/event-stream"])!
|
||||
return (response, sseEventData)
|
||||
}
|
||||
|
||||
try await transport.connect()
|
||||
try await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
let stream = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
let expectedData = #"{"key":"value"}"#.data(using: .utf8)!
|
||||
let receivedData = try await iterator.next()
|
||||
|
||||
#expect(receivedData == expectedData)
|
||||
}
|
||||
#endif // !canImport(FoundationNetworking)
|
||||
}
|
||||
|
||||
#endif // swift(>=6.1)
|
||||
127
Tests/MCPTests/StdioTransportTests.swift
Normal file
127
Tests/MCPTests/StdioTransportTests.swift
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import MCP
|
||||
|
||||
#if canImport(System)
|
||||
import System
|
||||
#else
|
||||
@preconcurrency import SystemPackage
|
||||
#endif
|
||||
|
||||
@Suite("Stdio Transport Tests")
|
||||
struct StdioTransportTests {
|
||||
@Test("Connection")
|
||||
func testStdioTransportConnection() async throws {
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Send Message")
|
||||
func testStdioTransportSendMessage() async throws {
|
||||
let (reader, output) = try FileDescriptor.pipe()
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Test sending a simple message
|
||||
let message = #"{"key":"value"}"#
|
||||
try await transport.send(message.data(using: .utf8)!)
|
||||
|
||||
// Read and verify the output
|
||||
var buffer = [UInt8](repeating: 0, count: 1024)
|
||||
let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in
|
||||
try reader.read(into: UnsafeMutableRawBufferPointer(pointer))
|
||||
}
|
||||
let data = Data(buffer[..<bytesRead])
|
||||
let expectedOutput = message.data(using: .utf8)! + "\n".data(using: .utf8)!
|
||||
#expect(data == expectedOutput)
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Receive Message")
|
||||
func testStdioTransportReceiveMessage() async throws {
|
||||
let (input, writer) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Write test message to input pipe
|
||||
let message = ["key": "value"]
|
||||
let messageData = try JSONEncoder().encode(message) + "\n".data(using: .utf8)!
|
||||
try writer.writeAll(messageData)
|
||||
try writer.close()
|
||||
|
||||
// Start receiving messages
|
||||
let stream: AsyncThrowingStream<Data, Swift.Error> = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
// Get first message
|
||||
let received = try await iterator.next()
|
||||
#expect(received == #"{"key":"value"}"#.data(using: .utf8)!)
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Invalid JSON")
|
||||
func testStdioTransportInvalidJSON() async throws {
|
||||
let (input, writer) = try FileDescriptor.pipe()
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(input: input, output: output, logger: nil)
|
||||
try await transport.connect()
|
||||
|
||||
// Write invalid JSON to input pipe
|
||||
let invalidJSON = #"{ invalid json }"#
|
||||
try writer.writeAll(invalidJSON.data(using: .utf8)!)
|
||||
try writer.close()
|
||||
|
||||
let stream: AsyncThrowingStream<Data, Swift.Error> = await transport.receive()
|
||||
var iterator = stream.makeAsyncIterator()
|
||||
|
||||
_ = try await iterator.next()
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Send Error")
|
||||
func testStdioTransportSendError() async throws {
|
||||
let (input, _) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(
|
||||
input: input,
|
||||
output: FileDescriptor(rawValue: -1), // Invalid fd
|
||||
logger: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await transport.connect()
|
||||
#expect(Bool(false), "Expected connect to throw an error")
|
||||
} catch {
|
||||
#expect(error is MCPError)
|
||||
}
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
|
||||
@Test("Receive Error")
|
||||
func testStdioTransportReceiveError() async throws {
|
||||
let (_, output) = try FileDescriptor.pipe()
|
||||
let transport = StdioTransport(
|
||||
input: FileDescriptor(rawValue: -1), // Invalid fd
|
||||
output: output,
|
||||
logger: nil
|
||||
)
|
||||
|
||||
do {
|
||||
try await transport.connect()
|
||||
#expect(Bool(false), "Expected connect to throw an error")
|
||||
} catch {
|
||||
#expect(error is MCPError)
|
||||
}
|
||||
|
||||
await transport.disconnect()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue