diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..baab102 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["swiftlang.swift-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..757f266 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "swiftlang.swift-vscode", + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e37fe81 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Loopwork Limited + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..eaee8ae --- /dev/null +++ b/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "cce36cb33302c2f1c28458e19b8439f736fc28106e4c6ea95d7992c74594c242", + "pins" : [ + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", + "version" : "1.6.2" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "c8a44d836fe7913603e246acab7c528c2e780168", + "version" : "1.4.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 7a3e270..daf946c 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,26 +6,34 @@ import PackageDescription let package = Package( name: "mcp-swift-sdk", platforms: [ - .macOS(.v12), - .iOS(.v15), - .tvOS(.v15), - .watchOS(.v8) + .macOS(.v14), + .iOS(.v17), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "MCP", - targets: ["MCP"]), + targets: ["MCP"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-system.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ], - dependencies: [], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. .target( name: "MCP", - dependencies: []), + dependencies: [ + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Logging", package: "swift-log"), + ]), .testTarget( name: "MCPTests", - dependencies: ["MCP"]), + dependencies: [ + "MCP", + .product(name: "SystemPackage", package: "swift-system"), + .product(name: "Logging", package: "swift-log"), + ]), ] ) diff --git a/README.md b/README.md index ccb738d..bc1aaba 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,12 @@ # MCP Swift SDK -A Swift implementation of the Model Context Protocol (MCP) client. This SDK provides a modern, Swift-native way to interact with MCP servers. - -## Features - -- Full async/await support -- Type-safe API -- Resource management -- Tool integration -- Configurable transport layer -- Swift concurrency with actor-based design +Swift implementation of the [Model Context Protocol][mcp] (MCP). ## Requirements -- Swift 5.7+ -- macOS 12.0+ -- iOS 15.0+ -- tvOS 15.0+ -- watchOS 8.0+ +- Swift 6.0+ / Xcode 16+ +- macOS 14.0+ (Sonoma) +- iOS 17.0+ ## Installation @@ -27,45 +16,65 @@ Add the following to your `Package.swift` file: ```swift dependencies: [ - .package(url: "https://github.com/yourusername/mcp-swift-sdk.git", from: "1.0.0") + .package(url: "https://github.com/loopwork-ai/mcp-swift-sdk.git", from: "0.1.0") ] ``` ## Usage -### Basic Setup +### Basic Client Setup ```swift import MCP -// Create a transport (e.g., StdioTransport) -let transport = StdioTransport(command: "/path/to/server") - -// Create client info -let clientInfo = Implementation(name: "MyApp", version: "1.0.0") - // Initialize the client -let client = MCPClient(transport: transport, clientInfo: clientInfo) +let client = Client(name: "MyApp", version: "1.0.0") -// Connect and initialize -try await client.connect() +// Create a transport and connect +let transport = StdioTransport() +try await client.connect(transport: transport) + +// Initialize the connection let result = try await client.initialize() ``` -### Working with Resources +### Basic Server Setup ```swift -// List available resources -let resources = try await client.listResources() +import MCP -// Read a resource -let resource = try await client.readResource("resource://example") +// Initialize the server with capabilities +let server = Server( + name: "MyServer", + version: "1.0.0", + capabilities: .init( + resources: .init( + list: true, + read: true, + subscribe: true + ) + ) +) -// Subscribe to resource updates -try await client.subscribeResource("resource://example") +// Create transport and start server +let transport = StdioTransport() +try await server.start(transport: transport) -// Unsubscribe from resource updates -try await client.unsubscribeResource("resource://example") +// Register method handlers +server.withMethodHandler(ReadResource.self) { params in + // Handle resource read request + let uri = params.uri + let content = [Resource.Content.text("Example content")] + return .init(contents: content) +} + +// Register notification handlers +server.onNotification(ResourceUpdatedNotification.self) { message in + // Handle resource update notification +} + +// Stop the server when done +await server.stop() ``` ### Working with Tools @@ -75,41 +84,57 @@ try await client.unsubscribeResource("resource://example") let tools = try await client.listTools() // Call a tool -let result = try await client.callTool(name: "example-tool", arguments: ["key": "value"]) -``` +let (content, isError) = try await client.callTool( + name: "example-tool", + arguments: ["key": "value"] +) -### Custom Transport - -You can create your own transport by implementing the `MCPTransport` protocol: - -```swift -public protocol MCPTransport { - func connect() async throws - func disconnect() async - func sendRequest(_ data: Data) async throws -> Data - func sendNotification(_ data: Data) async throws +// Handle tool content +for item in content { + switch item { + case .text(let text): + print(text) + case .image(let data, let mimeType, let metadata): + // Handle image data + } } ``` -## Error Handling - -The SDK uses the `MCPError` type for error handling: +### Working with Resources ```swift -public enum MCPError: LocalizedError { - case connectionClosed - case invalidResponse - case invalidParams(String) - case serverError(String) - case protocolError(String) - case transportError(Error) +// List available resources +let (resources, nextCursor) = try await client.listResources() + +// Read a resource +let contents = try await client.readResource(uri: "resource://example") + +// Subscribe to resource updates +try await client.subscribeToResource(uri: "resource://example") + +// Handle resource updates +await client.onNotification(ResourceUpdatedNotification.self) { message in + let uri = message.params.uri + let content = message.params.content + // Handle the update } ``` -## Contributing +### Working with Prompts -Contributions are welcome! Please feel free to submit a Pull Request. +```swift +// List available prompts +let (prompts, nextCursor) = try await client.listPrompts() + +// Get a prompt with arguments +let (description, messages) = try await client.getPrompt( + name: "example-prompt", + arguments: ["key": "value"] +) +``` ## License -This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file +This project is licensed under the Apache License, Version 2.0. + +[mcp]: https://modelcontextprotocol.io \ No newline at end of file diff --git a/Sources/MCP/Base/Error.swift b/Sources/MCP/Base/Error.swift new file mode 100644 index 0000000..0e9a705 --- /dev/null +++ b/Sources/MCP/Base/Error.swift @@ -0,0 +1,212 @@ +import Foundation + +/// A model context protocol error. +public enum Error: Sendable { + // Standard JSON-RPC 2.0 errors (-32700 to -32603) + case parseError(String?) // -32700 + case invalidRequest(String?) // -32600 + case methodNotFound(String?) // -32601 + case invalidParams(String?) // -32602 + case internalError(String?) // -32603 + + // Server errors (-32000 to -32099) + case serverError(code: Int, message: String) + + // Transport specific errors + case connectionClosed + case transportError(Swift.Error) + + /// The JSON-RPC 2.0 error code + public var code: Int { + switch self { + case .parseError: return -32700 + case .invalidRequest: return -32600 + case .methodNotFound: return -32601 + case .invalidParams: return -32602 + case .internalError: return -32603 + case .serverError(let code, _): return code + case .connectionClosed: return -32000 + case .transportError: return -32001 + } + } +} + +// MARK: LocalizedError + +extension Error: LocalizedError { + public var errorDescription: String? { + switch self { + case .parseError(let detail): + return "Parse error: Invalid JSON" + (detail.map { ": \($0)" } ?? "") + case .invalidRequest(let detail): + return "Invalid Request" + (detail.map { ": \($0)" } ?? "") + case .methodNotFound(let detail): + return "Method not found" + (detail.map { ": \($0)" } ?? "") + case .invalidParams(let detail): + return "Invalid params" + (detail.map { ": \($0)" } ?? "") + case .internalError(let detail): + return "Internal error" + (detail.map { ": \($0)" } ?? "") + case .serverError(_, let message): + return "Server error: \(message)" + case .connectionClosed: + return "Connection closed" + case .transportError(let error): + return "Transport error: \(error.localizedDescription)" + } + } + + public var failureReason: String? { + switch self { + case .parseError: + return "The server received invalid JSON that could not be parsed" + case .invalidRequest: + return "The JSON sent is not a valid Request object" + case .methodNotFound: + return "The method does not exist or is not available" + case .invalidParams: + return "Invalid method parameter(s)" + case .internalError: + return "Internal JSON-RPC error" + case .serverError: + return "Server-defined error occurred" + case .connectionClosed: + return "The connection to the server was closed" + case .transportError(let error): + return (error as? LocalizedError)?.failureReason ?? error.localizedDescription + } + } + + public var recoverySuggestion: String? { + switch self { + case .parseError: + return "Verify that the JSON being sent is valid and well-formed" + case .invalidRequest: + return "Ensure the request follows the JSON-RPC 2.0 specification format" + case .methodNotFound: + return "Check the method name and ensure it is supported by the server" + case .invalidParams: + return "Verify the parameters match the method's expected parameters" + case .connectionClosed: + return "Try reconnecting to the server" + default: + return nil + } + } +} + +// MARK: CustomDebugStringConvertible + +extension Error: CustomDebugStringConvertible { + public var debugDescription: String { + switch self { + case .transportError(let error): + return + "[\(code)] \(errorDescription ?? "") (Underlying error: \(String(reflecting: error)))" + default: + return "[\(code)] \(errorDescription ?? "")" + } + } + +} + +// MARK: Codable + +extension Error: Codable { + private enum CodingKeys: String, CodingKey { + case code, message, data + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(errorDescription ?? "Unknown error", forKey: .message) + + // Encode additional data if available + switch self { + case .parseError(let detail), + .invalidRequest(let detail), + .methodNotFound(let detail), + .invalidParams(let detail), + .internalError(let detail): + if let detail = detail { + try container.encode(["detail": detail], forKey: .data) + } + case .serverError(_, _): + // No additional data for server errors + break + case .connectionClosed: + break + case .transportError(let error): + try container.encode( + ["error": error.localizedDescription], + forKey: .data + ) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let code = try container.decode(Int.self, forKey: .code) + let message = try container.decode(String.self, forKey: .message) + let data = try container.decodeIfPresent([String: Value].self, forKey: .data) + + switch code { + case -32700: + self = .parseError(data?["detail"] as? String ?? message) + case -32600: + self = .invalidRequest(data?["detail"] as? String ?? message) + case -32601: + self = .methodNotFound(data?["detail"] as? String ?? message) + case -32602: + self = .invalidParams(data?["detail"] as? String ?? message) + case -32603: + self = .internalError(data?["detail"] as? String ?? message) + case -32000: + self = .connectionClosed + case -32001: + self = .transportError( + NSError( + domain: "org.jsonrpc.error", + code: code, + userInfo: [NSLocalizedDescriptionKey: message] + ) + ) + default: + self = .serverError(code: code, message: message) + } + } +} + +// MARK: Equatable + +extension Error: Equatable { + public static func == (lhs: Error, rhs: Error) -> Bool { + lhs.code == rhs.code + } +} + +// MARK: Hashable + +extension Error: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(code) + switch self { + case .parseError(let detail): + hasher.combine(detail) + case .invalidRequest(let detail): + hasher.combine(detail) + case .methodNotFound(let detail): + hasher.combine(detail) + case .invalidParams(let detail): + hasher.combine(detail) + case .internalError(let detail): + hasher.combine(detail) + case .serverError(_, let message): + hasher.combine(message) + case .connectionClosed: + break + case .transportError(let error): + hasher.combine(error.localizedDescription) + } + } +} diff --git a/Sources/MCP/Base/ID.swift b/Sources/MCP/Base/ID.swift new file mode 100644 index 0000000..271b615 --- /dev/null +++ b/Sources/MCP/Base/ID.swift @@ -0,0 +1,69 @@ +import struct Foundation.UUID + +/// A unique identifier for a request. +public enum ID: Hashable, Sendable { + /// A string ID. + case string(String) + + /// A number ID. + case number(Int) + + /// Generates a random string ID. + public static var random: ID { + return .string(UUID().uuidString) + } +} + +// MARK: - ExpressibleByStringLiteral + +extension ID: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +// MARK: - ExpressibleByIntegerLiteral + +extension ID: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(value) + } +} + +// MARK: - CustomStringConvertible + +extension ID: CustomStringConvertible { + public var description: String { + switch self { + case .string(let str): return str + case .number(let num): return String(num) + } + } +} + +// MARK: - Codable + +extension ID: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .string(string) + } else if let number = try? container.decode(Int.self) { + self = .number(number) + } else if container.decodeNil() { + // Handle unspecified/null IDs as empty string + self = .string("") + } else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "ID must be string or number") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let str): try container.encode(str) + case .number(let num): try container.encode(num) + } + } +} diff --git a/Sources/MCP/Base/Lifecycle.swift b/Sources/MCP/Base/Lifecycle.swift new file mode 100644 index 0000000..7d3e711 --- /dev/null +++ b/Sources/MCP/Base/Lifecycle.swift @@ -0,0 +1,56 @@ +/// The initialization phase MUST be the first interaction between client and server. +/// During this phase, the client and server: +/// - Establish protocol version compatibility +/// - Exchange and negotiate capabilities +/// - Share implementation details +/// +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/#initialization +public enum Initialize: Method { + public static let name: String = "initialize" + + public struct Parameters: Hashable, Codable, Sendable { + public let protocolVersion: String + public let capabilities: Client.Capabilities + public let clientInfo: Client.Info + + public init( + protocolVersion: String = Version.latest, + capabilities: Client.Capabilities, + clientInfo: Client.Info + ) { + self.protocolVersion = protocolVersion + self.capabilities = capabilities + self.clientInfo = clientInfo + } + + private enum CodingKeys: String, CodingKey { + case protocolVersion, capabilities, clientInfo + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + protocolVersion = + try container.decodeIfPresent(String.self, forKey: .protocolVersion) + ?? Version.latest + capabilities = + try container.decodeIfPresent(Client.Capabilities.self, forKey: .capabilities) + ?? .init() + clientInfo = + try container.decodeIfPresent(Client.Info.self, forKey: .clientInfo) + ?? .init(name: "unknown", version: "0.0.0") + } + } + + public struct Result: Hashable, Codable, Sendable { + public let protocolVersion: String + public let capabilities: Server.Capabilities + public let serverInfo: Server.Info + public let instructions: String? + } +} + +/// After successful initialization, the client MUST send an initialized notification to indicate it is ready to begin normal operations. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/lifecycle/#initialization +public struct InitializedNotification: Notification { + public static let name: String = "notifications/initialized" +} diff --git a/Sources/MCP/Base/Messages.swift b/Sources/MCP/Base/Messages.swift new file mode 100644 index 0000000..8214a70 --- /dev/null +++ b/Sources/MCP/Base/Messages.swift @@ -0,0 +1,332 @@ +import Foundation + +private let jsonrpc = "2.0" + +public struct Empty: Hashable, Codable, Sendable {} + +// MARK: - + +/// A method that can be used to send requests and receive responses. +public protocol Method { + /// The parameters of the method. + associatedtype Parameters: Codable, Hashable, Sendable = Empty + /// The result of the method. + associatedtype Result: Codable, Hashable, Sendable = Empty + /// The name of the method. + static var name: String { get } +} + +/// Type-erased method for request/response handling +struct AnyMethod: Method { + static var name: String { "" } + typealias Parameters = Value + typealias Result = Value +} + +extension Method where Parameters == Empty { + public static func request(id: ID = .random) -> Request { + Request(id: id, method: name, params: Empty()) + } +} + +extension Method where Result == Empty { + public static func response(id: ID) -> Response { + Response(id: id, result: Empty()) + } +} + +extension Method { + /// Create a request with the given parameters. + public static func request(id: ID = .random, _ parameters: Self.Parameters) -> Request { + Request(id: id, method: name, params: parameters) + } + + /// Create a response with the given result. + public static func response(id: ID, result: Self.Result) -> Response { + Response(id: id, result: result) + } + + /// Create a response with the given error. + public static func response(id: ID, error: Error) -> Response { + Response(id: id, error: error) + } +} + +// MARK: - + +/// A request message. +public struct Request: Hashable, Identifiable, Codable, Sendable { + /// The request ID. + public let id: ID + /// The method name. + public let method: String + /// The request parameters. + public let params: M.Parameters + + init(id: ID = .random, method: String, params: M.Parameters) { + self.id = id + self.method = method + self.params = params + } + + private enum CodingKeys: String, CodingKey { + case jsonrpc, id, method, params + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jsonrpc, forKey: .jsonrpc) + try container.encode(id, forKey: .id) + try container.encode(method, forKey: .method) + if M.Parameters.self != Empty.self { + try container.encode(params, forKey: .params) + } else { + // Encode empty object for Empty parameters + try container.encode(Empty(), forKey: .params) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(String.self, forKey: .jsonrpc) + guard version == jsonrpc else { + throw DecodingError.dataCorruptedError( + forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version") + } + id = try container.decode(ID.self, forKey: .id) + method = try container.decode(String.self, forKey: .method) + if M.Parameters.self == Empty.self { + if (try? container.decodeNil(forKey: .params)) != nil { + params = Empty() as! M.Parameters + } else if (try? container.decode(Empty.self, forKey: .params)) != nil { + params = Empty() as! M.Parameters + } else { + // If params field is missing, use Empty + params = Empty() as! M.Parameters + } + } else { + params = try container.decode(M.Parameters.self, forKey: .params) + } + } +} + +/// A type-erased request for request/response handling +typealias AnyRequest = Request + +/// A box for request handlers that can be type-erased +class RequestHandlerBox: @unchecked Sendable { + func callAsFunction(_ request: Request) async throws -> Response { + fatalError("Must override") + } +} + +/// A typed request handler that can be used to handle requests of a specific type +final class TypedRequestHandler: RequestHandlerBox, @unchecked Sendable { + private let _handle: @Sendable (Request) async throws -> Response + + init(_ handler: @escaping @Sendable (Request) async throws -> Response) { + self._handle = handler + super.init() + } + + override func callAsFunction(_ request: Request) async throws -> Response + { + // Create a concrete request from the type-erased one + let data = try JSONEncoder().encode(request.params) + let params = try JSONDecoder().decode(M.Parameters.self, from: data) + let typedRequest = Request(id: request.id, method: M.name, params: params) + + // Handle with concrete type + let response = try await _handle(typedRequest) + + // Convert result to AnyMethod response + switch response.result { + case .success(let result): + let resultData = try JSONEncoder().encode(result) + let resultValue = try JSONDecoder().decode(Value.self, from: resultData) + return Response(id: response.id, result: resultValue) + case .failure(let error): + return Response(id: response.id, error: error) + } + } +} + +// MARK: - + +/// A response message. +public struct Response: Hashable, Identifiable, Codable, Sendable { + /// The response ID. + public let id: ID + /// The response result. + public let result: Swift.Result + + public init(id: ID, result: M.Result) { + self.id = id + self.result = .success(result) + } + + public init(id: ID, error: Error) { + self.id = id + self.result = .failure(error) + } + + private enum CodingKeys: String, CodingKey { + case jsonrpc, id, result, error + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jsonrpc, forKey: .jsonrpc) + try container.encode(id, forKey: .id) + switch result { + case .success(let result): + try container.encode(result, forKey: .result) + case .failure(let error): + try container.encode(error, forKey: .error) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(String.self, forKey: .jsonrpc) + guard version == jsonrpc else { + throw DecodingError.dataCorruptedError( + forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version") + } + id = try container.decode(ID.self, forKey: .id) + if let result = try? container.decode(M.Result.self, forKey: .result) { + self.result = .success(result) + } else if let error = try? container.decode(Error.self, forKey: .error) { + self.result = .failure(error) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid response")) + } + } +} + +/// A type-erased response for request/response handling +typealias AnyResponse = Response + +// MARK: - + +/// A notification message. +public protocol Notification { + /// The parameters of the notification. + associatedtype Parameters: Hashable, Codable, Sendable = Empty + /// The name of the notification. + static var name: String { get } +} + +/// A type-erased notification for message handling +struct AnyNotification: Notification { + static var name: String { "" } + typealias Parameters = Empty +} + +/// A message that can be used to send notifications. +public struct Message: Hashable, Codable, Sendable { + /// The method name. + public let method: String + /// The notification parameters. + public let params: N.Parameters + + public init(method: String, params: N.Parameters) { + self.method = method + self.params = params + } + + private enum CodingKeys: String, CodingKey { + case jsonrpc, method, params + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(jsonrpc, forKey: .jsonrpc) + try container.encode(method, forKey: .method) + if N.Parameters.self != Empty.self { + try container.encode(params, forKey: .params) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let version = try container.decode(String.self, forKey: .jsonrpc) + guard version == jsonrpc else { + throw DecodingError.dataCorruptedError( + forKey: .jsonrpc, in: container, debugDescription: "Invalid JSON-RPC version") + } + method = try container.decode(String.self, forKey: .method) + + // Handle params field being optional + if N.Parameters.self == Empty.self { + // For Empty parameters, use Empty() if params is missing or null + params = Empty() as! N.Parameters + } else if let value = try? container.decode(N.Parameters.self, forKey: .params) { + // If params exists and can be decoded, use it + params = value + } else if !container.contains(.params) + || (try? container.decodeNil(forKey: .params)) == true + { + // If params is missing or explicitly null, use Empty for Empty parameters + // or throw for non-Empty parameters + if N.Parameters.self == Empty.self { + params = Empty() as! N.Parameters + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Missing required params field")) + } + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Invalid params field")) + } + } +} + +/// A type-erased message for message handling +typealias AnyMessage = Message + +extension Notification where Parameters == Empty { + /// Create a message with empty parameters. + public static func message() -> Message { + Message(method: name, params: Empty()) + } +} + +extension Notification { + /// Create a message with the given parameters. + public static func message(_ parameters: Parameters) -> Message { + Message(method: name, params: parameters) + } +} + +/// A box for notification handlers that can be type-erased +class NotificationHandlerBox: @unchecked Sendable { + func callAsFunction(_ notification: Message) async throws {} +} + +/// A typed notification handler that can be used to handle notifications of a specific type +final class TypedNotificationHandler: NotificationHandlerBox, + @unchecked Sendable +{ + private let _handle: @Sendable (Message) async throws -> Void + + init(_ handler: @escaping @Sendable (Message) async throws -> Void) { + self._handle = handler + super.init() + } + + override func callAsFunction(_ notification: Message) async throws { + // Create a concrete notification from the type-erased one + let data = try JSONEncoder().encode(notification) + let typedNotification = try JSONDecoder().decode(Message.self, from: data) + + try await _handle(typedNotification) + } +} diff --git a/Sources/MCP/Base/Transports.swift b/Sources/MCP/Base/Transports.swift new file mode 100644 index 0000000..b9a06da --- /dev/null +++ b/Sources/MCP/Base/Transports.swift @@ -0,0 +1,172 @@ +import Darwin +import Logging +import SystemPackage + +import struct Foundation.Data +import struct Foundation.Date +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +/// Protocol defining the transport layer for MCP communication +public protocol Transport: Actor { + var logger: Logger { get } + + /// Establishes connection with the transport + func connect() async throws + + /// Disconnects from the transport + func disconnect() async + + /// Sends a message string + func send(_ message: String) async throws + + /// Receives message strings as an async sequence + func receive() -> AsyncThrowingStream +} + +/// Standard input/output transport implementation +public actor StdioTransport: Transport { + private let input: FileDescriptor + private let output: FileDescriptor + public nonisolated let logger: Logger + + private var isConnected = false + private let messageStream: AsyncStream + private let messageContinuation: AsyncStream.Continuation + + public init( + input: FileDescriptor = FileDescriptor.standardInput, + output: FileDescriptor = FileDescriptor.standardOutput, + logger: Logger? = nil + ) { + self.input = input + self.output = output + self.logger = + logger + ?? Logger( + label: "mcp.transport.stdio", + factory: { _ in SwiftLogNoOpLogHandler() }) + + // Create message stream + var continuation: AsyncStream.Continuation! + self.messageStream = AsyncStream { continuation = $0 } + self.messageContinuation = continuation + } + + public func connect() async throws { + guard !isConnected else { return } + + // Set non-blocking mode + try setNonBlocking(fileDescriptor: input) + try setNonBlocking(fileDescriptor: output) + + isConnected = true + logger.info("Transport connected successfully") + + // Start reading loop in background + Task { + await readLoop() + } + } + + private func setNonBlocking(fileDescriptor: FileDescriptor) throws { + let flags = fcntl(fileDescriptor.rawValue, F_GETFL) + guard flags >= 0 else { + throw Error.transportError(Errno.badFileDescriptor) + } + let result = fcntl(fileDescriptor.rawValue, F_SETFL, flags | O_NONBLOCK) + guard result >= 0 else { + throw Error.transportError(Errno.badFileDescriptor) + } + } + + private func readLoop() async { + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + var pendingData = Data() + + while isConnected && !Task.isCancelled { + do { + let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in + try input.read(into: UnsafeMutableRawBufferPointer(pointer)) + } + + if bytesRead == 0 { + logger.notice("EOF received") + break + } + + pendingData.append(Data(buffer[.. 0 { + remaining = remaining.dropFirst(written) + } + } catch let error as Errno where error == .resourceTemporarilyUnavailable { + try await Task.sleep(nanoseconds: 10_000_000) // 10ms backoff + continue + } catch { + throw Error.transportError(error) + } + } + } + + public func receive() -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + Task { + for await message in messageStream { + continuation.yield(message) + } + continuation.finish() + } + } + } +} diff --git a/Sources/MCP/Base/Utilities/Ping.swift b/Sources/MCP/Base/Utilities/Ping.swift new file mode 100644 index 0000000..e526ad4 --- /dev/null +++ b/Sources/MCP/Base/Utilities/Ping.swift @@ -0,0 +1,5 @@ +/// The Model Context Protocol includes an optional ping mechanism that allows either party to verify that their counterpart is still responsive and the connection is alive. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/utilities/ping +public enum Ping: Method { + public static let name: String = "ping" +} diff --git a/Sources/MCP/Base/Value.swift b/Sources/MCP/Base/Value.swift new file mode 100644 index 0000000..732835b --- /dev/null +++ b/Sources/MCP/Base/Value.swift @@ -0,0 +1,402 @@ +import struct Foundation.Data +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +/// A codable value. +public enum Value: Hashable, Sendable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case data(mimeType: String? = nil, Data) + case array([Value]) + case object([String: Value]) + + /// Create a `Value` from a `Codable` value. + /// - Parameter value: The codable value + /// - Returns: A value + public init(_ value: T) throws { + if let valueAsValue = value as? Value { + self = valueAsValue + } else { + let data = try JSONEncoder().encode(value) + self = try JSONDecoder().decode(Value.self, from: data) + } + } + + /// Returns whether the value is `null`. + public var isNull: Bool { + return self == .null + } + + /// Returns the `Bool` value if the value is a `bool`, + /// otherwise returns `nil`. + public var boolValue: Bool? { + guard case let .bool(value) = self else { return nil } + return value + } + + /// Returns the `Int` value if the value is an `integer`, + /// otherwise returns `nil`. + public var intValue: Int? { + guard case let .int(value) = self else { return nil } + return value + } + + /// Returns the `Double` value if the value is a `double`, + /// otherwise returns `nil`. + public var doubleValue: Double? { + guard case let .double(value) = self else { return nil } + return value + } + + /// Returns the `String` value if the value is a `string`, + /// otherwise returns `nil`. + public var stringValue: String? { + guard case let .string(value) = self else { return nil } + return value + } + + /// Returns the data value and optional MIME type if the value is `data`, + /// otherwise returns `nil`. + public var dataValue: (mimeType: String?, Data)? { + guard case let .data(mimeType: mimeType, data) = self else { return nil } + return (mimeType: mimeType, data) + } + + /// Returns the `[Value]` value if the value is an `array`, + /// otherwise returns `nil`. + public var arrayValue: [Value]? { + guard case let .array(value) = self else { return nil } + return value + } + + /// Returns the `[String: Value]` value if the value is an `object`, + /// otherwise returns `nil`. + public var objectValue: [String: Value]? { + guard case let .object(value) = self else { return nil } + return value + } +} + +// MARK: - Codable + +extension Value: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } 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) + } + } else if let value = try? container.decode([Value].self) { + self = .array(value) + } else if let value = try? container.decode([String: Value].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "Value type not found") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case let .data(mimeType, value): + try container.encode(value.dataURLEncoded(mimeType: mimeType)) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } +} + +extension Value: CustomStringConvertible { + public var description: String { + switch self { + case .null: + return "" + case .bool(let value): + return value.description + case .int(let value): + return value.description + case .double(let value): + return value.description + case .string(let value): + return value.description + case let .data(mimeType, value): + return value.dataURLEncoded(mimeType: mimeType) + case .array(let value): + return value.description + case .object(let value): + return value.description + } + } +} + +// MARK: - ExpressibleByNilLiteral + +extension Value: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +// MARK: - ExpressibleByBooleanLiteral + +extension Value: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - ExpressibleByIntegerLiteral + +extension Value: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .int(value) + } +} + +// MARK: - ExpressibleByFloatLiteral + +extension Value: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +// MARK: - ExpressibleByStringLiteral + +extension Value: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +// MARK: - ExpressibleByArrayLiteral + +extension Value: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: Value...) { + self = .array(elements) + } +} + +// MARK: - ExpressibleByDictionaryLiteral + +extension Value: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, Value)...) { + var dictionary: [String: Value] = [:] + for (key, value) in elements { + dictionary[key] = value + } + self = .object(dictionary) + } +} + +// MARK: - ExpressibleByStringInterpolation + +extension Value: ExpressibleByStringInterpolation { + public struct StringInterpolation: StringInterpolationProtocol { + var stringValue: String + + public init(literalCapacity: Int, interpolationCount: Int) { + self.stringValue = "" + self.stringValue.reserveCapacity(literalCapacity + interpolationCount) + } + + public mutating func appendLiteral(_ literal: String) { + self.stringValue.append(literal) + } + + public mutating func appendInterpolation(_ value: T) { + self.stringValue.append(value.description) + } + } + + public init(stringInterpolation: StringInterpolation) { + self = .string(stringInterpolation.stringValue) + } +} + +// MARK: - Standard Library Type Extensions + +extension Bool { + /// Creates a boolean value from a `Value` instance. + /// + /// In strict mode, only `.bool` values are converted. In non-strict mode, the following conversions are supported: + /// - Integers: `1` is `true`, `0` is `false` + /// - Doubles: `1.0` is `true`, `0.0` is `false` + /// - Strings (lowercase only): + /// - `true`: "true", "t", "yes", "y", "on", "1" + /// - `false`: "false", "f", "no", "n", "off", "0" + /// + /// - Parameters: + /// - value: The `Value` to convert + /// - strict: When `true`, only converts from `.bool` values. Defaults to `true` + /// - Returns: A boolean value if conversion is possible, `nil` otherwise + /// + /// - Example: + /// ```swift + /// Bool(Value.bool(true)) // Returns true + /// Bool(Value.int(1), strict: false) // Returns true + /// Bool(Value.string("yes"), strict: false) // Returns true + /// ``` + public init?(_ value: Value, strict: Bool = true) { + switch value { + case .bool(let b): + self = b + case .int(let i) where !strict: + switch i { + case 0: self = false + case 1: self = true + default: return nil + } + case .double(let d) where !strict: + switch d { + case 0.0: self = false + case 1.0: self = true + default: return nil + } + case .string(let s) where !strict: + switch s { + case "true", "t", "yes", "y", "on", "1": + self = true + case "false", "f", "no", "n", "off", "0": + self = false + default: + return nil + } + default: + return nil + } + } +} + +extension Int { + /// Creates an integer value from a `Value` instance. + /// + /// In strict mode, only `.int` values are converted. In non-strict mode, the following conversions are supported: + /// - Doubles: Converted if they can be represented exactly as integers + /// - Strings: Parsed if they contain a valid integer representation + /// + /// - Parameters: + /// - value: The `Value` to convert + /// - strict: When `true`, only converts from `.int` values. Defaults to `true` + /// - Returns: An integer value if conversion is possible, `nil` otherwise + /// + /// - Example: + /// ```swift + /// Int(Value.int(42)) // Returns 42 + /// Int(Value.double(42.0), strict: false) // Returns 42 + /// Int(Value.string("42"), strict: false) // Returns 42 + /// Int(Value.double(42.5), strict: false) // Returns nil + /// ``` + public init?(_ value: Value, strict: Bool = true) { + switch value { + case .int(let i): + self = i + case .double(let d) where !strict: + guard let intValue = Int(exactly: d) else { return nil } + self = intValue + case .string(let s) where !strict: + guard let intValue = Int(s) else { return nil } + self = intValue + default: + return nil + } + } +} + +extension Double { + /// Creates a double value from a `Value` instance. + /// + /// In strict mode, converts from `.double` and `.int` values. In non-strict mode, the following conversions are supported: + /// - Integers: Converted to their double representation + /// - Strings: Parsed if they contain a valid floating-point representation + /// + /// - Parameters: + /// - value: The `Value` to convert + /// - strict: When `true`, only converts from `.double` and `.int` values. Defaults to `true` + /// - Returns: A double value if conversion is possible, `nil` otherwise + /// + /// - Example: + /// ```swift + /// Double(Value.double(42.5)) // Returns 42.5 + /// Double(Value.int(42)) // Returns 42.0 + /// Double(Value.string("42.5"), strict: false) // Returns 42.5 + /// ``` + public init?(_ value: Value, strict: Bool = true) { + switch value { + case .double(let d): + self = d + case .int(let i): + self = Double(i) + case .string(let s) where !strict: + guard let doubleValue = Double(s) else { return nil } + self = doubleValue + default: + return nil + } + } +} + +extension String { + /// Creates a string value from a `Value` instance. + /// + /// In strict mode, only `.string` values are converted. In non-strict mode, the following conversions are supported: + /// - Integers: Converted to their string representation + /// - Doubles: Converted to their string representation + /// - Booleans: Converted to "true" or "false" + /// + /// - Parameters: + /// - value: The `Value` to convert + /// - strict: When `true`, only converts from `.string` values. Defaults to `true` + /// - Returns: A string value if conversion is possible, `nil` otherwise + /// + /// - Example: + /// ```swift + /// String(Value.string("hello")) // Returns "hello" + /// String(Value.int(42), strict: false) // Returns "42" + /// String(Value.bool(true), strict: false) // Returns "true" + /// ``` + public init?(_ value: Value, strict: Bool = true) { + switch value { + case .string(let s): + self = s + case .int(let i) where !strict: + self = String(i) + case .double(let d) where !strict: + self = String(d) + case .bool(let b) where !strict: + self = String(b) + default: + return nil + } + } +} diff --git a/Sources/MCP/Base/Versioning.swift b/Sources/MCP/Base/Versioning.swift new file mode 100644 index 0000000..45c8de0 --- /dev/null +++ b/Sources/MCP/Base/Versioning.swift @@ -0,0 +1,11 @@ +import Foundation + +/// The Model Context Protocol uses string-based version identifiers +/// following the format YYYY-MM-DD, to indicate +/// the last date backwards incompatible changes were made. +/// +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/basic/versioning/ +public enum Version { + /// The current protocol version. + public static let latest = "2024-11-05" +} diff --git a/Sources/MCP/Client/Client.swift b/Sources/MCP/Client/Client.swift new file mode 100644 index 0000000..dc45f4e --- /dev/null +++ b/Sources/MCP/Client/Client.swift @@ -0,0 +1,377 @@ +import Logging +import SystemPackage + +import struct Foundation.Data +import struct Foundation.Date +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +/// Model Context Protocol client +public actor Client { + /// Implementation information + public struct Info: Hashable, Codable, Sendable { + /// The client name + public var name: String + /// The client version + public var version: String + + public init(name: String, version: String) { + self.name = name + self.version = version + } + } + + /// The client capabilities + public struct Capabilities: Hashable, Codable, Sendable { + /// The roots capabilities + public struct Roots: Hashable, Codable, Sendable { + /// Whether the list of roots has changed + public var listChanged: Bool? + + public init(listChanged: Bool? = nil) { + self.listChanged = listChanged + } + } + + /// The sampling capabilities + public struct Sampling: Hashable, Codable, Sendable { + public init() {} + } + + /// Whether the client supports sampling + public var sampling: Sampling? + /// Experimental features supported by the client + public var experimental: [String: String]? + /// Whether the client supports roots + public var roots: Capabilities.Roots? + + public init( + sampling: Sampling? = nil, + experimental: [String: String]? = nil, + roots: Capabilities.Roots? = nil + ) { + self.sampling = sampling + self.experimental = experimental + self.roots = roots + } + } + + /// The connection to the server + private var connection: (any Transport)? + /// The logger for the client + private var logger: Logger? { + get async { + await connection?.logger + } + } + + /// The client information + private let clientInfo: Client.Info + /// The client name + public nonisolated var name: String { clientInfo.name } + /// The client version + public nonisolated var version: String { clientInfo.version } + + /// The client capabilities + public var capabilities: Client.Capabilities + + /// The server capabilities + private var serverCapabilities: Server.Capabilities? + /// The server version + private var serverVersion: String? + /// The server instructions + private var instructions: String? + + /// A dictionary of type-erased notification handlers, keyed by method name + private var notificationHandlers: [String: [NotificationHandlerBox]] = [:] + /// The task for the message handling loop + private var task: Task? + + /// A pending request with a continuation for the result + private struct PendingRequest { + let continuation: CheckedContinuation + } + /// A dictionary of type-erased pending requests, keyed by request ID + private var pendingRequests: [ID: Any] = [:] + + public init( + name: String, + version: String + ) { + self.clientInfo = Client.Info(name: name, version: version) + self.capabilities = Capabilities() + } + + /// Connect to the server using the given transport + public func connect(transport: any Transport) async throws { + self.connection = transport + try await self.connection?.connect() + + await logger?.info( + "Client connected", metadata: ["name": "\(name)", "version": "\(version)"]) + + // Start message handling loop + task = Task { + guard let connection = self.connection else { return } + repeat { + // Check for cancellation before starting the iteration + if Task.isCancelled { break } + + do { + let stream = await connection.receive() + for try await string in stream { + if Task.isCancelled { break } // Check inside loop too + + // Decode and handle incoming message + guard let data = string.data(using: .utf8) else { + throw Error.parseError("Invalid UTF-8 data") + } + + // Attempt to decode string data as AnyResponse or AnyMessage + let decoder = JSONDecoder() + if let response = try? decoder.decode(AnyResponse.self, from: data) { + await handleResponse(response, for: response) + } else if let message = try? decoder.decode(AnyMessage.self, from: data) { + await handleMessage(message) + } else { + var metadata: Logger.Metadata = [:] + if let string = String(data: data, encoding: .utf8) { + metadata["message"] = .string(string) + } + await logger?.warning( + "Unexpected message received by client", metadata: metadata) + } + } + } catch let error as Errno where error == .resourceTemporarilyUnavailable { + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + continue + } catch { + await logger?.error( + "Error in message handling loop", metadata: ["error": "\(error)"]) + break + } + } while true + } + } + + /// Disconnect the client and cancel all pending requests + public func disconnect() async { + // Cancel all pending requests + for (id, request) in pendingRequests { + // We know this cast is safe because we only store PendingRequest values + if let typedRequest = request as? PendingRequest { + typedRequest.continuation.resume( + throwing: Error.internalError("Client disconnected")) + } + pendingRequests.removeValue(forKey: id) + } + + task?.cancel() + task = nil + if let connection = connection { + await connection.disconnect() + } + connection = nil + } + + // MARK: - Registration + + /// Register a handler for a notification + @discardableResult + public func onNotification( + _ type: N.Type, + handler: @escaping @Sendable (Message) async throws -> Void + ) async -> Self { + let handlers = notificationHandlers[N.name, default: []] + notificationHandlers[N.name] = handlers + [TypedNotificationHandler(handler)] + return self + } + + // MARK: - Requests + + /// Send a request and receive its response + public func send(_ request: Request) async throws -> M.Result { + guard let connection = connection else { + throw Error.internalError("Client connection not initialized") + } + + let requestData = try JSONEncoder().encode(request) + guard let requestString = String(data: requestData, encoding: .utf8) else { + throw Error.internalError("Failed to encode request") + } + + return try await withCheckedThrowingContinuation { continuation in + // Store the pending request first + Task { + self.addPendingRequest( + id: request.id, + continuation: continuation, + type: M.Result.self + ) + + // Send the request + do { + try await connection.send(requestString) + } catch { + continuation.resume(throwing: error) + self.removePendingRequest(id: request.id) + } + } + } + } + + private func addPendingRequest( + id: ID, + continuation: CheckedContinuation, + type: T.Type + ) { + pendingRequests[id] = PendingRequest(continuation: continuation) + } + + private func removePendingRequest(id: ID) { + pendingRequests.removeValue(forKey: id) + } + + // MARK: - Lifecycle + + public func initialize() async throws -> Initialize.Result { + let request = Initialize.request( + .init( + protocolVersion: Version.latest, + capabilities: capabilities, + clientInfo: clientInfo + )) + + let result = try await send(request) + + self.serverCapabilities = result.capabilities + self.serverVersion = result.protocolVersion + self.instructions = result.instructions + + return result + } + + public func ping() async throws { + let request = Ping.request() + _ = try await send(request) + } + + // MARK: - Prompts + + public func getPrompt(name: String, arguments: [String: Value]? = nil) async throws + -> (description: String?, messages: [Prompt.Message]) + { + _ = try checkCapability(\.prompts, "Prompts") + let request = GetPrompt.request(.init(name: name, arguments: arguments)) + let result = try await send(request) + return (description: result.description, messages: result.messages) + } + + public func listPrompts(cursor: String? = nil) async throws + -> (prompts: [Prompt], nextCursor: String?) + { + _ = try checkCapability(\.prompts, "Prompts") + let request = ListPrompts.request(.init(cursor: cursor)) + let result = try await send(request) + return (prompts: result.prompts, nextCursor: result.nextCursor) + } + + // MARK: - Resources + + public func readResource(uri: String) async throws -> [Resource.Content] { + _ = try checkCapability(\.resources?.read, "Resource reading") + let request = ReadResource.request(.init(uri: uri)) + let result = try await send(request) + return result.contents + } + + public func listResources(cursor: String? = nil) async throws -> ( + resources: [Resource], nextCursor: String? + ) { + _ = try checkCapability(\.resources?.list, "Resource listing") + let request = ListResources.request(.init(cursor: cursor)) + let result = try await send(request) + return (resources: result.resources, nextCursor: result.nextCursor) + } + + public func subscribeToResource(uri: String) async throws { + _ = try checkCapability(\.resources?.subscribe, "Resource subscription") + let request = ResourceSubscribe.request(.init(uri: uri)) + _ = try await send(request) + } + + // MARK: - Tools + + public func listTools(cursor: String? = nil) async throws -> [Tool] { + _ = try checkCapability(\.tools, "Tools") + let request = ListTools.request(.init(cursor: cursor)) + let result = try await send(request) + return result.tools + } + + public func callTool(name: String, arguments: [String: Value]? = nil) async throws -> ( + content: [Tool.Content], isError: Bool + ) { + _ = try checkCapability(\.tools, "Tools") + let request = CallTool.request(.init(name: name, arguments: arguments)) + let result = try await send(request) + return (content: result.content, isError: result.isError) + } + + // MARK: - + + private func handleResponse(_ response: Response, for request: Any) async { + await logger?.debug( + "Processing response", + metadata: ["id": "\(response.id)"]) + + // We know this cast is safe because we only store PendingRequest values + guard let typedRequest = request as? PendingRequest else { return } + + switch response.result { + case .success(let value): + typedRequest.continuation.resume(returning: value) + case .failure(let error): + typedRequest.continuation.resume(throwing: error) + } + + removePendingRequest(id: response.id) + } + + private func handleMessage(_ message: Message) async { + await logger?.debug( + "Processing notification", + metadata: ["method": "\(message.method)"]) + + // Find notification handlers for this method + guard let handlers = notificationHandlers[message.method] else { return } + + // Convert notification parameters to concrete type and call handlers + for handler in handlers { + do { + try await handler(message) + } catch { + await logger?.error( + "Error handling notification", + metadata: [ + "method": "\(message.method)", + "error": "\(error)", + ]) + } + } + } + + // MARK: - + + private func checkCapability(_ keyPath: KeyPath, _ name: String) + throws -> T + { + guard let capabilities = serverCapabilities else { + throw Error.methodNotFound("Server capabilities not initialized") + } + guard let value = capabilities[keyPath: keyPath] else { + throw Error.methodNotFound("\(name) is not supported by the server") + } + return value + } +} diff --git a/Sources/MCP/Extensions/Data+Extensions.swift b/Sources/MCP/Extensions/Data+Extensions.swift new file mode 100644 index 0000000..745b4a3 --- /dev/null +++ b/Sources/MCP/Extensions/Data+Extensions.swift @@ -0,0 +1,87 @@ +import Foundation +import RegexBuilder + +extension Data { + /// Regex pattern for data URLs + @inline(__always) private static var dataURLRegex: + Regex<(Substring, Substring, Substring?, Substring)> + { + Regex { + "data:" + Capture { + ZeroOrMore(.reluctant) { + CharacterClass.anyOf(",;").inverted + } + } + Optionally { + ";charset=" + Capture { + OneOrMore(.reluctant) { + CharacterClass.anyOf(",;").inverted + } + } + } + Optionally { ";base64" } + "," + Capture { + ZeroOrMore { .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 + } + + /// 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 + } + + // Extract components using strongly typed captures + let (_, mediatype, charset, encodedData) = match.output + + 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/") { + 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) + 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)" + } +} diff --git a/Sources/MCP/MCP.swift b/Sources/MCP/MCP.swift deleted file mode 100644 index db6ddd6..0000000 --- a/Sources/MCP/MCP.swift +++ /dev/null @@ -1,364 +0,0 @@ -import Foundation - -/// Protocol version constants -public enum MCPVersion { - public static let latest = "1.0.0" - public static let supported = ["1.0.0"] -} - -/// MCP error types -public enum MCPError: LocalizedError { - case connectionClosed - case invalidResponse - case invalidParams(String) - case serverError(String) - case protocolError(String) - case transportError(Error) - - public var errorDescription: String? { - switch self { - case .connectionClosed: - return "Connection closed" - case .invalidResponse: - return "Invalid response from server" - case .invalidParams(let message): - return "Invalid parameters: \(message)" - case .serverError(let message): - return "Server error: \(message)" - case .protocolError(let message): - return "Protocol error: \(message)" - case .transportError(let error): - return "Transport error: \(error.localizedDescription)" - } - } -} - -/// Base protocol for MCP messages -public protocol MCPMessage: Codable { - var jsonrpc: String { get } -} - -/// Protocol for MCP requests -public protocol MCPRequest: MCPMessage { - var id: String { get } - var method: String { get } -} - -/// Protocol for MCP notifications -public protocol MCPNotification: MCPMessage { - var method: String { get } -} - -/// Protocol for MCP responses -public protocol MCPResponse: MCPMessage { - var id: String { get } -} - -/// Implementation information -public struct Implementation: Codable { - public let name: String - public let version: String - - public init(name: String, version: String) { - self.name = name - self.version = version - } -} - -/// Client capabilities -public struct ClientCapabilities: Codable { - public var sampling: Bool? - public var experimental: [String: [String: Any]]? - public var roots: RootsCapability? - - public init(sampling: Bool? = nil, - experimental: [String: [String: Any]]? = nil, - roots: RootsCapability? = nil) { - self.sampling = sampling - self.experimental = experimental - self.roots = roots - } - - enum CodingKeys: String, CodingKey { - case sampling - case experimental - case roots - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - sampling = try container.decodeIfPresent(Bool.self, forKey: .sampling) - experimental = try container.decodeIfPresent([String: [String: Any]].self, forKey: .experimental) - roots = try container.decodeIfPresent(RootsCapability.self, forKey: .roots) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(sampling, forKey: .sampling) - try container.encodeIfPresent(experimental, forKey: .experimental) - try container.encodeIfPresent(roots, forKey: .roots) - } -} - -/// Roots capability configuration -public struct RootsCapability: Codable { - public var listChanged: Bool? - - public init(listChanged: Bool? = nil) { - self.listChanged = listChanged - } -} - -/// Server capabilities -public struct ServerCapabilities: Codable { - public var logging: Bool? - public var prompts: Bool? - public var resources: ResourcesCapability? - public var tools: Bool? - - public init(logging: Bool? = nil, - prompts: Bool? = nil, - resources: ResourcesCapability? = nil, - tools: Bool? = nil) { - self.logging = logging - self.prompts = prompts - self.resources = resources - self.tools = tools - } -} - -/// Resources capability configuration -public struct ResourcesCapability: Codable { - public var subscribe: Bool? - - public init(subscribe: Bool? = nil) { - self.subscribe = subscribe - } -} - -/// Initialize request parameters -public struct InitializeRequestParams: Codable { - public let protocolVersion: String - public let capabilities: ClientCapabilities - public let clientInfo: Implementation - - public init(protocolVersion: String = MCPVersion.latest, - capabilities: ClientCapabilities, - clientInfo: Implementation) { - self.protocolVersion = protocolVersion - self.capabilities = capabilities - self.clientInfo = clientInfo - } -} - -/// Initialize request -public struct InitializeRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "initialize" - public let params: InitializeRequestParams - - public init(id: String = UUID().uuidString, params: InitializeRequestParams) { - self.id = id - self.params = params - } -} - -/// Initialize result -public struct InitializeResult: MCPResponse { - public let jsonrpc: String = "2.0" - public let id: String - public let result: InitializeResultData - - public struct InitializeResultData: Codable { - public let protocolVersion: String - public let capabilities: ServerCapabilities - public let serverInfo: Implementation - public let instructions: String? - } -} - -/// MCP Client protocol -public protocol MCPClientProtocol { - func connect() async throws - func disconnect() async - func initialize() async throws -> InitializeResult - func ping() async throws - - // Resource methods - func listResources() async throws -> ListResourcesResponse - func readResource(_ uri: String) async throws -> ReadResourceResponse - func subscribeResource(_ uri: String) async throws - func unsubscribeResource(_ uri: String) async throws - - // Tool methods - func listTools() async throws -> ListToolsResponse - func callTool(name: String, arguments: [String: Any]? = nil) async throws -> CallToolResponse -} - -/// MCP Client implementation -public actor MCPClient: MCPClientProtocol { - private let transport: MCPTransport - private let clientInfo: Implementation - private var capabilities: ClientCapabilities - private var serverCapabilities: ServerCapabilities? - private var serverVersion: Implementation? - private var instructions: String? - - public init(transport: MCPTransport, - clientInfo: Implementation, - capabilities: ClientCapabilities = ClientCapabilities()) { - self.transport = transport - self.clientInfo = clientInfo - self.capabilities = capabilities - } - - public func connect() async throws { - try await transport.connect() - } - - public func disconnect() async { - await transport.disconnect() - } - - public func initialize() async throws -> InitializeResult { - let request = InitializeRequest(params: InitializeRequestParams( - capabilities: capabilities, - clientInfo: clientInfo - )) - - let response: InitializeResult = try await sendRequest(request) - - // Validate protocol version - guard MCPVersion.supported.contains(response.result.protocolVersion) else { - throw MCPError.protocolError("Unsupported protocol version: \(response.result.protocolVersion)") - } - - // Store server information - serverCapabilities = response.result.capabilities - serverVersion = response.result.serverInfo - instructions = response.result.instructions - - // Send initialized notification - try await sendNotification(InitializedNotification()) - - return response - } - - public func ping() async throws { - let request = PingRequest() - try await sendRequest(request) - } - - // MARK: - Resource Methods - - public func listResources() async throws -> ListResourcesResponse { - guard serverCapabilities?.resources != nil else { - throw MCPError.protocolError("Server does not support resources") - } - - let request = ListResourcesRequest() - return try await sendRequest(request) - } - - public func readResource(_ uri: String) async throws -> ReadResourceResponse { - guard serverCapabilities?.resources != nil else { - throw MCPError.protocolError("Server does not support resources") - } - - let request = ReadResourceRequest(uri: uri) - return try await sendRequest(request) - } - - public func subscribeResource(_ uri: String) async throws { - guard let resources = serverCapabilities?.resources, - resources.subscribe == true else { - throw MCPError.protocolError("Server does not support resource subscriptions") - } - - let request = SubscribeRequest(uri: uri) - try await sendRequest(request) - } - - public func unsubscribeResource(_ uri: String) async throws { - guard let resources = serverCapabilities?.resources, - resources.subscribe == true else { - throw MCPError.protocolError("Server does not support resource subscriptions") - } - - let request = UnsubscribeRequest(uri: uri) - try await sendRequest(request) - } - - // MARK: - Tool Methods - - public func listTools() async throws -> ListToolsResponse { - guard serverCapabilities?.tools == true else { - throw MCPError.protocolError("Server does not support tools") - } - - let request = ListToolsRequest() - return try await sendRequest(request) - } - - public func callTool(name: String, arguments: [String: Any]? = nil) async throws -> CallToolResponse { - guard serverCapabilities?.tools == true else { - throw MCPError.protocolError("Server does not support tools") - } - - let request = CallToolRequest(name: name, arguments: arguments) - return try await sendRequest(request) - } - - private func sendRequest(_ request: T) async throws -> U { - let data = try JSONEncoder().encode(request) - let responseData = try await transport.sendRequest(data) - return try JSONDecoder().decode(U.self, from: responseData) - } - - private func sendNotification(_ notification: T) async throws { - let data = try JSONEncoder().encode(notification) - try await transport.sendNotification(data) - } - - // MARK: - Public Properties - - /// Get the server's capabilities after initialization - public var serverCapabilitiesAfterInit: ServerCapabilities? { - serverCapabilities - } - - /// Get the server's version information after initialization - public var serverVersionAfterInit: Implementation? { - serverVersion - } - - /// Get the server's instructions after initialization - public var serverInstructions: String? { - instructions - } -} - -/// MCP Transport protocol -public protocol MCPTransport { - func connect() async throws - func disconnect() async - func sendRequest(_ data: Data) async throws -> Data - func sendNotification(_ data: Data) async throws -} - -/// Basic notification types -public struct InitializedNotification: MCPNotification { - public let jsonrpc: String = "2.0" - public let method: String = "notifications/initialized" -} - -public struct PingRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "ping" - - public init(id: String = UUID().uuidString) { - self.id = id - } -} diff --git a/Sources/MCP/Resources.swift b/Sources/MCP/Resources.swift deleted file mode 100644 index 3b7aced..0000000 --- a/Sources/MCP/Resources.swift +++ /dev/null @@ -1,166 +0,0 @@ -import Foundation - -/// Resource metadata -public struct ResourceMetadata: Codable { - public var name: String - public var uri: String - public var metadata: [String: String]? - - public init(name: String, uri: String, metadata: [String: String]? = nil) { - self.name = name - self.uri = uri - self.metadata = metadata - } -} - -/// Resource content types -public enum ResourceContent: Codable { - case text(String) - case binary(Data) - case embedded([EmbeddedResource]) - - private enum CodingKeys: String, CodingKey { - case type - case content - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "text": - let content = try container.decode(String.self, forKey: .content) - self = .text(content) - case "binary": - let content = try container.decode(Data.self, forKey: .content) - self = .binary(content) - case "embedded": - let content = try container.decode([EmbeddedResource].self, forKey: .content) - self = .embedded(content) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown resource type") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case .text(let content): - try container.encode("text", forKey: .type) - try container.encode(content, forKey: .content) - case .binary(let content): - try container.encode("binary", forKey: .type) - try container.encode(content, forKey: .content) - case .embedded(let content): - try container.encode("embedded", forKey: .type) - try container.encode(content, forKey: .content) - } - } -} - -/// Embedded resource type -public struct EmbeddedResource: Codable { - public var type: String - public var data: String - public var metadata: [String: String]? - - public init(type: String, data: String, metadata: [String: String]? = nil) { - self.type = type - self.data = data - self.metadata = metadata - } -} - -/// Resource request types -public struct ListResourcesRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "resources/list" - - public init(id: String = UUID().uuidString) { - self.id = id - } -} - -public struct ReadResourceRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "resources/read" - public let params: ReadResourceParams - - public struct ReadResourceParams: Codable { - public let uri: String - } - - public init(id: String = UUID().uuidString, uri: String) { - self.id = id - self.params = ReadResourceParams(uri: uri) - } -} - -public struct SubscribeRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "resources/subscribe" - public let params: SubscribeParams - - public struct SubscribeParams: Codable { - public let uri: String - } - - public init(id: String = UUID().uuidString, uri: String) { - self.id = id - self.params = SubscribeParams(uri: uri) - } -} - -public struct UnsubscribeRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "resources/unsubscribe" - public let params: UnsubscribeParams - - public struct UnsubscribeParams: Codable { - public let uri: String - } - - public init(id: String = UUID().uuidString, uri: String) { - self.id = id - self.params = UnsubscribeParams(uri: uri) - } -} - -/// Resource response types -public struct ListResourcesResponse: MCPResponse { - public let jsonrpc: String = "2.0" - public let id: String - public let result: ListResourcesResult - - public struct ListResourcesResult: Codable { - public let resources: [ResourceMetadata] - } -} - -public struct ReadResourceResponse: MCPResponse { - public let jsonrpc: String = "2.0" - public let id: String - public let result: ReadResourceResult - - public struct ReadResourceResult: Codable { - public let content: ResourceContent - } -} - -/// Resource notification types -public struct ResourceUpdatedNotification: MCPNotification { - public let jsonrpc: String = "2.0" - public let method: String = "notifications/resource/updated" - public let params: ResourceUpdatedParams - - public struct ResourceUpdatedParams: Codable { - public let uri: String - public let content: ResourceContent - } -} \ No newline at end of file diff --git a/Sources/MCP/Server/Prompts.swift b/Sources/MCP/Server/Prompts.swift new file mode 100644 index 0000000..859d816 --- /dev/null +++ b/Sources/MCP/Server/Prompts.swift @@ -0,0 +1,206 @@ +import Foundation + +/// The Model Context Protocol (MCP) provides a standardized way +/// for servers to expose prompt templates to clients. +/// Prompts allow servers to provide structured messages and instructions +/// for interacting with language models. +/// Clients can discover available prompts, retrieve their contents, +/// and provide arguments to customize them. +/// +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/ +public struct Prompt: Hashable, Codable, Sendable { + /// The prompt name + public let name: String + /// The prompt description + public let description: String? + /// The prompt arguments + public let arguments: [Argument]? + + public init(name: String, description: String? = nil, arguments: [Argument]? = nil) { + self.name = name + self.description = description + self.arguments = arguments + } + + /// An argument for a prompt + public struct Argument: Hashable, Codable, Sendable { + /// The argument name + public let name: String + /// The argument description + public let description: String? + /// Whether the argument is required + public let required: Bool? + + public init(name: String, description: String? = nil, required: Bool? = nil) { + self.name = name + self.description = description + self.required = required + } + } + + /// A message in a prompt + public struct Message: Hashable, Codable, Sendable { + /// The message role + public enum Role: String, Hashable, Codable, Sendable { + /// A user message + case user + /// An assistant message + case assistant + } + + /// The message role + public let role: Role + /// The message content + public let content: Content + + public init(role: Role, content: Content) { + self.role = role + self.content = content + } + + /// Content types for messages + public enum Content: Hashable, Codable, Sendable { + /// Text content + case text(text: String) + /// Image content + case image(data: String, mimeType: String) + /// Embedded resource content + case resource(uri: String, mimeType: String, text: String?, blob: String?) + + private enum CodingKeys: String, CodingKey { + case type, text, data, mimeType, uri, blob + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let data, let mimeType): + try container.encode("image", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + case .resource(let uri, let mimeType, let text, let blob): + try container.encode("resource", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + try container.encodeIfPresent(blob, forKey: .blob) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "text": + let text = try container.decode(String.self, forKey: .text) + self = .text(text: text) + case "image": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + self = .image(data: data, mimeType: mimeType) + case "resource": + let uri = try container.decode(String.self, forKey: .uri) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let text = try container.decodeIfPresent(String.self, forKey: .text) + let blob = try container.decodeIfPresent(String.self, forKey: .blob) + self = .resource(uri: uri, mimeType: mimeType, text: text, blob: blob) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, + in: container, + debugDescription: "Unknown content type") + } + } + } + } + + /// Reference type for prompts + public struct Reference: Hashable, Codable, Sendable { + /// The prompt reference name + public let name: String + + public init(name: String) { + self.name = name + } + + private enum CodingKeys: String, CodingKey { + case type, name + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("ref/prompt", forKey: .type) + try container.encode(name, forKey: .name) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + _ = try container.decode(String.self, forKey: .type) + name = try container.decode(String.self, forKey: .name) + } + } +} + +// MARK: - + +/// To retrieve available prompts, clients send a `prompts/list` request. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#listing-prompts +public enum ListPrompts: Method { + public static let name: String = "prompts/list" + + public struct Parameters: Hashable, Codable, Sendable { + public let cursor: String? + + public init(cursor: String? = nil) { + self.cursor = cursor + } + } + + public struct Result: Hashable, Codable, Sendable { + public let prompts: [Prompt] + public let nextCursor: String? + + public init(prompts: [Prompt], nextCursor: String? = nil) { + self.prompts = prompts + self.nextCursor = nextCursor + } + } +} + +/// To retrieve a specific prompt, clients send a `prompts/get` request. +/// Arguments may be auto-completed through the completion API. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#getting-a-prompt +public enum GetPrompt: Method { + public static let name: String = "prompts/get" + + public struct Parameters: Hashable, Codable, Sendable { + public let name: String + public let arguments: [String: Value]? + + public init(name: String, arguments: [String: Value]? = nil) { + self.name = name + self.arguments = arguments + } + } + + public struct Result: Hashable, Codable, Sendable { + public let description: String? + public let messages: [Prompt.Message] + + public init(description: String?, messages: [Prompt.Message]) { + self.description = description + self.messages = messages + } + } +} + +/// When the list of available prompts changes, servers that declared the listChanged capability SHOULD send a notification. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/prompts/#list-changed-notification +public struct PromptListChangedNotification: Notification { + public static let name: String = "notifications/prompts/list_changed" +} diff --git a/Sources/MCP/Server/Resources.swift b/Sources/MCP/Server/Resources.swift new file mode 100644 index 0000000..7299082 --- /dev/null +++ b/Sources/MCP/Server/Resources.swift @@ -0,0 +1,195 @@ +import Foundation + +/// The Model Context Protocol (MCP) provides a standardized way +/// for servers to expose resources to clients. +/// Resources allow servers to share data that provides context to language models, +/// such as files, database schemas, or application-specific information. +/// Each resource is uniquely identified by a URI. +/// +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/ +public struct Resource: Hashable, Codable, Sendable { + /// The resource name + public var name: String + /// The resource URI + public var uri: String + /// The resource description + public var description: String? + /// The resource MIME type + public var mimeType: String? + /// The resource metadata + public var metadata: [String: String]? + + public init( + name: String, + uri: String, + description: String? = nil, + mimeType: String? = nil, + metadata: [String: String]? = nil + ) { + self.name = name + self.uri = uri + self.description = description + self.mimeType = mimeType + self.metadata = metadata + } + + /// Content of a resource. + public struct Content: Hashable, Codable, Sendable { + /// The resource URI + public let uri: String + /// The resource MIME type + public let mimeType: String? + /// The resource text content + public let text: String? + /// The resource binary content + public let blob: String? + + public static func text(_ content: String, uri: String, mimeType: String? = nil) -> Self { + .init(uri: uri, mimeType: mimeType, text: content) + } + + public static func binary(_ data: Data, uri: String, mimeType: String? = nil) -> Self { + .init(uri: uri, mimeType: mimeType, blob: data.base64EncodedString()) + } + + private init(uri: String, mimeType: String? = nil, text: String? = nil) { + self.uri = uri + self.mimeType = mimeType + self.text = text + self.blob = nil + } + + private init(uri: String, mimeType: String? = nil, blob: String) { + self.uri = uri + self.mimeType = mimeType + self.text = nil + self.blob = blob + } + } + + /// A resource template. + public struct Template: Hashable, Codable, Sendable { + /// The URI template pattern + public var uriTemplate: String + /// The template name + public var name: String + /// The template description + public var description: String? + /// The resource MIME type + public var mimeType: String? + + public init( + uriTemplate: String, + name: String, + description: String? = nil, + mimeType: String? = nil + ) { + self.uriTemplate = uriTemplate + self.name = name + self.description = description + self.mimeType = mimeType + } + } +} + +// MARK: - + +/// To discover available resources, clients send a `resources/list` request. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#listing-resources +public enum ListResources: Method { + public static let name: String = "resources/list" + + public struct Parameters: Hashable, Codable, Sendable { + public let cursor: String? + + public init(cursor: String? = nil) { + self.cursor = cursor + } + } + + public struct Result: Hashable, Codable, Sendable { + public let resources: [Resource] + public let nextCursor: String? + + public init(resources: [Resource], nextCursor: String? = nil) { + self.resources = resources + self.nextCursor = nextCursor + } + } +} + +/// To retrieve resource contents, clients send a `resources/read` request: +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#reading-resources +public enum ReadResource: Method { + public static let name: String = "resources/read" + + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String + + public init(uri: String) { + self.uri = uri + } + } + + public struct Result: Hashable, Codable, Sendable { + public let contents: [Resource.Content] + + public init(contents: [Resource.Content]) { + self.contents = contents + } + } +} + +/// To discover available resource templates, clients send a `resources/templates/list` request. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#resource-templates +public enum ListResourceTemplates: Method { + public static let name: String = "resources/templates/list" + + public typealias Parameters = Empty + + public struct Result: Hashable, Codable, Sendable { + public let templates: [Resource.Template] + + public init(templates: [Resource.Template]) { + self.templates = templates + } + + private enum CodingKeys: String, CodingKey { + case templates = "resourceTemplates" + } + } +} + +/// When the list of available resources changes, servers that declared the listChanged capability SHOULD send a notification. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#list-changed-notification +public struct ResourceListChangedNotification: Notification { + public static let name: String = "notifications/resources/list_changed" + + public typealias Parameters = Empty +} + +/// Clients can subscribe to specific resources and receive notifications when they change. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions +public enum ResourceSubscribe: Method { + public static let name: String = "resources/subscribe" + + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String + } + + public typealias Result = Empty +} + +/// When a resource changes, servers that declared the updated capability SHOULD send a notification to subscribed clients. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/resources/#subscriptions +public struct ResourceUpdatedNotification: Notification { + public static let name: String = "notifications/resources/updated" + + public struct Parameters: Hashable, Codable, Sendable { + public let uri: String + + public init(uri: String) { + self.uri = uri + } + } +} diff --git a/Sources/MCP/Server/Server.swift b/Sources/MCP/Server/Server.swift new file mode 100644 index 0000000..c7d67f0 --- /dev/null +++ b/Sources/MCP/Server/Server.swift @@ -0,0 +1,393 @@ +import Logging +import SystemPackage + +import struct Foundation.Data +import struct Foundation.Date +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +/// Model Context Protocol server +public actor Server { + /// Implementation information + public struct Info: Hashable, Codable, Sendable { + /// The server name + public let name: String + /// The server version + public let version: String + + public init(name: String, version: String) { + self.name = name + self.version = version + } + } + + /// Server capabilities + public struct Capabilities: Hashable, Codable, Sendable { + /// Resources capabilities + public struct Resources: Hashable, Codable, Sendable { + /// Whether the list of resources has changed + public var list: Bool? + /// Whether the resource can be read + public var read: Bool? + /// Whether the resource can be subscribed to + public var subscribe: Bool? + /// Whether the list of resources has changed + public var listChanged: Bool? + + public init( + list: Bool? = nil, + read: Bool? = nil, + subscribe: Bool? = nil, + listChanged: Bool? = nil + ) { + self.list = list + self.read = read + self.subscribe = subscribe + self.listChanged = listChanged + } + } + + /// Tools capabilities + public struct Tools: Hashable, Codable, Sendable { + /// Whether the server notifies clients when tools change + public var listChanged: Bool? + + public init(listChanged: Bool? = nil) { + self.listChanged = listChanged + } + } + + /// Prompts capabilities + public struct Prompts: Hashable, Codable, Sendable { + /// Whether the server notifies clients when prompts change + public var listChanged: Bool? + + public init(listChanged: Bool? = nil) { + self.listChanged = listChanged + } + } + + /// Logging capabilities + public struct Logging: Hashable, Codable, Sendable { + public init() {} + } + + /// Logging capabilities + public var logging: Logging? + /// Prompts capabilities + public var prompts: Prompts? + /// Resources capabilities + public var resources: Resources? + /// Tools capabilities + public var tools: Tools? + + public init( + logging: Logging? = nil, + prompts: Prompts? = nil, + resources: Resources? = nil, + tools: Tools? = nil + ) { + self.logging = logging + self.prompts = prompts + self.resources = resources + self.tools = tools + } + } + + /// Server information + private let serverInfo: Server.Info + /// The server connection + private var connection: (any Transport)? + /// The server logger + private var logger: Logger? { + get async { + await connection?.logger + } + } + + /// The server name + public nonisolated var name: String { serverInfo.name } + /// The server version + public nonisolated var version: String { serverInfo.version } + /// The server capabilities + public var capabilities = Capabilities() + + /// Request handlers + private var methodHandlers: [String: RequestHandlerBox] = [:] + /// Notification handlers + private var notificationHandlers: [String: [NotificationHandlerBox]] = [:] + + /// Whether the server is initialized + private var isInitialized = false + /// The client information + private var clientInfo: Client.Info? + /// The client capabilities + private var clientCapabilities: Client.Capabilities? + /// The protocol version + private var protocolVersion: String? + /// The list of subscriptions + private var subscriptions: [String: Set] = [:] + /// The task for the message handling loop + private var task: Task? + + public init( + name: String, + version: String, + capabilities: Server.Capabilities = .init() + ) { + self.serverInfo = Server.Info(name: name, version: version) + self.capabilities = capabilities + } + + /// Start the server + public func start(transport: any Transport) async throws { + self.connection = transport + registerDefaultHandlers() + try await transport.connect() + + await logger?.info( + "Server started", metadata: ["name": "\(name)", "version": "\(version)"]) + + // Start message handling loop + task = Task { + do { + let stream = await transport.receive() + for try await string in stream { + if Task.isCancelled { break } // Check cancellation inside loop + + var requestID: ID? + do { + guard let data = string.data(using: .utf8) else { + throw Error.parseError("Invalid UTF-8 data") + } + + // Attempt to decode string data as AnyRequest or AnyMessage + let decoder = JSONDecoder() + if let request = try? decoder.decode(AnyRequest.self, from: data) { + try await handleRequest(request) + } else if let message = try? decoder.decode(AnyMessage.self, from: data) { + try await handleMessage(message) + } else { + // Try to extract request ID from raw JSON if possible + if let json = try? JSONDecoder().decode( + [String: Value].self, from: data), + let idValue = json["id"] + { + if let strValue = idValue.stringValue { + requestID = .string(strValue) + } else if let intValue = idValue.intValue { + requestID = .number(intValue) + } + } + throw Error.parseError("Invalid message format") + } + } catch let error as Errno where error == .resourceTemporarilyUnavailable { + // Resource temporarily unavailable, retry after a short delay + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + continue + } catch { + await logger?.error( + "Error processing message", metadata: ["error": "\(error)"]) + // Only send error responses for requests + if let requestID = requestID { + let mcpError = + error as? Error ?? Error.internalError(error.localizedDescription) + let response = AnyMethod.response(id: requestID, error: mcpError) + try? await send(response) + } + } + } + } catch { + await logger?.error( + "Fatal error in message handling loop", metadata: ["error": "\(error)"]) + } + } + } + + /// Stop the server + public func stop() async { + task?.cancel() + task = nil + if let connection = connection { + await connection.disconnect() + } + connection = nil + } + + // MARK: - Registration + + /// Register a method handler + @discardableResult + public func withMethodHandler( + _ type: M.Type, + handler: @escaping @Sendable (M.Parameters) async throws -> M.Result + ) -> Self { + methodHandlers[M.name] = TypedRequestHandler { (request: Request) -> Response in + let result = try await handler(request.params) + return Response(id: request.id, result: result) + } + return self + } + + /// Register a notification handler + @discardableResult + public func onNotification( + _ type: N.Type, + handler: @escaping @Sendable (Message) async throws -> Void + ) -> Self { + let handlers = notificationHandlers[N.name, default: []] + notificationHandlers[N.name] = handlers + [TypedNotificationHandler(handler)] + return self + } + + // MARK: - Sending + + /// Send a response to a client + public func send(_ response: Response) async throws { + guard let connection = connection else { + throw Error.internalError("Server connection not initialized") + } + let responseData = try JSONEncoder().encode(response) + if let responseStr = String(data: responseData, encoding: .utf8) { + try await connection.send(responseStr) + } + } + + /// Send a notification to connected clients + public func notify(_ notification: Message) async throws { + guard let connection = connection else { + throw Error.internalError("Server connection not initialized") + } + let notificationData = try JSONEncoder().encode(notification) + if let notificationStr = String(data: notificationData, encoding: .utf8) { + try await connection.send(notificationStr) + } + } + + // MARK: - + + private func handleRequest(_ request: Request) async throws { + await logger?.debug( + "Processing request", + metadata: [ + "method": "\(request.method)", + "id": "\(request.id)", + ]) + + // The client SHOULD NOT send requests other than pings + // before the server has responded to the initialize request. + switch request.method { + case Initialize.name, Ping.name: + break + default: + try checkInitialized() + } + + // Find handler for method name + guard let handler = methodHandlers[request.method] else { + let error = Error.methodNotFound("Unknown method: \(request.method)") + let response = AnyMethod.response(id: request.id, error: error) + try await send(response) + throw error + } + + do { + // Handle request and get response + let response = try await handler(request) + try await send(response) + } catch { + let mcpError = error as? Error ?? Error.internalError(error.localizedDescription) + let response = AnyMethod.response(id: request.id, error: mcpError) + try await send(response) + throw error + } + } + + private func handleMessage(_ message: Message) async throws { + await logger?.debug( + "Processing notification", + metadata: ["method": "\(message.method)"]) + + // Check initialization state unless this is an initialized notification + if message.method != InitializedNotification.name { + try checkInitialized() + } + + // Find notification handlers for this method + guard let handlers = notificationHandlers[message.method] else { return } + + // Convert notification parameters to concrete type and call handlers + for handler in handlers { + do { + try await handler(message) + } catch { + await logger?.error( + "Error handling notification", + metadata: [ + "method": "\(message.method)", + "error": "\(error)", + ]) + } + } + } + + private func checkInitialized() throws { + guard isInitialized else { + throw Error.invalidRequest("Server is not initialized") + } + } + + private func registerDefaultHandlers() { + // Initialize + withMethodHandler(Initialize.self) { [weak self] params in + guard let self = self else { + throw Error.internalError("Server was deallocated") + } + + guard await !self.isInitialized else { + throw Error.invalidRequest("Server is already initialized") + } + + // Validate protocol version + guard Version.latest == params.protocolVersion else { + throw Error.invalidRequest( + "Unsupported protocol version: \(params.protocolVersion)") + } + + await self.setInitialState( + clientInfo: params.clientInfo, + clientCapabilities: params.capabilities, + protocolVersion: params.protocolVersion + ) + + let result = Initialize.Result( + protocolVersion: Version.latest, + capabilities: await self.capabilities, + serverInfo: self.serverInfo, + instructions: nil + ) + + // Send initialized notification after a short delay + Task { + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + try? await self.notify(InitializedNotification.message()) + } + + return result + } + + // Ping + withMethodHandler(Ping.self) { _ in return Empty() } + } + + private func setInitialState( + clientInfo: Client.Info, + clientCapabilities: Client.Capabilities, + protocolVersion: String + ) async { + self.clientInfo = clientInfo + self.clientCapabilities = clientCapabilities + self.protocolVersion = protocolVersion + self.isInitialized = true + } +} diff --git a/Sources/MCP/Server/Tools.swift b/Sources/MCP/Server/Tools.swift new file mode 100644 index 0000000..aacb282 --- /dev/null +++ b/Sources/MCP/Server/Tools.swift @@ -0,0 +1,170 @@ +import Foundation + +/// The Model Context Protocol (MCP) allows servers to expose tools +/// that can be invoked by language models. +/// Tools enable models to interact with external systems, such as +/// querying databases, calling APIs, or performing computations. +/// Each tool is uniquely identified by a name and includes metadata +/// describing its schema. +/// +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/ +public struct Tool: Hashable, Codable, Sendable { + /// The tool name + public let name: String + /// The tool description + public let description: String + /// The tool input schema + public let inputSchema: Value? + + public init(name: String, description: String, inputSchema: Value? = nil) { + self.name = name + self.description = description + self.inputSchema = inputSchema + } + + /// Content types that can be returned by a tool + public enum Content: Hashable, Codable, Sendable { + /// Text content + case text(String) + /// Image content + case image(data: String, mimeType: String, metadata: [String: String]?) + /// Embedded resource content + case resource(uri: String, mimeType: String, text: String?) + + private enum CodingKeys: String, CodingKey { + case type + case text + case image + case resource + case uri + case mimeType + case data + case metadata + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "text": + let text = try container.decode(String.self, forKey: .text) + self = .text(text) + case "image": + let data = try container.decode(String.self, forKey: .data) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let metadata = try container.decodeIfPresent( + [String: String].self, forKey: .metadata) + self = .image(data: data, mimeType: mimeType, metadata: metadata) + case "resource": + let uri = try container.decode(String.self, forKey: .uri) + let mimeType = try container.decode(String.self, forKey: .mimeType) + let text = try container.decodeIfPresent(String.self, forKey: .text) + self = .resource(uri: uri, mimeType: mimeType, text: text) + default: + throw DecodingError.dataCorruptedError( + forKey: .type, in: container, debugDescription: "Unknown tool content type") + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .text(let text): + try container.encode("text", forKey: .type) + try container.encode(text, forKey: .text) + case .image(let data, let mimeType, let metadata): + try container.encode("image", forKey: .type) + try container.encode(data, forKey: .data) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(metadata, forKey: .metadata) + case .resource(let uri, let mimeType, let text): + try container.encode("resource", forKey: .type) + try container.encode(uri, forKey: .uri) + try container.encode(mimeType, forKey: .mimeType) + try container.encodeIfPresent(text, forKey: .text) + } + } + } + + private enum CodingKeys: String, CodingKey { + case name + case description + case inputSchema + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + description = try container.decode(String.self, forKey: .description) + inputSchema = try container.decodeIfPresent(Value.self, forKey: .inputSchema) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(description, forKey: .description) + if let schema = inputSchema { + try container.encode(schema, forKey: .inputSchema) + } + } +} + +// MARK: - + +/// To discover available tools, clients send a `tools/list` request. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#listing-tools +public enum ListTools: Method { + public static let name = "tools/list" + + public struct Parameters: Hashable, Codable, Sendable { + public let cursor: String? + + public init(cursor: String? = nil) { + self.cursor = cursor + } + } + + public struct Result: Hashable, Codable, Sendable { + public let tools: [Tool] + public let nextCursor: String? + + public init(tools: [Tool], nextCursor: String? = nil) { + self.tools = tools + self.nextCursor = nextCursor + } + } +} + +/// To call a tool, clients send a `tools/call` request. +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#calling-tools +public enum CallTool: Method { + public static let name = "tools/call" + + public struct Parameters: Hashable, Codable, Sendable { + public let name: String + public let arguments: [String: Value]? + + public init(name: String, arguments: [String: Value]? = nil) { + self.name = name + self.arguments = arguments + } + } + + public struct Result: Hashable, Codable, Sendable { + public let content: [Tool.Content] + public let isError: Bool + + public init(content: [Tool.Content], isError: Bool) { + self.content = content + self.isError = isError + } + } +} + +/// When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification: +/// - SeeAlso: https://spec.modelcontextprotocol.io/specification/2024-11-05/server/tools/#list-changed-notification +public struct ToolListChangedNotification: Notification { + public static let name: String = "notifications/tools/list_changed" +} diff --git a/Sources/MCP/StdioTransport.swift b/Sources/MCP/StdioTransport.swift deleted file mode 100644 index b322e01..0000000 --- a/Sources/MCP/StdioTransport.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// A transport implementation that communicates over standard input/output -public actor StdioTransport: MCPTransport { - private var isConnected = false - private let processQueue = DispatchQueue(label: "com.mcp.stdio", qos: .userInitiated) - private let process: Process - private let inputPipe = Pipe() - private let outputPipe = Pipe() - private let errorPipe = Pipe() - - public init(command: String, arguments: [String] = [], environment: [String: String]? = nil) { - self.process = Process() - self.process.executableURL = URL(fileURLWithPath: command) - self.process.arguments = arguments - if let env = environment { - self.process.environment = env - } - - self.process.standardInput = inputPipe - self.process.standardOutput = outputPipe - self.process.standardError = errorPipe - } - - public func connect() async throws { - guard !isConnected else { return } - - try process.run() - isConnected = true - - // Start reading output asynchronously - Task { - await readOutput() - } - } - - public func disconnect() async { - guard isConnected else { return } - - process.terminate() - isConnected = false - } - - public func sendRequest(_ data: Data) async throws -> Data { - guard isConnected else { - throw MCPError.connectionClosed - } - - // Write request - try await withCheckedThrowingContinuation { continuation in - processQueue.async { - do { - let message = data + "\n".data(using: .utf8)! - try self.inputPipe.fileHandleForWriting.write(contentsOf: message) - - // Read response - if let response = try self.outputPipe.fileHandleForReading.readToEnd() { - continuation.resume(returning: response) - } else { - continuation.resume(throwing: MCPError.invalidResponse) - } - } catch { - continuation.resume(throwing: MCPError.transportError(error)) - } - } - } - } - - public func sendNotification(_ data: Data) async throws { - guard isConnected else { - throw MCPError.connectionClosed - } - - try await withCheckedThrowingContinuation { continuation in - processQueue.async { - do { - let message = data + "\n".data(using: .utf8)! - try self.inputPipe.fileHandleForWriting.write(contentsOf: message) - continuation.resume() - } catch { - continuation.resume(throwing: MCPError.transportError(error)) - } - } - } - } - - private func readOutput() async { - let handle = outputPipe.fileHandleForReading - - while isConnected { - do { - let data = try handle.read(upToCount: 4096) - if let data = data, !data.isEmpty { - // Process received data - // In a real implementation, you would parse the JSON-RPC messages - // and handle them appropriately - print("Received data: \(String(data: data, encoding: .utf8) ?? "")") - } - } catch { - print("Error reading output: \(error)") - break - } - } - } -} \ No newline at end of file diff --git a/Sources/MCP/Tools.swift b/Sources/MCP/Tools.swift deleted file mode 100644 index ec3adf9..0000000 --- a/Sources/MCP/Tools.swift +++ /dev/null @@ -1,175 +0,0 @@ -import Foundation - -/// Tool metadata -public struct Tool: Codable { - public let name: String - public let description: String? - public let inputSchema: [String: Any]? - - enum CodingKeys: String, CodingKey { - case name - case description - case inputSchema - } - - public init(name: String, description: String? = nil, inputSchema: [String: Any]? = nil) { - self.name = name - self.description = description - self.inputSchema = inputSchema - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - description = try container.decodeIfPresent(String.self, forKey: .description) - inputSchema = try container.decodeIfPresent([String: Any].self, forKey: .inputSchema) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - try container.encodeIfPresent(description, forKey: .description) - if let schema = inputSchema { - try container.encode(schema, forKey: .inputSchema) - } - } -} - -/// Tool request types -public struct ListToolsRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "tools/list" - - public init(id: String = UUID().uuidString) { - self.id = id - } -} - -public struct CallToolRequest: MCPRequest { - public let jsonrpc: String = "2.0" - public let id: String - public let method: String = "tools/call" - public let params: CallToolParams - - public struct CallToolParams: Codable { - public let name: String - public let arguments: [String: Any]? - - enum CodingKeys: String, CodingKey { - case name - case arguments - } - - public init(name: String, arguments: [String: Any]? = nil) { - self.name = name - self.arguments = arguments - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - arguments = try container.decodeIfPresent([String: Any].self, forKey: .arguments) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(name, forKey: .name) - if let args = arguments { - try container.encode(args, forKey: .arguments) - } - } - } - - public init(id: String = UUID().uuidString, name: String, arguments: [String: Any]? = nil) { - self.id = id - self.params = CallToolParams(name: name, arguments: arguments) - } -} - -/// Tool response types -public struct ListToolsResponse: MCPResponse { - public let jsonrpc: String = "2.0" - public let id: String - public let result: ListToolsResult - - public struct ListToolsResult: Codable { - public let tools: [Tool] - } -} - -public struct CallToolResponse: MCPResponse { - public let jsonrpc: String = "2.0" - public let id: String - public let result: CallToolResult - - public struct CallToolResult: Codable { - public let content: [ToolContent] - public let isError: Bool? - - public init(content: [ToolContent], isError: Bool? = nil) { - self.content = content - self.isError = isError - } - } -} - -/// Tool content types -public enum ToolContent: Codable { - case text(String) - case image(ImageContent) - - private enum CodingKeys: String, CodingKey { - case type - case text - case image - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - - switch type { - case "text": - let text = try container.decode(String.self, forKey: .text) - self = .text(text) - case "image": - let image = try container.decode(ImageContent.self, forKey: .image) - self = .image(image) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown tool content type") - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self { - case .text(let text): - try container.encode("text", forKey: .type) - try container.encode(text, forKey: .text) - case .image(let image): - try container.encode("image", forKey: .type) - try container.encode(image, forKey: .image) - } - } -} - -/// Image content type -public struct ImageContent: Codable { - public let data: String - public let mimeType: String - public let metadata: [String: String]? - - public init(data: String, mimeType: String, metadata: [String: String]? = nil) { - self.data = data - self.mimeType = mimeType - self.metadata = metadata - } -} - -/// Tool notification types -public struct ToolListChangedNotification: MCPNotification { - public let jsonrpc: String = "2.0" - public let method: String = "notifications/tools/list_changed" -} \ No newline at end of file diff --git a/Tests/MCPTests/ClientTests.swift b/Tests/MCPTests/ClientTests.swift new file mode 100644 index 0000000..a2cba6b --- /dev/null +++ b/Tests/MCPTests/ClientTests.swift @@ -0,0 +1,124 @@ +import Testing + +@testable import MCP + +@Suite("Client Tests") +struct ClientTests { + @Test("Client connect and disconnect") + func testClientConnectAndDisconnect() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + #expect(await transport.isConnected == false) + try await client.connect(transport: transport) + #expect(await transport.isConnected == true) + await client.disconnect() + #expect(await transport.isConnected == false) + } + + @Test( + "Initialize request", + .timeLimit(.minutes(1)) + ) + func testClientInitialize() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + try await client.connect(transport: transport) + // Small delay to ensure message loop is started + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + // Create a task for initialize that we'll cancel + let initTask = Task { + try await client.initialize() + } + + // Give it a moment to send the request + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + #expect(await transport.sentMessages.count == 1) + #expect(await transport.sentMessages[0].contains(Initialize.name)) + #expect(await transport.sentMessages[0].contains(client.name)) + #expect(await transport.sentMessages[0].contains(client.version)) + + // Cancel the initialize task + initTask.cancel() + + // Disconnect client to clean up message loop and give time for continuation cleanup + await client.disconnect() + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + + @Test( + "Ping request", + .timeLimit(.minutes(1)) + ) + func testClientPing() async throws { + let transport = MockTransport() + let client = Client(name: "TestClient", version: "1.0") + + try await client.connect(transport: transport) + // Small delay to ensure message loop is started + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + // Create a task for the ping that we'll cancel + let pingTask = Task { + try await client.ping() + } + + // Give it a moment to send the request + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + #expect(await transport.sentMessages.count == 1) + #expect(await transport.sentMessages[0].contains(Ping.name)) + + // Cancel the ping task + pingTask.cancel() + + // Disconnect client to clean up message loop and give time for continuation cleanup + await client.disconnect() + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + } + + @Test("Connection failure handling") + func testClientConnectionFailure() async { + let transport = MockTransport() + await transport.setFailConnect(true) + let client = Client(name: "TestClient", version: "1.0") + + do { + try await client.connect(transport: transport) + #expect(Bool(false), "Expected connection to fail") + } catch let error as Error { + if case Error.transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } + + @Test("Send failure handling") + func testClientSendFailure() async throws { + let transport = MockTransport() + await transport.setFailSend(true) + let client = Client(name: "TestClient", version: "1.0") + + try await client.connect(transport: transport) + + do { + try await client.ping() + #expect(Bool(false), "Expected ping to fail") + } catch let error as Error { + if case Error.transportError = error { + #expect(Bool(true)) + } else { + #expect(Bool(false), "Expected transport error") + } + } catch { + #expect(Bool(false), "Expected MCP.Error") + } + } +} diff --git a/Tests/MCPTests/Helpers/MockTransport.swift b/Tests/MCPTests/Helpers/MockTransport.swift new file mode 100644 index 0000000..3abb57f --- /dev/null +++ b/Tests/MCPTests/Helpers/MockTransport.swift @@ -0,0 +1,106 @@ +import Foundation +import Logging + +@testable import MCP + +/// Mock transport for testing +actor MockTransport: Transport { + var logger: Logger + var isConnected = false + private(set) var sentMessages: [String] = [] + private var messagesToReceive: [String] = [] + private var messageStreamContinuation: AsyncThrowingStream.Continuation? + var shouldFailConnect = false + var shouldFailSend = false + + init(logger: Logger = Logger(label: "mcp.test.transport")) { + self.logger = logger + } + + func connect() async throws { + if shouldFailConnect { + throw Error.transportError(POSIXError(.ECONNREFUSED)) + } + isConnected = true + } + + func disconnect() async { + isConnected = false + messageStreamContinuation?.finish() + messageStreamContinuation = nil + } + + func send(_ message: T) async throws { + if shouldFailSend { + throw Error.transportError(POSIXError(.EIO)) + } + let data = try JSONEncoder().encode(message) + let str = String(data: data, encoding: .utf8)! + sentMessages.append(str) + } + + func receive() -> AsyncThrowingStream { + return AsyncThrowingStream { continuation in + messageStreamContinuation = continuation + // Send any queued messages + for message in messagesToReceive { + continuation.yield(message) + } + messagesToReceive.removeAll() + } + } + + func queueRequest(_ request: Request) throws { + let data = try JSONEncoder().encode(request) + let str = String(data: data, encoding: .utf8)! + if let continuation = messageStreamContinuation { + continuation.yield(str) + } else { + sentMessages.append(str) + } + } + + func queueResponse(_ response: Response) throws { + let data = try JSONEncoder().encode(response) + let str = String(data: data, encoding: .utf8)! + if let continuation = messageStreamContinuation { + continuation.yield(str) + } else { + messagesToReceive.append(str) + } + } + + func queueNotification(_ notification: Message) throws { + let data = try JSONEncoder().encode(notification) + let str = String(data: data, encoding: .utf8)! + if let continuation = messageStreamContinuation { + continuation.yield(str) + } else { + messagesToReceive.append(str) + } + } + + func getLastSentMessage() -> T? { + print("SENT:", sentMessages) + guard let lastMessage = sentMessages.last else { return nil } + do { + let data = lastMessage.data(using: .utf8)! + return try JSONDecoder().decode(T.self, from: data) + } catch { + return nil + } + } + + func clearMessages() { + sentMessages.removeAll() + messagesToReceive.removeAll() + } + + func setFailConnect(_ shouldFail: Bool) { + shouldFailConnect = shouldFail + } + + func setFailSend(_ shouldFail: Bool) { + shouldFailSend = shouldFail + } +} diff --git a/Tests/MCPTests/IDTests.swift b/Tests/MCPTests/IDTests.swift new file mode 100644 index 0000000..0abf28e --- /dev/null +++ b/Tests/MCPTests/IDTests.swift @@ -0,0 +1,51 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder +import struct Foundation.UUID + +@testable import MCP + +@Suite("ID Tests") +struct IDTests { + @Test("String ID initialization and encoding") + func testStringID() throws { + let id: ID = "test-id" + #expect(id.description == "test-id") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(id) + let decoded = try decoder.decode(ID.self, from: data) + #expect(decoded == id) + } + + @Test("Number ID initialization and encoding") + func testNumberID() throws { + let id: ID = 42 + #expect(id.description == "42") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(id) + let decoded = try decoder.decode(ID.self, from: data) + #expect(decoded == id) + } + + @Test("Random ID generation") + func testRandomID() throws { + let id1 = ID.random + let id2 = ID.random + #expect(id1 != id2, "Random IDs should be unique") + + if case .string(let str) = id1 { + #expect(!str.isEmpty) + // Verify it's a valid UUID string + #expect(UUID(uuidString: str) != nil) + } else { + #expect(Bool(false), "Random ID should be string type") + } + } +} diff --git a/Tests/MCPTests/MCPTests.swift b/Tests/MCPTests/MCPTests.swift deleted file mode 100644 index bd28e3a..0000000 --- a/Tests/MCPTests/MCPTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import MCP - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/MCPTests/NotificationTests.swift b/Tests/MCPTests/NotificationTests.swift new file mode 100644 index 0000000..39886e3 --- /dev/null +++ b/Tests/MCPTests/NotificationTests.swift @@ -0,0 +1,89 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Notification Tests") +struct NotificationTests { + struct TestNotification: Notification { + struct Parameters: Codable, Hashable, Sendable { + let event: String + } + static let name = "test.notification" + } + + struct InitializedNotification: Notification { + static let name = "notifications/initialized" + } + + @Test("Notification initialization with parameters") + func testNotificationWithParameters() throws { + let params = TestNotification.Parameters(event: "test-event") + let notification = TestNotification.message(params) + + #expect(notification.method == TestNotification.name) + #expect(notification.params.event == "test-event") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(notification) + let decoded = try decoder.decode(Message.self, from: data) + + #expect(decoded.method == notification.method) + #expect(decoded.params.event == notification.params.event) + } + + @Test("Empty parameters notification") + func testEmptyParametersNotification() throws { + struct EmptyNotification: Notification { + static let name = "empty.notification" + } + + let notification = EmptyNotification.message() + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(notification) + let decoded = try decoder.decode(Message.self, from: data) + + #expect(decoded.method == notification.method) + } + + @Test("Initialized notification encoding") + func testInitializedNotificationEncoding() throws { + let notification = InitializedNotification.message() + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(notification) + + // Verify the exact JSON structure + let json = try JSONDecoder().decode([String: Value].self, from: data) + #expect(json["jsonrpc"] == "2.0") + #expect(json["method"] == "notifications/initialized") + #expect(json.count == 2, "Should only contain jsonrpc and method fields") + + // Verify we can decode it back + let decoded = try decoder.decode(Message.self, from: data) + #expect(decoded.method == InitializedNotification.name) + } + + @Test("Initialized notification decoding") + func testInitializedNotificationDecoding() throws { + // Create a minimal JSON string + let jsonString = """ + {"jsonrpc":"2.0","method":"notifications/initialized"} + """ + let data = jsonString.data(using: .utf8)! + + let decoder = JSONDecoder() + let decoded = try decoder.decode(Message.self, from: data) + + #expect(decoded.method == InitializedNotification.name) + } +} diff --git a/Tests/MCPTests/PromptTests.swift b/Tests/MCPTests/PromptTests.swift new file mode 100644 index 0000000..30651c3 --- /dev/null +++ b/Tests/MCPTests/PromptTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Prompt Tests") +struct PromptTests { + @Test("Prompt initialization with valid parameters") + func testPromptInitialization() throws { + let argument = Prompt.Argument( + name: "test_arg", + description: "A test argument", + required: true + ) + + let prompt = Prompt( + name: "test_prompt", + description: "A test prompt", + arguments: [argument] + ) + + #expect(prompt.name == "test_prompt") + #expect(prompt.description == "A test prompt") + #expect(prompt.arguments?.count == 1) + #expect(prompt.arguments?[0].name == "test_arg") + #expect(prompt.arguments?[0].description == "A test argument") + #expect(prompt.arguments?[0].required == true) + } + + @Test("Prompt Message encoding and decoding") + func testPromptMessageEncodingDecoding() throws { + let textMessage = Prompt.Message( + role: .user, + content: .text(text: "Hello, world!") + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(textMessage) + let decoded = try decoder.decode(Prompt.Message.self, from: data) + + #expect(decoded.role == .user) + if case .text(let text) = decoded.content { + #expect(text == "Hello, world!") + } else { + #expect(Bool(false), "Expected text content") + } + } + + @Test("Prompt Message Content types encoding and decoding") + func testPromptMessageContentTypes() throws { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + // Test text content + let textContent = Prompt.Message.Content.text(text: "Test text") + let textData = try encoder.encode(textContent) + let decodedText = try decoder.decode(Prompt.Message.Content.self, from: textData) + if case .text(let text) = decodedText { + #expect(text == "Test text") + } else { + #expect(Bool(false), "Expected text content") + } + + // Test image content + let imageContent = Prompt.Message.Content.image(data: "base64data", mimeType: "image/png") + let imageData = try encoder.encode(imageContent) + let decodedImage = try decoder.decode(Prompt.Message.Content.self, from: imageData) + if case .image(let data, let mimeType) = decodedImage { + #expect(data == "base64data") + #expect(mimeType == "image/png") + } else { + #expect(Bool(false), "Expected image content") + } + + // Test resource content + let resourceContent = Prompt.Message.Content.resource( + uri: "file://test.txt", + mimeType: "text/plain", + text: "Sample text", + blob: "blob_data" + ) + let resourceData = try encoder.encode(resourceContent) + let decodedResource = try decoder.decode(Prompt.Message.Content.self, from: resourceData) + if case .resource(let uri, let mimeType, let text, let blob) = decodedResource { + #expect(uri == "file://test.txt") + #expect(mimeType == "text/plain") + #expect(text == "Sample text") + #expect(blob == "blob_data") + } else { + #expect(Bool(false), "Expected resource content") + } + } + + @Test("Prompt Reference validation") + func testPromptReference() throws { + let reference = Prompt.Reference(name: "test_prompt") + #expect(reference.name == "test_prompt") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(reference) + let decoded = try decoder.decode(Prompt.Reference.self, from: data) + + #expect(decoded.name == "test_prompt") + } + + @Test("GetPrompt parameters validation") + func testGetPromptParameters() throws { + let arguments: [String: Value] = [ + "param1": .string("value1"), + "param2": .int(42), + ] + + let params = GetPrompt.Parameters(name: "test_prompt", arguments: arguments) + #expect(params.name == "test_prompt") + #expect(params.arguments?["param1"] == .string("value1")) + #expect(params.arguments?["param2"] == .int(42)) + } + + @Test("GetPrompt result validation") + func testGetPromptResult() throws { + let messages = [ + Prompt.Message(role: .user, content: .text(text: "User message")), + Prompt.Message(role: .assistant, content: .text(text: "Assistant response")), + ] + + let result = GetPrompt.Result(description: "Test description", messages: messages) + #expect(result.description == "Test description") + #expect(result.messages.count == 2) + #expect(result.messages[0].role == .user) + #expect(result.messages[1].role == .assistant) + } + + @Test("ListPrompts parameters validation") + func testListPromptsParameters() throws { + let params = ListPrompts.Parameters(cursor: "next_page") + #expect(params.cursor == "next_page") + + let emptyParams = ListPrompts.Parameters() + #expect(emptyParams.cursor == nil) + } + + @Test("ListPrompts result validation") + func testListPromptsResult() throws { + let prompts = [ + Prompt(name: "prompt1", description: "First prompt"), + Prompt(name: "prompt2", description: "Second prompt"), + ] + + let result = ListPrompts.Result(prompts: prompts, nextCursor: "next_page") + #expect(result.prompts.count == 2) + #expect(result.prompts[0].name == "prompt1") + #expect(result.prompts[1].name == "prompt2") + #expect(result.nextCursor == "next_page") + } + + @Test("PromptListChanged notification name validation") + func testPromptListChangedNotification() throws { + #expect(PromptListChangedNotification.name == "notifications/prompts/list_changed") + } +} diff --git a/Tests/MCPTests/RequestTests.swift b/Tests/MCPTests/RequestTests.swift new file mode 100644 index 0000000..add85ac --- /dev/null +++ b/Tests/MCPTests/RequestTests.swift @@ -0,0 +1,79 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Request Tests") +struct RequestTests { + struct TestMethod: Method { + struct Parameters: Codable, Hashable, Sendable { + let value: String + } + struct Result: Codable, Hashable, Sendable { + let success: Bool + } + static let name = "test.method" + } + + struct EmptyMethod: Method { + static let name = "empty.method" + } + + @Test("Request initialization with parameters") + func testRequestInitialization() throws { + let id: ID = "test-id" + let params = TestMethod.Parameters(value: "test") + let request = Request(id: id, method: TestMethod.name, params: params) + + #expect(request.id == id) + #expect(request.method == TestMethod.name) + #expect(request.params.value == "test") + } + + @Test("Request encoding and decoding") + func testRequestEncodingDecoding() throws { + let request = TestMethod.request(id: "test-id", TestMethod.Parameters(value: "test")) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(request) + let decoded = try decoder.decode(Request.self, from: data) + + #expect(decoded.id == request.id) + #expect(decoded.method == request.method) + #expect(decoded.params.value == request.params.value) + } + + @Test("Empty parameters request encoding") + func testEmptyParametersRequestEncoding() throws { + let request = EmptyMethod.request(id: "test-id") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(request) + + // Verify we can decode it back + let decoded = try decoder.decode(Request.self, from: data) + #expect(decoded.id == request.id) + #expect(decoded.method == request.method) + } + + @Test("Empty parameters request decoding") + func testEmptyParametersRequestDecoding() throws { + // Create a minimal JSON string + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","method":"empty.method"} + """ + let data = jsonString.data(using: .utf8)! + + let decoder = JSONDecoder() + let decoded = try decoder.decode(Request.self, from: data) + + #expect(decoded.id == "test-id") + #expect(decoded.method == EmptyMethod.name) + } +} diff --git a/Tests/MCPTests/ResourceTests.swift b/Tests/MCPTests/ResourceTests.swift new file mode 100644 index 0000000..eb1096b --- /dev/null +++ b/Tests/MCPTests/ResourceTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Resource Tests") +struct ResourceTests { + @Test("Resource initialization with valid parameters") + func testResourceInitialization() throws { + let resource = Resource( + name: "test_resource", + uri: "file://test.txt", + description: "A test resource", + mimeType: "text/plain", + metadata: ["key": "value"] + ) + + #expect(resource.name == "test_resource") + #expect(resource.uri == "file://test.txt") + #expect(resource.description == "A test resource") + #expect(resource.mimeType == "text/plain") + #expect(resource.metadata?["key"] == "value") + } + + @Test("Resource encoding and decoding") + func testResourceEncodingDecoding() throws { + let resource = Resource( + name: "test_resource", + uri: "file://test.txt", + description: "Test resource description", + mimeType: "text/plain", + metadata: ["key1": "value1", "key2": "value2"] + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(resource) + let decoded = try decoder.decode(Resource.self, from: data) + + #expect(decoded.name == resource.name) + #expect(decoded.uri == resource.uri) + #expect(decoded.description == resource.description) + #expect(decoded.mimeType == resource.mimeType) + #expect(decoded.metadata == resource.metadata) + } + + @Test("Resource.Content text initialization and encoding") + func testResourceContentTextEncoding() throws { + let content = Resource.Content.text( + "Hello, world!", uri: "file://test.txt", mimeType: "text/plain") + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Resource.Content.self, from: data) + + #expect(decoded.uri == "file://test.txt") + #expect(decoded.mimeType == "text/plain") + #expect(decoded.text == "Hello, world!") + #expect(decoded.blob == nil) + } + + @Test("Resource.Content binary initialization and encoding") + func testResourceContentBinaryEncoding() throws { + let binaryData = "Test binary data".data(using: .utf8)! + let content = Resource.Content.binary( + binaryData, uri: "file://test.bin", mimeType: "application/octet-stream") + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Resource.Content.self, from: data) + + #expect(decoded.uri == "file://test.bin") + #expect(decoded.mimeType == "application/octet-stream") + #expect(decoded.text == nil) + #expect(decoded.blob == binaryData.base64EncodedString()) + } + + @Test("ListResources parameters validation") + func testListResourcesParameters() throws { + let params = ListResources.Parameters(cursor: "next_page") + #expect(params.cursor == "next_page") + + let emptyParams = ListResources.Parameters() + #expect(emptyParams.cursor == nil) + } + + @Test("ListResources result validation") + func testListResourcesResult() throws { + let resources = [ + Resource(name: "resource1", uri: "file://test1.txt"), + Resource(name: "resource2", uri: "file://test2.txt"), + ] + + let result = ListResources.Result(resources: resources, nextCursor: "next_page") + #expect(result.resources.count == 2) + #expect(result.resources[0].name == "resource1") + #expect(result.resources[1].name == "resource2") + #expect(result.nextCursor == "next_page") + } + + @Test("ReadResource parameters validation") + func testReadResourceParameters() throws { + let params = ReadResource.Parameters(uri: "file://test.txt") + #expect(params.uri == "file://test.txt") + } + + @Test("ReadResource result validation") + func testReadResourceResult() throws { + let contents = [ + Resource.Content.text("Content 1", uri: "file://test1.txt"), + Resource.Content.text("Content 2", uri: "file://test2.txt"), + ] + + let result = ReadResource.Result(contents: contents) + #expect(result.contents.count == 2) + } + + @Test("ResourceSubscribe parameters validation") + func testResourceSubscribeParameters() throws { + let params = ResourceSubscribe.Parameters(uri: "file://test.txt") + #expect(params.uri == "file://test.txt") + } + + @Test("ResourceUpdatedNotification parameters validation") + func testResourceUpdatedNotification() throws { + let params = ResourceUpdatedNotification.Parameters(uri: "file://test.txt") + #expect(params.uri == "file://test.txt") + #expect(ResourceUpdatedNotification.name == "notifications/resources/updated") + } + + @Test("ResourceListChangedNotification name validation") + func testResourceListChangedNotification() throws { + #expect(ResourceListChangedNotification.name == "notifications/resources/list_changed") + } +} diff --git a/Tests/MCPTests/ResponseTests.swift b/Tests/MCPTests/ResponseTests.swift new file mode 100644 index 0000000..f09a916 --- /dev/null +++ b/Tests/MCPTests/ResponseTests.swift @@ -0,0 +1,119 @@ +import Testing + +import class Foundation.JSONDecoder +import class Foundation.JSONEncoder + +@testable import MCP + +@Suite("Response Tests") +struct ResponseTests { + struct TestMethod: Method { + struct Parameters: Codable, Hashable, Sendable { + let value: String + } + struct Result: Codable, Hashable, Sendable { + let success: Bool + } + static let name = "test.method" + } + + struct EmptyMethod: Method { + static let name = "empty.method" + } + + @Test("Success response initialization and encoding") + func testSuccessResponse() throws { + let id: ID = "test-id" + let result = TestMethod.Result(success: true) + let response = Response(id: id, result: result) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(response) + let decoded = try decoder.decode(Response.self, from: data) + + if case .success(let decodedResult) = decoded.result { + #expect(decodedResult.success == true) + } else { + #expect(Bool(false), "Expected success result") + } + } + + @Test("Error response initialization and encoding") + func testErrorResponse() throws { + let id: ID = "test-id" + let error = MCP.Error.parseError(nil) + let response = Response(id: id, error: error) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(response) + let decoded = try decoder.decode(Response.self, from: data) + + if case .failure(let decodedError) = decoded.result { + #expect(decodedError.code == -32700) + #expect( + decodedError.localizedDescription + == "Parse error: Invalid JSON: Parse error: Invalid JSON") + } else { + #expect(Bool(false), "Expected error result") + } + } + + @Test("Error response with detail") + func testErrorResponseWithDetail() throws { + let id: ID = "test-id" + let error = MCP.Error.parseError("Invalid syntax") + let response = Response(id: id, error: error) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(response) + let decoded = try decoder.decode(Response.self, from: data) + + if case .failure(let decodedError) = decoded.result { + #expect(decodedError.code == -32700) + #expect( + decodedError.localizedDescription + == "Parse error: Invalid JSON: Parse error: Invalid JSON: Invalid syntax") + } else { + #expect(Bool(false), "Expected error result") + } + } + + @Test("Empty result response encoding") + func testEmptyResultResponseEncoding() throws { + let response = EmptyMethod.response(id: "test-id") + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(response) + + // Verify we can decode it back + let decoded = try decoder.decode(Response.self, from: data) + #expect(decoded.id == response.id) + } + + @Test("Empty result response decoding") + func testEmptyResultResponseDecoding() throws { + // Create a minimal JSON string + let jsonString = """ + {"jsonrpc":"2.0","id":"test-id","result":{}} + """ + let data = jsonString.data(using: .utf8)! + + let decoder = JSONDecoder() + let decoded = try decoder.decode(Response.self, from: data) + + #expect(decoded.id == "test-id") + if case .success = decoded.result { + // Success + } else { + #expect(Bool(false), "Expected success result") + } + } +} diff --git a/Tests/MCPTests/RoundtripTests.swift b/Tests/MCPTests/RoundtripTests.swift new file mode 100644 index 0000000..cecb2e9 --- /dev/null +++ b/Tests/MCPTests/RoundtripTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Logging +import SystemPackage +import Testing + +@testable import MCP + +@Suite("Roundtrip Tests") +struct RoundtripTests { + @Test( + "Initialize roundtrip", + .timeLimit(.minutes(1)) + ) + func testInitializeRoundtrip() async throws { + let (clientToServerRead, clientToServerWrite) = try FileDescriptor.pipe() + let (serverToClientRead, serverToClientWrite) = try FileDescriptor.pipe() + + var logger = Logger( + label: "mcp.test.initialize", + factory: { StreamLogHandler.standardError(label: $0) }) + logger.logLevel = .debug + + let serverTransport = StdioTransport( + input: clientToServerRead, + output: serverToClientWrite, + logger: logger + ) + let clientTransport = StdioTransport( + input: serverToClientRead, + output: clientToServerWrite, + logger: logger + ) + + let server = Server( + name: "TestServer", + version: "1.0.0", + capabilities: .init(prompts: .init(), tools: .init()) + ) + let client = Client(name: "TestClient", version: "1.0") + + try await server.start(transport: serverTransport) + try await client.connect(transport: clientTransport) + + let initTask = Task { + let result = try await client.initialize() + + #expect(result.serverInfo.name == "TestServer") + #expect(result.serverInfo.version == "1.0.0") + #expect(result.capabilities.prompts != nil) + #expect(result.capabilities.tools != nil) + #expect(result.protocolVersion == Version.latest) + } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await Task.sleep(for: .seconds(1)) + initTask.cancel() + throw CancellationError() + } + group.addTask { + try await initTask.value + } + try await group.next() + group.cancelAll() + } + + await server.stop() + await client.disconnect() + try? clientToServerRead.close() + try? clientToServerWrite.close() + try? serverToClientRead.close() + try? serverToClientWrite.close() + } +} diff --git a/Tests/MCPTests/ServerTests.swift b/Tests/MCPTests/ServerTests.swift new file mode 100644 index 0000000..e733c01 --- /dev/null +++ b/Tests/MCPTests/ServerTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Server Tests") +struct ServerTests { + @Test("Start and stop server") + func testServerStartAndStop() async throws { + let transport = MockTransport() + let server = Server(name: "TestServer", version: "1.0") + + #expect(await transport.isConnected == false) + try await server.start(transport: transport) + #expect(await transport.isConnected == true) + await server.stop() + #expect(await transport.isConnected == false) + } + + @Test("Initialize request handling") + func testServerHandleInitialize() async throws { + let transport = MockTransport() + + // Queue an initialize request + try await transport.queueRequest( + Initialize.request( + .init( + protocolVersion: Version.latest, + capabilities: .init(), + clientInfo: .init(name: "TestClient", version: "1.0") + ) + )) + + // Start the server + let server: Server = Server( + name: "TestServer", + version: "1.0" + ) + try await server.start(transport: transport) + + // Wait for message processing and response + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + + #expect(await transport.sentMessages.count == 1) + #expect(await transport.sentMessages[0].contains(Initialize.name)) + + // Clean up + await server.stop() + await transport.disconnect() + } +} diff --git a/Tests/MCPTests/ToolTests.swift b/Tests/MCPTests/ToolTests.swift new file mode 100644 index 0000000..da8bd9a --- /dev/null +++ b/Tests/MCPTests/ToolTests.swift @@ -0,0 +1,164 @@ +import Foundation +import Testing + +@testable import MCP + +@Suite("Tool Tests") +struct ToolTests { + @Test("Tool initialization with valid parameters") + func testToolInitialization() throws { + let tool = Tool( + name: "test_tool", + description: "A test tool", + inputSchema: .object([ + "param1": .string("Test parameter") + ]) + ) + + #expect(tool.name == "test_tool") + #expect(tool.description == "A test tool") + #expect(tool.inputSchema != nil) + } + + @Test("Tool encoding and decoding") + func testToolEncodingDecoding() throws { + let tool = Tool( + name: "test_tool", + description: "Test tool description", + inputSchema: .object([ + "param1": .string("String parameter"), + "param2": .int(42), + ]) + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(tool) + let decoded = try decoder.decode(Tool.self, from: data) + + #expect(decoded.name == tool.name) + #expect(decoded.description == tool.description) + #expect(decoded.inputSchema == tool.inputSchema) + } + + @Test("Text content encoding and decoding") + func testToolContentTextEncoding() throws { + let content = Tool.Content.text("Hello, world!") + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .text(let text) = decoded { + #expect(text == "Hello, world!") + } else { + #expect(Bool(false), "Expected text content") + } + } + + @Test("Image content encoding and decoding") + func testToolContentImageEncoding() throws { + let content = Tool.Content.image( + data: "base64data", + mimeType: "image/png", + metadata: ["width": "100", "height": "100"] + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .image(let data, let mimeType, let metadata) = decoded { + #expect(data == "base64data") + #expect(mimeType == "image/png") + #expect(metadata?["width"] == "100") + #expect(metadata?["height"] == "100") + } else { + #expect(Bool(false), "Expected image content") + } + } + + @Test("Resource content encoding and decoding") + func testToolContentResourceEncoding() throws { + let content = Tool.Content.resource( + uri: "file://test.txt", + mimeType: "text/plain", + text: "Sample text" + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let data = try encoder.encode(content) + let decoded = try decoder.decode(Tool.Content.self, from: data) + + if case .resource(let uri, let mimeType, let text) = decoded { + #expect(uri == "file://test.txt") + #expect(mimeType == "text/plain") + #expect(text == "Sample text") + } else { + #expect(Bool(false), "Expected resource content") + } + } + + @Test("ListTools parameters validation") + func testListToolsParameters() throws { + let params = ListTools.Parameters(cursor: "next_page") + #expect(params.cursor == "next_page") + + let emptyParams = ListTools.Parameters() + #expect(emptyParams.cursor == nil) + } + + @Test("ListTools result validation") + func testListToolsResult() throws { + let tools = [ + Tool(name: "tool1", description: "First tool", inputSchema: nil), + Tool(name: "tool2", description: "Second tool", inputSchema: nil), + ] + + let result = ListTools.Result(tools: tools, nextCursor: "next_page") + #expect(result.tools.count == 2) + #expect(result.tools[0].name == "tool1") + #expect(result.tools[1].name == "tool2") + #expect(result.nextCursor == "next_page") + } + + @Test("CallTool parameters validation") + func testCallToolParameters() throws { + let arguments: [String: Value] = [ + "param1": .string("value1"), + "param2": .int(42), + ] + + let params = CallTool.Parameters(name: "test_tool", arguments: arguments) + #expect(params.name == "test_tool") + #expect(params.arguments?["param1"] == .string("value1")) + #expect(params.arguments?["param2"] == .int(42)) + } + + @Test("CallTool result validation") + func testCallToolResult() throws { + let content = [ + Tool.Content.text("Result 1"), + Tool.Content.text("Result 2"), + ] + + let result = CallTool.Result(content: content, isError: false) + #expect(result.content.count == 2) + #expect(result.isError == false) + + if case .text(let text) = result.content[0] { + #expect(text == "Result 1") + } else { + #expect(Bool(false), "Expected text content") + } + } + + @Test("ToolListChanged notification name validation") + func testToolListChangedNotification() throws { + #expect(ToolListChangedNotification.name == "notifications/tools/list_changed") + } +} diff --git a/Tests/MCPTests/TransportTests.swift b/Tests/MCPTests/TransportTests.swift new file mode 100644 index 0000000..c56f646 --- /dev/null +++ b/Tests/MCPTests/TransportTests.swift @@ -0,0 +1,122 @@ +import Foundation +import SystemPackage +import Testing + +@testable import MCP + +@Suite("Stdio Transport Tests") +struct StdioTransportTests { + @Test("Connection") + func testStdioTransportConnection() async throws { + let (input, _) = try FileDescriptor.pipe() + let (_, output) = try FileDescriptor.pipe() + let transport = StdioTransport(input: input, output: output, logger: nil) + try await transport.connect() + await transport.disconnect() + } + + @Test("Send Message") + func testStdioTransportSendMessage() async throws { + let (reader, output) = try FileDescriptor.pipe() + let (input, _) = try FileDescriptor.pipe() + let transport = StdioTransport(input: input, output: output, logger: nil) + try await transport.connect() + + // Test sending a simple message + let message = #"{"key":"value"}"# + try await transport.send(message) + + // Read and verify the output + var buffer = [UInt8](repeating: 0, count: 1024) + let bytesRead = try buffer.withUnsafeMutableBufferPointer { pointer in + try reader.read(into: UnsafeMutableRawBufferPointer(pointer)) + } + let data = Data(buffer[.. = await transport.receive() + var iterator = stream.makeAsyncIterator() + + // Get first message + let received = try await iterator.next() + #expect(received == #"{"key":"value"}"#) + + await transport.disconnect() + } + + @Test("Invalid JSON") + func testStdioTransportInvalidJSON() async throws { + let (input, writer) = try FileDescriptor.pipe() + let (_, output) = try FileDescriptor.pipe() + let transport = StdioTransport(input: input, output: output, logger: nil) + try await transport.connect() + + // Write invalid JSON to input pipe + let invalidJSON = #"{ invalid json }"# + try writer.writeAll(invalidJSON.data(using: .utf8)!) + try writer.close() + + let stream: AsyncThrowingStream = await transport.receive() + var iterator = stream.makeAsyncIterator() + + _ = try await iterator.next() + + await transport.disconnect() + } + + @Test("Send Error") + func testStdioTransportSendError() async throws { + let (input, _) = try FileDescriptor.pipe() + let transport = StdioTransport( + input: input, + output: FileDescriptor(rawValue: -1), // Invalid fd + logger: nil + ) + + do { + try await transport.connect() + #expect(Bool(false), "Expected connect to throw an error") + } catch { + #expect(error is MCP.Error) + } + + await transport.disconnect() + } + + @Test("Receive Error") + func testStdioTransportReceiveError() async throws { + let (_, output) = try FileDescriptor.pipe() + let transport = StdioTransport( + input: FileDescriptor(rawValue: -1), // Invalid fd + output: output, + logger: nil + ) + + do { + try await transport.connect() + #expect(Bool(false), "Expected connect to throw an error") + } catch { + #expect(error is MCP.Error) + } + + await transport.disconnect() + } +}