List tools from DI once (#115)

* List tools from DI once

- Follow best practice to not mutate options outside of IConfigureOptions
- Long-term, we should move the repeated work from SetToolsHandler into
  an IPostConfigureOptions service or IMcpServerConnectionFactory

* Add Can_Create_Multiple_Servers_From_Options_And_List_Registered_Tools

* Flip stdin and stdout in StreamClientTransport

* s/McpToolType/McpServerToolType

* Clarify stdin and stdout refers to server process in StreamClientTransport

* fixup
This commit is contained in:
Stephen Halter 2025-03-27 04:26:14 -07:00 • committed by GitHub
commit 4a53f6f6d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 133 additions and 49 deletions

View file

@ -80,7 +80,7 @@ var response = await chatClient.GetResponseAsync(
Here is an example of how to create an MCP server and register all tools from the current application. Here is an example of how to create an MCP server and register all tools from the current application.
It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file... It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file...
the employed overload of `WithTools` examines the current assembly for classes with the `McpToolType` attribute, and registers all methods with the the employed overload of `WithTools` examines the current assembly for classes with the `McpServerToolType` attribute, and registers all methods with the
`McpTool` attribute as tools.) `McpTool` attribute as tools.)
```csharp ```csharp

View file

@ -85,7 +85,7 @@ var response = await chatClient.GetResponseAsync(
Here is an example of how to create an MCP server and register all tools from the current application. Here is an example of how to create an MCP server and register all tools from the current application.
It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file... It includes a simple echo tool as an example (this is included in the same file here for easy of copy and paste, but it needn't be in the same file...
the employed overload of `WithTools` examines the current assembly for classes with the `McpToolType` attribute, and registers all methods with the the employed overload of `WithTools` examines the current assembly for classes with the `McpServerToolType` attribute, and registers all methods with the
`McpTool` attribute as tools.) `McpTool` attribute as tools.)
```csharp ```csharp
@ -101,7 +101,7 @@ builder.Services
.WithTools(); .WithTools();
await builder.Build().RunAsync(); await builder.Build().RunAsync();
[McpToolType] [McpServerToolType]
public static class EchoTool public static class EchoTool
{ {
[McpTool, Description("Echoes the message back to the client.")] [McpTool, Description("Echoes the message back to the client.")]

View file

@ -15,6 +15,8 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
{ {
private readonly IServerTransport? _serverTransport; private readonly IServerTransport? _serverTransport;
private readonly string _serverDescription; private readonly string _serverDescription;
private readonly EventHandler? _toolsChangedDelegate;
private volatile bool _isInitializing; private volatile bool _isInitializing;
/// <summary> /// <summary>
@ -32,17 +34,29 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
Throw.IfNull(options); Throw.IfNull(options);
_serverTransport = transport as IServerTransport; _serverTransport = transport as IServerTransport;
ServerInstructions = options.ServerInstructions; ServerOptions = options;
Services = serviceProvider; Services = serviceProvider;
_serverDescription = $"{options.ServerInfo.Name} {options.ServerInfo.Version}"; _serverDescription = $"{options.ServerInfo.Name} {options.ServerInfo.Version}";
_toolsChangedDelegate = delegate
{
_ = SendMessageAsync(new JsonRpcNotification()
{
Method = NotificationMethods.ToolListChangedNotification,
});
};
AddNotificationHandler("notifications/initialized", _ => AddNotificationHandler("notifications/initialized", _ =>
{ {
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
{
tools.Changed += _toolsChangedDelegate;
}
IsInitialized = true; IsInitialized = true;
return Task.CompletedTask; return Task.CompletedTask;
}); });
SetToolsHandler(ref options); SetToolsHandler(options);
SetInitializeHandler(options); SetInitializeHandler(options);
SetCompletionHandler(options); SetCompletionHandler(options);
@ -50,18 +64,15 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
SetPromptsHandler(options); SetPromptsHandler(options);
SetResourcesHandler(options); SetResourcesHandler(options);
SetSetLoggingLevelHandler(options); SetSetLoggingLevelHandler(options);
ServerOptions = options;
} }
public ServerCapabilities? ServerCapabilities { get; set; }
public ClientCapabilities? ClientCapabilities { get; set; } public ClientCapabilities? ClientCapabilities { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public Implementation? ClientInfo { get; set; } public Implementation? ClientInfo { get; set; }
/// <inheritdoc />
public string? ServerInstructions { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public McpServerOptions ServerOptions { get; } public McpServerOptions ServerOptions { get; }
@ -111,6 +122,15 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
} }
} }
protected override Task CleanupAsync()
{
if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
{
tools.Changed -= _toolsChangedDelegate;
}
return base.CleanupAsync();
}
private void SetPingHandler() private void SetPingHandler()
{ {
SetRequestHandler<JsonNode, PingResult>("ping", SetRequestHandler<JsonNode, PingResult>("ping",
@ -127,9 +147,9 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
return Task.FromResult(new InitializeResult() return Task.FromResult(new InitializeResult()
{ {
ProtocolVersion = options.ProtocolVersion, ProtocolVersion = options.ProtocolVersion,
Instructions = ServerInstructions, Instructions = options.ServerInstructions,
ServerInfo = options.ServerInfo, ServerInfo = options.ServerInfo,
Capabilities = options.Capabilities ?? new ServerCapabilities(), Capabilities = ServerCapabilities ?? new(),
}); });
}); });
} }
@ -198,7 +218,7 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct)); SetRequestHandler<GetPromptRequestParams, GetPromptResult>("prompts/get", (request, ct) => getPromptHandler(new(this, request), ct));
} }
private void SetToolsHandler(ref McpServerOptions options) private void SetToolsHandler(McpServerOptions options)
{ {
ToolsCapability? toolsCapability = options.Capabilities?.Tools; ToolsCapability? toolsCapability = options.Capabilities?.Tools;
var listToolsHandler = toolsCapability?.ListToolsHandler; var listToolsHandler = toolsCapability?.ListToolsHandler;
@ -261,25 +281,25 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
return tool.InvokeAsync(request, cancellationToken); return tool.InvokeAsync(request, cancellationToken);
}; };
toolsCapability ??= new(); ServerCapabilities = new()
toolsCapability.CallToolHandler = callToolHandler;
toolsCapability.ListToolsHandler = listToolsHandler;
toolsCapability.ToolCollection = tools;
toolsCapability.ListChanged = true;
options.Capabilities ??= new();
options.Capabilities.Tools = toolsCapability;
tools.Changed += delegate
{ {
_ = SendMessageAsync(new JsonRpcNotification() Experimental = options.Capabilities?.Experimental,
Logging = options.Capabilities?.Logging,
Prompts = options.Capabilities?.Prompts,
Resources = options.Capabilities?.Resources,
Tools = new()
{ {
Method = NotificationMethods.ToolListChangedNotification, ListToolsHandler = listToolsHandler,
}); CallToolHandler = callToolHandler,
ToolCollection = tools,
ListChanged = true,
}
}; };
} }
else else
{ {
ServerCapabilities = options.Capabilities;
if (toolsCapability is null) if (toolsCapability is null)
{ {
// No tools, and no tools capability was declared, so nothing to do. // No tools, and no tools capability was declared, so nothing to do.

View file

@ -359,7 +359,7 @@ internal abstract class McpJsonRpcEndpoint : IAsyncDisposable
/// Cleans up the endpoint and releases resources. /// Cleans up the endpoint and releases resources.
/// </summary> /// </summary>
/// <returns></returns> /// <returns></returns>
protected async Task CleanupAsync() protected virtual async Task CleanupAsync()
{ {
if (_isDisposed) if (_isDisposed)
return; return;

View file

@ -38,8 +38,8 @@ public class McpClientExtensionsTests
{ {
await _server.StartAsync(TestContext.Current.CancellationToken); await _server.StartAsync(TestContext.Current.CancellationToken);
var stdin = new StreamReader(_serverToClientPipe.Reader.AsStream()); var serverStdinWriter = new StreamWriter(_clientToServerPipe.Writer.AsStream());
var stdout = new StreamWriter(_clientToServerPipe.Writer.AsStream()); var serverStdoutReader = new StreamReader(_serverToClientPipe.Reader.AsStream());
var serverConfig = new McpServerConfig() var serverConfig = new McpServerConfig()
{ {
@ -50,7 +50,7 @@ public class McpClientExtensionsTests
return await McpClientFactory.CreateAsync( return await McpClientFactory.CreateAsync(
serverConfig, serverConfig,
createTransportFunc: (_, _) => new StreamClientTransport(stdin, stdout), createTransportFunc: (_, _) => new StreamClientTransport(serverStdinWriter, serverStdoutReader),
cancellationToken: TestContext.Current.CancellationToken); cancellationToken: TestContext.Current.CancellationToken);
} }

View file

@ -11,38 +11,45 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
using System.Threading.Channels; using System.Threading.Channels;
using ModelContextProtocol.Protocol.Messages; using ModelContextProtocol.Protocol.Messages;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Tests.Utils;
using Microsoft.Extensions.Logging;
namespace ModelContextProtocol.Tests.Configuration; namespace ModelContextProtocol.Tests.Configuration;
public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
{ {
private Pipe _clientToServerPipe = new(); private readonly Pipe _clientToServerPipe = new();
private Pipe _serverToClientPipe = new(); private readonly Pipe _serverToClientPipe = new();
private readonly ServiceProvider _serviceProvider;
private readonly IMcpServerBuilder _builder; private readonly IMcpServerBuilder _builder;
private readonly IMcpServer _server; private readonly IMcpServer _server;
public McpServerBuilderExtensionsToolsTests() public McpServerBuilderExtensionsToolsTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{ {
ServiceCollection sc = new(); ServiceCollection sc = new();
sc.AddSingleton(LoggerFactory);
sc.AddSingleton<IServerTransport>(new StdioServerTransport("TestServer", _clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream())); sc.AddSingleton<IServerTransport>(new StdioServerTransport("TestServer", _clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()));
sc.AddSingleton(new ObjectWithId()); sc.AddSingleton(new ObjectWithId());
_builder = sc.AddMcpServer().WithTools<EchoTool>(); _builder = sc.AddMcpServer().WithTools<EchoTool>();
_server = sc.BuildServiceProvider().GetRequiredService<IMcpServer>(); _serviceProvider = sc.BuildServiceProvider();
_server = _serviceProvider.GetRequiredService<IMcpServer>();
} }
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
_clientToServerPipe.Writer.Complete(); _clientToServerPipe.Writer.Complete();
_serverToClientPipe.Writer.Complete(); _serverToClientPipe.Writer.Complete();
return _server.DisposeAsync(); return _serviceProvider.DisposeAsync();
} }
private async Task<IMcpClient> CreateMcpClientForServer() private async Task<IMcpClient> CreateMcpClientForServer()
{ {
await _server.StartAsync(TestContext.Current.CancellationToken); await _server.StartAsync(TestContext.Current.CancellationToken);
var stdin = new StreamReader(_serverToClientPipe.Reader.AsStream()); var serverStdinWriter = new StreamWriter(_clientToServerPipe.Writer.AsStream());
var stdout = new StreamWriter(_clientToServerPipe.Writer.AsStream()); var serverStdoutReader = new StreamReader(_serverToClientPipe.Reader.AsStream());
var serverConfig = new McpServerConfig() var serverConfig = new McpServerConfig()
{ {
@ -53,7 +60,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
return await McpClientFactory.CreateAsync( return await McpClientFactory.CreateAsync(
serverConfig, serverConfig,
createTransportFunc: (_, _) => new StreamClientTransport(stdin, stdout), createTransportFunc: (_, _) => new StreamClientTransport(serverStdinWriter, serverStdoutReader),
cancellationToken: TestContext.Current.CancellationToken); cancellationToken: TestContext.Current.CancellationToken);
} }
@ -86,6 +93,63 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
Assert.Equal("Echoes the input back to the client.", doubleEchoTool.Description); Assert.Equal("Echoes the input back to the client.", doubleEchoTool.Description);
} }
[Fact]
public async Task Can_Create_Multiple_Servers_From_Options_And_List_Registered_Tools()
{
var options = _serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var loggerFactory = _serviceProvider.GetRequiredService<ILoggerFactory>();
for (int i = 0; i < 2; i++)
{
var stdinPipe = new Pipe();
var stdoutPipe = new Pipe();
try
{
var transport = new StdioServerTransport($"TestServer_{i}", stdinPipe.Reader.AsStream(), stdoutPipe.Writer.AsStream());
var server = McpServerFactory.Create(transport, options, loggerFactory, _serviceProvider);
await server.StartAsync(TestContext.Current.CancellationToken);
var serverStdinWriter = new StreamWriter(stdinPipe.Writer.AsStream());
var serverStdoutReader = new StreamReader(stdoutPipe.Reader.AsStream());
var serverConfig = new McpServerConfig()
{
Id = $"TestServer_{i}",
Name = $"TestServer_{i}",
TransportType = "ignored",
};
var client = await McpClientFactory.CreateAsync(
serverConfig,
createTransportFunc: (_, _) => new StreamClientTransport(serverStdinWriter, serverStdoutReader),
cancellationToken: TestContext.Current.CancellationToken);
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
Assert.Equal(11, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
Assert.Equal("Echo", echoTool.Name);
Assert.Equal("Echoes the input back to the client.", echoTool.Description);
Assert.Equal("object", echoTool.JsonSchema.GetProperty("type").GetString());
Assert.Equal(JsonValueKind.Object, echoTool.JsonSchema.GetProperty("properties").GetProperty("message").ValueKind);
Assert.Equal("the echoes message", echoTool.JsonSchema.GetProperty("properties").GetProperty("message").GetProperty("description").GetString());
Assert.Equal(1, echoTool.JsonSchema.GetProperty("required").GetArrayLength());
McpClientTool doubleEchoTool = tools.First(t => t.Name == "double_echo");
Assert.Equal("double_echo", doubleEchoTool.Name);
Assert.Equal("Echoes the input back to the client.", doubleEchoTool.Description);
}
finally
{
stdinPipe.Writer.Complete();
stdoutPipe.Writer.Complete();
}
}
}
[Fact] [Fact]
public async Task Can_Be_Notified_Of_Tool_Changes() public async Task Can_Be_Notified_Of_Tool_Changes()
{ {

View file

@ -9,16 +9,16 @@ namespace ModelContextProtocol.Tests.Transport;
internal sealed class StreamClientTransport : TransportBase, IClientTransport internal sealed class StreamClientTransport : TransportBase, IClientTransport
{ {
private readonly JsonSerializerOptions _jsonOptions = McpJsonUtilities.DefaultOptions; private readonly JsonSerializerOptions _jsonOptions = McpJsonUtilities.DefaultOptions;
private Task? _readTask; private readonly Task? _readTask;
private CancellationTokenSource _shutdownCts = new CancellationTokenSource(); private readonly CancellationTokenSource _shutdownCts = new CancellationTokenSource();
private readonly TextReader _stdin; private readonly TextReader _serverStdoutReader;
private readonly TextWriter _stdout; private readonly TextWriter _serverStdinWriter;
public StreamClientTransport(TextReader stdin, TextWriter stdout) public StreamClientTransport(TextWriter serverStdinWriter, TextReader serverStdoutReader)
: base(NullLoggerFactory.Instance) : base(NullLoggerFactory.Instance)
{ {
_stdin = stdin; _serverStdoutReader = serverStdoutReader;
_stdout = stdout; _serverStdinWriter = serverStdinWriter;
_readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None); _readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None);
SetConnected(true); SetConnected(true);
} }
@ -31,13 +31,13 @@ internal sealed class StreamClientTransport : TransportBase, IClientTransport
messageWithId.Id.ToString() : messageWithId.Id.ToString() :
"(no id)"; "(no id)";
await _stdout.WriteLineAsync(JsonSerializer.Serialize(message)).ConfigureAwait(false); await _serverStdinWriter.WriteLineAsync(JsonSerializer.Serialize(message)).ConfigureAwait(false);
await _stdout.FlushAsync(cancellationToken).ConfigureAwait(false); await _serverStdinWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
} }
private async Task ReadMessagesAsync(CancellationToken cancellationToken) private async Task ReadMessagesAsync(CancellationToken cancellationToken)
{ {
while (await _stdin.ReadLineAsync(cancellationToken).ConfigureAwait(false) is string line) while (await _serverStdoutReader.ReadLineAsync(cancellationToken).ConfigureAwait(false) is string line)
{ {
if (!string.IsNullOrWhiteSpace(line)) if (!string.IsNullOrWhiteSpace(line))
{ {