From ae93a099ee1b7ad7704b7d40d074294ed3cd9f60 Mon Sep 17 00:00:00 2001 From: Akhil Date: Wed, 21 Oct 2026 22:46:25 -0700 Subject: [PATCH 1/2] fix: decode any percent-encoded byte in non-base64 data URLs Non-base64 payloads were percent-decoded through a String round trip, which rejected bytes that are not valid UTF-8 (e.g. %FF). Escapes are now decoded directly into bytes, as RFC 2397 allows any octet. --- Sources/MCP/Extensions/Data+Extensions.swift | 38 +++++++++++++++++--- Tests/MCPTests/Base64DataTests.swift | 10 ++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Sources/MCP/Extensions/Data+Extensions.swift b/Sources/MCP/Extensions/Data+Extensions.swift index 745b4a30..081314d2 100644 --- a/Sources/MCP/Extensions/Data+Extensions.swift +++ b/Sources/MCP/Extensions/Data+Extensions.swift @@ -65,16 +65,46 @@ extension Data { guard let base64Data = Data(base64Encoded: String(encodedData)) else { return nil } decodedData = base64Data } else { - guard - let percentDecodedData = String(encodedData).removingPercentEncoding?.data( - using: .utf8) - else { return nil } + guard let percentDecodedData = percentDecoded(encodedData) else { return nil } decodedData = percentDecodedData } return (mimeType: mimeType, data: decodedData) } + /// Decodes `%XX` escapes directly into bytes, so any byte value is accepted. + /// + /// - Parameter string: The percent-encoded string. + /// - Returns: The decoded bytes, or `nil` if an escape is malformed. + private static func percentDecoded(_ string: Substring) -> Data? { + let utf8 = Array(string.utf8) + var bytes = Data(capacity: utf8.count) + var index = 0 + while index < utf8.count { + guard utf8[index] == UInt8(ascii: "%") else { + bytes.append(utf8[index]) + index += 1 + continue + } + guard index + 2 < utf8.count, + let high = hexValue(utf8[index + 1]), + let low = hexValue(utf8[index + 2]) + else { return nil } + bytes.append(high << 4 | low) + index += 3 + } + return bytes + } + + private static func hexValue(_ byte: UInt8) -> UInt8? { + switch byte { + case UInt8(ascii: "0")...UInt8(ascii: "9"): return byte - UInt8(ascii: "0") + case UInt8(ascii: "a")...UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A")...UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10 + default: return nil + } + } + /// Encodes the data as a data URL string with an optional MIME type. /// /// - Parameter mimeType: The MIME type of the data. If `nil`, "text/plain" will be used. diff --git a/Tests/MCPTests/Base64DataTests.swift b/Tests/MCPTests/Base64DataTests.swift index 96025935..4883de1b 100644 --- a/Tests/MCPTests/Base64DataTests.swift +++ b/Tests/MCPTests/Base64DataTests.swift @@ -88,6 +88,16 @@ struct Base64DataTests { } } + @Test("Parse percent-encoded bytes that are not valid UTF-8") + func testParseDataURLNonUTF8PercentEncoding() { + let result = Data.parseDataURL("data:application/octet-stream,%FF%00") + #expect(result != nil) + if let (mimeType, data) = result { + #expect(mimeType == "application/octet-stream") + #expect(data == Data([0xFF, 0x00])) + } + } + @Test("Parse invalid data URLs") func testParseInvalidDataURLs() { // Not a data URL From d893aef9b2c08b20fd9c1739485101ce7af98f31 Mon Sep 17 00:00:00 2001 From: Akhil Date: Wed, 21 Oct 2026 23:03:27 -0700 Subject: [PATCH 2/2] test: cover the RFC 2397 charset example and malformed escapes --- Sources/MCP/Extensions/Data+Extensions.swift | 18 +++++-------- Tests/MCPTests/Base64DataTests.swift | 27 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/Sources/MCP/Extensions/Data+Extensions.swift b/Sources/MCP/Extensions/Data+Extensions.swift index 081314d2..e75f589a 100644 --- a/Sources/MCP/Extensions/Data+Extensions.swift +++ b/Sources/MCP/Extensions/Data+Extensions.swift @@ -77,21 +77,17 @@ extension Data { /// - Parameter string: The percent-encoded string. /// - Returns: The decoded bytes, or `nil` if an escape is malformed. private static func percentDecoded(_ string: Substring) -> Data? { - let utf8 = Array(string.utf8) - var bytes = Data(capacity: utf8.count) - var index = 0 - while index < utf8.count { - guard utf8[index] == UInt8(ascii: "%") else { - bytes.append(utf8[index]) - index += 1 + var bytes = Data(capacity: string.utf8.count) + var iterator = string.utf8.makeIterator() + while let byte = iterator.next() { + guard byte == UInt8(ascii: "%") else { + bytes.append(byte) continue } - guard index + 2 < utf8.count, - let high = hexValue(utf8[index + 1]), - let low = hexValue(utf8[index + 2]) + guard let high = iterator.next().flatMap(hexValue), + let low = iterator.next().flatMap(hexValue) else { return nil } bytes.append(high << 4 | low) - index += 3 } return bytes } diff --git a/Tests/MCPTests/Base64DataTests.swift b/Tests/MCPTests/Base64DataTests.swift index 4883de1b..c80b8f5b 100644 --- a/Tests/MCPTests/Base64DataTests.swift +++ b/Tests/MCPTests/Base64DataTests.swift @@ -98,6 +98,33 @@ struct Base64DataTests { } } + @Test("Parse RFC 2397 example with non-UTF-8 charset") + func testParseDataURLRFCCharsetExample() { + // Example from RFC 2397: ISO-8859-7 bytes that are not valid UTF-8 + let result = Data.parseDataURL("data:text/plain;charset=iso-8859-7,%be%d3%be") + #expect(result != nil) + if let (mimeType, data) = result { + #expect(mimeType == "text/plain;charset=iso-8859-7") + #expect(data == Data([0xBE, 0xD3, 0xBE])) + } + } + + @Test("Parse data URL with malformed percent escapes") + func testParseDataURLMalformedPercentEscapes() { + // Truncated escape at end of payload + #expect(Data.parseDataURL("data:,%") == nil) + #expect(Data.parseDataURL("data:,%F") == nil) + + // Non-hex digit + #expect(Data.parseDataURL("data:,%fg") == nil) + } + + @Test("Parse data URL preserves literal plus sign") + func testParseDataURLPreservesPlus() { + // RFC 2397 uses URL percent-encoding, not form encoding, so `+` is not a space + #expect(Data.parseDataURL("data:,a+b%2B")?.data == Data("a+b+".utf8)) + } + @Test("Parse invalid data URLs") func testParseInvalidDataURLs() { // Not a data URL