diff --git a/Sources/MCP/Base/Value.swift b/Sources/MCP/Base/Value.swift index 732835b8..05f09572 100644 --- a/Sources/MCP/Base/Value.swift +++ b/Sources/MCP/Base/Value.swift @@ -2,13 +2,15 @@ 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. Decode the string explicitly when binary data is expected. case data(mimeType: String? = nil, Data) case array([Value]) case object([String: Value]) @@ -95,13 +97,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/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) + } +}