Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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 }}
Expand Down
37 changes: 29 additions & 8 deletions Sources/MCP/Base/PendingRequest.swift
Original file line number Diff line number Diff line change
@@ -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<T> {
Expand All @@ -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)
Expand Down
60 changes: 20 additions & 40 deletions Tests/MCPTests/ClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down Expand Up @@ -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<Initialize>.self, from: data)
let data = await transport.waitForFirstSentData()
if let request = try? JSONDecoder().decode(Request<Initialize>.self, from: data)
{
let response = Initialize.response(
id: request.id,
Expand Down
15 changes: 15 additions & 0 deletions Tests/MCPTests/Helpers/MockTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ actor MockTransport: Transport {
var isConnected = false

private(set) var sentData: [Data] = []
private var firstSendWaiters: [CheckedContinuation<Data, Never>] = []
var sentMessages: [String] {
return sentData.compactMap { data in
guard let string = String(data: data, encoding: .utf8) else {
Expand Down Expand Up @@ -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<Data, Swift.Error> {
Expand Down
52 changes: 52 additions & 0 deletions Tests/MCPTests/PendingRequestTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading