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/Value.swift b/Sources/MCP/Base/Value.swift index 838c2852..e91a072a 100644 --- a/Sources/MCP/Base/Value.swift +++ b/Sources/MCP/Base/Value.swift @@ -2,13 +2,16 @@ import struct Foundation.Data import class Foundation.JSONDecoder import class Foundation.JSONEncoder -/// A codable value. +/// A JSON value. Encoding `.data` produces a data-URL string; decoding that JSON +/// produces `.string`, since an ordinary JSON string does not carry a binary type. public enum Value: Hashable, Sendable { case null case bool(Bool) case int(Int) case double(Double) case string(String) + /// Encodes as a data-URL string. Decoding that JSON produces `.string`. + /// Use `Data.parseDataURL(_:)` to recover the bytes when needed. case data(mimeType: String? = nil, Data) case array([Value]) case object([String: Value]) @@ -120,13 +123,7 @@ extension Value: Codable { } else if let value = try? container.decode(Double.self) { self = .double(value) } else if let value = try? container.decode(String.self) { - if Data.isDataURL(string: value), - case let (mimeType, data)? = Data.parseDataURL(value) - { - self = .data(mimeType: mimeType, data) - } else { - self = .string(value) - } + self = .string(value) } else if let value = try? container.decode([Value].self) { self = .array(value) } else if let value = try? container.decode([String: Value].self) { 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/ValueDecodingTests.swift b/Tests/MCPTests/ValueDecodingTests.swift new file mode 100644 index 00000000..5059c390 --- /dev/null +++ b/Tests/MCPTests/ValueDecodingTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Value JSON String Tests") +struct ValueDecodingTests { + @Test("Data-URL-looking strings stay strings in nested JSON") + func dataURLStringsStayStrings() throws { + let strings = [ + "data:text/plain,Hello%20World", + "data:text/plain;base64,SGVsbG8=", + "data:application/octet-stream;base64,AAEC/w==", + ] + let expected = Value.object(["values": .array(strings.map(Value.string))]) + let json = try JSONEncoder().encode(expected) + #expect(try JSONDecoder().decode(Value.self, from: json) == expected) + } + + @Test("Explicit data encoding does not change how generic JSON strings decode") + func explicitDataNeedsAnExplicitType() throws { + let data = Value.data(mimeType: "application/octet-stream", Data([0, 1, 2])) + let json = try JSONEncoder().encode(data) + let string = try JSONDecoder().decode(String.self, from: json) + #expect(string == "data:application/octet-stream;base64,AAEC") + #expect(try JSONDecoder().decode(Value.self, from: json) == .string(string)) + let parsed = try #require(Data.parseDataURL(string)) + #expect(parsed.mimeType == "application/octet-stream") + #expect(parsed.data == Data([0, 1, 2])) + } + + @Test("Tool text content survives Value erasure") + func toolTextContentSurvivesValueErasure() throws { + let text = "data:text/plain,Hello%20World" + let result = CallTool.Result(content: [ + .text(text: text, annotations: nil, _meta: nil) + ]) + let erased = try Value(result) + let recovered = try JSONDecoder().decode( + CallTool.Result.self, + from: JSONEncoder().encode(erased) + ) + guard case .text(let recoveredText, _, _) = recovered.content.first else { + Issue.record("Expected text content") + return + } + #expect(recoveredText == text) + } +}