Skip to content

Added support for macOS 12 #60

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 2 additions & 2 deletions Sources/MCP/Base/Transports/StdioTransport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Sources/MCP/Client/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
96 changes: 64 additions & 32 deletions Sources/MCP/Extensions/Data+Extensions.swift
Original file line number Diff line number Diff line change
@@ -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)>
{
Expand All @@ -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..<string.endIndex, in: string)
return regex.firstMatch(in: string, options: [], range: range) != nil
}

/// Parses a data URL string into its MIME type and data components.
///
/// - Parameter string: The data URL string to parse.
/// - Returns: A tuple containing the MIME type and decoded data, or `nil` if parsing fails.
/// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html)
public static func parseDataURL(_ string: String) -> (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.endIndex, in: string)
guard let match = regex.firstMatch(in: string, options: [], range: range) else { return nil }

let nsString = string as NSString
let mediatype = nsString.substring(with: match.range(at: 1))
let charset: String? = match.range(at: 2).location != NSNotFound
? nsString.substring(with: match.range(at: 2))
: nil
let encodedData = nsString.substring(with: match.range(at: 3))

let isBase64 = string.contains(";base64,")

// Process MIME type
var mimeType = mediatype.isEmpty ? "text/plain" : String(mediatype)
if let charset = charset, !charset.isEmpty, mimeType.starts(with: "text/") {
var mimeType = mediatype.isEmpty ? "text/plain" : mediatype
if let charset = charset, !charset.isEmpty, mimeType.hasPrefix("text/") {
mimeType += ";charset=\(charset)"
}

// Decode data

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)
guard let percentDecodedData = encodedData.removingPercentEncoding?
.data(using: .utf8)
else { return nil }
decodedData = percentDecodedData
}

return (mimeType: mimeType, data: decodedData)
}

/// 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.
/// - Returns: A data URL string representation of the data.
/// - SeeAlso: [RFC 2397](https://www.rfc-editor.org/rfc/rfc2397.html)
public func dataURLEncoded(mimeType: String? = nil) -> String {
let base64Data = self.base64EncodedString()
return "data:\(mimeType ?? "text/plain");base64,\(base64Data)"
Expand Down
2 changes: 1 addition & 1 deletion Sources/MCP/Server/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
195 changes: 195 additions & 0 deletions Tests/MCPTests/DataExtensionsTests.swift
Original file line number Diff line number Diff line change
@@ -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,<h1>Hello%2C%20World!</h1>",
"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,<h1>Hello%2C%20World!</h1>"
let result = Data.parseDataURL(url)

XCTAssertNotNil(result)
XCTAssertEqual(result?.mimeType, "text/html;charset=UTF-8")
XCTAssertEqual(String(data: result?.data ?? Data(), encoding: .utf8), "<h1>Hello, World!</h1>")
}

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"),
("<html><body>Test</body></html>", "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)")
}
}
}