Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
23 changes: 19 additions & 4 deletions Sources/MCPToolkit/Documentation.docc/MCPToolkit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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(
Expand All @@ -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.
Expand All @@ -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``
71 changes: 22 additions & 49 deletions Sources/MCPToolkit/Extensions/MCPTool+MCP.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import Foundation
import JSONSchema
import JSONSchemaBuilder
import MCP
Expand All @@ -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<M: ResponseMessaging>(
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)
Expand All @@ -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"}"#
}
}
27 changes: 15 additions & 12 deletions Sources/MCPToolkit/Extensions/Server+register.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<M: ResponseMessaging>(
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)
)
}
}
Expand Down
Loading