Improve tool handling (#28)

* Improve tool handling

* Rework stuff with new hosting options

* Update readme

* Fix build

* Try run client tests

* Fix build

* Fix issues on linux

* Try to run testserver on linux

* Fix test issue

* Fix code smells
This commit is contained in:
Toni Wenzel 2025-03-07 16:48:55 +01:00 • committed by GitHub
commit ec9bdfcf5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1036 additions and 235 deletions

View file

@ -17,7 +17,7 @@ public class IntegrationTests
{
Id = "everything",
Name = "Everything",
TransportType = McpDotNet.Protocol.Transport.TransportTypes.StdIo,
TransportType = "stdio",
TransportOptions = new Dictionary<string, string>
{
["command"] = "npx",

View file

@ -27,7 +27,6 @@
<ItemGroup>
<ProjectReference Include="..\..\src\McpDotNet.Extensions.AI\McpDotNet.Extensions.AI.csproj" />
<ProjectReference Include="..\..\src\mcpdotnet\mcpdotnet.csproj" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.3.0-preview.1.25114.11" />
</ItemGroup>

View file

@ -5,7 +5,7 @@ using McpDotNet.Server;
using Microsoft.Extensions.Logging;
using Serilog;
internal class Program
internal static class Program
{
private static ILoggerFactory CreateLoggerFactory()
{
@ -17,7 +17,6 @@ internal class Program
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
.CreateLogger();
var logsPath = Path.Combine(AppContext.BaseDirectory, "testserver.log");
return LoggerFactory.Create(builder =>
{
builder.AddSerilog();
@ -26,7 +25,7 @@ internal class Program
private static async Task Main(string[] args)
{
Console.WriteLine("Starting server...");
Log.Logger.Information("Starting server...");
McpServerOptions options = new McpServerOptions()
{
@ -40,11 +39,12 @@ internal class Program
ProtocolVersion = "2024-11-05",
ServerInstructions = "This is a test server with only stub functionality",
};
var loggerFactory = CreateLoggerFactory();
McpServerFactory factory = new McpServerFactory(new StdioServerTransport("TestServer", loggerFactory), options, loggerFactory);
IMcpServer server = factory.CreateServer();
Console.WriteLine("Server object created, registering handlers.");
Log.Logger.Information("Server object created, registering handlers.");
#region Helped method
static CreateMessageRequestParams CreateRequestSamplingParams(string context, string uri, int maxTokens = 100)
@ -69,6 +69,7 @@ internal class Program
#endregion
#region Tools
server.ListToolsHandler = (request, cancellationToken) =>
{
return Task.FromResult(new ListToolsResult()
@ -108,22 +109,22 @@ internal class Program
server.CallToolHandler = async (request, cancellationToken) =>
{
if (request?.Name == "echo")
if (request.Params?.Name == "echo")
{
if (request.Arguments is null || !request.Arguments.TryGetValue("message", out var message))
if (request.Params?.Arguments is null || !request.Params.Arguments.TryGetValue("message", out var message))
{
throw new McpServerException("Missing required argument 'message'");
}
return new CallToolResponse()
{
Content = [new Content() { Text = "Echo: " + message.ToString(), Type = "text" }]
Content = [new Content() { Text = "Echo: " + message?.ToString(), Type = "text" }]
};
}
else if (request?.Name == "sampleLLM")
else if (request.Params?.Name == "sampleLLM")
{
if (request.Arguments is null ||
!request.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Arguments.TryGetValue("maxTokens", out var maxTokens))
if (request.Params?.Arguments is null ||
!request.Params.Arguments.TryGetValue("prompt", out var prompt) ||
!request.Params.Arguments.TryGetValue("maxTokens", out var maxTokens))
{
throw new McpServerException("Missing required arguments 'prompt' and 'maxTokens'");
}
@ -137,7 +138,7 @@ internal class Program
}
else
{
throw new McpServerException($"Unknown tool: {request?.Name}");
throw new McpServerException($"Unknown tool: {request.Params?.Name}");
}
};
#endregion
@ -186,12 +187,12 @@ internal class Program
server.ListResourcesHandler = (request, cancellationToken) =>
{
int startIndex = 0;
if (request is null) request = new();
if (request.Cursor is not null)
request ??= new(server, new());
if (request.Params?.Cursor is not null)
{
try
{
var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Cursor));
var startIndexAsString = Encoding.UTF8.GetString(Convert.FromBase64String(request.Params.Cursor));
startIndex = Convert.ToInt32(startIndexAsString);
}
catch
@ -216,12 +217,11 @@ internal class Program
server.ReadResourceHandler = (request, cancellationToken) =>
{
if (request?.Uri is null)
if (request.Params?.Uri is null)
{
throw new McpServerException("Missing required argument 'uri'");
}
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Uri)
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
?? throw new McpServerException("Resource not found");
return Task.FromResult(new ReadResourceResult()
@ -268,8 +268,8 @@ internal class Program
server.GetPromptHandler = (request, cancellationToken) =>
{
List<PromptMessage> messages = [];
if (request?.Name == "simple_prompt")
List<PromptMessage> messages = new();
if (request.Params?.Name == "simple_prompt")
{
messages.Add(new PromptMessage()
{
@ -281,10 +281,10 @@ internal class Program
}
});
}
else if (request?.Name == "complex_prompt")
else if (request.Params?.Name == "complex_prompt")
{
string temperature = request.Arguments?["temperature"]?.ToString() ?? "unknown";
string style = request.Arguments?["style"]?.ToString() ?? "unknown";
string temperature = request.Params.Arguments?["temperature"]?.ToString() ?? "unknown";
string style = request.Params.Arguments?["style"]?.ToString() ?? "unknown";
messages.Add(new PromptMessage()
{
Role = Role.User,
@ -316,7 +316,7 @@ internal class Program
}
else
{
throw new McpServerException($"Unknown prompt: {request?.Name}");
throw new McpServerException($"Unknown prompt: {request.Params?.Name}");
}
return Task.FromResult(new GetPromptResult()
@ -329,11 +329,11 @@ internal class Program
#region Sampling
#endregion
Console.WriteLine("Server initialized.");
Log.Logger.Information("Server initialized.");
await server.StartAsync();
Console.WriteLine("Server started.");
Log.Logger.Information("Server started.");
// Run until process is stopped by the client (parent process)
while (true)

View file

@ -6,7 +6,6 @@ using McpDotNet.Protocol.Types;
namespace McpDotNet.Tests;
[Trait("Execution", "Manual")]
public class ClientIntegrationTests : IClassFixture<ClientIntegrationTestFixture>
{
private readonly ClientIntegrationTestFixture _fixture;

View file

@ -0,0 +1,138 @@
using McpDotNet.Configuration;
using McpDotNet.Protocol.Types;
using McpDotNet.Server;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
namespace McpDotNet.Tests;
public class McpServerBuilderExtensionsHandlerTests
{
private readonly Mock<IMcpServerBuilder> _builder;
private readonly ServiceCollection _services;
public McpServerBuilderExtensionsHandlerTests()
{
_services = new ServiceCollection();
_builder = new Mock<IMcpServerBuilder>();
_builder.SetupGet(b => b.Services).Returns(_services);
}
[Fact]
public void WithListToolsHandler_Sets_Handler()
{
Func<RequestContext<ListToolsRequestParams>, CancellationToken, Task<ListToolsResult>> handler = (context, token) => Task.FromResult(new ListToolsResult());
_builder.Object.WithListToolsHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.ListToolsHandler);
}
[Fact]
public void WithCallToolHandler_Sets_Handler()
{
Func<RequestContext<CallToolRequestParams>, CancellationToken, Task<CallToolResponse>> handler = (context, token) => Task.FromResult(new CallToolResponse());
_builder.Object.WithCallToolHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.CallToolHandler);
}
[Fact]
public void WithListPromptsHandler_Sets_Handler()
{
Func<RequestContext<ListPromptsRequestParams>, CancellationToken, Task<ListPromptsResult>> handler = (context, token) => Task.FromResult(new ListPromptsResult());
_builder.Object.WithListPromptsHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.ListPromptsHandler);
}
[Fact]
public void WithGetPromptHandler_Sets_Handler()
{
Func<RequestContext<GetPromptRequestParams>, CancellationToken, Task<GetPromptResult>> handler = (context, token) => Task.FromResult(new GetPromptResult());
_builder.Object.WithGetPromptHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.GetPromptHandler);
}
[Fact]
public void WithListResourcesHandler_Sets_Handler()
{
Func<RequestContext<ListResourcesRequestParams>, CancellationToken, Task<ListResourcesResult>> handler = (context, token) => Task.FromResult(new ListResourcesResult());
_builder.Object.WithListResourcesHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.ListResourcesHandler);
}
[Fact]
public void WithReadResourceHandler_Sets_Handler()
{
Func<RequestContext<ReadResourceRequestParams>, CancellationToken, Task<ReadResourceResult>> handler = (context, token) => Task.FromResult(new ReadResourceResult());
_builder.Object.WithReadResourceHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.ReadResourceHandler);
}
[Fact]
public void WithGetCompletionHandler_Sets_Handler()
{
Func<RequestContext<CompleteRequestParams>, CancellationToken, Task<CompleteResult>> handler = (context, token) => Task.FromResult(new CompleteResult());
_builder.Object.WithGetCompletionHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.GetCompletionHandler);
}
[Fact]
public void WithSubscribeToResourcesHandler_Sets_Handler()
{
Func<RequestContext<string>, CancellationToken, Task> handler = (context, token) => Task.CompletedTask;
_builder.Object.WithSubscribeToResourcesHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.SubscribeToResourcesHandler);
}
[Fact]
public void WithUnsubscribeFromResourcesHandler_Sets_Handler()
{
Func<RequestContext<string>, CancellationToken, Task> handler = (context, token) => Task.CompletedTask;
_builder.Object.WithUnsubscribeFromResourcesHandler(handler);
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.Equal(handler, options.UnsubscribeFromResourcesHandler);
}
}

View file

@ -0,0 +1,163 @@
using McpDotNet.Configuration;
using McpDotNet.Server;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
namespace McpDotNet.Tests;
public class McpServerBuilderExtensionsToolsTests
{
private readonly Mock<IMcpServerBuilder> _builder;
private readonly ServiceCollection _services;
public McpServerBuilderExtensionsToolsTests()
{
_services = new ServiceCollection();
_builder = new Mock<IMcpServerBuilder>();
_builder.SetupGet(b => b.Services).Returns(_services);
}
[Fact]
public void Adds_Tools_To_Server()
{
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
Assert.NotNull(options.ListToolsHandler);
Assert.NotNull(options.CallToolHandler);
}
[Fact]
public async Task Can_List_Registered_Tool()
{
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
var result = await options.ListToolsHandler!(new(Mock.Of<IMcpServer>(), new()), CancellationToken.None);
Assert.NotNull(result);
Assert.NotEmpty(result.Tools);
var tool = result.Tools[0];
Assert.Equal("Echo", tool.Name);
Assert.Equal("Echoes the input back to the client.", tool.Description);
Assert.NotNull(tool.InputSchema);
Assert.Equal("object", tool.InputSchema.Type);
Assert.NotNull(tool.InputSchema.Properties);
Assert.NotEmpty(tool.InputSchema.Properties);
Assert.Contains("message", tool.InputSchema.Properties);
Assert.Equal("string", tool.InputSchema.Properties["message"].Type);
Assert.Equal("the echoes message", tool.InputSchema.Properties["message"].Description);
Assert.NotNull(tool.InputSchema.Required);
Assert.NotEmpty(tool.InputSchema.Required);
Assert.Contains("message", tool.InputSchema.Required);
tool = result.Tools[1];
Assert.Equal("double_echo", tool.Name);
Assert.Equal("Echoes the input back to the client.", tool.Description);
}
[Fact]
public async Task Can_Call_Registered_Tool()
{
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
var result = await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo", Arguments = new() { { "message", "Peter" } } }), CancellationToken.None);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("hello Peter", result.Content[0].Text);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Dependency_Injection()
{
bool dependencyInjectionCalled = false;
_services.AddTransient<IDependendService>(sp =>
{
dependencyInjectionCalled = true;
return Mock.Of<IDependendService>();
});
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoToolWithDi));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
var mcpServer = new Mock<IMcpServer>();
mcpServer.SetupGet(s => s.ServiceProvider).Returns(serviceProvider);
var result = await options.CallToolHandler!(new(mcpServer.Object, new() { Name = "Echo", Arguments = new() { { "message", "Peter" } } }), CancellationToken.None);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("hello Peter", result.Content[0].Text);
Assert.True(dependencyInjectionCalled);
}
[Fact]
public async Task Throws_Exception_On_Unknown_Tool()
{
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "NotRegisteredTool" }), CancellationToken.None));
Assert.Equal("Unknown tool: NotRegisteredTool", exception.Message);
}
[Fact]
public async Task Throws_Exception_Missing_Parameter()
{
McpServerBuilderExtensions.WithTools(_builder.Object, typeof(EchoTool));
var serviceProvider = _services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerDelegates>>().Value;
var exception = await Assert.ThrowsAsync<McpServerException>(async () => await options.CallToolHandler!(new(Mock.Of<IMcpServer>(), new() { Name = "Echo" }), CancellationToken.None));
Assert.Equal("Missing required argument 'message'.", exception.Message);
}
private static class EchoTool
{
[McpTool(Description = "Echoes the input back to the client.")]
public static string Echo([McpParameter(true, Description = "the echoes message")] string message)
{
return "hello " + message;
}
[McpTool(Name = "double_echo", Description = "Echoes the input back to the client.")]
public static string Echo2([McpParameter(true)] string message)
{
return "hello hello" + message;
}
}
public interface IDependendService
{
}
private class EchoToolWithDi
{
public EchoToolWithDi(IDependendService service)
{
}
[McpTool(Description = "Echoes the input back to the client.")]
public Task<string> Echo([McpParameter(true)] string message)
{
return Task.FromResult("hello " + message);
}
}
}

View file

@ -0,0 +1,23 @@
using McpDotNet.Configuration;
using McpDotNet.Protocol.Transport;
using Microsoft.Extensions.DependencyInjection;
using Moq;
namespace McpDotNet.Tests;
public class McpServerBuilderExtensionsTransportsTests
{
[Fact]
public void WithStdioServerTransport_Sets_Transport()
{
var services = new ServiceCollection();
var builder = new Mock<IMcpServerBuilder>();
builder.SetupGet(b => b.Services).Returns(services);
builder.Object.WithStdioServerTransport();
var transportType = services.FirstOrDefault(s => s.ServiceType == typeof(IServerTransport));
Assert.NotNull(transportType);
Assert.Equal(typeof(StdioServerTransport), transportType.ImplementationType);
}
}

View file

@ -31,11 +31,14 @@ public class ServerIntegrationTestFixture : IDisposable
TransportType = TransportTypes.StdIo,
TransportOptions = new Dictionary<string, string>
{
["command"] = "TestServer.exe",
["command"] = OperatingSystem.IsWindows() ? "TestServer.exe" : "dotnet",
// Change to ["arguments"] = "mcp-server-everything" if you want to run the server locally after creating a symlink
}
};
if (!OperatingSystem.IsWindows())
DefaultConfig.TransportOptions["arguments"] = "TestServer.dll";
// Inject the mock transport into the factory
Factory = new McpClientFactory(
[DefaultConfig],

View file

@ -1,6 +1,4 @@
using McpDotNet.Client;
using McpDotNet.Configuration;
using McpDotNet.Protocol.Messages;
using McpDotNet.Protocol.Types;
namespace McpDotNet.Tests;
@ -18,7 +16,7 @@ public class ServerIntegrationTests : IClassFixture<ServerIntegrationTestFixture
public async Task ConnectAndPing_Stdio_TestServer()
{
// Arrange
// Act
var client = await _fixture.Factory.GetClientAsync("test_server");
await client.PingAsync(CancellationToken.None);
@ -45,7 +43,7 @@ public class ServerIntegrationTests : IClassFixture<ServerIntegrationTestFixture
public async Task ListTools_Stdio_TestServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("test_server");
var tools = await client.ListToolsAsync();
@ -59,7 +57,7 @@ public class ServerIntegrationTests : IClassFixture<ServerIntegrationTestFixture
public async Task CallTool_Stdio_EchoServer()
{
// arrange
// act
var client = await _fixture.Factory.GetClientAsync("test_server");
var result = await client.CallToolAsync(

View file

@ -7,6 +7,7 @@
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>McpDotNet.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
@ -31,7 +32,10 @@
</ItemGroup>
<ItemGroup>
<Content Include="..\mcpdotnet.TestServer\bin\$(Configuration)\net8.0\TestServer.exe">
<Content Condition="$([MSBuild]::IsOSPlatform('Windows'))" Include="..\mcpdotnet.TestServer\bin\$(Configuration)\net8.0\TestServer.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Condition="$([MSBuild]::IsOSPlatform('Linux'))" Include="..\mcpdotnet.TestServer\bin\$(Configuration)\net8.0\TestServer.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>