From db6a964e3d516d0a9b163907a00b204136d4cb57 Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Wed, 8 Oct 2025 18:35:53 -0400 Subject: [PATCH 1/4] Add response messaging customization hooks --- .../Vapor/VaporStreamableHTTPTransport.swift | 1 + README.md | 18 +- .../Documentation.docc/MCPToolkit.md | 23 +- .../MCPToolkit/Extensions/MCPTool+MCP.swift | 71 ++-- .../Extensions/Server+register.swift | 27 +- Sources/MCPToolkit/ResponseMessaging.swift | 307 ++++++++++++++++++ .../ResponseMessagingTests.swift | 95 ++++++ docs/response-customization-plan.md | 55 ++++ 8 files changed, 531 insertions(+), 66 deletions(-) create mode 100644 Sources/MCPToolkit/ResponseMessaging.swift create mode 100644 Tests/MCPToolkitTests/ResponseMessagingTests.swift create mode 100644 docs/response-customization-plan.md 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..6c10c3c 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ 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 +139,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..26a5d26 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`` to match the previous behaviour. /// - 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..b678e9b --- /dev/null +++ b/Sources/MCPToolkit/ResponseMessaging.swift @@ -0,0 +1,307 @@ +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 mirroring the previous hard-coded strings. +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() + } +} diff --git a/docs/response-customization-plan.md b/docs/response-customization-plan.md new file mode 100644 index 0000000..dc836f3 --- /dev/null +++ b/docs/response-customization-plan.md @@ -0,0 +1,55 @@ +# Response Customization Strategy + +## Overview +Client applications now have complete control over how the toolkit communicates back to the model and, by extension, the user. Prior to this work, the toolkit shipped fixed English strings for success and error paths. That limitation prevented product teams from expressing their own voice, surfacing structured data, or tailoring the tone to specific scenarios (support, compliance, experimentation, etc.). + +The implementation introduces a flexible response customization layer that keeps the previous behaviour by default while enabling integrators to override any message or payload the toolkit emits. + +## Current State +- `Server.register(tools:)` constructs `CallTool.Result` responses in-line with hard-coded strings whenever registration or invocation fails.【F:Sources/MCPToolkit/Extensions/Server+register.swift†L18-L42】 +- `MCPTool.call(arguments:)` translates validation and decoding failures directly into English-only error messages.【F:Sources/MCPToolkit/Extensions/MCPTool+MCP.swift†L13-L56】 +- Callers cannot intercept or modify the payload before it is returned to the model, so adopting a different tone, format, or locale requires forking the toolkit. + +## Goals +1. Decouple message creation from business logic so every response can be customized. +2. Preserve backwards compatibility by providing a default configuration that reproduces the current strings and structure. +3. Allow overrides to supply plain strings, richly formatted results, or async closures that compute content dynamically. +4. Ensure the customization API remains ergonomic for both lightweight tweaks (e.g. replacing a single message) and fully branded experiences. + +## Implemented Solution +### 1. Response Customization Provider +- Added a `ResponseMessaging` protocol enumerating every toolkit-managed response. Each method receives a strongly typed context (e.g. `ResponseMessagingToolErrorContext`) so overrides can inspect tool names, thrown errors, parse issues, or validation results. +- Introduced `DefaultResponseMessaging`, which preserves the previous hard-coded English messages to keep the API backwards compatible. + +### 2. Wiring Through Core APIs +- Extended `Server.register(tools:messaging:)` with an optional `messaging` parameter that defaults to `DefaultResponseMessaging()`. +- Updated `MCPTool.call(arguments:messaging:)` to accept the provider and route parsing/validation failures through it. +- All toolkit-owned error surfaces—unknown tool, missing arguments, thrown errors, parsing failures, validation failures—now flow through the messaging abstraction. + +### 3. Convenience Builders +- Added `ResponseMessagingFactory.defaultWithOverrides(_:)` so integrators can override only the messages they care about while inheriting defaults for the rest. +- Builder closures are `@Sendable`, making them safe for concurrent invocation. + +### 4. Documentation & Migration Guidance +- README and DocC samples now show how to pass a custom messaging provider when registering tools. +- The `docs/` plan doubles as a quick reference for the available contexts and extension points. + +### 5. Testing & Validation +- Added unit tests covering default behaviour, custom parsing overrides, and `Server.register` integration with bespoke messaging. +- Existing tests exercising validation output continue to pass, ensuring no regressions for the default strings. + +## Rollout Strategy +1. **Introduce** – Ship the protocol, default implementation, and builder APIs with backwards compatible defaults. +2. **Integrate** – Route all existing response paths through the provider. +3. **Document** – Publish examples and guidance in README, DocC, and this document. +4. **Iterate** – Gather adopter feedback for future enhancements (e.g. async overrides or richer metadata helpers). + +## Risks & Mitigations +- **API surface growth**: Keep protocol requirements tightly scoped to existing message types and evolve via new methods when necessary. +- **Performance**: Closures should execute quickly; document that expensive operations belong outside the response layer. +- **Backward compatibility**: Default implementation preserves today’s behavior, and the new parameter remains optional. + +## Acceptance Criteria +- Integrators can customize any toolkit-generated message without duplicating toolkit logic. +- Default responses remain unchanged for consumers who do not opt in. +- Examples and tests clearly illustrate how to adopt the new customization layer for tone, structure, or localization. From 221625ae1f9a48486325f9cfef687e4acdeada1f Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Thu, 9 Oct 2025 16:34:56 -0400 Subject: [PATCH 2/4] Minor doc tweaks --- Sources/MCPToolkit/Extensions/Server+register.swift | 2 +- Sources/MCPToolkit/ResponseMessaging.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MCPToolkit/Extensions/Server+register.swift b/Sources/MCPToolkit/Extensions/Server+register.swift index 26a5d26..3d49dc1 100644 --- a/Sources/MCPToolkit/Extensions/Server+register.swift +++ b/Sources/MCPToolkit/Extensions/Server+register.swift @@ -12,7 +12,7 @@ extension Server { /// - 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`` to match the previous behaviour. + /// responses. Defaults to ``DefaultResponseMessaging``. /// - SeeAlso: https://modelcontextprotocol.io/specification/2025-06-18/server/tools public func register( tools: [any MCPTool], diff --git a/Sources/MCPToolkit/ResponseMessaging.swift b/Sources/MCPToolkit/ResponseMessaging.swift index b678e9b..f654012 100644 --- a/Sources/MCPToolkit/ResponseMessaging.swift +++ b/Sources/MCPToolkit/ResponseMessaging.swift @@ -15,7 +15,7 @@ public protocol ResponseMessaging: Sendable { func unexpectedError(_ context: ResponseMessagingUnexpectedErrorContext) -> CallTool.Result } -/// Provides the default set of toolkit responses mirroring the previous hard-coded strings. +/// Provides the default set of toolkit responses public struct DefaultResponseMessaging: ResponseMessaging { public init() {} From 553f7c1d46a1f75f683d332861c85147148c9b4e Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Thu, 9 Oct 2025 16:36:30 -0400 Subject: [PATCH 3/4] Remove plan and edit readme --- README.md | 3 +- docs/response-customization-plan.md | 55 ----------------------------- 2 files changed, 1 insertion(+), 57 deletions(-) delete mode 100644 docs/response-customization-plan.md diff --git a/README.md b/README.md index 6c10c3c..029698b 100644 --- a/README.md +++ b/README.md @@ -127,8 +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. +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 diff --git a/docs/response-customization-plan.md b/docs/response-customization-plan.md deleted file mode 100644 index dc836f3..0000000 --- a/docs/response-customization-plan.md +++ /dev/null @@ -1,55 +0,0 @@ -# Response Customization Strategy - -## Overview -Client applications now have complete control over how the toolkit communicates back to the model and, by extension, the user. Prior to this work, the toolkit shipped fixed English strings for success and error paths. That limitation prevented product teams from expressing their own voice, surfacing structured data, or tailoring the tone to specific scenarios (support, compliance, experimentation, etc.). - -The implementation introduces a flexible response customization layer that keeps the previous behaviour by default while enabling integrators to override any message or payload the toolkit emits. - -## Current State -- `Server.register(tools:)` constructs `CallTool.Result` responses in-line with hard-coded strings whenever registration or invocation fails.【F:Sources/MCPToolkit/Extensions/Server+register.swift†L18-L42】 -- `MCPTool.call(arguments:)` translates validation and decoding failures directly into English-only error messages.【F:Sources/MCPToolkit/Extensions/MCPTool+MCP.swift†L13-L56】 -- Callers cannot intercept or modify the payload before it is returned to the model, so adopting a different tone, format, or locale requires forking the toolkit. - -## Goals -1. Decouple message creation from business logic so every response can be customized. -2. Preserve backwards compatibility by providing a default configuration that reproduces the current strings and structure. -3. Allow overrides to supply plain strings, richly formatted results, or async closures that compute content dynamically. -4. Ensure the customization API remains ergonomic for both lightweight tweaks (e.g. replacing a single message) and fully branded experiences. - -## Implemented Solution -### 1. Response Customization Provider -- Added a `ResponseMessaging` protocol enumerating every toolkit-managed response. Each method receives a strongly typed context (e.g. `ResponseMessagingToolErrorContext`) so overrides can inspect tool names, thrown errors, parse issues, or validation results. -- Introduced `DefaultResponseMessaging`, which preserves the previous hard-coded English messages to keep the API backwards compatible. - -### 2. Wiring Through Core APIs -- Extended `Server.register(tools:messaging:)` with an optional `messaging` parameter that defaults to `DefaultResponseMessaging()`. -- Updated `MCPTool.call(arguments:messaging:)` to accept the provider and route parsing/validation failures through it. -- All toolkit-owned error surfaces—unknown tool, missing arguments, thrown errors, parsing failures, validation failures—now flow through the messaging abstraction. - -### 3. Convenience Builders -- Added `ResponseMessagingFactory.defaultWithOverrides(_:)` so integrators can override only the messages they care about while inheriting defaults for the rest. -- Builder closures are `@Sendable`, making them safe for concurrent invocation. - -### 4. Documentation & Migration Guidance -- README and DocC samples now show how to pass a custom messaging provider when registering tools. -- The `docs/` plan doubles as a quick reference for the available contexts and extension points. - -### 5. Testing & Validation -- Added unit tests covering default behaviour, custom parsing overrides, and `Server.register` integration with bespoke messaging. -- Existing tests exercising validation output continue to pass, ensuring no regressions for the default strings. - -## Rollout Strategy -1. **Introduce** – Ship the protocol, default implementation, and builder APIs with backwards compatible defaults. -2. **Integrate** – Route all existing response paths through the provider. -3. **Document** – Publish examples and guidance in README, DocC, and this document. -4. **Iterate** – Gather adopter feedback for future enhancements (e.g. async overrides or richer metadata helpers). - -## Risks & Mitigations -- **API surface growth**: Keep protocol requirements tightly scoped to existing message types and evolve via new methods when necessary. -- **Performance**: Closures should execute quickly; document that expensive operations belong outside the response layer. -- **Backward compatibility**: Default implementation preserves today’s behavior, and the new parameter remains optional. - -## Acceptance Criteria -- Integrators can customize any toolkit-generated message without duplicating toolkit logic. -- Default responses remain unchanged for consumers who do not opt in. -- Examples and tests clearly illustrate how to adopt the new customization layer for tone, structure, or localization. From 10105721f1176cd0935d91c4d91d48f2280f3c9e Mon Sep 17 00:00:00 2001 From: Austin Evans Date: Thu, 9 Oct 2025 16:37:08 -0400 Subject: [PATCH 4/4] Format --- Sources/MCPToolkit/ResponseMessaging.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/MCPToolkit/ResponseMessaging.swift b/Sources/MCPToolkit/ResponseMessaging.swift index f654012..2874d71 100644 --- a/Sources/MCPToolkit/ResponseMessaging.swift +++ b/Sources/MCPToolkit/ResponseMessaging.swift @@ -155,9 +155,10 @@ private struct ClosureResponseMessaging: ResponseMessaging { toolThrew: @escaping Handler, parsingFailed: @escaping Handler, validationFailed: @escaping Handler, - parsingAndValidationFailed: @escaping Handler< - ResponseMessagingParsingAndValidationFailedContext - >, + parsingAndValidationFailed: + @escaping Handler< + ResponseMessagingParsingAndValidationFailedContext + >, unexpectedError: @escaping Handler ) { self.unknownToolHandler = unknownTool