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

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
}
}

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);
}
}

View file

@ -1,12 +1,9 @@
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
namespace ModelContextProtocol;
@ -23,23 +20,17 @@ public static partial class McpServerBuilderExtensions
/// <typeparam name="TTool">The tool type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static IMcpServerBuilder WithTool<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] TTool>(this IMcpServerBuilder builder)
public static IMcpServerBuilder WithTools<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] TTool>(
this IMcpServerBuilder builder)
{
Throw.IfNull(builder);
List<AIFunction> functions = [];
PopulateFunctions(typeof(TTool), functions);
return WithTools(builder, functions);
}
/// <summary>
/// Adds all tools marked with <see cref="McpToolTypeAttribute"/> from the current assembly to the server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder)
{
return WithToolsFromAssembly(builder, Assembly.GetCallingAssembly());
foreach (var toolMethod in GetToolMethods(typeof(TTool)))
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, services: services));
}
return builder;
}
/// <summary>
@ -55,147 +46,42 @@ public static partial class McpServerBuilderExtensions
Throw.IfNull(builder);
Throw.IfNull(toolTypes);
List<AIFunction> functions = [];
foreach (var toolType in toolTypes)
{
if (toolType is null)
if (toolType is not null)
{
throw new ArgumentNullException(nameof(toolTypes), $"A tool type provided by the enumerator was null.");
foreach (var toolMethod in GetToolMethods(toolType))
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, services: services));
}
}
PopulateFunctions(toolType, functions);
}
return WithTools(builder, functions);
}
/// <summary>
/// Adds tools to the server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="functions"><see cref="AIFunction"/> instances to use as the tools.</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="functions"/> is <see langword="null"/>.</exception>
public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params IEnumerable<AIFunction> functions)
{
Throw.IfNull(builder);
Throw.IfNull(functions);
List<Tool> tools = [];
Dictionary<string, Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>>> callbacks = [];
foreach (AIFunction function in functions)
{
if (function is null)
{
throw new ArgumentNullException(nameof(functions), $"A function provided by the enumerator was null.");
}
tools.Add(new()
{
Name = function.Name,
Description = function.Description,
InputSchema = function.JsonSchema,
});
callbacks.Add(function.Name, async (request, cancellationToken) =>
{
cancellationToken.ThrowIfCancellationRequested();
object? result;
try
{
result = await function.InvokeAsync((request.Params?.Arguments ?? [])!, cancellationToken).ConfigureAwait(false);
}
catch (Exception e) when (e is not OperationCanceledException)
{
return new CallToolResponse()
{
IsError = true,
Content = [new() { Text = e.Message, Type = "text" }],
};
}
switch (result)
{
case JsonElement je when je.ValueKind == JsonValueKind.Null:
return new() { Content = [] };
case JsonElement je when je.ValueKind == JsonValueKind.Array:
return new() { Content = je.EnumerateArray().Select(x => new Content() { Text = x.ToString(), Type = "text" }).ToList() };
default:
return new() { Content = [new() { Text = result?.ToString(), Type = "text" }] };
}
});
}
builder.WithListToolsHandler((_, _) => Task.FromResult(new ListToolsResult() { Tools = tools }));
builder.WithCallToolHandler(async (request, cancellationToken) =>
{
if (request.Params is null || !callbacks.TryGetValue(request.Params.Name, out var callback))
{
throw new McpServerException($"Unknown tool '{request.Params?.Name}'");
}
return await callback(request, cancellationToken).ConfigureAwait(false);
});
return builder;
}
/// <summary>
/// Adds types marked with the <see cref="McpToolTypeAttribute"/> attribute from the given assembly as tools to the server.
/// Adds types marked with the <see cref="McpServerToolTypeAttribute"/> attribute from the given assembly as tools to the server.
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <param name="assembly">The assembly to load the types from. Null to get the current assembly</param>
/// <param name="toolAssembly">The assembly to load the types from. Null to get the current assembly</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithToolsFromAssembly(this IMcpServerBuilder builder, Assembly? assembly = null)
public static IMcpServerBuilder WithToolsFromAssembly(this IMcpServerBuilder builder, Assembly? toolAssembly = null)
{
assembly ??= Assembly.GetCallingAssembly();
Throw.IfNull(builder);
List<Type> toolTypes = [];
toolAssembly ??= Assembly.GetCallingAssembly();
foreach (var type in assembly.GetTypes())
{
if (type.GetCustomAttribute<McpToolTypeAttribute>() is null)
{
continue;
}
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (method.GetCustomAttribute<McpToolAttribute>() is not null)
{
toolTypes.Add(type);
break;
}
}
}
return toolTypes.Count > 0 ?
WithTools(builder, toolTypes) :
builder;
return builder.WithTools(
from t in toolAssembly.GetTypes()
where t.GetCustomAttribute<McpServerToolTypeAttribute>() is not null
select t);
}
private static void PopulateFunctions(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type toolType,
List<AIFunction> functions)
{
foreach (var method in toolType.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
if (method.GetCustomAttribute<McpToolAttribute>() is not { } attribute)
{
continue;
}
functions.Add(AIFunctionFactory.Create(method, target: null, new()
{
Name = attribute.Name ?? method.Name,
}));
}
}
private static IEnumerable<MethodInfo> GetToolMethods(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type toolType) =>
from method in toolType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
where method.GetCustomAttribute<McpServerToolAttribute>() is not null
select method;
}

View file

@ -1,5 +1,4 @@
using System.Reflection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Utils;
@ -7,30 +6,66 @@ using ModelContextProtocol.Utils;
namespace ModelContextProtocol.Configuration;
/// <summary>
/// Configures the McpServerOptions using provided server handlers.
/// Configures the McpServerOptions using addition services from DI.
/// </summary>
/// <param name="serverHandlers">The server handlers configuration options.</param>
internal sealed class McpServerOptionsSetup(IOptions<McpServerHandlers> serverHandlers) : IConfigureOptions<McpServerOptions>
/// <param name="serverTools">Tools individually registered.</param>
internal sealed class McpServerOptionsSetup(
IOptions<McpServerHandlers> serverHandlers,
IEnumerable<McpServerTool> serverTools) : IConfigureOptions<McpServerOptions>
{
/// <summary>
/// Configures the given McpServerOptions instance by setting server information
/// and applying custom server handlers.
/// and applying custom server handlers and tools.
/// </summary>
/// <param name="options">The options instance to be configured.</param>
public void Configure(McpServerOptions options)
{
Throw.IfNull(options);
var assemblyName = Assembly.GetEntryAssembly()?.GetName();
// Set server information based on the entry assembly
options.ServerInfo = new Implementation
// Configure the option's server information based on the current process,
// if it otherwise lacks server information.
var assemblyName = (Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly()).GetName();
if (options.ServerInfo is not { } serverInfo ||
serverInfo.Name is null ||
serverInfo.Version is null)
{
Name = assemblyName?.Name ?? "McpServer",
Version = assemblyName?.Version?.ToString() ?? "1.0.0",
};
options.ServerInfo = options.ServerInfo is null ?
new()
{
Name = assemblyName.Name ?? "McpServer",
Version = assemblyName.Version?.ToString() ?? "1.0.0",
} :
options.ServerInfo with
{
Name = options.ServerInfo.Name ?? assemblyName.Name ?? "McpServer",
Version = options.ServerInfo.Version ?? assemblyName.Version?.ToString() ?? "1.0.0",
};
}
// Apply custom server handlers
// Collect all of the provided tools into a tools collection. If the options already has
// a collection, add to it, otherwise create a new one. We want to maintain the identity
// of an existing collection in case someone has provided their own derived type, wants
// change notifications, etc.
McpServerToolCollection toolsCollection = options.Capabilities?.Tools?.ToolCollection ?? [];
foreach (var tool in serverTools)
{
toolsCollection.TryAdd(tool);
}
if (!toolsCollection.IsEmpty)
{
options.Capabilities = options.Capabilities is null ?
new() { Tools = new() { ToolCollection = toolsCollection } } :
options.Capabilities with
{
Tools = options.Capabilities.Tools is null ?
new() { ToolCollection = toolsCollection } :
options.Capabilities.Tools with { ToolCollection = toolsCollection },
};
}
// Apply custom server handlers.
serverHandlers.Value.OverwriteWithSetHandlers(options);
}
}

View file

@ -25,6 +25,7 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
private readonly TextReader _stdInReader;
private readonly Stream _stdOutStream;
private SemaphoreSlim _sendLock = new(1, 1);
private Task? _readTask;
private CancellationTokenSource? _shutdownCts;
@ -137,6 +138,8 @@ public sealed class StdioServerTransport : TransportBase, IServerTransport
/// <inheritdoc/>
public override async Task SendMessageAsync(IJsonRpcMessage message, CancellationToken cancellationToken = default)
{
using var _ = await _sendLock.LockAsync(cancellationToken).ConfigureAwait(false);
if (!IsConnected)
{
_logger.TransportNotConnected(EndpointName);

View file

@ -16,5 +16,5 @@ public class CallToolRequestParams
/// Optional arguments to pass to the tool.
/// </summary>
[System.Text.Json.Serialization.JsonPropertyName("arguments")]
public Dictionary<string,object>? Arguments { get; init; }
public Dictionary<string,object>? Arguments { get; init; }
}

View file

@ -155,7 +155,7 @@ public record ResourcesCapability
public record ToolsCapability
{
/// <summary>
/// Whether this server supports notifications for changes to the tool list.
/// Gets or sets whether this server supports notifications for changes to the tool list.
/// </summary>
[JsonPropertyName("listChanged")]
public bool? ListChanged { get; init; }
@ -171,4 +171,16 @@ public record ToolsCapability
/// </summary>
[JsonIgnore]
public Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>>? CallToolHandler { get; init; }
/// <summary>Gets or sets a collection of tools served by the server.</summary>
/// <remarks>
/// Tools will specified via <see cref="ToolCollection"/> augment the <see cref="ListToolsHandler"/> and
/// <see cref="CallToolHandler"/>, if provided. ListTools requests will output information about every tool
/// in <see cref="ToolCollection"/> and then also any tools output by <see cref="ListToolsHandler"/>, if it's
/// non-<see langword="null"/>. CallTool requests will first check <see cref="ToolCollection"/> for the tool
/// being requested, and if the tool is not found in the <see cref="ToolCollection"/>, any specified <see cref="CallToolHandler"/>
/// will be invoked as a fallback.
/// </remarks>
[JsonIgnore]
public McpServerToolCollection? ToolCollection { get; init; }
}

View file

@ -1,6 +1,4 @@
using ModelContextProtocol.Protocol.Types;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Protocol.Types;

View file

@ -10,6 +10,8 @@ namespace ModelContextProtocol.Protocol.Types;
/// </summary>
public class Tool
{
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
/// <summary>
/// The name of the tool.
/// </summary>
@ -42,6 +44,4 @@ public class Tool
_inputSchema = value;
}
}
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
}

View file

@ -0,0 +1,231 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Reflection;
using System.Text.Json;
namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed class AIFunctionMcpServerTool : McpServerTool
{
/// <summary>Key used temporarily for flowing request context into an AIFunction.</summary>
/// <remarks>This will be replaced with use of AIFunctionArguments.Context.</remarks>
private const string RequestContextKey = "__temporary_RequestContext";
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
public static new AIFunctionMcpServerTool Create(
Delegate method,
string? name,
string? description,
IServiceProvider? services)
{
Throw.IfNull(method);
return Create(method.Method, method.Target, name, description, services);
}
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
public static new AIFunctionMcpServerTool Create(
MethodInfo method,
object? target,
string? name,
string? description,
IServiceProvider? services)
{
Throw.IfNull(method);
// TODO: Once this repo consumes a new build of Microsoft.Extensions.AI containing
// https://github.com/dotnet/extensions/pull/6158,
// https://github.com/dotnet/extensions/pull/6162, and
// https://github.com/dotnet/extensions/pull/6175, switch over to using the real
// AIFunctionFactory, delete the TemporaryXx types, and fix-up the mechanism by
// which the arguments are passed.
return Create(TemporaryAIFunctionFactory.Create(method, target, new TemporaryAIFunctionFactoryOptions()
{
Name = name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name,
Description = description,
MarshalResult = static (result, _, cancellationToken) => Task.FromResult(result),
ConfigureParameterBinding = pi =>
{
if (pi.ParameterType == typeof(RequestContext<CallToolRequestParams>))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args),
};
}
if (pi.ParameterType == typeof(IMcpServer))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
};
}
// We assume that if the services used to create the tool support a particular type,
// so too do the services associated with the server. This is the same basic assumption
// made in ASP.NET.
if (services is not null &&
services.GetService<IServiceProviderIsService>() is { } ispis &&
ispis.IsService(pi.ParameterType))
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
GetRequestContext(args)?.Server?.Services?.GetService(pi.ParameterType) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
}
if (pi.GetCustomAttribute<FromKeyedServicesAttribute>() is { } keyedAttr)
{
return new()
{
ExcludeFromSchema = true,
BindParameter = (pi, args) =>
(GetRequestContext(args)?.Server?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
(pi.HasDefaultValue ? null :
throw new ArgumentException("No service of the requested type was found.")),
};
}
return default;
static RequestContext<CallToolRequestParams>? GetRequestContext(IReadOnlyDictionary<string, object?> args)
{
if (args.TryGetValue(RequestContextKey, out var orc) &&
orc is RequestContext<CallToolRequestParams> requestContext)
{
return requestContext;
}
return null;
}
},
}));
}
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
public static new AIFunctionMcpServerTool Create(AIFunction function)
{
Throw.IfNull(function);
return new AIFunctionMcpServerTool(function);
}
/// <summary>Gets the <see cref="AIFunction"/> wrapped by this tool.</summary>
internal AIFunction AIFunction { get; }
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
private AIFunctionMcpServerTool(AIFunction function)
{
AIFunction = function;
ProtocolTool = new()
{
Name = function.Name,
Description = function.Description,
InputSchema = function.JsonSchema,
};
}
/// <inheritdoc />
public override string ToString() => AIFunction.ToString();
/// <inheritdoc />
public override Tool ProtocolTool { get; }
/// <inheritdoc />
public override async Task<CallToolResponse> InvokeAsync(
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
{
Throw.IfNull(request);
cancellationToken.ThrowIfCancellationRequested();
// TODO: Once we shift to the real AIFunctionFactory, the request should be passed via AIFunctionArguments.Context.
Dictionary<string, object?> arguments = request.Params?.Arguments is IDictionary<string, object?> existingArgs ?
new(existingArgs) :
[];
arguments[RequestContextKey] = request;
object? result;
try
{
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (Exception e) when (e is not OperationCanceledException)
{
return new CallToolResponse()
{
IsError = true,
Content = [new() { Text = e.Message, Type = "text" }],
};
}
switch (result)
{
case null:
return new()
{
Content = []
};
case string text:
return new()
{
Content = [new() { Text = text, Type = "text" }]
};
case TextContent textContent:
return new()
{
Content = [new() { Text = textContent.Text, Type = "text" }]
};
case DataContent dataContent:
return new()
{
Content = [new()
{
Data = dataContent.GetBase64Data(),
MimeType = dataContent.MediaType,
Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource",
}]
};
case string[] texts:
return new()
{
Content = texts
.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })
.ToList()
};
// TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69:
// Add specialization for annotations.
default:
return new()
{
Content = [new()
{
Text = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
Type = "text"
}]
};
}
}
}

View file

@ -0,0 +1,34 @@
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerTool"/> that delegates all operations to an inner <see cref="McpServerTool"/>.</summary>
/// <remarks>
/// This is recommended as a base type when building tools that can be chained around an underlying <see cref="McpServerTool"/>.
/// The default implementation simply passes each call to the inner tool instance.
/// </remarks>
public abstract class DelegatingMcpServerTool : McpServerTool
{
private readonly McpServerTool _innerTool;
/// <summary>Initializes a new instance of the <see cref="DelegatingMcpServerTool"/> class around the specified <paramref name="innerTool"/>.</summary>
/// <param name="innerTool">The inner tool wrapped by this delegating tool.</param>
protected DelegatingMcpServerTool(McpServerTool innerTool)
{
Throw.IfNull(innerTool);
_innerTool = innerTool;
}
/// <inheritdoc />
public override Tool ProtocolTool => _innerTool.ProtocolTool;
/// <inheritdoc />
public override Task<CallToolResponse> InvokeAsync(
RequestContext<CallToolRequestParams> request,
CancellationToken cancellationToken = default) =>
_innerTool.InvokeAsync(request, cancellationToken);
/// <inheritdoc />
public override string ToString() => _innerTool.ToString();
}

View file

@ -23,10 +23,13 @@ public interface IMcpServer : IAsyncDisposable
/// </summary>
Implementation? ClientInfo { get; }
/// <summary>Gets the options used to construct this server.</summary>
McpServerOptions ServerOptions { get; }
/// <summary>
/// Gets the service provider for the server.
/// </summary>
IServiceProvider? ServiceProvider { get; }
IServiceProvider? Services { get; }
/// <summary>
/// Adds a handler for client notifications of a specific method.

View file

@ -1,12 +1,11 @@
using ModelContextProtocol.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Logging;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Shared;
using ModelContextProtocol.Utils;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Server;
@ -15,9 +14,9 @@ namespace ModelContextProtocol.Server;
internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
{
private readonly IServerTransport? _serverTransport;
private readonly McpServerOptions _options;
private volatile bool _isInitializing;
private readonly ILogger _logger;
private readonly string _serverDescription;
private volatile bool _isInitializing;
/// <summary>
/// Creates a new instance of <see cref="McpServer"/>.
@ -34,10 +33,10 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
Throw.IfNull(options);
_serverTransport = transport as IServerTransport;
_options = options;
_logger = (ILogger?)loggerFactory?.CreateLogger<McpServer>() ?? NullLogger.Instance;
ServerInstructions = options.ServerInstructions;
ServiceProvider = serviceProvider;
Services = serviceProvider;
_serverDescription = $"{options.ServerInfo.Name} {options.ServerInfo.Version}";
AddNotificationHandler("notifications/initialized", _ =>
{
@ -45,13 +44,16 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
return Task.CompletedTask;
});
SetToolsHandler(ref options);
SetInitializeHandler(options);
SetCompletionHandler(options);
SetPingHandler();
SetToolsHandler(options);
SetPromptsHandler(options);
SetResourcesHandler(options);
SetSetLoggingLevelHandler(options);
ServerOptions = options;
}
public ClientCapabilities? ClientCapabilities { get; set; }
@ -63,11 +65,14 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
public string? ServerInstructions { get; set; }
/// <inheritdoc />
public IServiceProvider? ServiceProvider { get; }
public McpServerOptions ServerOptions { get; }
/// <inheritdoc />
public IServiceProvider? Services { get; }
/// <inheritdoc />
public override string EndpointName =>
$"Server ({_options.ServerInfo.Name} {_options.ServerInfo.Version}), Client ({ClientInfo?.Name} {ClientInfo?.Version})";
$"Server ({_serverDescription}), Client ({ClientInfo?.Name} {ClientInfo?.Version})";
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken = default)
@ -125,7 +130,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
{
ProtocolVersion = options.ProtocolVersion,
Instructions = ServerInstructions,
ServerInfo = _options.ServerInfo,
ServerInfo = options.ServerInfo,
Capabilities = options.Capabilities ?? new ServerCapabilities(),
});
});
@ -195,17 +200,110 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct));
}
private void SetToolsHandler(McpServerOptions options)
private void SetToolsHandler(ref McpServerOptions options)
{
if (options.Capabilities?.Tools is not { } toolsCapability)
ToolsCapability? toolsCapability = options.Capabilities?.Tools;
var listToolsHandler = toolsCapability?.ListToolsHandler;
var callToolHandler = toolsCapability?.CallToolHandler;
var tools = toolsCapability?.ToolCollection;
if (listToolsHandler is null != callToolHandler is null)
{
return;
throw new McpServerException("ListTools and CallTool handlers should be specified together.");
}
if (toolsCapability.ListToolsHandler is not { } listToolsHandler ||
toolsCapability.CallToolHandler is not { } callToolHandler)
// Handle tools provided via DI.
if (tools is { IsEmpty: false })
{
throw new McpServerException("ListTools and/or CallTool handlers were specified but the Tools capability was not enabled.");
var originalListToolsHandler = listToolsHandler;
var originalCallToolHandler = callToolHandler;
// Synthesize the handlers, making sure a ToolsCapability is specified.
listToolsHandler = async (request, cancellationToken) =>
{
ListToolsResult result = new();
foreach (McpServerTool tool in tools)
{
result.Tools.Add(tool.ProtocolTool);
}
if (originalListToolsHandler is not null)
{
string? nextCursor = null;
do
{
ListToolsResult extraResults = await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false);
result.Tools.AddRange(extraResults.Tools);
nextCursor = extraResults.NextCursor;
if (nextCursor is not null)
{
request = request with { Params = new() { Cursor = nextCursor } };
}
}
while (nextCursor is not null);
}
return result;
};
callToolHandler = (request, cancellationToken) =>
{
if (request.Params is null ||
!tools.TryGetTool(request.Params.Name, out var tool))
{
if (originalCallToolHandler is not null)
{
return originalCallToolHandler(request, cancellationToken);
}
throw new McpServerException($"Unknown tool '{request.Params?.Name}'");
}
return tool.InvokeAsync(request, cancellationToken);
};
toolsCapability = toolsCapability is null ?
new()
{
CallToolHandler = callToolHandler,
ListToolsHandler = listToolsHandler,
ToolCollection = tools,
ListChanged = true,
} :
toolsCapability with
{
CallToolHandler = callToolHandler,
ListToolsHandler = listToolsHandler,
ToolCollection = tools,
ListChanged = true,
};
options.Capabilities = options.Capabilities is null ?
new() { Tools = toolsCapability } :
options.Capabilities with { Tools = toolsCapability };
tools.Changed += delegate
{
_ = SendMessageAsync(new JsonRpcNotification()
{
Method = NotificationMethods.ToolListChangedNotification,
});
};
}
else
{
if (toolsCapability is null)
{
// No tools, and no tools capability was declared, so nothing to do.
return;
}
// Make sure the handlers are provided if the capability is enabled.
if (listToolsHandler is null || callToolHandler is null)
{
throw new McpServerException("ListTools and/or CallTool handlers were not specified but the Tools capability was enabled.");
}
}
SetRequestHandler<ListToolsRequestParams, ListToolsResult>("tools/list", (request, ct) => listToolsHandler(new(this, request), ct));
@ -226,4 +324,4 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
SetRequestHandler<SetLevelRequestParams, EmptyResult>("logging/setLevel", (request, ct) => setLoggingLevelHandler(new(this, request), ct));
}
}
}

View file

@ -96,12 +96,7 @@ public static class McpServerExtensions
{
Type = "image",
MimeType = dataContent.MediaType,
Data = Convert.ToBase64String(dataContent.Data.
#if NET
Span),
#else
ToArray()),
#endif
Data = dataContent.GetBase64Data(),
},
});
break;
@ -126,33 +121,7 @@ public static class McpServerExtensions
ModelPreferences = modelPreferences,
}, cancellationToken).ConfigureAwait(false);
ChatMessage responseMessage = new()
{
Role = result.Role == "user" ? ChatRole.User : ChatRole.Assistant
};
if (result.Content is { Type: "text" })
{
responseMessage.Contents.Add(new TextContent(result.Content.Text));
}
else if (result.Content is { Type: "image", MimeType: not null, Data: not null })
{
responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(result.Content.Data), result.Content.MimeType));
}
else if (result.Content is { Type: "resource" } && result.Content.Resource is { } resourceContents)
{
if (resourceContents.Text is not null)
{
responseMessage.Contents.Add(new TextContent(resourceContents.Text));
}
if (resourceContents.Blob is not null && resourceContents.MimeType is not null)
{
responseMessage.Contents.Add(new DataContent(Convert.FromBase64String(resourceContents.Blob), resourceContents.MimeType));
}
}
return new(responseMessage)
return new(new ChatMessage(new(result.Role), [result.Content.ToAIContent()]))
{
ModelId = result.Model,
FinishReason = result.StopReason switch

View file

@ -0,0 +1,97 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol.Types;
using System.ComponentModel;
using System.Reflection;
namespace ModelContextProtocol.Server;
/// <summary>Represents an invocable tool used by Model Context Protocol clients and servers.</summary>
public abstract class McpServerTool
{
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
protected McpServerTool()
{
}
/// <summary>Gets the protocol <see cref="Tool"/> type for this instance.</summary>
public abstract Tool ProtocolTool { get; }
/// <summary>Invokes the <see cref="McpServerTool"/>.</summary>
/// <param name="request">The request information resulting in the invocation of this tool.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>The call response from invoking the tool.</returns>
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
public abstract Task<CallToolResponse> InvokeAsync(
RequestContext<CallToolRequestParams> request,
CancellationToken cancellationToken = default);
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpServerTool"/>.</param>
/// <param name="name">
/// The name to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but an <see cref="McpServerToolAttribute"/>
/// is applied to <paramref name="method"/>, the name from the attribute will be used. If that's not present, the name based
/// on <paramref name="method"/>'s name will be used.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but a <see cref="DescriptionAttribute"/>
/// is applied to <paramref name="method"/>, the description from that attribute will be used.
/// </param>
/// <param name="services">
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
/// used to determine which parameters should be satisifed from dependency injection, and so what services
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </param>
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static McpServerTool Create(
Delegate method,
string? name = null,
string? description = null,
IServiceProvider? services = null) =>
AIFunctionMcpServerTool.Create(method, name, description, services);
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
/// <param name="method">The method to be represented via the created <see cref="McpServerTool"/>.</param>
/// <param name="target">The instance if <paramref name="method"/> is an instance method; otherwise, <see langword="null"/>.</param>
/// <param name="name">
/// The name to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but an <see cref="McpServerToolAttribute"/>
/// is applied to <paramref name="method"/>, the name from the attribute will be used. If that's not present, the name based
/// on <paramref name="method"/>'s name will be used.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but a <see cref="DescriptionAttribute"/>
/// is applied to <paramref name="method"/>, the description from that attribute will be used.
/// </param>
/// <param name="services">
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
/// used to determine which parameters should be satisifed from dependency injection, and so what services
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </param>
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException"><paramref name="method"/> is an instance method but <paramref name="target"/> is <see langword="null"/>.</exception>
public static McpServerTool Create(
MethodInfo method,
object? target = null,
string? name = null,
string? description = null,
IServiceProvider? services = null) =>
AIFunctionMcpServerTool.Create(method, target, name, description, services);
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to wrap.</param>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>
/// <remarks>
/// Unlike the other overloads of Create, the <see cref="McpServerTool"/> created by <see cref="Create(AIFunction)"/>
/// does not provide all of the special parameter handling for MCP-specific concepts, like <see cref="IMcpServer"/>.
/// </remarks>
public static McpServerTool Create(AIFunction function) =>
AIFunctionMcpServerTool.Create(function);
/// <inheritdoc />
public override string ToString() => ProtocolTool.Name;
}

View file

@ -0,0 +1,18 @@
namespace ModelContextProtocol.Server;
/// <summary>
/// Used to mark a public method as an MCP tool.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class McpServerToolAttribute : Attribute
{
/// <summary>Gets the name of the tool.</summary>
/// <remarks>If <see langword="null"/>, the method name will be used.</remarks>
public string? Name { get; }
/// <summary>
/// Initializes a new instance of the <see cref="McpServerToolTypeAttribute"/> class.
/// </summary>
/// <param name="name">The name of the tool. If <see langword="null"/>, the method name will be used.</param>
public McpServerToolAttribute(string? name = null) => Name = name;
}

View file

@ -0,0 +1,164 @@
using ModelContextProtocol.Utils;
using System.Collections;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
namespace ModelContextProtocol.Server;
/// <summary>Provides a thread-safe collection of <see cref="McpServerTool"/> instances, indexed by their names.</summary>
public class McpServerToolCollection : ICollection<McpServerTool>, IReadOnlyCollection<McpServerTool>
{
/// <summary>Concurrent dictionary of tools, indexed by their names.</summary>
private readonly ConcurrentDictionary<string, McpServerTool> _tools = [];
/// <summary>
/// Initializes a new instance of the <see cref="McpServerToolCollection"/> class.
/// </summary>
public McpServerToolCollection()
{
}
/// <summary>Occurs when the collection is changed.</summary>
/// <remarks>
/// By default, this is raised when a tool is added or removed. However, a derived implementation
/// may raise this event for other reasons, such as when a tool is modified.
/// </remarks>
public event EventHandler? Changed;
/// <summary>Gets the number of tools in the collection.</summary>
public int Count => _tools.Count;
/// <summary>Gets whether there are any tools in the collection.</summary>
public bool IsEmpty => _tools.IsEmpty;
/// <summary>Raises <see cref="Changed"/> if there are registered handlers.</summary>
protected void RaiseChanged() => Changed?.Invoke(this, EventArgs.Empty);
/// <summary>Gets the <see cref="McpServerTool"/> with the specified <paramref name="name"/> from the collection.</summary>
/// <param name="name">The name of the tool to retrieve.</param>
/// <returns>The <see cref="McpServerTool"/> with the specified name.</returns>
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
/// <exception cref="KeyNotFoundException">A tool with the specified name does not exist in the collection.</exception>
public McpServerTool this[string name]
{
get
{
Throw.IfNull(name);
return _tools[name];
}
}
/// <summary>Clears all tools from the collection.</summary>
public virtual void Clear()
{
_tools.Clear();
RaiseChanged();
}
/// <summary>Adds the specified <see cref="McpServerTool"/> to the collection.</summary>
/// <param name="tool">The tool to be added.</param>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">A tool with the same name as <paramref name="tool"/> already exists in the collection.</exception>
public void Add(McpServerTool tool)
{
if (!TryAdd(tool))
{
throw new ArgumentException($"A tool with the same name '{tool.ProtocolTool.Name}' already exists in the collection.", nameof(tool));
}
}
/// <summary>Adds the specified <see cref="McpServerTool"/> to the collection.</summary>
/// <param name="tool">The tool to be added.</param>
/// <returns><see langword="true"/> if the tool was added; otherwise, <see langword="false"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
public virtual bool TryAdd(McpServerTool tool)
{
Throw.IfNull(tool);
bool added = _tools.TryAdd(tool.ProtocolTool.Name, tool);
if (added)
{
RaiseChanged();
}
return added;
}
/// <summary>Removes the specified toolfrom the collection.</summary>
/// <param name="tool">The tool to be removed from the collection.</param>
/// <returns>
/// <see langword="true"/> if the tool was found in the collection and removed; otherwise, <see langword="false"/> if it couldn't be found.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
public virtual bool Remove(McpServerTool tool)
{
Throw.IfNull(tool);
bool removed = ((ICollection<KeyValuePair<string, McpServerTool>>)_tools).Remove(new(tool.ProtocolTool.Name, tool));
if (removed)
{
RaiseChanged();
}
return removed;
}
/// <summary>Attempts to get the tool with the specified name from the collection.</summary>
/// <param name="name">The name of the tool to retrieve.</param>
/// <param name="tool">The tool, if found; otherwise, <see langword="null"/>.</param>
/// <returns>
/// <see langword="true"/> if the tool was found in the collection and return; otherwise, <see langword="false"/> if it couldn't be found.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="name"/> is <see langword="null"/>.</exception>
public virtual bool TryGetTool(string name, [NotNullWhen(true)] out McpServerTool? tool)
{
Throw.IfNull(name);
return _tools.TryGetValue(name, out tool);
}
/// <summary>Checks if a specific tool is present in the collection of tools.</summary>
/// <param name="tool">The tool to search for in the collection.</param>
/// <see langword="true"/> if the tool was found in the collection and return; otherwise, <see langword="false"/> if it couldn't be found.
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
public virtual bool Contains(McpServerTool tool)
{
Throw.IfNull(tool);
return ((ICollection<KeyValuePair<string, McpServerTool>>)_tools).Contains(new(tool.ProtocolTool.Name, tool));
}
/// <summary>Gets the names of all of the tools in the collection.</summary>
public virtual ICollection<string> ToolNames => _tools.Keys;
/// <summary>Creates an array containing all of the tools in the collection.</summary>
/// <returns>An array containing all of the tools in the collection.</returns>
public virtual McpServerTool[] ToArray() => _tools.Values.ToArray();
/// <inheritdoc/>
public virtual void CopyTo(McpServerTool[] array, int arrayIndex)
{
Throw.IfNull(array);
foreach (var entry in _tools)
{
array[arrayIndex++] = entry.Value;
}
}
/// <inheritdoc/>
public virtual IEnumerator<McpServerTool> GetEnumerator()
{
foreach (var entry in _tools)
{
yield return entry.Value;
}
}
/// <inheritdoc/>
IEnumerator<McpServerTool> IEnumerable<McpServerTool>.GetEnumerator() => GetEnumerator();
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
/// <inheritdoc/>
bool ICollection<McpServerTool>.IsReadOnly => false;
}

View file

@ -0,0 +1,7 @@
namespace ModelContextProtocol.Server;
/// <summary>
/// Used to attribute a type containing methods that should be exposed as MCP tools.
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public sealed class McpServerToolTypeAttribute : Attribute;

View file

@ -1,20 +0,0 @@
namespace ModelContextProtocol.Server;
/// <summary>
/// Attribute to mark a method as an MCP tool.
/// </summary>
[AttributeUsage(AttributeTargets.Method)]
public sealed class McpToolAttribute : Attribute
{
/// <summary>Gets the name of the tool.</summary>
/// <remarks>If not provided, the method name will be used.</remarks>
public string? Name { get; }
/// <summary>
/// Attribute to mark a method as an MCP tool.
/// </summary>
/// <param name="name">The name of the tool. If not provided, the method name will be used.</param>
public McpToolAttribute(string? name = null)
{
Name = name;
}
}

Some files were not shown because too many files have changed in this diff Show more