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
6 changed files with 391 additions and 38 deletions

View file

@ -14,32 +14,51 @@ public static partial class McpServerBuilderExtensions
{
private const string RequiresUnreferencedCodeMessage = "This method requires dynamic lookup of method metadata and might not work in Native AOT.";
/// <summary>
/// Adds a tool to the server.
/// </summary>
/// <summary>Adds <see cref="McpServerTool"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TTool">The tool type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
public static IMcpServerBuilder WithTools<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] TTool>(
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <typeparamref name="TTool"/>
/// type, where the methods are attributed as <see cref="McpServerToolAttribute"/>, and adds an <see cref="McpServerTool"/>
/// instance for each. For instance methods, an instance will be constructed for each invocation of the tool.
/// </remarks>
public static IMcpServerBuilder WithTools<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicConstructors)] TTool>(
this IMcpServerBuilder builder)
{
Throw.IfNull(builder);
foreach (var toolMethod in GetToolMethods(typeof(TTool)))
foreach (var toolMethod in typeof(TTool).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, services: services));
if (toolMethod.GetCustomAttribute<McpServerToolAttribute>() is not null)
{
if (toolMethod.IsStatic)
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, services: services));
}
else
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, typeof(TTool), services: services));
}
}
}
return builder;
}
/// <summary>
/// Adds tools to the server.
/// </summary>
/// <summary>Adds <see cref="McpServerTool"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="toolTypes">Types with marked methods to add as tools to the server.</param>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentNullException"><paramref name="toolTypes"/> is <see langword="null"/>.</exception>
/// <remarks>
/// This method discovers all instance and static methods (public and non-public) on the specified <paramref name="toolTypes"/>
/// types, where the methods are attributed as <see cref="McpServerToolAttribute"/>, and adds an <see cref="McpServerTool"/>
/// instance for each. For instance methods, an instance will be constructed for each invocation of the tool.
/// </remarks>
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
public static IMcpServerBuilder WithTools(this IMcpServerBuilder builder, params IEnumerable<Type> toolTypes)
{
@ -50,13 +69,23 @@ public static partial class McpServerBuilderExtensions
{
if (toolType is not null)
{
foreach (var toolMethod in GetToolMethods(toolType))
foreach (var method in toolType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
builder.Services.AddSingleton(services => McpServerTool.Create(toolMethod, services: services));
if (method.GetCustomAttribute<McpServerToolAttribute>() is not null)
{
if (method.IsStatic)
{
builder.Services.AddSingleton(services => McpServerTool.Create(method, services: services));
}
else
{
builder.Services.AddSingleton(services => McpServerTool.Create(method, toolType, services: services));
}
}
}
}
}
return builder;
}
@ -78,10 +107,4 @@ public static partial class McpServerBuilderExtensions
where t.GetCustomAttribute<McpServerToolTypeAttribute>() is not null
select t);
}
private static IEnumerable<MethodInfo> GetToolMethods(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods)] Type toolType) =>
from method in toolType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
where method.GetCustomAttribute<McpServerToolAttribute>() is not null
select method;
}

View file

@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Text.Json;
@ -13,7 +14,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
{
/// <summary>Key used temporarily for flowing request context into an AIFunction.</summary>
/// <remarks>This will be replaced with use of AIFunctionArguments.Context.</remarks>
private const string RequestContextKey = "__temporary_RequestContext";
internal const string RequestContextKey = "__temporary_RequestContext";
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
@ -48,7 +49,27 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
// AIFunctionFactory, delete the TemporaryXx types, and fix-up the mechanism by
// which the arguments are passed.
return Create(TemporaryAIFunctionFactory.Create(method, target, new TemporaryAIFunctionFactoryOptions()
return Create(TemporaryAIFunctionFactory.Create(method, target, CreateAIFunctionFactoryOptions(method, name, description, services)));
}
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
/// </summary>
public static new AIFunctionMcpServerTool Create(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
string? name = null,
string? description = null,
IServiceProvider? services = null)
{
Throw.IfNull(method);
return Create(TemporaryAIFunctionFactory.Create(method, targetType, CreateAIFunctionFactoryOptions(method, name, description, services)));
}
private static TemporaryAIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
MethodInfo method, string? name, string? description, IServiceProvider? services) =>
new TemporaryAIFunctionFactoryOptions()
{
Name = name ?? method.GetCustomAttribute<McpServerToolAttribute>()?.Name,
Description = description,
@ -115,8 +136,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
return null;
}
},
}));
}
};
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
public static new AIFunctionMcpServerTool Create(AIFunction function)

View file

@ -1,6 +1,7 @@
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol.Types;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
namespace ModelContextProtocol.Server;
@ -40,7 +41,7 @@ public abstract class McpServerTool
/// </param>
/// <param name="services">
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
/// used to determine which parameters should be satisifed from dependency injection, and so what services
/// used to determine which parameters should be satisifed from dependency injection; what services
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </param>
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
@ -68,7 +69,7 @@ public abstract class McpServerTool
/// </param>
/// <param name="services">
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
/// used to determine which parameters should be satisifed from dependency injection, and so what services
/// used to determine which parameters should be satisifed from dependency injection; what services
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </param>
/// <returns>The created <see cref="McpServerTool"/> for invoking <paramref name="method"/>.</returns>
@ -82,6 +83,43 @@ public abstract class McpServerTool
IServiceProvider? services = null) =>
AIFunctionMcpServerTool.Create(method, target, name, description, services);
/// <summary>
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via an <see cref="MethodInfo"/> for
/// and instance method, along with a <see cref="Type"/> representing the type of the target object to
/// instantiate each time the method is invoked.
/// </summary>
/// <param name="method">The instance method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="targetType">
/// The <see cref="Type"/> to construct an instance of on which to invoke <paramref name="method"/> when
/// the resulting <see cref="AIFunction"/> is invoked. If services are provided,
/// ActivatorUtilities.CreateInstance will be used to construct the instance using those services; otherwise,
/// <see cref="Activator.CreateInstance(Type)"/> is used, utilizing the type's public parameterless constructor.
/// If an instance can't be constructed, an exception is thrown during the function's invocation.
/// </param>
/// <param name="name">
/// The name to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but an <see cref="McpServerToolAttribute"/>
/// is applied to <paramref name="method"/>, the name from the attribute will be used. If that's not present, the name based
/// on <paramref name="method"/>'s name will be used.
/// </param>
/// <param name="description">
/// The description to use for the <see cref="McpServerTool"/>. If <see langword="null"/>, but a <see cref="DescriptionAttribute"/>
/// is applied to <paramref name="method"/>, the description from that attribute will be used.
/// </param>
/// <param name="services">
/// Optional services used in the construction of the <see cref="McpServerTool"/>. These services will be
/// used to determine which parameters should be satisifed from dependency injection; what services
/// are satisfied via this provider should match what's satisfied via the provider passed in at invocation time.
/// </param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static McpServerTool Create(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
string? name = null,
string? description = null,
IServiceProvider? services = null) =>
AIFunctionMcpServerTool.Create(method, targetType, name, description, services);
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
/// <param name="function">The function to wrap.</param>
/// <exception cref="ArgumentNullException"><paramref name="function"/> is <see langword="null"/>.</exception>

View file

@ -1,10 +1,15 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
#if !NET
using System.Linq;
#endif
@ -110,6 +115,42 @@ internal static partial class TemporaryAIFunctionFactory
return ReflectionAIFunction.Build(method, target, options ?? _defaultOptions);
}
/// <summary>
/// Creates an <see cref="AIFunction"/> instance for a method, specified via an <see cref="MethodInfo"/> instance
/// and an optional target object if the method is an instance method.
/// </summary>
/// <param name="method">The instance method to be represented via the created <see cref="AIFunction"/>.</param>
/// <param name="targetType">
/// The <see cref="Type"/> to construct an instance of on which to invoke <paramref name="method"/> when
/// the resulting <see cref="AIFunction"/> is invoked. If services are provided,
/// ActivatorUtilities.CreateInstance will be used to construct the instance using those services; otherwise,
/// <see cref="Activator.CreateInstance(Type)"/> is used, utilizing the type's public parameterless constructor.
/// If an instance can't be constructed, an exception is thrown during the function's invocation.
/// </param>
/// <param name="options">Metadata to use to override defaults inferred from <paramref name="method"/>.</param>
/// <returns>The created <see cref="AIFunction"/> for invoking <paramref name="method"/>.</returns>
/// <remarks>
/// <para>
/// Return values are serialized to <see cref="JsonElement"/> using <paramref name="options"/>'s
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/>. Arguments that are not already of the expected type are
/// marshaled to the expected type via JSON and using <paramref name="options"/>'s
/// <see cref="AIFunctionFactoryOptions.SerializerOptions"/>. If the argument is a <see cref="JsonElement"/>,
/// <see cref="JsonDocument"/>, or <see cref="JsonNode"/>, it is deserialized directly. If the argument is anything else unknown,
/// it is round-tripped through JSON, serializing the object as JSON and then deserializing it to the expected type.
/// </para>
/// </remarks>
/// <exception cref="ArgumentNullException"><paramref name="method"/> is <see langword="null"/>.</exception>
public static AIFunction Create(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions? options = null)
{
Throw.IfNull(method);
Throw.IfNull(targetType);
return ReflectionAIFunction.Build(method, targetType, options ?? _defaultOptions);
}
/// <summary>
/// Creates an <see cref="AIFunction"/> instance for a method, specified via an <see cref="MethodInfo"/> instance
/// and an optional target object if the method is an instance method.
@ -176,6 +217,32 @@ internal static partial class TemporaryAIFunctionFactory
return new(functionDescriptor, target, options);
}
public static ReflectionAIFunction Build(
MethodInfo method,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions options)
{
Throw.IfNull(method);
if (method.ContainsGenericParameters)
{
throw new ArgumentException("Open generic methods are not supported", nameof(method));
}
if (method.IsStatic)
{
throw new ArgumentException("The method must be an instance method.", nameof(method));
}
if (method.DeclaringType is { } declaringType &&
!declaringType.IsAssignableFrom(targetType))
{
throw new ArgumentException("The target type must be assignable to the method's declaring type.", nameof(targetType));
}
return new(ReflectionAIFunctionDescriptor.GetOrCreate(method, options), targetType, options);
}
private ReflectionAIFunction(ReflectionAIFunctionDescriptor functionDescriptor, object? target, TemporaryAIFunctionFactoryOptions options)
{
FunctionDescriptor = functionDescriptor;
@ -183,8 +250,20 @@ internal static partial class TemporaryAIFunctionFactory
AdditionalProperties = options.AdditionalProperties ?? new Dictionary<string, object?>();
}
private ReflectionAIFunction(
ReflectionAIFunctionDescriptor functionDescriptor,
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] Type targetType,
TemporaryAIFunctionFactoryOptions options)
{
FunctionDescriptor = functionDescriptor;
TargetType = targetType;
AdditionalProperties = options.AdditionalProperties ?? new Dictionary<string, object?>();
}
public ReflectionAIFunctionDescriptor FunctionDescriptor { get; }
public object? Target { get; }
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type? TargetType { get; }
public override IReadOnlyDictionary<string, object?> AdditionalProperties { get; }
public override string Name => FunctionDescriptor.Name;
public override string Description => FunctionDescriptor.Description;
@ -192,22 +271,59 @@ internal static partial class TemporaryAIFunctionFactory
public override JsonElement JsonSchema => FunctionDescriptor.JsonSchema;
public override JsonSerializerOptions JsonSerializerOptions => FunctionDescriptor.JsonSerializerOptions;
protected override Task<object?> InvokeCoreAsync(
protected override async Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments,
CancellationToken cancellationToken)
{
var paramMarshallers = FunctionDescriptor.ParameterMarshallers;
object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : [];
Dictionary<string, object?> argumentsDictionary = arguments.ToDictionary();
for (int i = 0; i < args.Length; i++)
bool disposeTarget = false;
object? target = Target;
try
{
args[i] = paramMarshallers[i](argumentsDictionary, cancellationToken);
}
if (TargetType is { } targetType)
{
Debug.Assert(target is null, "Expected target to be null when we have a non-null target type");
Debug.Assert(!FunctionDescriptor.Method.IsStatic, "Expected an instance method");
return FunctionDescriptor.ReturnParameterMarshaller(
ReflectionInvoke(FunctionDescriptor.Method, Target, args), cancellationToken);
if (argumentsDictionary.TryGetValue(AIFunctionMcpServerTool.RequestContextKey, out object? value) &&
value is RequestContext<CallToolRequestParams> requestContext &&
requestContext.Server?.Services is { } services)
{
target = ActivatorUtilities.CreateInstance(services, targetType!);
}
else
{
target = Activator.CreateInstance(targetType);
}
disposeTarget = true;
}
var paramMarshallers = FunctionDescriptor.ParameterMarshallers;
object?[] args = paramMarshallers.Length != 0 ? new object?[paramMarshallers.Length] : [];
for (int i = 0; i < args.Length; i++)
{
args[i] = paramMarshallers[i](argumentsDictionary, cancellationToken);
}
return await FunctionDescriptor.ReturnParameterMarshaller(
ReflectionInvoke(FunctionDescriptor.Method, target, args), cancellationToken).ConfigureAwait(false);
}
finally
{
if (disposeTarget)
{
if (target is IAsyncDisposable ad)
{
await ad.DisposeAsync().ConfigureAwait(false);
}
else if (target is IDisposable d)
{
d.Dispose();
}
}
}
}
}

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;
}
}
}