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
6 changed files with 726 additions and 0 deletions

View file

@ -12,6 +12,7 @@ permissions:
jobs:
test:
timeout-minutes: 5
strategy:
matrix:
os: [macos-latest, ubuntu-latest]

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.index-build/

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

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

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