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

View file

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

View file

@ -2,7 +2,7 @@ using ModelContextProtocol;
using AspNetCoreSseServer;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer().WithTools();
builder.Services.AddMcpServer().WithToolsFromAssembly();
var app = builder.Build();
app.MapGet("/", () => "Hello World!");

View file

@ -3,10 +3,10 @@ using System.ComponentModel;
namespace TestServerWithHosting.Tools;
[McpToolType]
[McpServerToolType]
public static class EchoTool
{
[McpTool, Description("Echoes the input back to the client.")]
[McpServerTool, Description("Echoes the input back to the client.")]
public static string Echo(string message)
{
return "hello " + message;

View file

@ -1,51 +1,36 @@
using ModelContextProtocol.Protocol.Types;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Server;
using System.ComponentModel;
namespace TestServerWithHosting.Tools;
/// <summary>
/// This tool uses depenency injection and async method
/// This tool uses dependency injection and async method
/// </summary>
[McpToolType]
public class SampleLlmTool
[McpServerToolType]
public static class SampleLlmTool
{
private readonly IMcpServer _server;
public SampleLlmTool(IMcpServer server)
{
_server = server ?? throw new ArgumentNullException(nameof(server));
}
[McpTool("sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public async Task<string> SampleLLM(
[McpServerTool("sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public static async Task<string> SampleLLM(
IMcpServer thisServer,
[Description("The prompt to send to the LLM")] string prompt,
[Description("Maximum number of tokens to generate")] int maxTokens,
CancellationToken cancellationToken)
{
var samplingParams = CreateRequestSamplingParams(prompt ?? string.Empty, "sampleLLM", maxTokens);
var sampleResult = await _server.RequestSamplingAsync(samplingParams, cancellationToken);
ChatMessage[] messages =
[
new(ChatRole.System, "You are a helpful test server."),
new(ChatRole.User, prompt),
];
return $"LLM sampling result: {sampleResult.Content.Text}";
}
private static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100)
{
return new CreateMessageRequestParams()
ChatOptions options = new()
{
Messages = [new SamplingMessage()
{
Role = Role.User,
Content = new Content()
{
Type = "text",
Text = $"Resource {uri} context: {context}"
}
}],
SystemPrompt = "You are a helpful test server.",
MaxTokens = maxTokens,
MaxOutputTokens = maxTokens,
Temperature = 0.7f,
IncludeContext = ContextInclusion.ThisServer
};
var samplingResponse = await thisServer.AsSamplingChatClient().GetResponseAsync(messages, options, cancellationToken);
return $"LLM sampling result: {samplingResponse}";
}
}

View file

@ -11,7 +11,6 @@
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
<PackageReference Include="Anthropic.SDK" />
<PackageReference Include="System.Linq.AsyncEnumerable" />
</ItemGroup>
<ItemGroup>

View file

@ -19,7 +19,7 @@ var mcpClient = await McpClientFactory.CreateAsync(
// Get all available tools
Console.WriteLine("Tools available:");
var tools = await mcpClient.GetAIFunctionsAsync();
var tools = await mcpClient.ListToolsAsync();
foreach (var tool in tools)
{
Console.WriteLine($" {tool}");

View file

@ -19,7 +19,7 @@ try
builder.Services.AddSerilog();
builder.Services.AddMcpServer()
.WithStdioServerTransport()
.WithTools();
.WithToolsFromAssembly();
var app = builder.Build();

View file

@ -3,10 +3,10 @@ using System.ComponentModel;
namespace TestServerWithHosting.Tools;
[McpToolType]
[McpServerToolType]
public static class EchoTool
{
[McpTool, Description("Echoes the input back to the client.")]
[McpServerTool, Description("Echoes the input back to the client.")]
public static string Echo(string message)
{
return "hello " + message;

View file

@ -7,7 +7,7 @@ namespace TestServerWithHosting.Tools;
/// <summary>
/// This tool uses depenency injection and async method
/// </summary>
[McpToolType]
[McpServerToolType]
public class SampleLlmTool
{
private readonly IMcpServer _server;
@ -17,7 +17,7 @@ public class SampleLlmTool
_server = server ?? throw new ArgumentNullException(nameof(server));
}
[McpTool("sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
[McpServerTool("sampleLLM"), Description("Samples from an LLM using MCP's sampling feature")]
public async Task<string> SampleLLM(
[Description("The prompt to send to the LLM")] string prompt,
[Description("Maximum number of tokens to generate")] int maxTokens,

View file

@ -15,4 +15,7 @@ internal static class CollectionExtensions
return dictionary.TryGetValue(key, out TValue? value) ? value : defaultValue;
}
public static Dictionary<TKey, TValue> ToDictionary<TKey, TValue>(this IEnumerable<KeyValuePair<TKey, TValue>> source) =>
source.ToDictionary(kv => kv.Key, kv => kv.Value);
}

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

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