diff --git a/Example/Sources/MCPToolkitExample/Vapor/VaporStreamableHTTPTransport.swift b/Example/Sources/MCPToolkitExample/Vapor/VaporStreamableHTTPTransport.swift index b14d066..1cf0dec 100644 --- a/Example/Sources/MCPToolkitExample/Vapor/VaporStreamableHTTPTransport.swift +++ b/Example/Sources/MCPToolkitExample/Vapor/VaporStreamableHTTPTransport.swift @@ -86,6 +86,7 @@ public actor VaporStreamableHTTPTransport: Transport { /// Handles a Streamable HTTP `POST` request by forwarding the payload into the MCP server. /// - Parameter req: Incoming Vapor request. /// - Returns: A streaming response when the payload contains requests needing replies, otherwise `202 Accepted`. + /// - Throws: ``Abort`` errors for malformed requests or decoding failures propagated from ``JSONRPCEnvelope`` parsing. func handlePost(_ req: HTTPRequest) async throws -> HTTPResponse { guard var body = req.body.data, diff --git a/README.md b/README.md index 1a9348c..029698b 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,7 @@ struct VanillaWeatherTool { ### Step 2: Register the Tool with a MCP Server Create the same `Server` instance you would when using the `swift-sdk`, then call `register(tools:)` with your tool instance(s). +The optional `messaging:` parameter lets you customise every toolkit-managed response if you want to adjust tone, add metadata, or localise error messages. ```swift import MCPToolkit @@ -137,9 +138,23 @@ let server = Server( capabilities: .init(tools: .init(listChanged: true)) ) -await server.register(tools: [WeatherTool()]) +await server.register( + tools: [WeatherTool()], + messaging: ResponseMessagingFactory.defaultWithOverrides { overrides in + overrides.toolThrew = { context in + CallTool.Result( + content: [ + .text("Weather machine failure: \(context.error.localizedDescription)") + ], + isError: true + ) + } + } +) ``` +If you are happy with the toolkit's defaults, simply omit the `messaging:` argument. + ## Running the Example Server with MCP Inspector [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) is an interactive development tool for MCP servers. diff --git a/Sources/MCPToolkit/Documentation.docc/MCPToolkit.md b/Sources/MCPToolkit/Documentation.docc/MCPToolkit.md index c8b58f8..cf214da 100644 --- a/Sources/MCPToolkit/Documentation.docc/MCPToolkit.md +++ b/Sources/MCPToolkit/Documentation.docc/MCPToolkit.md @@ -7,7 +7,7 @@ Build Model Context Protocol (MCP) tools in Swift using structured concurrency, The MCP specification standardises how AI assistants discover and invoke server-side tools. This package focuses on the tooling surface that server authors most frequently implement: - `MCPTool` defines a strongly typed contract between your Swift code and `tools/call` requests. -- `Server/register(tools:)` wires those tools into the SDK's `Server` actor so clients can list and execute them. +- `Server/register(tools:messaging:)` wires those tools into the SDK's `Server` actor so clients can list and execute them, while exposing hooks for customising toolkit-managed responses. - `MCPTool/call(arguments:)` bridges raw MCP arguments into validated Swift values using `JSONSchemaBuilder`. ### Why MCPToolkit? @@ -38,7 +38,7 @@ The MCP specification standardises how AI assistants discover and invoke server- } ``` -2. **Register Tools** on your `Server`: +2. **Register Tools** on your `Server` and optionally tailor messaging: ```swift let server = Server( @@ -47,7 +47,19 @@ The MCP specification standardises how AI assistants discover and invoke server- capabilities: .init(tools: .init(listChanged: true)) ) - await server.register(tools: [WeatherTool()]) + await server.register( + tools: [WeatherTool()], + messaging: ResponseMessagingFactory.defaultWithOverrides { overrides in + overrides.toolThrew = { context in + CallTool.Result( + content: [ + .text("Weather machine failure: \(context.error.localizedDescription)") + ], + isError: true + ) + } + } + ) ``` 3. **Respond to Clients** – incoming `tools/call` requests are parsed, validated, and routed without additional glue code. @@ -59,4 +71,7 @@ The MCP specification standardises how AI assistants discover and invoke server- - `MCPTool` - `MCPTool/call(arguments:)` - `MCPTool/toTool()` -- `Server/register(tools:)` +- `Server/register(tools:messaging:)` +- ``ResponseMessaging`` +- ``DefaultResponseMessaging`` +- ``ResponseMessagingFactory`` diff --git a/Sources/MCPToolkit/Extensions/MCPTool+MCP.swift b/Sources/MCPToolkit/Extensions/MCPTool+MCP.swift index 443ca97..a3ffd00 100644 --- a/Sources/MCPToolkit/Extensions/MCPTool+MCP.swift +++ b/Sources/MCPToolkit/Extensions/MCPTool+MCP.swift @@ -1,4 +1,3 @@ -import Foundation import JSONSchema import JSONSchemaBuilder import MCP @@ -11,51 +10,44 @@ extension MCPTool { /// 2. Parse and validate them against the tool's declared schema. /// 3. Forward the confirmed payload into ``MCPTool/call(with:)``. /// - /// Any parsing or validation problems are wrapped in a `CallTool.Result` containing - /// [`Tool.Content.text`](https://github.com/modelcontextprotocol/swift-sdk/blob/main/Sources/MCP/Server/Tools.swift), - /// matching the expectations laid out in the MCP "Calling Tools" spec. + /// Any parsing or validation problems are routed through the provided ``ResponseMessaging`` + /// implementation, allowing callers to customize every surface returned to the model. /// - /// - Parameter arguments: The raw JSON-like dictionary the MCP client provided. + /// - Parameters: + /// - arguments: The raw JSON-like dictionary the MCP client provided. + /// - messaging: The response messaging provider that should format any failures. Defaults to + /// ``DefaultResponseMessaging`` to preserve the toolkit's existing behaviour. /// - Returns: Either a successful tool result or an error response describing validation issues. /// - Throws: Rethrows errors produced by ``MCPTool/call(with:)``. /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/server/tools#calling-tools - public func call(arguments: [String: MCP.Value]) async throws -> CallTool.Result { + public func call( + arguments: [String: MCP.Value], + messaging: M = DefaultResponseMessaging() + ) async throws -> CallTool.Result { let object = arguments.mapValues { JSONValue(value: $0) } let params: Parameters do { params = try parameters.parseAndValidate(.object(object)) } catch ParseAndValidateIssue.parsingFailed(let parseIssues) { - return .init( - content: [.text("Failed to parse arguments for tool \(name): \(parseIssues.description)")], - isError: true + return messaging.parsingFailed( + .init(toolName: name, issues: parseIssues) ) } catch ParseAndValidateIssue.validationFailed(let validationResult) { - return .init( - content: [ - .text( - "Arguments for tool \(name) failed validation: \(validationResult.prettyJSONString())" - ) - ], - isError: true + return messaging.validationFailed( + .init(toolName: name, result: validationResult) ) } catch ParseAndValidateIssue.parsingAndValidationFailed(let parseErrors, let validationResult) { - return .init( - content: [ - .text( - "Arguments for tool \(name) failed parsing and validation. Parsing errors: \(parseErrors.description). Validation errors: \(validationResult.prettyJSONString())" - ) - ], - isError: true + return messaging.parsingAndValidationFailed( + .init( + toolName: name, + parseIssues: parseErrors, + validationResult: validationResult + ) ) } catch { - return .init( - content: [ - .text( - "Unexpected error occurred while parsing/validating arguments for tool \(name): \(error)" - ) - ], - isError: true + return messaging.unexpectedError( + .init(toolName: name, error: error) ) } return try await call(with: params) @@ -76,22 +68,3 @@ extension MCPTool { ) } } - -// MARK: - Error Printing - -extension Array where Element == ParseIssue { - fileprivate var description: String { - self.map(\.description).joined(separator: "; ") - } -} - -extension ValidationResult { - fileprivate func prettyJSONString() -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - guard let data = try? encoder.encode(self) else { - return #"{"error": "failed to encode ValidationResult"}"# - } - return String(data: data, encoding: .utf8) ?? #"{"error": "utf8 conversion failed"}"# - } -} diff --git a/Sources/MCPToolkit/Extensions/Server+register.swift b/Sources/MCPToolkit/Extensions/Server+register.swift index 2203670..3d49dc1 100644 --- a/Sources/MCPToolkit/Extensions/Server+register.swift +++ b/Sources/MCPToolkit/Extensions/Server+register.swift @@ -9,36 +9,39 @@ extension Server { /// [swift-sdk README](https://github.com/modelcontextprotocol/swift-sdk) while removing the /// boilerplate for JSON parsing, validation, and error reporting. /// - /// - Parameter tools: The collection of tools that should be surfaced to MCP clients. + /// - Parameters: + /// - tools: The collection of tools that should be surfaced to MCP clients. + /// - messaging: The response messaging provider responsible for formatting toolkit-managed + /// responses. Defaults to ``DefaultResponseMessaging``. /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/server/tools - public func register(tools: [any MCPTool]) async { + public func register( + tools: [any MCPTool], + messaging: M = DefaultResponseMessaging() + ) async { self.withMethodHandler(ListTools.self) { _ in .init(tools: tools.map { $0.toTool() }) } self.withMethodHandler(CallTool.self) { params async in guard let tool = tools.first(where: { $0.name == params.name }) else { - return .init( - content: [.text("Unknown tool: \(params.name)")], - isError: true + return messaging.unknownTool( + .init(requestedName: params.name) ) } if let arguments = params.arguments { do { - let result = try await tool.call(arguments: arguments) + let result = try await tool.call(arguments: arguments, messaging: messaging) return result } catch { - return .init( - content: [.text("Error occurred while calling tool \(params.name): \(error)")], - isError: true + return messaging.toolThrew( + .init(toolName: tool.name, error: error) ) } } - return .init( - content: [.text("Missing arguments for tool \(params.name)")], - isError: true + return messaging.missingArguments( + .init(toolName: tool.name) ) } } diff --git a/Sources/MCPToolkit/ResponseMessaging.swift b/Sources/MCPToolkit/ResponseMessaging.swift new file mode 100644 index 0000000..2874d71 --- /dev/null +++ b/Sources/MCPToolkit/ResponseMessaging.swift @@ -0,0 +1,308 @@ +import Foundation +import JSONSchema +import MCP + +/// Describes the set of toolkit-managed responses that can be customized by callers. +public protocol ResponseMessaging: Sendable { + func unknownTool(_ context: ResponseMessagingUnknownToolContext) -> CallTool.Result + func missingArguments(_ context: ResponseMessagingMissingArgumentsContext) -> CallTool.Result + func toolThrew(_ context: ResponseMessagingToolErrorContext) -> CallTool.Result + func parsingFailed(_ context: ResponseMessagingParsingFailedContext) -> CallTool.Result + func validationFailed(_ context: ResponseMessagingValidationFailedContext) -> CallTool.Result + func parsingAndValidationFailed( + _ context: ResponseMessagingParsingAndValidationFailedContext + ) -> CallTool.Result + func unexpectedError(_ context: ResponseMessagingUnexpectedErrorContext) -> CallTool.Result +} + +/// Provides the default set of toolkit responses +public struct DefaultResponseMessaging: ResponseMessaging { + public init() {} + + public func unknownTool(_ context: ResponseMessagingUnknownToolContext) -> CallTool.Result { + .init( + content: [.text("Unknown tool: \(context.requestedName)")], + isError: true + ) + } + + public func missingArguments( + _ context: ResponseMessagingMissingArgumentsContext + ) -> CallTool.Result { + .init( + content: [.text("Missing arguments for tool \(context.toolName)")], + isError: true + ) + } + + public func toolThrew(_ context: ResponseMessagingToolErrorContext) -> CallTool.Result { + .init( + content: [ + .text("Error occurred while calling tool \(context.toolName): \(context.error)") + ], + isError: true + ) + } + + public func parsingFailed( + _ context: ResponseMessagingParsingFailedContext + ) -> CallTool.Result { + let issues = context.issues.map(\.description).joined(separator: "; ") + return .init( + content: [ + .text("Failed to parse arguments for tool \(context.toolName): \(issues)") + ], + isError: true + ) + } + + public func validationFailed( + _ context: ResponseMessagingValidationFailedContext + ) -> CallTool.Result { + .init( + content: [ + .text( + "Arguments for tool \(context.toolName) failed validation: \(context.result.prettyJSONString())" + ) + ], + isError: true + ) + } + + public func parsingAndValidationFailed( + _ context: ResponseMessagingParsingAndValidationFailedContext + ) -> CallTool.Result { + let parseIssues = context.parseIssues.map(\.description).joined(separator: "; ") + let validation = context.validationResult.prettyJSONString() + return .init( + content: [ + .text( + "Arguments for tool \(context.toolName) failed parsing and validation. Parsing errors: \(parseIssues). Validation errors: \(validation)" + ) + ], + isError: true + ) + } + + public func unexpectedError( + _ context: ResponseMessagingUnexpectedErrorContext + ) -> CallTool.Result { + .init( + content: [ + .text( + "Unexpected error occurred while parsing/validating arguments for tool \(context.toolName): \(context.error)" + ) + ], + isError: true + ) + } +} + +/// A convenience factory that allows callers to override a subset of messaging behaviours. +public enum ResponseMessagingFactory { + /// Mutable container for configuring response overrides. + public struct Overrides: Sendable { + public typealias Handler = @Sendable (Context) -> CallTool.Result + + public var unknownTool: Handler? + public var missingArguments: Handler? + public var toolThrew: Handler? + public var parsingFailed: Handler? + public var validationFailed: Handler? + public var parsingAndValidationFailed: + Handler? + public var unexpectedError: Handler? + + public init() {} + } + + /// Creates a response messaging implementation starting from ``DefaultResponseMessaging`` + /// and applying the provided overrides. + public static func defaultWithOverrides( + _ configure: (inout Overrides) -> Void + ) -> some ResponseMessaging { + var overrides = Overrides() + configure(&overrides) + + let base = DefaultResponseMessaging() + return ClosureResponseMessaging( + unknownTool: overrides.unknownTool ?? base.unknownTool, + missingArguments: overrides.missingArguments ?? base.missingArguments, + toolThrew: overrides.toolThrew ?? base.toolThrew, + parsingFailed: overrides.parsingFailed ?? base.parsingFailed, + validationFailed: overrides.validationFailed ?? base.validationFailed, + parsingAndValidationFailed: overrides.parsingAndValidationFailed + ?? base.parsingAndValidationFailed, + unexpectedError: overrides.unexpectedError ?? base.unexpectedError + ) + } +} + +private struct ClosureResponseMessaging: ResponseMessaging { + typealias Handler = @Sendable (Context) -> CallTool.Result + + let unknownToolHandler: Handler + let missingArgumentsHandler: Handler + let toolThrewHandler: Handler + let parsingFailedHandler: Handler + let validationFailedHandler: Handler + let parsingAndValidationFailedHandler: Handler + let unexpectedErrorHandler: Handler + + init( + unknownTool: @escaping Handler, + missingArguments: @escaping Handler, + toolThrew: @escaping Handler, + parsingFailed: @escaping Handler, + validationFailed: @escaping Handler, + parsingAndValidationFailed: + @escaping Handler< + ResponseMessagingParsingAndValidationFailedContext + >, + unexpectedError: @escaping Handler + ) { + self.unknownToolHandler = unknownTool + self.missingArgumentsHandler = missingArguments + self.toolThrewHandler = toolThrew + self.parsingFailedHandler = parsingFailed + self.validationFailedHandler = validationFailed + self.parsingAndValidationFailedHandler = parsingAndValidationFailed + self.unexpectedErrorHandler = unexpectedError + } + + func unknownTool(_ context: ResponseMessagingUnknownToolContext) -> CallTool.Result { + unknownToolHandler(context) + } + + func missingArguments( + _ context: ResponseMessagingMissingArgumentsContext + ) -> CallTool.Result { + missingArgumentsHandler(context) + } + + func toolThrew(_ context: ResponseMessagingToolErrorContext) -> CallTool.Result { + toolThrewHandler(context) + } + + func parsingFailed( + _ context: ResponseMessagingParsingFailedContext + ) -> CallTool.Result { + parsingFailedHandler(context) + } + + func validationFailed( + _ context: ResponseMessagingValidationFailedContext + ) -> CallTool.Result { + validationFailedHandler(context) + } + + func parsingAndValidationFailed( + _ context: ResponseMessagingParsingAndValidationFailedContext + ) -> CallTool.Result { + parsingAndValidationFailedHandler(context) + } + + func unexpectedError( + _ context: ResponseMessagingUnexpectedErrorContext + ) -> CallTool.Result { + unexpectedErrorHandler(context) + } +} + +// MARK: - Context Types + +public struct ResponseMessagingUnknownToolContext: Sendable { + /// The tool name requested by the client. + public let requestedName: String + + public init(requestedName: String) { + self.requestedName = requestedName + } +} + +public struct ResponseMessagingMissingArgumentsContext: Sendable { + /// The tool whose invocation was missing arguments. + public let toolName: String + + public init(toolName: String) { + self.toolName = toolName + } +} + +public struct ResponseMessagingToolErrorContext: Sendable { + /// The tool whose invocation threw an error. + public let toolName: String + /// The thrown error. + public let error: any Error + + public init(toolName: String, error: any Error) { + self.toolName = toolName + self.error = error + } +} + +public struct ResponseMessagingParsingFailedContext: Sendable { + /// The tool whose arguments failed to parse. + public let toolName: String + /// The issues emitted by the parser. + public let issues: [ParseIssue] + + public init(toolName: String, issues: [ParseIssue]) { + self.toolName = toolName + self.issues = issues + } +} + +public struct ResponseMessagingValidationFailedContext: Sendable { + /// The tool whose arguments failed validation. + public let toolName: String + /// The validation result describing the failure. + public let result: ValidationResult + + public init(toolName: String, result: ValidationResult) { + self.toolName = toolName + self.result = result + } +} + +public struct ResponseMessagingParsingAndValidationFailedContext: Sendable { + /// The tool whose arguments failed both parsing and validation. + public let toolName: String + /// The parsing issues encountered. + public let parseIssues: [ParseIssue] + /// The validation failures encountered. + public let validationResult: ValidationResult + + public init( + toolName: String, + parseIssues: [ParseIssue], + validationResult: ValidationResult + ) { + self.toolName = toolName + self.parseIssues = parseIssues + self.validationResult = validationResult + } +} + +public struct ResponseMessagingUnexpectedErrorContext: Sendable { + /// The tool whose arguments triggered an unexpected error. + public let toolName: String + /// The unexpected error that occurred. + public let error: any Error + + public init(toolName: String, error: any Error) { + self.toolName = toolName + self.error = error + } +} + +extension ValidationResult { + fileprivate func prettyJSONString() -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + guard let data = try? encoder.encode(self) else { + return #"{"error": "failed to encode ValidationResult"}"# + } + return String(data: data, encoding: .utf8) ?? #"{"error": "utf8 conversion failed"}"# + } +} diff --git a/Tests/MCPToolkitTests/ResponseMessagingTests.swift b/Tests/MCPToolkitTests/ResponseMessagingTests.swift new file mode 100644 index 0000000..2480a41 --- /dev/null +++ b/Tests/MCPToolkitTests/ResponseMessagingTests.swift @@ -0,0 +1,95 @@ +import Foundation +import MCPToolkit +import Testing + +@Suite("Response messaging customization") +struct ResponseMessagingTests { + @Test("Default messaging mirrors legacy strings") + func defaultMessagingMatchesLegacyBehaviour() { + let messaging = DefaultResponseMessaging() + + let unknown = messaging.unknownTool(.init(requestedName: "mystery")) + #expect(unknown.isError == true) + #expect(unknown.content == [.text("Unknown tool: mystery")]) + + let missingArguments = messaging.missingArguments(.init(toolName: "addition")) + #expect(missingArguments.content == [.text("Missing arguments for tool addition")]) + } + + @Test("call(arguments:) surfaces override for parsing failures") + func callUsesCustomParsingMessaging() async throws { + let messaging = ResponseMessagingFactory.defaultWithOverrides { overrides in + overrides.parsingFailed = { context in + #expect(context.toolName == "addition") + #expect(!context.issues.isEmpty) + return .init( + content: [.text("Custom parse failure for \(context.toolName)")], + isError: true + ) + } + overrides.parsingAndValidationFailed = { context in + #expect(context.toolName == "addition") + #expect(!context.parseIssues.isEmpty) + return .init( + content: [.text("Custom parse failure for \(context.toolName)")], + isError: true + ) + } + } + + let result = try await AdditionTool().call( + arguments: [ + "left": .int(1), + "right": .string("not-a-number"), + ], + messaging: messaging + ) + + #expect(result.isError == true) + #expect(result.content == [.text("Custom parse failure for addition")]) + } + + @Test("Server.register uses custom messaging for toolkit errors") + func serverUsesCustomMessaging() async throws { + let transport = TestTransport() + let server = Server(name: "Messaging Server", version: "1.0.0") + let tool = AdditionTool() + + let messaging = ResponseMessagingFactory.defaultWithOverrides { overrides in + overrides.missingArguments = { context in + #expect(context.toolName == tool.name) + return .init(content: [.text("Provide args for \(context.toolName)!")], isError: true) + } + overrides.toolThrew = { context in + return .init(content: [.text("Tool boom: \(context.error)")], isError: true) + } + } + + await server.register(tools: [tool], messaging: messaging) + try await server.start(transport: transport) + + do { + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + await transport.push( + try encoder.encode(CallTool.request(.init(name: tool.name, arguments: nil))) + ) + + let responses = try await transport.waitForSent(count: 1) + let data = try #require(responses.first) + let response = try decoder.decode(Response.self, from: data) + let result = try response.result.get() + + #expect(result.isError == true) + #expect(result.content == [.text("Provide args for addition!")]) + } catch { + await transport.finish() + await server.stop() + throw error + } + + await transport.finish() + await server.stop() + } +}