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
6 changed files with 726 additions and 0 deletions
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
|
|
@ -12,6 +12,7 @@ permissions:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
|
timeout-minutes: 5
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest, ubuntu-latest]
|
os: [macos-latest, ubuntu-latest]
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,3 +6,4 @@ DerivedData/
|
||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
.index-build/
|
||||||
20
Sources/MCP/Base/Transport.swift
Normal file
20
Sources/MCP/Base/Transport.swift
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
import struct Foundation.Data
|
||||||
|
|
||||||
|
/// Protocol defining the transport layer for MCP communication
|
||||||
|
public protocol Transport: Actor {
|
||||||
|
var logger: Logger { get }
|
||||||
|
|
||||||
|
/// Establishes connection with the transport
|
||||||
|
func connect() async throws
|
||||||
|
|
||||||
|
/// Disconnects from the transport
|
||||||
|
func disconnect() async
|
||||||
|
|
||||||
|
/// Sends data
|
||||||
|
func send(_ data: Data) async throws
|
||||||
|
|
||||||
|
/// Receives data in an async sequence
|
||||||
|
func receive() -> AsyncThrowingStream<Data, Swift.Error>
|
||||||
|
}
|
||||||
305
Sources/MCP/Base/Transports/HTTPClientTransport.swift
Normal file
305
Sources/MCP/Base/Transports/HTTPClientTransport.swift
Normal file
|
|
@ -0,0 +1,305 @@
|
||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
import FoundationNetworking
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public actor HTTPClientTransport: Actor, Transport {
|
||||||
|
public let endpoint: URL
|
||||||
|
private let session: URLSession
|
||||||
|
public private(set) var sessionID: String?
|
||||||
|
private let streaming: Bool
|
||||||
|
private var streamingTask: Task<Void, Never>?
|
||||||
|
private var lastEventID: String?
|
||||||
|
public nonisolated let logger: Logger
|
||||||
|
|
||||||
|
private var isConnected = false
|
||||||
|
private let messageStream: AsyncThrowingStream<Data, Swift.Error>
|
||||||
|
private let messageContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation
|
||||||
|
|
||||||
|
public init(
|
||||||
|
endpoint: URL,
|
||||||
|
configuration: URLSessionConfiguration = .default,
|
||||||
|
streaming: Bool = false,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
endpoint: endpoint,
|
||||||
|
session: URLSession(configuration: configuration),
|
||||||
|
streaming: streaming,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal init(
|
||||||
|
endpoint: URL,
|
||||||
|
session: URLSession,
|
||||||
|
streaming: Bool = false,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) {
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.session = session
|
||||||
|
self.streaming = streaming
|
||||||
|
|
||||||
|
// Create message stream
|
||||||
|
var continuation: AsyncThrowingStream<Data, Swift.Error>.Continuation!
|
||||||
|
self.messageStream = AsyncThrowingStream { continuation = $0 }
|
||||||
|
self.messageContinuation = continuation
|
||||||
|
|
||||||
|
self.logger =
|
||||||
|
logger
|
||||||
|
?? Logger(
|
||||||
|
label: "mcp.transport.http.client",
|
||||||
|
factory: { _ in SwiftLogNoOpLogHandler() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Establishes connection with the transport
|
||||||
|
public func connect() async throws {
|
||||||
|
guard !isConnected else { return }
|
||||||
|
isConnected = true
|
||||||
|
|
||||||
|
if streaming {
|
||||||
|
// Start listening to server events
|
||||||
|
streamingTask = Task { await startListeningForServerEvents() }
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("HTTP transport connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disconnects from the transport
|
||||||
|
public func disconnect() async {
|
||||||
|
guard isConnected else { return }
|
||||||
|
isConnected = false
|
||||||
|
|
||||||
|
// Cancel streaming task if active
|
||||||
|
streamingTask?.cancel()
|
||||||
|
streamingTask = nil
|
||||||
|
|
||||||
|
// Cancel any in-progress requests
|
||||||
|
session.invalidateAndCancel()
|
||||||
|
|
||||||
|
// Clean up message stream
|
||||||
|
messageContinuation.finish()
|
||||||
|
|
||||||
|
logger.info("HTTP clienttransport disconnected")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends data through an HTTP POST request
|
||||||
|
public func send(_ data: Data) async throws {
|
||||||
|
guard isConnected else {
|
||||||
|
throw MCPError.internalError("Transport not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: endpoint)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.addValue("application/json, text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
|
||||||
|
// Add session ID if available
|
||||||
|
if let sessionID = sessionID {
|
||||||
|
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
let (responseData, response) = try await session.data(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MCPError.internalError("Invalid HTTP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the response based on content type and status code
|
||||||
|
let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") ?? ""
|
||||||
|
|
||||||
|
// Extract session ID if present
|
||||||
|
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
|
||||||
|
self.sessionID = newSessionID
|
||||||
|
logger.debug("Session ID received", metadata: ["sessionID": "\(newSessionID)"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response types
|
||||||
|
switch httpResponse.statusCode {
|
||||||
|
case 200, 201, 202:
|
||||||
|
// For SSE, the processing happens in the streaming task
|
||||||
|
if contentType.contains("text/event-stream") {
|
||||||
|
logger.debug("Received SSE response, processing in streaming task")
|
||||||
|
// The streaming is handled by the SSE task if active
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// For JSON responses, deliver the data directly
|
||||||
|
if contentType.contains("application/json") && !responseData.isEmpty {
|
||||||
|
logger.debug("Received JSON response", metadata: ["size": "\(responseData.count)"])
|
||||||
|
messageContinuation.yield(responseData)
|
||||||
|
}
|
||||||
|
case 404:
|
||||||
|
// If we get a 404 with a session ID, it means our session is invalid
|
||||||
|
if sessionID != nil {
|
||||||
|
logger.warning("Session has expired")
|
||||||
|
sessionID = nil
|
||||||
|
throw MCPError.internalError("Session expired")
|
||||||
|
}
|
||||||
|
throw MCPError.internalError("Endpoint not found")
|
||||||
|
default:
|
||||||
|
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receives data in an async sequence
|
||||||
|
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
|
||||||
|
return AsyncThrowingStream { continuation in
|
||||||
|
Task {
|
||||||
|
for try await message in messageStream {
|
||||||
|
continuation.yield(message)
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SSE
|
||||||
|
|
||||||
|
/// Starts listening for server events using SSE
|
||||||
|
private func startListeningForServerEvents() async {
|
||||||
|
guard isConnected else { return }
|
||||||
|
|
||||||
|
// Retry loop for connection drops
|
||||||
|
while isConnected && !Task.isCancelled {
|
||||||
|
do {
|
||||||
|
try await connectToEventStream()
|
||||||
|
} catch {
|
||||||
|
if !Task.isCancelled {
|
||||||
|
logger.error("SSE connection error: \(error)")
|
||||||
|
// Wait before retrying
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(FoundationNetworking)
|
||||||
|
private func connectToEventStream() async throws {
|
||||||
|
logger.warning("SSE is not supported on this platform")
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
/// Establishes an SSE connection to the server
|
||||||
|
private func connectToEventStream() async throws {
|
||||||
|
guard isConnected else { return }
|
||||||
|
|
||||||
|
var request = URLRequest(url: endpoint)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.addValue("text/event-stream", forHTTPHeaderField: "Accept")
|
||||||
|
|
||||||
|
// Add session ID if available
|
||||||
|
if let sessionID = sessionID {
|
||||||
|
request.addValue(sessionID, forHTTPHeaderField: "Mcp-Session-Id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Last-Event-ID header for resumability if available
|
||||||
|
if let lastEventID = lastEventID {
|
||||||
|
request.addValue(lastEventID, forHTTPHeaderField: "Last-Event-ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Starting SSE connection")
|
||||||
|
|
||||||
|
// Create URLSession task for SSE
|
||||||
|
let (stream, response) = try await session.bytes(for: request)
|
||||||
|
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw MCPError.internalError("Invalid HTTP response")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check response status
|
||||||
|
guard httpResponse.statusCode == 200 else {
|
||||||
|
throw MCPError.internalError("HTTP error: \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract session ID if present
|
||||||
|
if let newSessionID = httpResponse.value(forHTTPHeaderField: "Mcp-Session-Id") {
|
||||||
|
self.sessionID = newSessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the SSE stream
|
||||||
|
var buffer = ""
|
||||||
|
var eventType = ""
|
||||||
|
var eventID: String?
|
||||||
|
var eventData = ""
|
||||||
|
|
||||||
|
for try await byte in stream {
|
||||||
|
if Task.isCancelled { break }
|
||||||
|
|
||||||
|
guard let char = String(bytes: [byte], encoding: .utf8) else { continue }
|
||||||
|
buffer.append(char)
|
||||||
|
|
||||||
|
// Process complete lines
|
||||||
|
while let newlineIndex = buffer.firstIndex(of: "\n") {
|
||||||
|
let line = buffer[..<newlineIndex]
|
||||||
|
buffer = String(buffer[buffer.index(after: newlineIndex)...])
|
||||||
|
|
||||||
|
// Empty line marks the end of an event
|
||||||
|
if line.isEmpty || line == "\r" || line == "\n" || line == "\r\n" {
|
||||||
|
if !eventData.isEmpty {
|
||||||
|
// Process the event
|
||||||
|
if eventType == "id" {
|
||||||
|
lastEventID = eventID
|
||||||
|
} else {
|
||||||
|
// Default event type is "message" if not specified
|
||||||
|
if let data = eventData.data(using: .utf8) {
|
||||||
|
logger.debug(
|
||||||
|
"SSE event received",
|
||||||
|
metadata: [
|
||||||
|
"type": "\(eventType.isEmpty ? "message" : eventType)",
|
||||||
|
"id": "\(eventID ?? "none")",
|
||||||
|
])
|
||||||
|
messageContinuation.yield(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset for next event
|
||||||
|
eventType = ""
|
||||||
|
eventData = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lines starting with ":" are comments
|
||||||
|
if line.hasPrefix(":") { continue }
|
||||||
|
|
||||||
|
// Parse field: value format
|
||||||
|
if let colonIndex = line.firstIndex(of: ":") {
|
||||||
|
let field = String(line[..<colonIndex])
|
||||||
|
var value = String(line[line.index(after: colonIndex)...])
|
||||||
|
|
||||||
|
// Trim leading space
|
||||||
|
if value.hasPrefix(" ") {
|
||||||
|
value = String(value.dropFirst())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process based on field
|
||||||
|
switch field {
|
||||||
|
case "event":
|
||||||
|
eventType = value
|
||||||
|
case "data":
|
||||||
|
if !eventData.isEmpty {
|
||||||
|
eventData.append("\n")
|
||||||
|
}
|
||||||
|
eventData.append(value)
|
||||||
|
case "id":
|
||||||
|
if !value.contains("\0") { // ID must not contain NULL
|
||||||
|
eventID = value
|
||||||
|
lastEventID = value
|
||||||
|
}
|
||||||
|
case "retry":
|
||||||
|
// Retry timing not implemented
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
// Unknown fields are ignored per SSE spec
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
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