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:
parent
aafa9d929d
commit
caca2ae61d
1 changed files with 2703 additions and 686 deletions
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue