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
3 changed files with 133 additions and 49 deletions

View file

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

View file

@ -11,38 +11,45 @@ using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;
using System.Threading.Channels;
using ModelContextProtocol.Protocol.Messages;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Tests.Utils;
using Microsoft.Extensions.Logging;
namespace ModelContextProtocol.Tests.Configuration;
public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
public class McpServerBuilderExtensionsToolsTests : LoggedTest, IAsyncDisposable
{
private Pipe _clientToServerPipe = new();
private Pipe _serverToClientPipe = new();
private readonly Pipe _clientToServerPipe = new();
private readonly Pipe _serverToClientPipe = new();
private readonly ServiceProvider _serviceProvider;
private readonly IMcpServerBuilder _builder;
private readonly IMcpServer _server;
public McpServerBuilderExtensionsToolsTests()
public McpServerBuilderExtensionsToolsTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
ServiceCollection sc = new();
sc.AddSingleton(LoggerFactory);
sc.AddSingleton<IServerTransport>(new StdioServerTransport("TestServer", _clientToServerPipe.Reader.AsStream(), _serverToClientPipe.Writer.AsStream()));
sc.AddSingleton(new ObjectWithId());
_builder = sc.AddMcpServer().WithTools<EchoTool>();
_server = sc.BuildServiceProvider().GetRequiredService<IMcpServer>();
_serviceProvider = sc.BuildServiceProvider();
_server = _serviceProvider.GetRequiredService<IMcpServer>();
}
public ValueTask DisposeAsync()
{
_clientToServerPipe.Writer.Complete();
_serverToClientPipe.Writer.Complete();
return _server.DisposeAsync();
return _serviceProvider.DisposeAsync();
}
private async Task<IMcpClient> CreateMcpClientForServer()
{
await _server.StartAsync(TestContext.Current.CancellationToken);
var stdin = new StreamReader(_serverToClientPipe.Reader.AsStream());
var stdout = new StreamWriter(_clientToServerPipe.Writer.AsStream());
var serverStdinWriter = new StreamWriter(_clientToServerPipe.Writer.AsStream());
var serverStdoutReader = new StreamReader(_serverToClientPipe.Reader.AsStream());
var serverConfig = new McpServerConfig()
{
@ -53,7 +60,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
return await McpClientFactory.CreateAsync(
serverConfig,
createTransportFunc: (_, _) => new StreamClientTransport(stdin, stdout),
createTransportFunc: (_, _) => new StreamClientTransport(serverStdinWriter, serverStdoutReader),
cancellationToken: TestContext.Current.CancellationToken);
}
@ -86,6 +93,63 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
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]
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
{
private readonly JsonSerializerOptions _jsonOptions = McpJsonUtilities.DefaultOptions;
private Task? _readTask;
private CancellationTokenSource _shutdownCts = new CancellationTokenSource();
private readonly TextReader _stdin;
private readonly TextWriter _stdout;
private readonly Task? _readTask;
private readonly CancellationTokenSource _shutdownCts = new CancellationTokenSource();
private readonly TextReader _serverStdoutReader;
private readonly TextWriter _serverStdinWriter;
public StreamClientTransport(TextReader stdin, TextWriter stdout)
public StreamClientTransport(TextWriter serverStdinWriter, TextReader serverStdoutReader)
: base(NullLoggerFactory.Instance)
{
_stdin = stdin;
_stdout = stdout;
_serverStdoutReader = serverStdoutReader;
_serverStdinWriter = serverStdinWriter;
_readTask = Task.Run(() => ReadMessagesAsync(_shutdownCts.Token), CancellationToken.None);
SetConnected(true);
}
@ -31,13 +31,13 @@ internal sealed class StreamClientTransport : TransportBase, IClientTransport
messageWithId.Id.ToString() :
"(no id)";
await _stdout.WriteLineAsync(JsonSerializer.Serialize(message)).ConfigureAwait(false);
await _stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
await _serverStdinWriter.WriteLineAsync(JsonSerializer.Serialize(message)).ConfigureAwait(false);
await _serverStdinWriter.FlushAsync(cancellationToken).ConfigureAwait(false);
}
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))
{