Overhaul tool handling (#89)

* Overhaul tool handling

- Renames [McpTool{Type}] to [McpServerTool{Type}], in order to distinguish representations of tools on the server from tools on the client.
- Enables [McpServerTool] methods to have arguments injected from DI, as well as a specific set of types from the implementation directly, like IMcpServer.
- Renames WithTools to WithToolsFromAssembly.
- All of the WithToolsXx methods now publish each individual McpServerTool into DI; other code can do so as well. The options setup code gathers all of the tools from DI and combines them into a collection which is then stored in McpServerOptions and used to construct the server.
- The server tools specified via DI as well as manually-provided handlers, using CallToolHandler as a fallback in case the requested tool doesn't exist in the tools collection, and ListToolsHandler augments the information for whatever tools exist.
- The tools are stored in McpServerOptions in a new McpServerToolCollection type, which is a thread-safe container that exposes add/removal notification. Adding/removing tools triggers a change notification that in turn sends a notification to the client about a tools update.
- The ServerOptions are exposed from the server instance.
- Removed cursor-based APIs from McpClientExtensions.
- Changed McpClientExtensions APIs to return `Task<IList<...>` rather than `IAsyncEnumerable<...>`.

I'm sure this will need to evolve further before we're "done", but it's a significant improvement from where we are now. One area I'm not super happy with is the notifying collection; that might work ok for a stdio server, where you can grab the created collection from the server's options and mutate it, but I don't know how well that's going to work for sse servers.

* Make McpServerTool{Collection} extensible

* Fix README.md example

* Add notifications test

* Address feedback
This commit is contained in:
Stephen Toub 2025-03-25 15:32:46 -04:00 • committed by GitHub
commit caca2ae61d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -44,7 +44,7 @@ await foreach (var tool in client.ListToolsAsync())
// Execute a tool (this would normally be driven by LLM tool invocations).
var result = await client.CallToolAsync(
"echo",
new() { ["message"] = "Hello MCP!" },
new Dictionary<string, object?>() { ["message"] = "Hello MCP!" },
CancellationToken.None);
// echo always returns one and only one text content object
@ -59,16 +59,13 @@ Tools can be exposed easily as `AIFunction` instances so that they are immediate
```csharp
// Get available functions.
IList<AIFunction> tools = await client.GetAIFunctionsAsync();
IList<McpClientTool> tools = await client.ListToolsAsync();
// Call the chat client using the tools.
IChatClient chatClient = ...;
var response = await chatClient.GetResponseAsync(
"your prompt here",
new()
{
Tools = [.. tools],
});
new() { Tools = [.. tools] },
```
## Getting Started (Server)
@ -88,17 +85,47 @@ var builder = Host.CreateEmptyApplicationBuilder(settings: null);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools();
.WithToolsFromAssembly();
await builder.Build().RunAsync();
[McpToolType]
[McpServerToolType]
public static class EchoTool
{
[McpTool, Description("Echoes the message back to the client.")]
[McpServerTool, Description("Echoes the message back to the client.")]
public static string Echo(string message) => $"hello {message}";
}
```
Tools can have the `IMcpServer` representing the server injected via a parameter to the method, and can use that for interaction with
the connected client. Similarly, arguments may be injected via dependency injection. For example, this tool will use the supplied
`IMcpServer` to make sampling requests back to the client in order to summarize content it downloads from the specified url via
an `HttpClient` injected via dependency injection.
```csharp
[McpServerTool("SummarizeContentFromUrl"), Description("Summarizes content downloaded from a specific URI")]
public static async Task<string> SummarizeDownloadedContent(
IMcpServer thisServer,
HttpClient httpClient,
[Description("The url from which to download the content to summarize")] string url,
CancellationToken cancellationToken)
{
string content = await httpClient.GetStringAsync(url);
ChatMessage[] messages =
[
new(ChatRole.User, "Briefly summarize the following downloaded content:"),
new(ChatRole.User, content),
]
ChatOptions options = new()
{
MaxOutputTokens = 256,
Temperature = 0.3f,
};
return $"Summary: {await thisServer.AsSamplingChatClient().GetResponseAsync(messages, options, cancellationToken)}";
}
```
More control is also available, with fine-grained control over configuring the server and how it should handle client requests. For example:
```csharp
@ -124,14 +151,18 @@ McpServerOptions options = new()
{
Name = "echo",
Description = "Echoes the input back to the client.",
InputSchema = new JsonSchema()
{
Type = "object",
Properties = new Dictionary<string, JsonSchemaProperty>()
InputSchema = JsonSerializer.Deserialize<JsonElement>("""
{
["message"] = new JsonSchemaProperty() { Type = "string", Description = "The input to echo back." }
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "The input to echo back"
}
},
"required": ["message"]
}
},
"""),
}
]
};