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:
parent
6fa1443a6c
commit
b61cdef994
2 changed files with 391 additions and 38 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue