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:
Mattt 2025-04-15 09:06:29 -07:00 • committed by GitHub
commit d583151871
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 726 additions and 0 deletions

View 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)

View 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()
}
}