Overhaul tool handling (#89)

* Overhaul tool handling

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

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

* Make McpServerTool{Collection} extensible

* Fix README.md example

* Add notifications test

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

View file

@ -7,7 +7,6 @@ using System.Text.Json;
using ModelContextProtocol.Configuration;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Tests.Utils;
using Xunit.Sdk;
using System.Text.Encodings.Web;
using System.Text.Json.Serialization.Metadata;
using System.Text.Json.Serialization;
@ -18,6 +17,8 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
{
private static readonly string? s_openAIKey = Environment.GetEnvironmentVariable("AI:OpenAI:ApiKey")!;
public static bool NoOpenAIKeySet => string.IsNullOrWhiteSpace(s_openAIKey);
private readonly ClientIntegrationTestFixture _fixture;
public ClientIntegrationTests(ClientIntegrationTestFixture fixture, ITestOutputHelper testOutputHelper)
@ -68,13 +69,10 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken);
var aiFunctions = await client.GetAIFunctionsAsync(TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
// assert
Assert.NotEmpty(tools);
Assert.NotEmpty(aiFunctions);
Assert.Equal(tools.Count, aiFunctions.Count);
}
[Theory]
@ -87,7 +85,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
await using var client = await _fixture.CreateClientAsync(clientId);
var result = await client.CallToolAsync(
"echo",
new Dictionary<string, object>
new Dictionary<string, object?>
{
["message"] = "Hello MCP!"
},
@ -109,7 +107,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
var aiFunctions = await client.GetAIFunctionsAsync(TestContext.Current.CancellationToken);
var aiFunctions = await client.ListToolsAsync(TestContext.Current.CancellationToken);
var echo = aiFunctions.Single(t => t.Name == "echo");
var result = await echo.InvokeAsync([new KeyValuePair<string, object?>("message", "Hello MCP!")], TestContext.Current.CancellationToken);
@ -126,7 +124,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken);
var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
// assert
Assert.NotEmpty(prompts);
@ -158,7 +156,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
var arguments = new Dictionary<string, object>
var arguments = new Dictionary<string, object?>
{
{ "temperature", "0.7" },
{ "style", "formal" }
@ -191,7 +189,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
List<ResourceTemplate> allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken);
IList<ResourceTemplate> allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken);
// The server provides a single test resource template
Assert.Single(allResourceTemplates);
@ -206,15 +204,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
await using var client = await _fixture.CreateClientAsync(clientId);
List<Resource> allResources = [];
string? cursor = null;
do
{
var resources = await client.ListResourcesAsync(cursor, CancellationToken.None);
allResources.AddRange(resources.Resources);
cursor = resources.NextCursor;
}
while (cursor != null);
IList<Resource> allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
// The server provides 100 test resources
Assert.Equal(100, allResources.Count);
@ -393,7 +383,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// Call the server's sampleLLM tool which should trigger our sampling handler
var result = await client.CallToolAsync(
"sampleLLM",
new Dictionary<string, object>
new Dictionary<string, object?>
{
["prompt"] = "Test prompt",
["maxTokens"] = 100
@ -483,7 +473,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// act
var result = await client.CallToolAsync(
"read_graph",
[],
new Dictionary<string, object?>(),
TestContext.Current.CancellationToken);
// assert
@ -494,17 +484,15 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
await client.DisposeAsync();
}
[Fact]
public async Task GetAIFunctionsAsync_UsingEverythingServer_ToolsAreProperlyCalled()
[Fact(Skip = "Requires OpenAI API Key", SkipWhen = nameof(NoOpenAIKeySet))]
public async Task ListToolsAsync_UsingEverythingServer_ToolsAreProperlyCalled()
{
SkipTestIfNoOpenAIKey();
// Get the MCP client and tools from it.
await using var client = await McpClientFactory.CreateAsync(
_fixture.EverythingServerConfig,
_fixture.DefaultOptions,
cancellationToken: TestContext.Current.CancellationToken);
var mappedTools = await client.GetAIFunctionsAsync(TestContext.Current.CancellationToken);
var mappedTools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
// Create the chat client.
using IChatClient chatClient = new OpenAIClient(s_openAIKey).AsChatClient("gpt-4o-mini")
@ -527,11 +515,9 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
Assert.Contains("Echo: Hello MCP!", response.Text);
}
[Fact]
[Fact(Skip = "Requires OpenAI API Key", SkipWhen = nameof(NoOpenAIKeySet))]
public async Task SamplingViaChatClient_RequestResponseProperlyPropagated()
{
SkipTestIfNoOpenAIKey();
await using var client = await McpClientFactory.CreateAsync(_fixture.EverythingServerConfig, new()
{
ClientInfo = new() { Name = nameof(SamplingViaChatClient_RequestResponseProperlyPropagated), Version = "1.0.0" },
@ -544,7 +530,7 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
},
}, cancellationToken: TestContext.Current.CancellationToken);
var result = await client.CallToolAsync("sampleLLM", new()
var result = await client.CallToolAsync("sampleLLM", new Dictionary<string, object?>()
{
["prompt"] = "In just a few words, what is the most famous tower in Paris?",
}, TestContext.Current.CancellationToken);
@ -590,9 +576,4 @@ public class ClientIntegrationTests : LoggedTest, IClassFixture<ClientIntegratio
// assert
Assert.True(logCounter > 0);
}
private static void SkipTestIfNoOpenAIKey()
{
Assert.SkipWhen(s_openAIKey is null, "No OpenAI key provided. Skipping test.");
}
}