Update transports to send and receive data instead of strings (#42)

* Update transports to send and receive data instead of strings

* Rename MockTransport helper methods

* Adopt Task.sleep(for:...) instead of variant taking nanoseconds

* Update comment

* Revert inadvertant change to visibility of send method
This commit is contained in:
Mattt 2025-04-03 04:42:56 -07:00 • committed by GitHub
commit a9bcbf9ad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 138 additions and 147 deletions

View file

@ -27,7 +27,7 @@ struct ClientTests {
try await client.connect(transport: transport)
// Small delay to ensure message loop is started
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
try await Task.sleep(for: .milliseconds(10))
// Create a task for initialize that we'll cancel
let initTask = Task {
@ -35,19 +35,19 @@ struct ClientTests {
}
// Give it a moment to send the request
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
try await Task.sleep(for: .milliseconds(10))
#expect(await transport.sentMessages.count == 1)
#expect(await transport.sentMessages[0].contains(Initialize.name))
#expect(await transport.sentMessages[0].contains(client.name))
#expect(await transport.sentMessages[0].contains(client.version))
#expect(await transport.sentMessages.first?.contains(Initialize.name) == true)
#expect(await transport.sentMessages.first?.contains(client.name) == true)
#expect(await transport.sentMessages.first?.contains(client.version) == true)
// Cancel the initialize task
initTask.cancel()
// Disconnect client to clean up message loop and give time for continuation cleanup
await client.disconnect()
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
try await Task.sleep(for: .milliseconds(50))
}
@Test(
@ -60,7 +60,7 @@ struct ClientTests {
try await client.connect(transport: transport)
// Small delay to ensure message loop is started
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
try await Task.sleep(for: .milliseconds(10))
// Create a task for the ping that we'll cancel
let pingTask = Task {
@ -68,17 +68,17 @@ struct ClientTests {
}
// Give it a moment to send the request
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
try await Task.sleep(for: .milliseconds(10))
#expect(await transport.sentMessages.count == 1)
#expect(await transport.sentMessages[0].contains(Ping.name))
#expect(await transport.sentMessages.first?.contains(Ping.name) == true)
// Cancel the ping task
pingTask.cancel()
// Disconnect client to clean up message loop and give time for continuation cleanup
await client.disconnect()
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
try await Task.sleep(for: .milliseconds(50))
}
@Test("Connection failure handling")
@ -168,7 +168,7 @@ struct ClientTests {
// Wait a bit for any setup to complete
try await Task.sleep(for: .milliseconds(10))
// Send the listPrompts request and immediately provide an error response
let promptsTask = Task {
do {
@ -187,7 +187,7 @@ struct ClientTests {
id: decodedRequest.id,
error: Error.methodNotFound("Test: Prompts capability not available")
)
try await transport.queueResponse(errorResponse)
try await transport.queue(response: errorResponse)
// Try the request now that we have a response queued
do {

View file

@ -6,10 +6,28 @@ import Logging
/// Mock transport for testing
actor MockTransport: Transport {
var logger: Logger
let encoder = JSONEncoder()
let decoder = JSONDecoder()
var isConnected = false
private(set) var sentMessages: [String] = []
private var messagesToReceive: [String] = []
private var messageStreamContinuation: AsyncThrowingStream<String, Swift.Error>.Continuation?
private(set) var sentData: [Data] = []
var sentMessages: [String] {
return sentData.compactMap { data in
guard let string = String(data: data, encoding: .utf8) else {
logger.error("Failed to decode sent data as UTF-8")
return nil
}
return string
}
}
private var dataToReceive: [Data] = []
private(set) var receivedMessages: [String] = []
private var dataStreamContinuation: AsyncThrowingStream<Data, Swift.Error>.Continuation?
var shouldFailConnect = false
var shouldFailSend = false
@ -17,85 +35,39 @@ actor MockTransport: Transport {
self.logger = logger
}
func connect() async throws {
public func connect() async throws {
if shouldFailConnect {
throw Error.transportError(POSIXError(.ECONNREFUSED))
}
isConnected = true
}
func disconnect() async {
public func disconnect() async {
isConnected = false
messageStreamContinuation?.finish()
messageStreamContinuation = nil
dataStreamContinuation?.finish()
dataStreamContinuation = nil
}
func send<T: Encodable & Sendable>(_ message: T) async throws {
public func send(_ message: Data) async throws {
if shouldFailSend {
throw Error.transportError(POSIXError(.EIO))
}
let data = try JSONEncoder().encode(message)
let str = String(data: data, encoding: .utf8)!
sentMessages.append(str)
sentData.append(message)
}
func receive() -> AsyncThrowingStream<String, Swift.Error> {
return AsyncThrowingStream<String, Swift.Error> { continuation in
messageStreamContinuation = continuation
// Send any queued messages
for message in messagesToReceive {
public func receive() -> AsyncThrowingStream<Data, Swift.Error> {
return AsyncThrowingStream<Data, Swift.Error> { continuation in
dataStreamContinuation = continuation
for message in dataToReceive {
continuation.yield(message)
if let string = String(data: message, encoding: .utf8) {
receivedMessages.append(string)
}
}
messagesToReceive.removeAll()
dataToReceive.removeAll()
}
}
func queueRequest<M: MCP.Method>(_ request: Request<M>) throws {
let data = try JSONEncoder().encode(request)
let str = String(data: data, encoding: .utf8)!
if let continuation = messageStreamContinuation {
continuation.yield(str)
} else {
sentMessages.append(str)
}
}
func queueResponse<M: MCP.Method>(_ response: Response<M>) throws {
let data = try JSONEncoder().encode(response)
let str = String(data: data, encoding: .utf8)!
if let continuation = messageStreamContinuation {
continuation.yield(str)
} else {
messagesToReceive.append(str)
}
}
func queueNotification<N: MCP.Notification>(_ notification: Message<N>) throws {
let data = try JSONEncoder().encode(notification)
let str = String(data: data, encoding: .utf8)!
if let continuation = messageStreamContinuation {
continuation.yield(str)
} else {
messagesToReceive.append(str)
}
}
func getLastSentMessage<T: Decodable>() -> T? {
print("SENT:", sentMessages)
guard let lastMessage = sentMessages.last else { return nil }
do {
let data = lastMessage.data(using: .utf8)!
return try JSONDecoder().decode(T.self, from: data)
} catch {
return nil
}
}
func clearMessages() {
sentMessages.removeAll()
messagesToReceive.removeAll()
}
func setFailConnect(_ shouldFail: Bool) {
shouldFailConnect = shouldFail
}
@ -103,4 +75,37 @@ actor MockTransport: Transport {
func setFailSend(_ shouldFail: Bool) {
shouldFailSend = shouldFail
}
func queue<M: MCP.Method>(request: Request<M>) throws {
let data = try encoder.encode(request)
if let continuation = dataStreamContinuation {
continuation.yield(data)
} else {
dataToReceive.append(data)
}
}
func queue<M: MCP.Method>(response: Response<M>) throws {
let data = try encoder.encode(response)
dataToReceive.append(data)
}
func queue<N: MCP.Notification>(notification: Message<N>) throws {
let data = try encoder.encode(notification)
dataToReceive.append(data)
}
func decodeLastSentMessage<T: Decodable>() -> T? {
guard let lastMessage = sentData.last else { return nil }
do {
return try decoder.decode(T.self, from: lastMessage)
} catch {
return nil
}
}
func clearMessages() {
sentData.removeAll()
dataToReceive.removeAll()
}
}

View file

@ -22,8 +22,8 @@ struct ServerTests {
let transport = MockTransport()
// Queue an initialize request
try await transport.queueRequest(
Initialize.request(
try await transport.queue(
request: Initialize.request(
.init(
protocolVersion: Version.latest,
capabilities: .init(),
@ -42,7 +42,11 @@ struct ServerTests {
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
#expect(await transport.sentMessages.count == 1)
#expect(await transport.sentMessages[0].contains(Initialize.name))
let messages = await transport.sentMessages
if let response = messages.first {
#expect(response.contains("serverInfo"))
}
// Clean up
await server.stop()
@ -68,13 +72,13 @@ struct ServerTests {
#expect(clientInfo.version == "1.0")
await state.setHookCalled()
}
// Wait for server to initialize
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
try await Task.sleep(for: .milliseconds(10))
// Queue an initialize request
try await transport.queueRequest(
Initialize.request(
try await transport.queue(
request: Initialize.request(
.init(
protocolVersion: Version.latest,
capabilities: .init(),
@ -83,7 +87,7 @@ struct ServerTests {
))
// Wait for message processing and hook execution
try await Task.sleep(nanoseconds: 200_000_000) // 200ms
try await Task.sleep(for: .milliseconds(500))
#expect(await state.wasHookCalled() == true)
#expect(await transport.sentMessages.count >= 1)
@ -94,6 +98,7 @@ struct ServerTests {
}
await server.stop()
await transport.disconnect()
}
@Test("Initialize hook - rejection")
@ -107,13 +112,13 @@ struct ServerTests {
throw Error.invalidRequest("Client not allowed")
}
}
// Wait for server to initialize
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
// Queue an initialize request from blocked client
try await transport.queueRequest(
Initialize.request(
try await transport.queue(
request: Initialize.request(
.init(
protocolVersion: Version.latest,
capabilities: .init(),
@ -124,13 +129,15 @@ struct ServerTests {
// Wait for message processing
try await Task.sleep(nanoseconds: 200_000_000) // 200ms
#expect(await transport.sentMessages.count >= 2)
#expect(await transport.sentMessages.count >= 1)
let messages = await transport.sentMessages
if let response = messages.first {
#expect(response.contains("error"))
#expect(response.contains("Client not allowed"))
}
await server.stop()
await transport.disconnect()
}
}

View file

@ -29,7 +29,7 @@ struct StdioTransportTests {
// Test sending a simple message
let message = #"{"key":"value"}"#
try await transport.send(message)
try await transport.send(message.data(using: .utf8)!)
// Read and verify the output
var buffer = [UInt8](repeating: 0, count: 1024)
@ -57,12 +57,12 @@ struct StdioTransportTests {
try writer.close()
// Start receiving messages
let stream: AsyncThrowingStream<String, Swift.Error> = await transport.receive()
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"}"#)
#expect(received == #"{"key":"value"}"#.data(using: .utf8)!)
await transport.disconnect()
}
@ -79,7 +79,7 @@ struct StdioTransportTests {
try writer.writeAll(invalidJSON.data(using: .utf8)!)
try writer.close()
let stream: AsyncThrowingStream<String, Swift.Error> = await transport.receive()
let stream: AsyncThrowingStream<Data, Swift.Error> = await transport.receive()
var iterator = stream.makeAsyncIterator()
_ = try await iterator.next()