diff --git a/Package.swift b/Package.swift index 270b045..f89b5b3 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "mcp-swift-sdk", platforms: [ - .macOS("13.0"), + .macOS("12.0"), .macCatalyst("16.0"), .iOS("16.0"), .watchOS("9.0"), diff --git a/Sources/MCP/Base/Transports/StdioTransport.swift b/Sources/MCP/Base/Transports/StdioTransport.swift index 3808aad..9d99bb2 100644 --- a/Sources/MCP/Base/Transports/StdioTransport.swift +++ b/Sources/MCP/Base/Transports/StdioTransport.swift @@ -117,7 +117,7 @@ import struct Foundation.Data } } } catch let error where MCPError.isResourceTemporarilyUnavailable(error) { - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10 * 1_000_000) continue } catch { if !Task.isCancelled { @@ -163,7 +163,7 @@ import struct Foundation.Data remaining = remaining.dropFirst(written) } } catch let error where MCPError.isResourceTemporarilyUnavailable(error) { - try await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10 * 1_000_000) continue } catch { throw MCPError.transportError(error) diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift index f6ec77a..c2d8f69 100644 --- a/Sources/MCP/Client/Client.swift +++ b/Sources/MCP/Client/Client.swift @@ -209,7 +209,7 @@ public actor Client { } } } catch let error where MCPError.isResourceTemporarilyUnavailable(error) { - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10 * 1_000_000) continue } catch { await logger?.error( diff --git a/Sources/MCP/Extensions/Data+Extensions.swift b/Sources/MCP/Extensions/Data+Extensions.swift index 745b4a3..7eddc9c 100644 --- a/Sources/MCP/Extensions/Data+Extensions.swift +++ b/Sources/MCP/Extensions/Data+Extensions.swift @@ -1,8 +1,11 @@ import Foundation +#if canImport(RegexBuilder) import RegexBuilder +#endif extension Data { - /// Regex pattern for data URLs + // macOS 13+ implementation using RegexBuilder. + @available(macOS 13, *) @inline(__always) private static var dataURLRegex: Regex<(Substring, Substring, Substring?, Substring)> { @@ -24,62 +27,91 @@ extension Data { Optionally { ";base64" } "," Capture { - ZeroOrMore { .any } + OneOrMore { .any } } } } - + /// Checks if a given string is a valid data URL. - /// - /// - Parameter string: The string to check. - /// - Returns: `true` if the string is a valid data URL, otherwise `false`. - /// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html) public static func isDataURL(string: String) -> Bool { - return string.wholeMatch(of: dataURLRegex) != nil + if #available(macOS 13, *) { + return string.wholeMatch(of: dataURLRegex) != nil + } else { + return _isDataURL_legacy(string: string) + } } + public static func _isDataURL_legacy(string: String) -> Bool { + let pattern = "^data:([^,;]*)(?:;charset=([^,;]+))?(?:;base64)?,(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return false } + let range = NSRange(string.startIndex.. (mimeType: String, data: Data)? { - guard let match = string.wholeMatch(of: dataURLRegex) else { - return nil + if #available(macOS 13, *) { + guard let match = string.wholeMatch(of: dataURLRegex) else { + return nil + } + let (_, mediatype, charset, encodedData) = match.output + let isBase64 = string.contains(";base64,") + + var mimeType = mediatype.isEmpty ? "text/plain" : String(mediatype) + if let charset = charset, !charset.isEmpty, mimeType.starts(with: "text/") { + mimeType += ";charset=\(charset)" + } + + let decodedData: Data + if isBase64 { + 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 } + decodedData = percentDecodedData + } + return (mimeType: mimeType, data: decodedData) + } else { + return _parseDataURL_legacy(string) } + } - // Extract components using strongly typed captures - let (_, mediatype, charset, encodedData) = match.output - + public static func _parseDataURL_legacy(_ string: String) -> (mimeType: String, data: Data)? { + let pattern = "^data:([^,;]*)(?:;charset=([^,;]+))?(?:;base64)?,(.+)$" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return nil } + let range = NSRange(string.startIndex.. String { let base64Data = self.base64EncodedString() return "data:\(mimeType ?? "text/plain");base64,\(base64Data)" diff --git a/Sources/MCP/Server/Server.swift b/Sources/MCP/Server/Server.swift index 9d348d2..c938152 100644 --- a/Sources/MCP/Server/Server.swift +++ b/Sources/MCP/Server/Server.swift @@ -201,7 +201,7 @@ public actor Server { } } catch let error where MCPError.isResourceTemporarilyUnavailable(error) { // Resource temporarily unavailable, retry after a short delay - try? await Task.sleep(for: .milliseconds(10)) + try? await Task.sleep(nanoseconds: 10 * 1_000_000) continue } catch { await logger?.error( diff --git a/Tests/MCPTests/DataExtensionsTests.swift b/Tests/MCPTests/DataExtensionsTests.swift new file mode 100644 index 0000000..4ecd4d5 --- /dev/null +++ b/Tests/MCPTests/DataExtensionsTests.swift @@ -0,0 +1,195 @@ +import XCTest +@testable import MCP + +final class DataExtensionsTests: XCTestCase { + + let validURLs = [ + "data:,Hello%2C%20World!", + "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==", + "data:text/html;charset=UTF-8,

Hello%2C%20World!

", + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA", // Minimal valid PNG + "data:text/plain;charset=UTF-8;base64,SGVsbG8sIFdvcmxkIQ==", + "data:application/json;base64,eyJrZXkiOiAidmFsdWUifQ==" // {"key": "value"} + ] + + let invalidURLs = [ + "", + "http://example.com", + "data:", // Missing comma and data + "data:text/plain", // Missing comma and data + "data:text/plain;base64", // Missing comma and data + "data:text/plain,", // Missing data + "data:;base64,SGVsbG8sIFdvcmxkIQ==", // Missing mime type (allowed, defaults to text/plain) + ] + + // MARK: - Data URL Validation Tests + + func testIsDataURL() { + for url in validURLs { + XCTAssertTrue(Data.isDataURL(string: url), "Should be a valid data URL: \(url)") + } + + for url in invalidURLs { + // Special case: "data:;base64,SGVsbG8sIFdvcmxkIQ==" *is* valid for parsing, + // but our current regex requires a non-empty mediatype if ';base64' is present. + // Let's adjust the expectation for this specific case if needed based on desired behavior. + // For now, assuming the current regex logic is the desired validation. + if url == "data:;base64,SGVsbG8sIFdvcmxkIQ==" { + // This might be considered valid by some parsers, but fails our regex. + // If strict validation against the regex is intended, this is correct. + XCTAssertTrue(Data.isDataURL(string: url), "Should be a valid data URL (allows empty mediatype): \(url)") + } else if url == "data:;base64,invalid-base64!" { + // This is invalid because the base64 content itself is bad, though the structure might pass regex. + // isDataURL only checks structure, not content validity. + XCTAssertTrue(Data.isDataURL(string: url), "Should be structurally valid (base64 content ignored by isDataURL): \(url)") + } else { + XCTAssertFalse(Data.isDataURL(string: url), "Should be an invalid data URL: \(url)") + } + } + } + + func testIsDataURLLegacy() { + for url in validURLs { + XCTAssertTrue(Data._isDataURL_legacy(string: url), "Should be a valid data URL: \(url)") + } + + for url in invalidURLs { + // Special case: "data:;base64,SGVsbG8sIFdvcmxkIQ==" *is* valid for parsing, + // but our current regex requires a non-empty mediatype if ';base64' is present. + // Let's adjust the expectation for this specific case if needed based on desired behavior. + // For now, assuming the current regex logic is the desired validation. + if url == "data:;base64,SGVsbG8sIFdvcmxkIQ==" { + // This might be considered valid by some parsers, but fails our regex. + // If strict validation against the regex is intended, this is correct. + XCTAssertTrue(Data._isDataURL_legacy(string: url), "Should be a valid data URL (allows empty mediatype): \(url)") + } else if url == "data:;base64,invalid-base64!" { + // This is invalid because the base64 content itself is bad, though the structure might pass regex. + // isDataURL only checks structure, not content validity. + XCTAssertTrue(Data._isDataURL_legacy(string: url), "Should be structurally valid (base64 content ignored by isDataURL): \(url)") + } else { + XCTAssertFalse(Data._isDataURL_legacy(string: url), "Should be an invalid data URL: \(url)") + } + } + } + + // MARK: - Data URL Parsing Tests + + func testParseTextPlainDataURL() { + let url = "data:,Hello%2C%20World!" + let result = Data.parseDataURL(url) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.mimeType, "text/plain") + XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), "Hello, World!") + } + + func testParseBase64DataURL() { + let url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==" + let result = Data.parseDataURL(url) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.mimeType, "text/plain") + XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), "Hello, World!") + } + + func testParseDataURLWithCharset() { + let url = "data:text/html;charset=UTF-8,

Hello%2C%20World!

" + let result = Data.parseDataURL(url) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.mimeType, "text/html;charset=UTF-8") + XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), "

Hello, World!

") + } + + func testParseDataURLWithOnlyBase64() { + // Test case where mediatype is empty but base64 is specified + let url = "data:;base64,SGVsbG8sIFdvcmxkIQ==" + let result = Data.parseDataURL(url) + + XCTAssertNotNil(result) + XCTAssertEqual(result?.mimeType, "text/plain") // Defaults to text/plain + XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), "Hello, World!") + } + + func testParseInvalidDataURLsForParsing() { + let urlsToTest = [ + "", + "http://example.com", + "data:", + "data:text/plain", + "data:text/plain;base64", + "data:text/plain,", + "data:text/plain;base64,invalid-base64!" // Invalid base64 should fail parsing + ] + + for url in urlsToTest { + XCTAssertNil(Data.parseDataURL(url), "Should return nil for invalid data URL during parsing: \(url)") + } + } + + func testParseInvalidDataURLsForParsingLegacy() { + let urlsToTest = [ + "", + "http://example.com", + "data:", + "data:text/plain", + "data:text/plain;base64", + "data:text/plain,", + "data:text/plain;base64,invalid-base64!" // Invalid base64 should fail parsing + ] + + for url in urlsToTest { + XCTAssertNil(Data._parseDataURL_legacy(url), "Should return nil for invalid data URL during parsing: \(url)") + } + } +} + +final class DataExtensionsTests_Encoding: XCTestCase { + + // MARK: - Data URL Encoding Tests (Common) + + func testDataURLEncoding() { + let originalText = "Hello, World!" + let data = originalText.data(using: .utf8)! + + // Test with default MIME type + let defaultEncodedURL = data.dataURLEncoded() + XCTAssertTrue(Data.isDataURL(string: defaultEncodedURL)) // Use public API for checking + let defaultResult = Data.parseDataURL(defaultEncodedURL) // Use public API for parsing + XCTAssertNotNil(defaultResult) + XCTAssertEqual(defaultResult?.mimeType, "text/plain") + XCTAssertEqual(String(data: defaultResult?.data ?? Data(), encoding: .utf8), originalText) + + // Test with custom MIME type + let customEncodedURL = data.dataURLEncoded(mimeType: "application/octet-stream") + XCTAssertTrue(Data.isDataURL(string: customEncodedURL)) + let customResult = Data.parseDataURL(customEncodedURL) + XCTAssertNotNil(customResult) + XCTAssertEqual(customResult?.mimeType, "application/octet-stream") + XCTAssertEqual(String(data: customResult?.data ?? Data(), encoding: .utf8), originalText) + } + + func testRoundTripEncoding() { + let testCases = [ + ("Hello, World!", "text/plain"), + ("{ \"key\": \"value\" }", "application/json"), + ("Test", "text/html"), + ("12345", "text/plain") + ] + + for (text, mimeType) in testCases { + let originalData = text.data(using: .utf8)! + let encodedURL = originalData.dataURLEncoded(mimeType: mimeType) + + // Verify structure first + XCTAssertTrue(Data.isDataURL(string: encodedURL), "Encoded URL should be valid: \(encodedURL)") + + // Verify parsing and content + let result = Data.parseDataURL(encodedURL) + XCTAssertNotNil(result, "Parsing encoded URL should succeed: \(encodedURL)") + XCTAssertEqual(result?.mimeType, mimeType, "MIME type mismatch for: \(encodedURL)") + XCTAssertEqual(result?.data, originalData, "Data mismatch for: \(encodedURL)") + XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), text, "Decoded string mismatch for: \(encodedURL)") + } + } +}