diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba30a965..2aa8e355 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: # Current leg: catches breakage from new Xcode and SDK releases. - os: macos-latest swift-version: Xcode default - - os: ubuntu-latest + - os: ubuntu-24.04 swift-version: 6.1.0 runs-on: ${{ matrix.os }} @@ -35,13 +35,13 @@ jobs: - uses: actions/checkout@v4 - name: Prepare Linux apt dependencies - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' run: | sudo -n apt-get update sudo -n apt-get install -y --no-install-recommends libcurl4-openssl-dev - name: Setup Swift on Linux - if: matrix.os == 'ubuntu-latest' + if: matrix.os == 'ubuntu-24.04' uses: vapor/swiftly-action@v0.2 with: toolchain: ${{ matrix.swift-version }} diff --git a/Sources/MCP/Base/PendingRequest.swift b/Sources/MCP/Base/PendingRequest.swift index b6e0a83b..56917b7b 100644 --- a/Sources/MCP/Base/PendingRequest.swift +++ b/Sources/MCP/Base/PendingRequest.swift @@ -1,8 +1,18 @@ import class Foundation.JSONDecoder import class Foundation.JSONEncoder -/// An error indicating a type mismatch when decoding a pending request response. -struct TypeMismatchError: Swift.Error {} +/// A response could not be decoded as the result type expected by the request. +public struct TypeMismatchError: Swift.Error { + public let expectedType: String + public let rawValue: Value? + public let underlyingError: (any Swift.Error)? + + init(expectedType: String, rawValue: Value?, underlyingError: (any Swift.Error)?) { + self.expectedType = expectedType + self.rawValue = rawValue + self.underlyingError = underlyingError + } +} /// A pending request with a continuation for the result. struct PendingRequest { @@ -19,13 +29,24 @@ struct AnyPendingRequest: Sendable { case .success(let value): if let typedValue = value as? T { request.continuation.resume(returning: typedValue) - } else if let value = value as? Value, - let data = try? JSONEncoder().encode(value), - let decoded = try? JSONDecoder().decode(T.self, from: data) - { - request.continuation.resume(returning: decoded) + } else if let value = value as? Value { + do { + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(T.self, from: data) + request.continuation.resume(returning: decoded) + } catch { + request.continuation.resume(throwing: TypeMismatchError( + expectedType: String(reflecting: T.self), + rawValue: value, + underlyingError: error + )) + } } else { - request.continuation.resume(throwing: TypeMismatchError()) + request.continuation.resume(throwing: TypeMismatchError( + expectedType: String(reflecting: T.self), + rawValue: nil, + underlyingError: nil + )) } case .failure(let error): request.continuation.resume(throwing: error) diff --git a/Tests/MCPTests/ClientTests.swift b/Tests/MCPTests/ClientTests.swift index 8a2882a4..4d2d8c71 100644 --- a/Tests/MCPTests/ClientTests.swift +++ b/Tests/MCPTests/ClientTests.swift @@ -14,10 +14,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -124,10 +122,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -174,10 +170,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -230,10 +224,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -321,10 +313,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -401,10 +391,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -481,10 +469,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -521,10 +507,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -656,10 +640,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, @@ -717,10 +699,8 @@ struct ClientTests { // Set up a task to handle the initialize response let initTask = Task { - try await Task.sleep(for: .milliseconds(10)) - if let lastMessage = await transport.sentMessages.last, - let data = lastMessage.data(using: .utf8), - let request = try? JSONDecoder().decode(Request.self, from: data) + let data = await transport.waitForFirstSentData() + if let request = try? JSONDecoder().decode(Request.self, from: data) { let response = Initialize.response( id: request.id, diff --git a/Tests/MCPTests/Helpers/MockTransport.swift b/Tests/MCPTests/Helpers/MockTransport.swift index 5979e9ad..2f45d8da 100644 --- a/Tests/MCPTests/Helpers/MockTransport.swift +++ b/Tests/MCPTests/Helpers/MockTransport.swift @@ -17,6 +17,7 @@ actor MockTransport: Transport { var isConnected = false private(set) var sentData: [Data] = [] + private var firstSendWaiters: [CheckedContinuation] = [] var sentMessages: [String] { return sentData.compactMap { data in guard let string = String(data: data, encoding: .utf8) else { @@ -57,6 +58,20 @@ actor MockTransport: Transport { throw MCPError.transportError(POSIXError(.EIO)) } sentData.append(message) + let waiters = firstSendWaiters + firstSendWaiters.removeAll() + for waiter in waiters { + waiter.resume(returning: message) + } + } + + func waitForFirstSentData() async -> Data { + if let data = sentData.first { + return data + } + return await withCheckedContinuation { continuation in + firstSendWaiters.append(continuation) + } } public func receive() -> AsyncThrowingStream { diff --git a/Tests/MCPTests/PendingRequestTests.swift b/Tests/MCPTests/PendingRequestTests.swift new file mode 100644 index 00000000..2541087f --- /dev/null +++ b/Tests/MCPTests/PendingRequestTests.swift @@ -0,0 +1,52 @@ +import Testing + +@testable import MCP + +@Suite("Pending request tests") +struct PendingRequestTests { + @Test("Failed tool result decoding exposes the raw value and decoding error") + func failedDecodingKeepsResponse() async throws { + let raw = Value.object(["unexpected": .string("response")]) + + do { + let _: CallTool.Result = try await withCheckedThrowingContinuation { continuation in + let pending = AnyPendingRequest(PendingRequest(continuation: continuation)) + pending.resume(returning: raw) + } + Issue.record("Expected decoding to fail") + } catch let error as TypeMismatchError { + #expect(error.expectedType.contains("CallTool.Result")) + #expect(error.rawValue == raw) + guard case .keyNotFound(let key, _) = error.underlyingError as? DecodingError else { + Issue.record("Expected the missing content key to be reported") + return + } + #expect(key.stringValue == "content") + } + } + + @Test("Valid tool result still decodes") + func validResultStillDecodes() async throws { + let raw = Value.object(["content": .array([])]) + let result: CallTool.Result = try await withCheckedThrowingContinuation { continuation in + let pending = AnyPendingRequest(PendingRequest(continuation: continuation)) + pending.resume(returning: raw) + } + #expect(result.content.isEmpty) + } + + @Test("Non-JSON response reports the expected type") + func mismatchedResponseType() async throws { + do { + let _: CallTool.Result = try await withCheckedThrowingContinuation { continuation in + let pending = AnyPendingRequest(PendingRequest(continuation: continuation)) + pending.resume(returning: 42) + } + Issue.record("Expected a type mismatch") + } catch let error as TypeMismatchError { + #expect(error.expectedType.contains("CallTool.Result")) + #expect(error.rawValue == nil) + #expect(error.underlyingError == nil) + } + } +}