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
2 changed files with 2703 additions and 686 deletions

View file

@ -3,8 +3,8 @@ using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Runtime.CompilerServices;
namespace ModelContextProtocol.Client;
@ -23,6 +23,7 @@ public static class McpClientExtensions
public static Task SendNotificationAsync(this IMcpClient client, string method, object? parameters = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(method);
return client.SendMessageAsync(
new JsonRpcNotification { Method = method, Params = parameters },
@ -45,42 +46,67 @@ public static class McpClientExtensions
}
/// <summary>
/// Retrieves a sequence of available tools from the server.
/// Retrieves a list of available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of tool information.</returns>
public static async IAsyncEnumerable<Tool> ListToolsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? cursor = null;
do
{
var tools = await ListToolsAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var tool in tools.Tools)
{
yield return tool;
}
cursor = tools.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a sequence of available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the server's response with tool information.</returns>
public static Task<ListToolsResult> ListToolsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
/// <returns>A list of all available tools.</returns>
public static async Task<IList<McpClientTool>> ListToolsAsync(
this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)),
cancellationToken);
List<McpClientTool>? tools = null;
string? cursor = null;
do
{
var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
tools ??= new List<McpClientTool>(toolResults.Tools.Count);
foreach (var tool in toolResults.Tools)
{
tools.Add(new McpClientTool(client, tool));
}
cursor = toolResults.NextCursor;
}
while (cursor is not null);
return tools;
}
/// <summary>
/// Creates an enumerable for asynchronously enumerating all available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of all available tools.</returns>
/// <remarks>
/// Every iteration through the returned <see cref="IAsyncEnumerable{McpClientTool}"/>
/// will result in requerying the server and yielding the sequence of available tools.
/// </remarks>
public static async IAsyncEnumerable<McpClientTool> EnumerateToolsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
string? cursor = null;
do
{
var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest("tools/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
foreach (var tool in toolResults.Tools)
{
yield return new McpClientTool(client, tool);
}
cursor = toolResults.NextCursor;
}
while (cursor is not null);
}
/// <summary>
@ -88,40 +114,69 @@ public static class McpClientExtensions
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of prompt information.</returns>
public static async IAsyncEnumerable<Prompt> ListPromptsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
/// <returns>A list of all available prompts.</returns>
public static async Task<IList<Prompt>> ListPromptsAsync(
this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
List<Prompt>? prompts = null;
string? cursor = null;
do
{
var prompts = await ListPromptsAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var prompt in prompts.Prompts)
var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
if (prompts is null)
{
prompts = promptResults.Prompts;
}
else
{
prompts.AddRange(promptResults.Prompts);
}
cursor = promptResults.NextCursor;
}
while (cursor is not null);
return prompts;
}
/// <summary>
/// Creates an enumerable for asynchronously enumerating all available prompts from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of all available prompts.</returns>
/// <remarks>
/// Every iteration through the returned <see cref="IAsyncEnumerable{Prompt}"/>
/// will result in requerying the server and yielding the sequence of available prompts.
/// </remarks>
public static async IAsyncEnumerable<Prompt> EnumeratePromptsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
string? cursor = null;
do
{
var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
foreach (var prompt in promptResults.Prompts)
{
yield return prompt;
}
cursor = prompts.NextCursor;
cursor = promptResults.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a list of available prompts from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the server's response with prompt information.</returns>
public static Task<ListPromptsResult> ListPromptsAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListPromptsResult>(
CreateRequest("prompts/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
/// <summary>
/// Retrieves a specific prompt with optional arguments.
/// </summary>
@ -130,9 +185,11 @@ public static class McpClientExtensions
/// <param name="arguments">Optional arguments for the prompt</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the prompt's content and messages.</returns>
public static Task<GetPromptResult> GetPromptAsync(this IMcpClient client, string name, Dictionary<string, object>? arguments = null, CancellationToken cancellationToken = default)
public static Task<GetPromptResult> GetPromptAsync(
this IMcpClient client, string name, Dictionary<string, object?>? arguments = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(name);
return client.SendRequestAsync<GetPromptResult>(
CreateRequest("prompts/get", CreateParametersDictionary(name, arguments)),
@ -140,24 +197,69 @@ public static class McpClientExtensions
}
/// <summary>
/// Retrieves a sequence of available resource templates from the server.
/// Retrieves a list of available resource templates from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of resource template information.</returns>
public static async IAsyncEnumerable<ResourceTemplate> ListResourceTemplatesAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
/// <returns>A list of all available resource templates.</returns>
public static async Task<IList<ResourceTemplate>> ListResourceTemplatesAsync(
this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
List<ResourceTemplate>? templates = null;
string? cursor = null;
do
{
var resources = await ListResourceTemplatesAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var resource in resources.ResourceTemplates)
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
if (templates is null)
{
yield return resource;
templates = templateResults.ResourceTemplates;
}
else
{
templates.AddRange(templateResults.ResourceTemplates);
}
cursor = resources.NextCursor;
cursor = templateResults.NextCursor;
}
while (cursor is not null);
return templates;
}
/// <summary>
/// Creates an enumerable for asynchronously enumerating all available resource templates from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of all available resource templates.</returns>
/// <remarks>
/// Every iteration through the returned <see cref="IAsyncEnumerable{ResourceTemplate}"/>
/// will result in requerying the server and yielding the sequence of available resource templates.
/// </remarks>
public static async IAsyncEnumerable<ResourceTemplate> EnumerateResourceTemplatesAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
string? cursor = null;
do
{
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
foreach (var template in templateResults.ResourceTemplates)
{
yield return template;
}
cursor = templateResults.NextCursor;
}
while (cursor is not null);
}
@ -166,64 +268,81 @@ public static class McpClientExtensions
/// Retrieves a list of available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task<ListResourceTemplatesResult> ListResourceTemplatesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
/// <returns>A list of all available resources.</returns>
public static async Task<IList<Resource>> ListResourcesAsync(
this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
List<Resource>? resources = null;
/// <summary>
/// Retrieves a sequence of available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of resource information.</returns>
public static async IAsyncEnumerable<Resource> ListResourcesAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
string? cursor = null;
do
{
var resources = await ListResourcesAsync(client, cursor, cancellationToken).ConfigureAwait(false);
foreach (var resource in resources.Resources)
var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
if (resources is null)
{
resources = resourceResults.Resources;
}
else
{
resources.AddRange(resourceResults.Resources);
}
cursor = resourceResults.NextCursor;
}
while (cursor is not null);
return resources;
}
/// <summary>
/// Creates an enumerable for asynchronously enumerating all available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of all available resources.</returns>
/// <remarks>
/// Every iteration through the returned <see cref="IAsyncEnumerable{Resource}"/>
/// will result in requerying the server and yielding the sequence of available resources.
/// </remarks>
public static async IAsyncEnumerable<Resource> EnumerateResourcesAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
string? cursor = null;
do
{
var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
foreach (var resource in resourceResults.Resources)
{
yield return resource;
}
cursor = resources.NextCursor;
cursor = resourceResults.NextCursor;
}
while (cursor is not null);
}
/// <summary>
/// Retrieves a list of available resources from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="cursor">A cursor to paginate the results.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task<ListResourcesResult> ListResourcesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
return client.SendRequestAsync<ListResourcesResult>(
CreateRequest("resources/list", CreateCursorDictionary(cursor)),
cancellationToken);
}
/// <summary>
/// Reads a resource from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="uri">The uri of the resource.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task<ReadResourceResult> ReadResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
public static Task<ReadResourceResult> ReadResourceAsync(
this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<ReadResourceResult>(
CreateRequest("resources/read", new() { ["uri"] = uri }),
@ -267,6 +386,7 @@ public static class McpClientExtensions
public static Task SubscribeToResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/subscribe", new() { ["uri"] = uri }),
@ -282,6 +402,7 @@ public static class McpClientExtensions
public static Task UnsubscribeFromResourceAsync(this IMcpClient client, string uri, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>(
CreateRequest("resources/unsubscribe", new() { ["uri"] = uri }),
@ -296,48 +417,17 @@ public static class McpClientExtensions
/// <param name="arguments">Optional arguments for the tool.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the tool's response.</returns>
public static Task<CallToolResponse> CallToolAsync(this IMcpClient client, string toolName, Dictionary<string, object> arguments, CancellationToken cancellationToken = default)
public static Task<CallToolResponse> CallToolAsync(
this IMcpClient client, string toolName, IReadOnlyDictionary<string, object?>? arguments = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNull(toolName);
return client.SendRequestAsync<CallToolResponse>(
CreateRequest("tools/call", CreateParametersDictionary(toolName, arguments)),
cancellationToken);
}
/// <summary>Gets <see cref="AIFunction"/> instances for all of the tools available through the specified <see cref="IMcpClient"/>.</summary>
/// <param name="client">The client for which <see cref="AIFunction"/> instances should be created.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing a list of the available functions.</returns>
public static async Task<IList<AIFunction>> GetAIFunctionsAsync(this IMcpClient client, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
List<AIFunction> functions = [];
await foreach (var tool in client.ListToolsAsync(cancellationToken).ConfigureAwait(false))
{
functions.Add(AsAIFunction(client, tool));
}
return functions;
}
/// <summary>Gets an <see cref="AIFunction"/> for invoking <see cref="Tool"/> via this <see cref="IMcpClient"/>.</summary>
/// <param name="client">The client with which to perform the invocation.</param>
/// <param name="tool">The tool to be invoked.</param>
/// <returns>An <see cref="AIFunction"/> for performing the call.</returns>
/// <remarks>
/// This operation does not validate that <paramref name="tool"/> is valid for the specified <paramref name="client"/>.
/// If the tool is not valid for the client, it will fail when invoked.
/// </remarks>
public static AIFunction AsAIFunction(this IMcpClient client, Tool tool)
{
Throw.IfNull(client);
Throw.IfNull(tool);
return new McpAIFunction(client, tool);
}
/// <summary>
/// Converts the contents of a <see cref="CreateMessageRequestParams"/> into a pair of
/// <see cref="IEnumerable{ChatMessage}"/> and <see cref="ChatOptions"/> instances to use
@ -428,12 +518,7 @@ public static class McpClientExtensions
{
Type = "image",
MimeType = dc.MediaType,
Data = Convert.ToBase64String(dc.Data
#if NET
.Span),
#else
.ToArray()),
#endif
Data = dc.GetBase64Data(),
};
}
}
@ -499,7 +584,8 @@ public static class McpClientExtensions
private static Dictionary<string, object?>? CreateCursorDictionary(string? cursor) =>
cursor != null ? new() { ["cursor"] = cursor } : null;
private static Dictionary<string, object?> CreateParametersDictionary(string nameParameter, Dictionary<string, object>? arguments)
private static Dictionary<string, object?> CreateParametersDictionary(
string nameParameter, IReadOnlyDictionary<string, object?>? arguments)
{
Dictionary<string, object?> parameters = new()
{
@ -526,20 +612,20 @@ public static class McpClientExtensions
/// <inheritdoc/>
public override JsonElement JsonSchema => tool.InputSchema;
/// <inheritdoc/>
public override JsonSerializerOptions JsonSerializerOptions => McpJsonUtilities.DefaultOptions;
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments, CancellationToken cancellationToken)
{
Throw.IfNull(arguments);
Dictionary<string, object> argDict = [];
foreach (var arg in arguments)
{
if (arg.Value is not null)
{
argDict[arg.Key] = arg.Value;
}
}
IReadOnlyDictionary<string, object?> argDict =
arguments as IReadOnlyDictionary<string, object?> ??
#if NET
arguments.ToDictionary();
#else
arguments.ToDictionary(kv => kv.Key, kv => kv.Value);
#endif
CallToolResponse result = await client.CallToolAsync(tool.Name, argDict, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);

View file

@ -0,0 +1,43 @@
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Text.Json;
namespace ModelContextProtocol.Client;
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
public sealed class McpClientTool : AIFunction
{
private readonly IMcpClient _client;
private readonly Tool _tool;
internal McpClientTool(IMcpClient client, Tool tool)
{
_client = client;
_tool = tool;
}
/// <inheritdoc/>
public override string Name => _tool.Name;
/// <inheritdoc/>
public override string Description => _tool.Description ?? string.Empty;
/// <inheritdoc/>
public override JsonElement JsonSchema => _tool.InputSchema;
/// <inheritdoc/>
public override JsonSerializerOptions JsonSerializerOptions => McpJsonUtilities.DefaultOptions;
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments, CancellationToken cancellationToken)
{
IReadOnlyDictionary<string, object?> argDict =
arguments as IReadOnlyDictionary<string, object?> ??
arguments.ToDictionary();
CallToolResponse result = await _client.CallToolAsync(_tool.Name, argDict, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
}
}