Add [McpServerTool] support for instance methods (#100)

* Add [McpServerTool] support for instance methods

By popular demand, enables [McpServerTool] on instance methods. The instance is constructed per invocation, using ActivatorUtilities.CreateInstance when possible so that constructor arguments are satisfied from DI.

* Add disposal tests
This commit is contained in:
Stephen Toub 2025-03-26 09:34:30 -04:00 • committed by GitHub
commit b61cdef994
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 391 additions and 38 deletions

View file

@ -25,6 +25,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
{
ServiceCollection sc = new();
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>();
}
@ -70,7 +71,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
Assert.Equal(10, tools.Count);
Assert.Equal(11, tools.Count);
McpClientTool echoTool = tools.First(t => t.Name == "Echo");
Assert.Equal("Echo", echoTool.Name);
@ -91,7 +92,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
Assert.Equal(10, tools.Count);
Assert.Equal(11, tools.Count);
Channel<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
client.AddNotificationHandler("notifications/tools/list_changed", notification =>
@ -111,7 +112,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
await notificationRead;
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
Assert.Equal(11, tools.Count);
Assert.Equal(12, tools.Count);
Assert.Contains(tools, t => t.Name == "NewTool");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
@ -120,7 +121,7 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
await notificationRead;
tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
Assert.Equal(10, tools.Count);
Assert.Equal(11, tools.Count);
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}
@ -224,6 +225,35 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Instance_Method()
{
IMcpClient client = await CreateMcpClientForServer();
string[][] parts = new string[2][];
for (int i = 0; i < 2; i++)
{
var result = await client.CallToolAsync(
nameof(EchoTool.GetCtorParameter),
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
parts[i] = result.Content[0].Text?.Split(':') ?? [];
Assert.Equal(2, parts[i].Length);
}
string random1 = parts[0][0];
string random2 = parts[1][0];
Assert.NotEqual(random1, random2);
string id1 = parts[0][1];
string id2 = parts[1][1];
Assert.Equal(id1, id2);
}
[Fact]
public async Task Returns_IsError_Content_When_Tool_Fails()
{
@ -334,8 +364,10 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
}
[McpServerToolType]
public sealed class EchoTool
public sealed class EchoTool(ObjectWithId objectFromDI)
{
private string _randomValue = Guid.NewGuid().ToString("N");
[McpServerTool, Description("Echoes the input back to the client.")]
public static string Echo([Description("the echoes message")] string message)
{
@ -395,6 +427,9 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
{
return complex.Name!;
}
[McpServerTool]
public string GetCtorParameter() => $"{_randomValue}:{objectFromDI.Id}";
}
[McpServerToolType]
@ -421,4 +456,9 @@ public class McpServerBuilderExtensionsToolsTests : IAsyncDisposable
public string? Name { get; set; }
public int Age { get; set; }
}
public class ObjectWithId
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
}
}

View file

@ -4,6 +4,7 @@ using ModelContextProtocol.Server;
using Moq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ModelContextProtocol.Tests.Server;
@ -89,5 +90,120 @@ public class McpServerToolTests
Assert.Equal("42", result.Content[0].Text);
}
[Fact]
public async Task SupportsDisposingInstantiatedDisposableTargets()
{
McpServerTool tool1 = McpServerTool.Create(
typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!,
typeof(DisposableToolType));
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(null!, null),
TestContext.Current.CancellationToken);
Assert.Equal("""{"disposals":1}""", result.Content[0].Text);
}
[Fact]
public async Task SupportsAsyncDisposingInstantiatedAsyncDisposableTargets()
{
McpServerTool tool1 = McpServerTool.Create(
typeof(AsyncDisposableToolType).GetMethod(nameof(AsyncDisposableToolType.InstanceMethod))!,
typeof(AsyncDisposableToolType));
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(null!, null),
TestContext.Current.CancellationToken);
Assert.Equal("""{"asyncDisposals":1}""", result.Content[0].Text);
}
[Fact]
public async Task SupportsAsyncDisposingInstantiatedAsyncDisposableAndDisposableTargets()
{
McpServerTool tool1 = McpServerTool.Create(
typeof(AsyncDisposableAndDisposableToolType).GetMethod(nameof(AsyncDisposableAndDisposableToolType.InstanceMethod))!,
typeof(AsyncDisposableAndDisposableToolType));
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(null!, null),
TestContext.Current.CancellationToken);
Assert.Equal("""{"asyncDisposals":1,"disposals":0}""", result.Content[0].Text);
}
private sealed class MyService;
private class DisposableToolType : IDisposable
{
public int Disposals { get; private set; }
public void Dispose()
{
Disposals++;
}
public object InstanceMethod()
{
if (Disposals != 0)
{
throw new InvalidOperationException("Dispose was called");
}
return this;
}
}
private class AsyncDisposableToolType : IAsyncDisposable
{
public int AsyncDisposals { get; private set; }
public ValueTask DisposeAsync()
{
AsyncDisposals++;
return default;
}
public object InstanceMethod()
{
if (AsyncDisposals != 0)
{
throw new InvalidOperationException("DisposeAsync was called");
}
return this;
}
}
private class AsyncDisposableAndDisposableToolType : IAsyncDisposable, IDisposable
{
[JsonPropertyOrder(0)]
public int AsyncDisposals { get; private set; }
[JsonPropertyOrder(1)]
public int Disposals { get; private set; }
public void Dispose()
{
Disposals++;
}
public ValueTask DisposeAsync()
{
AsyncDisposals++;
return default;
}
public object InstanceMethod()
{
if (Disposals != 0)
{
throw new InvalidOperationException("Dispose was called");
}
if (AsyncDisposals != 0)
{
throw new InvalidOperationException("DisposeAsync was called");
}
return this;
}
}
}