diff --git a/Package.resolved b/Package.resolved index eaee8ae..96aee79 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "cce36cb33302c2f1c28458e19b8439f736fc28106e4c6ea95d7992c74594c242", + "originHash" : "8375176f0e64ba7d128bf657147c408dc96035d49440183a1f643c94e3f77122", "pins" : [ + { + "identity" : "swift-json-schema", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ajevans99/swift-json-schema", + "state" : { + "revision" : "2ba78e486722955e0147574fb6806027abb29faa", + "version" : "0.4.0" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", @@ -10,6 +19,15 @@ "version" : "1.6.2" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, { "identity" : "swift-system", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 270b045..ddb9969 100644 --- a/Package.swift +++ b/Package.swift @@ -6,22 +6,24 @@ import PackageDescription let package = Package( name: "mcp-swift-sdk", platforms: [ - .macOS("13.0"), - .macCatalyst("16.0"), - .iOS("16.0"), - .watchOS("9.0"), - .tvOS("16.0"), - .visionOS("1.0"), + .macOS(.v14), + .macCatalyst(.v17), + .iOS(.v17), + .watchOS(.v10), + .tvOS(.v17), + .visionOS(.v1), ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "MCP", - targets: ["MCP"]) + targets: ["MCP", "SchemaMCP"] + ), ], 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"), + .package(url: "https://github.com/ajevans99/swift-json-schema", from: "0.0.0"), ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -31,13 +33,36 @@ let package = Package( dependencies: [ .product(name: "SystemPackage", package: "swift-system"), .product(name: "Logging", package: "swift-log"), - ]), + ], + path: "Sources/MCP" + ), .testTarget( name: "MCPTests", dependencies: [ "MCP", .product(name: "SystemPackage", package: "swift-system"), .product(name: "Logging", package: "swift-log"), - ]), + ], + path: "Tests/MCPTests" + ), + .target( + name: "SchemaMCP", + dependencies: [ + "MCP", + .product(name: "JSONSchema", package: "swift-json-schema"), + .product(name: "JSONSchemaBuilder", package: "swift-json-schema"), + ], + path: "SchemaMCP/Sources" + ), + .testTarget( + name: "SchemaMCPTests", + dependencies: [ + "MCP", + "SchemaMCP", + .product(name: "JSONSchema", package: "swift-json-schema"), + .product(name: "JSONSchemaBuilder", package: "swift-json-schema"), + ], + path: "SchemaMCP/Tests" + ), ] ) diff --git a/SchemaMCP/Sources/SchemaTool.swift b/SchemaMCP/Sources/SchemaTool.swift new file mode 100644 index 0000000..eaf64fc --- /dev/null +++ b/SchemaMCP/Sources/SchemaTool.swift @@ -0,0 +1,153 @@ +import Foundation +import JSONSchema +import JSONSchemaBuilder +import MCP + +public extension Server { + /// Registers a toolbox of tools with the server. + /// - Parameter toolBox: The toolbox to register. + /// - Returns: The server instance for chaining. + @discardableResult + func withTools( + _ toolBox: ToolBox< repeat each S> + ) -> Self { + withMethodHandler(ListTools.self) { parameters in + try .init(tools: toolBox.mcpTools(), nextCursor: parameters.cursor) + } + + return withMethodHandler(CallTool.self) { call in + for tool in repeat each toolBox.tools { + if call.name == tool.name { + let result = try await tool.handler(call.arguments) + return result + } + } + + return .init(content: [.text("Tool `\(call.name)` not found")], isError: true) + } + } +} + +/// A toolbox holding a variadic list of schema tools. +public struct ToolBox: Sendable { + /// The tuple of tools. + public let tools: (repeat SchemaTool) + + /// Initializes a toolbox with the given tools. + /// - Parameter tools: The tuple of tools. + public init(tools: (repeat SchemaTool)) { + self.tools = tools + } + + /// Converts all tools to MCP tool definitions. + /// - Throws: `MCPError` if any conversion fails. + public func mcpTools() throws(MCPError) -> [Tool] { + var mcpTools: [Tool] = [] + for tool in repeat (each tools) { + try mcpTools.append(tool.toMCPTool()) + } + return mcpTools + } +} + +/// Represents a tool with a schema-based input and async handler. +public struct SchemaTool: Identifiable, Sendable { + /// The tool name. + public let name: String + /// The tool description. + public let description: String + /// The tool input schema type. + public let inputType: Schema.Type + /// The tool handler. + private let handlerClosure: @Sendable (Schema) async throws -> CallTool.Result + /// Schema used to parse or validate input. + private let inputSchema: Schema.Schema + + /// The tool's unique identifier (same as name). + public var id: String { name } + + /// Parses arguments into the schema type. + /// - Parameter arguments: The arguments to parse. + /// - Throws: `MCPError.parseError` if parsing fails or type mismatch. + public func parse(_ arguments: [String: Value]?) throws(MCPError) -> Schema { + let output = try inputSchema.parse(arguments) + guard let schema = output as? Schema else { + throw MCPError.parseError("Schema.Schema.Output != Schema") + } + return schema + } + + /// Handles a tool call with arguments. + /// - Parameter arguments: The arguments to handle. + /// - Returns: The result of the tool call. + public func handler( + _ arguments: [String: Value]? + ) async throws -> CallTool.Result { + do { + let schema = try parse(arguments) + return try await handlerClosure(schema) + } catch let error as MCPError { + return .init(content: [.text("Invalid arguments: \(error)")], isError: true) + } + } + + /// Initializes a new `SchemaTool`. + /// - Parameters: + /// - name: The tool name. + /// - description: The tool description. + /// - inputType: The schema type for input. + /// - handler: The async handler closure. + public init( + name: String, + description: String, + inputType: Schema.Type, + handler: @escaping @Sendable (Schema) async throws -> CallTool.Result + ) { + self.name = name + self.description = description + self.inputType = inputType + handlerClosure = handler + inputSchema = inputType.schema + } + + /// Converts the tool to an MCP tool definition. + /// - Throws: `MCPError` if conversion fails. + public func toMCPTool() throws(MCPError) -> Tool { + try .init( + name: name, + description: description, + inputSchema: .init(schema: inputSchema) + ) + } +} + +/// Extension to initialize `Value` from a JSONSchemaComponent. +public extension Value { + /// Initializes a `Value` from a schema component. + /// - Parameter schema: The schema component to encode. + /// - Throws: `MCPError.parseError` if encoding or decoding fails. + init(schema: some JSONSchemaComponent) throws(MCPError) { + do { + let data = try JSONEncoder().encode(schema.definition()) + self = try JSONDecoder().decode(Value.self, from: data) + } catch { + throw MCPError.parseError("\(error)") + } + } +} + +/// Extension to parse arguments using a JSONSchemaComponent. +public extension JSONSchemaComponent { + /// Parses and validates arguments using the schema. + /// - Parameter arguments: The arguments to parse. + /// - Throws: `MCPError.invalidParams` if parsing fails. + func parse(_ arguments: [String: Value]?) throws(MCPError) -> Output { + do { + let data = try JSONEncoder().encode(arguments) + let string = String(data: data, encoding: .utf8) ?? "" + return try parseAndValidate(instance: string) + } catch { + throw MCPError.invalidParams("\(error)") + } + } +} diff --git a/SchemaMCP/Tests/SchemaToolTest.swift b/SchemaMCP/Tests/SchemaToolTest.swift new file mode 100644 index 0000000..c639095 --- /dev/null +++ b/SchemaMCP/Tests/SchemaToolTest.swift @@ -0,0 +1,358 @@ +import Foundation +import JSONSchema +import JSONSchemaBuilder +@testable import MCP +@testable import SchemaMCP +import System +import Testing + +@Schemable +struct HelloInput { + @SchemaOptions(description: "The name to say hello to", examples: "World") + let name: String +} + +@Schemable +struct AddInput { + @SchemaOptions(description: "The first number to add") + let a: Int + + @SchemaOptions(description: "The second number to add") + let b: Int +} + +@Schemable +struct EchoInput { + @SchemaOptions(description: "The message to echo") + let message: String +} + +@Suite("Schema Tool Conversion Tests") +struct SchemaToolConversionTests { + @Test func testSchemaToTool() async throws { + let schemaTool = SchemaTool( + name: "test", + description: "Test tool", + inputType: HelloInput.self, + handler: { input in + .init(content: [.text("Hello, \(input.name)")], isError: false) + } + ) + let tool = try schemaTool.toMCPTool() + + #expect(tool.name == "test") + #expect(tool.description == "Test tool") + + let inputSchema: Value = .object([ + "required": .array([.string("name")]), + "type": .string("object"), + "properties": .object([ + "name": .object([ + "type": .string("string"), + "description": .string("The name to say hello to"), + "examples": .string("World"), + ]), + ]), + ]) + #expect(tool.inputSchema == inputSchema) + } + + @Test func testInputParsing() async throws { + let tool = SchemaTool( + name: "add", + description: "Add two numbers", + inputType: AddInput.self, + handler: { input in + .init(content: [.text("Sum: \(input.a + input.b)")]) + } + ) + let args: [String: Value] = ["a": .int(2), "b": .int(3)] + let parsed = try tool.parse(args) + #expect(parsed.a == 2) + #expect(parsed.b == 3) + } + + @Test func testEchoResult() async throws { + let tool = SchemaTool( + name: "echo", + description: "Echo a message", + inputType: EchoInput.self, + handler: { input in + .init(content: [.text(input.message)]) + } + ) + let args: [String: Value] = ["message": .string("hi!")] + let result = try await tool.handler(args) + #expect(result.content == [.text("hi!")]) + } + + @Test func testMultipleToolsConversion() async throws { + let addTool = SchemaTool( + name: "add", + description: "Add two numbers", + inputType: AddInput.self, + handler: { _ in .init(content: [.text("")]) } + ) + let echoTool = SchemaTool( + name: "echo", + description: "Echo a message", + inputType: EchoInput.self, + handler: { _ in .init(content: [.text("")]) } + ) + let box = ToolBox(tools: (addTool, echoTool)) + let mcpTools = try box.mcpTools() + #expect(mcpTools.count == 2) + #expect(mcpTools[0].name == "add" || mcpTools[1].name == "add") + #expect(mcpTools[0].name == "echo" || mcpTools[1].name == "echo") + } +} + +@Suite("Schema Tool Error Tests") +struct SchemaToolErrorTests { + @Test func testInvalidInput() async throws { + let tool = SchemaTool( + name: "add", + description: "Add two numbers", + inputType: AddInput.self, + handler: { input in + .init(content: [.text("Sum: \(input.a + input.b)")]) + } + ) + let args: [String: Value] = ["a": .string("oops"), "b": .int(3)] + let result = try await tool.handler(args) + #expect(result.isError == true) + #expect(result.content.count == 1) + + guard + case let .text(text) = result.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text.contains("Type mismatch: the instance of type `string` does not match the expected type `integer`.")) + } +} + +@Suite("Schema Tool Server Tests") +class SchemaToolServerTests { + let serverReadEnd: FileDescriptor + let clientWriteEnd: FileDescriptor + let clientReadEnd: FileDescriptor + let serverWriteEnd: FileDescriptor + + let serverTransport: StdioTransport + let clientTransport: StdioTransport + + let client: Client + let server: Server + + init() throws { + let clientToServer = try FileDescriptor.pipe() + let serverToClient = try FileDescriptor.pipe() + serverReadEnd = clientToServer.readEnd + clientWriteEnd = clientToServer.writeEnd + clientReadEnd = serverToClient.readEnd + serverWriteEnd = serverToClient.writeEnd + + let serverTransport = StdioTransport( + input: serverReadEnd, + output: serverWriteEnd + ) + self.serverTransport = serverTransport + + let server = Server(name: "Server", version: "1.0.0") + self.server = server + + let clientTransport = StdioTransport( + input: clientReadEnd, + output: clientWriteEnd + ) + self.clientTransport = clientTransport + + let client = Client(name: "Client", version: "1.0.0") + self.client = client + } + + private func setup() async throws { + try await serverTransport.connect() + try await clientTransport.connect() + try await server.start(transport: serverTransport) + try await client.connect(transport: clientTransport) + } + + private func registerHelloTool() async { + let helloTool = SchemaTool( + name: "hello", + description: "Say hello", + inputType: HelloInput.self, + handler: { input in .init(content: [.text("Hello, \(input.name)")]) } + ) + let toolBox = ToolBox(tools: helloTool) + await server.withTools(toolBox) + } + + private func registerTools() async { + let helloTool = SchemaTool( + name: "hello", + description: "Say hello", + inputType: HelloInput.self, + handler: { input in .init(content: [.text("Hello, \(input.name)")]) } + ) + let addTool = SchemaTool( + name: "add", + description: "Add two numbers", + inputType: AddInput.self, + handler: { input in .init(content: [.text("Sum: \(input.a + input.b)")]) } + ) + let toolBox = ToolBox(tools: (helloTool, addTool)) + await server.withTools(toolBox) + } + + private func registerErrorThrowingTool() async { + let errorThrowingTool = SchemaTool( + name: "error", + description: "Throw an error", + inputType: HelloInput.self, + handler: { _ in throw URLError(.unknown) } + ) + let toolBox = ToolBox(tools: errorThrowingTool) + await server.withTools(toolBox) + } + + private func teardown() async { + await server.stop() + await client.disconnect() + await serverTransport.disconnect() + await clientTransport.disconnect() + } + + deinit { + try? serverReadEnd.close() + try? clientWriteEnd.close() + try? clientReadEnd.close() + try? serverWriteEnd.close() + } + + @Test func testCallToolResult() async throws { + try await setup() + await registerHelloTool() + + let callResult = try await client.callTool( + name: "hello", arguments: ["name": .string("World")] + ) + #expect(callResult.isError == nil) + #expect(callResult.content.count == 1) + guard + case let .text(text) = callResult.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text == "Hello, World") + + await teardown() + } + + @Test func testCallToolError() async throws { + try await setup() + await registerHelloTool() + + let undefinedTool = try await client.callTool( + name: "undefined", arguments: ["name": .string("World")] + ) + #expect(undefinedTool.isError == true) + #expect(undefinedTool.content.count == 1) + guard + case let .text(text) = undefinedTool.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text == "Tool `undefined` not found") + + let invalidParams = try await client.callTool( + name: "hello", arguments: ["name": .bool(true)] + ) + #expect(invalidParams.isError == true) + #expect(invalidParams.content.count == 1) + guard + case let .text(text) = invalidParams.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text.contains("Type mismatch: the instance of type `boolean` does not match the expected type `string`.")) + + await teardown() + } + + @Test func testErrorThrowingTool() async throws { + try await setup() + await registerErrorThrowingTool() + + do { + _ = try await client.callTool(name: "error", arguments: ["name": .string("World")]) + Issue.record("Expected a Error") + } catch let error as MCPError { + #expect(error == .internalError("The operation couldn’t be completed. (NSURLErrorDomain error -1.)")) + } catch { + Issue.record("Expected a MCPError") + } + + await teardown() + } + + @Test func testListTools() async throws { + try await setup() + await registerHelloTool() + + let toolsResult = try await client.listTools() + #expect(toolsResult.tools.count == 1) + #expect(toolsResult.tools[0].name == "hello") + #expect(toolsResult.tools[0].description == "Say hello") + + let inputSchema: Value = .object([ + "properties": .object([ + "name": .object([ + "type": .string("string"), + "description": .string("The name to say hello to"), + "examples": .string("World"), + ]), + ]), + "required": .array([.string("name")]), + "type": .string("object"), + ]) + #expect(toolsResult.tools[0].inputSchema == inputSchema) + + await teardown() + } + + @Test func testMultipleTools() async throws { + try await setup() + await registerTools() + + let helloResult = try await client.callTool( + name: "hello", arguments: ["name": .string("World")] + ) + guard + case let .text(text) = helloResult.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text == "Hello, World") + + let addResult = try await client.callTool( + name: "add", arguments: ["a": .int(2), "b": .int(3)] + ) + guard + case let .text(text) = addResult.content.first + else { + Issue.record("Expected a text message") + return + } + #expect(text == "Sum: 5") + + await teardown() + } +}