-
Notifications
You must be signed in to change notification settings - Fork 33
Provide high-level interface to Server
#27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Comments
Thinking out loud: How should the high- and low-level APIs should interact? It would be surprising (bad) if the API consumer could inadvertently wipe out tools configured with the high-level API by calling Also, setting tools with the high-level API impacts the method handlers for both What if we totally isolated the high-level API from the low-level API? This would solve the split brain problem, but I'm wary of APIs that don't provide an escape hatch. But is that Yagni? Can we imagine a situation where someone might want to, for example, define a fixed set of tools with dynamic responses to resources? That sounds plausible. But then again, maybe that's the price of admission. There might be a clever way to cut the knot, and solve the contradiction another way. For example:
|
One of the challenge of doing so is to provide a Swift compliant set of types to expose the tools. The wrapper is here https://gist.github.com/sebsto/9cdc1bfec3ab905c8cb037b167373f5f The client code is below import MCP
#if canImport(FoundationEssentials)
import FoundatioNEssentials
#else
import Foundation
#endif
let myCosineToolSchema =
"""
{
"type": "object",
"properties": {
"angle": {
"description": "The angle to compute the cosine, expressed in degrees",
"type": "number"
}
},
"required": [
"angle"
]
}
"""
let myToolDescription =
"This tool compute a cosine. This is a trigonometric function that relates the adjacent side of a right triangle to its hypotenuse. The angle value is expected in degrees."
let myCosineTool = ClosureMCPTool(
name: "cosine",
description: myToolDescription,
inputSchema: myCosineToolSchema
) { (input: Double) async throws -> Double in
// return the cosine of the angle received in degrees (converted to radians)
return cos(input * .pi / 180.0)
}
try await withMCPServer(name: "MyMCPServer", version: "1.0.0", tool: myCosineTool) { params in
// check if we received an "angle" parameter
guard let value = params.arguments?["angle"] else {
throw MCPError.missingParam("angle")
}
// extract the double value from the "angle" parameter
// the MCP explorer sends a "number" which is interpreted as an int or a double depending on the presence of a decimal part
var param: Double = 0.0
switch value {
case .double(let d):
param = d
case .int(let i):
param = Double(i)
default:
throw MCPError.invalidParam("angle", "\(value)")
}
return param
} What I like is that it allows to expose tools as closures.
|
@sebsto Thanks for taking the time to go through that exercise and share your experience. I agree that a big challenge is figuring out how to translate type-safe Swift function signatures to the JSON Schema used by MCP. The way I'm thinking about this is that there are three distinct jobs to be done:
I've seen a few different approaches to this in Swift, both for MCP and for tool-calling for Ollama and other inference providers. And the biggest problem I've seen with them is that they conflate 1 and 2 and don't account for 3. For example, I can think of a couple libraries that have a /// Add two numbers together
/// - Parameter x: The first number
/// - Parameter y: The second number
@Tool
func add(x: Int, y: Int) -> Int {
return x + y
} Setting aside the friction introduced by Swift macros, this approach promises to eliminate all boilerplate. You write Swift code and it magically goes through MCP. This works great for pure functions / toy examples like I'm still chewing on this part of the design problem, but I take inspiration from @pointfreeco's approach of using structures with closure properties instead of protocols for dependencies. If nothing else, I think that pattern would work well for tool execution. "Should I run this tool?" is a policy decision that can be informed by tool annotations (#47) and controlled by "human-in-the-loop". This would also be a natural place to put logic for how to route named tools to their respective implementations. // Structure as dependency
struct Runner {
var run: (_ tool: Tool, input: CallTool.Parameters) async -> CallTool.Result
}
// Example helper method
func createRunner(
for tools: [Tool: (CallTool.Parameters) async -> CallTool.Result],
approval: (CallTool.Parameters) async -> Bool
) -> Runner { /* ... */ } Again, still figuring all this out. Footnotes |
The current implementation of
Server
focuses on a low-level interface. API consumers create a server instance with defined capabilities, callwithMethodHandler
and pass a closure that's called in response to requests of the specified type. This is in line with the TypeScript SDK's low-level interface.It'd be nice to provide a simpler, high-level API for use cases that don't require this level of control. Looking again at the TS SDK, we could provide an alternative interface that let API consumers specify the prompts, resources, and/or tools to serve, and let the implementation take care of the rest (including automatically determining capabilities #12).
The text was updated successfully, but these errors were encountered: