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

@ -0,0 +1,104 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using System.Runtime.InteropServices;
namespace ModelContextProtocol;
/// <summary>Provides helpers for conversions related to <see cref="AIContent"/>.</summary>
public static class AIContentExtensions
{
/// <summary>Creates a <see cref="ChatMessage"/> from a <see cref="PromptMessage"/>.</summary>
/// <param name="promptMessage">The message to convert.</param>
/// <returns>The created <see cref="ChatMessage"/>.</returns>
public static ChatMessage ToChatMessage(this PromptMessage promptMessage)
{
Throw.IfNull(promptMessage);
return new()
{
RawRepresentation = promptMessage,
Role = promptMessage.Role == Role.User ? ChatRole.User : ChatRole.Assistant,
Contents = [ToAIContent(promptMessage.Content)]
};
}
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="Content"/>.</summary>
/// <param name="content">The <see cref="Content"/> to convert.</param>
/// <returns>The created <see cref="AIContent"/>.</returns>
public static AIContent ToAIContent(this Content content)
{
Throw.IfNull(content);
AIContent ac;
if (content is { Type: "image", MimeType: not null, Data: not null })
{
ac = new DataContent(Convert.FromBase64String(content.Data), content.MimeType);
}
else if (content is { Type: "resource" } && content.Resource is { } resourceContents)
{
ac = resourceContents.Blob is not null && resourceContents.MimeType is not null ?
new DataContent(Convert.FromBase64String(resourceContents.Blob), resourceContents.MimeType) :
new TextContent(resourceContents.Text);
(ac.AdditionalProperties ??= [])["uri"] = resourceContents.Uri;
}
else
{
ac = new TextContent(content.Text);
}
ac.RawRepresentation = content;
return ac;
}
/// <summary>Creates a new <see cref="AIContent"/> from the content of a <see cref="ResourceContents"/>.</summary>
/// <param name="content">The <see cref="ResourceContents"/> to convert.</param>
/// <returns>The created <see cref="AIContent"/>.</returns>
public static AIContent ToAIContent(this ResourceContents content)
{
Throw.IfNull(content);
AIContent ac = content.Blob is not null && content.MimeType is not null ?
new DataContent(Convert.FromBase64String(content.Blob), content.MimeType) :
new TextContent(content.Text);
(ac.AdditionalProperties ??= [])["uri"] = content.Uri;
ac.RawRepresentation = content;
return ac;
}
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="Content"/>.</summary>
/// <param name="contents">The <see cref="Content"/> instances to convert.</param>
/// <returns>The created <see cref="AIContent"/> instances.</returns>
public static IList<AIContent> ToAIContents(this IEnumerable<Content> contents)
{
Throw.IfNull(contents);
return contents.Select(ToAIContent).ToList();
}
/// <summary>Creates a list of <see cref="AIContent"/> from a sequence of <see cref="ResourceContents"/>.</summary>
/// <param name="contents">The <see cref="ResourceContents"/> instances to convert.</param>
/// <returns>The created <see cref="AIContent"/> instances.</returns>
public static IList<AIContent> ToAIContents(this IEnumerable<ResourceContents> contents)
{
Throw.IfNull(contents);
return contents.Select(ToAIContent).ToList();
}
/// <summary>Extracts the data from a <see cref="DataContent"/> as a Base64 string.</summary>
internal static string GetBase64Data(this DataContent dataContent)
{
#if NET
return Convert.ToBase64String(dataContent.Data.Span);
#else
return MemoryMarshal.TryGetArray(dataContent.Data, out ArraySegment<byte> segment) ?
Convert.ToBase64String(segment.Array!, segment.Offset, segment.Count) :
Convert.ToBase64String(dataContent.Data.ToArray());
#endif
}
}