csharp-sdk/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs
Stephen Toub d86b3277d4
Add McpServer/ClientResource{Template} and friends (#391)
* Add McpServer/ClientResource{Template} and friends

This PR copies the same design used by tools and prompts to add McpServerResource, McpServerResourceAttribute, McpServerResourceTypeAttribute, McpClientResource, and then all of those for ResourceTemplate as well.

Unlike the others, [McpServerResource] is permitted not only on methods but also on properties. This convenience is afforded because such resources aren't parameterized, and thus can be easily modeled as properties, likely auto-props, e.g.
```C#
[McpServerResource]
public static BlobResourceContents MyAmazingData { get; } = Create();
```

Details about resources can be encoded into the attributes or provided via McpServerResource{Template}.Create methods. If not URI is explicitly defined, one is derived from the member name and arguments. For templates, this means manufacturing a URI template that includes variables for all non-DI parameters.

This also fixes a few issues discovered along the way:
- McpClientExtensions are updated to use `ValueTask<T>` instead of `Task<T>` where relevant.
- McpServerPrompt was missing handling for progress notifications; that's been rectified. It was also missing configuring the description on the ProtocolPrompt.
- Renamed IMcpServerPrimitive.Name to Id. For resources, names aren't unique.
- After going back and forth, updated McpServer to not fail for missing handlers and to instead register default handlers. It simplifies the implementation and makes it more consistent.
- Added a bunch of primitive types into the McpJsonUtilities JSON source generator context so that such primitives are usable with tools/prompts/resources without folks being required to add them into their own context for Native AOT. I think we should consider pushing this down to AIJsonUtilities, instead, and that will then flow up to McpJsonUtilities automatically.

* Address feedback
2025-05-09 18:21:11 -04:00

806 lines
No EOL
31 KiB
C#

using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Transport;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Server;
using ModelContextProtocol.Utils.Json;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.IO.Pipelines;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Configuration;
public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
{
public McpServerBuilderExtensionsToolsTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder
.WithListToolsHandler(async (request, cancellationToken) =>
{
var cursor = request.Params?.Cursor;
switch (cursor)
{
case null:
return new()
{
NextCursor = "abc",
Tools = [new()
{
Name = "FirstCustomTool",
Description = "First tool returned by custom handler",
InputSchema = JsonSerializer.Deserialize<JsonElement>("""
{
"type": "object",
"properties": {},
"required": []
}
""", McpJsonUtilities.DefaultOptions),
}],
};
case "abc":
return new()
{
NextCursor = "def",
Tools = [new()
{
Name = "SecondCustomTool",
Description = "Second tool returned by custom handler",
InputSchema = JsonSerializer.Deserialize<JsonElement>("""
{
"type": "object",
"properties": {},
"required": []
}
""", McpJsonUtilities.DefaultOptions),
}],
};
case "def":
return new()
{
NextCursor = null,
Tools = [new()
{
Name = "FinalCustomTool",
Description = "Third tool returned by custom handler",
InputSchema = JsonSerializer.Deserialize<JsonElement>("""
{
"type": "object",
"properties": {},
"required": []
}
""", McpJsonUtilities.DefaultOptions),
}],
};
default:
throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
}
})
.WithCallToolHandler(async (request, cancellationToken) =>
{
switch (request.Params?.Name)
{
case "FirstCustomTool":
case "SecondCustomTool":
case "FinalCustomTool":
return new CallToolResponse()
{
Content = [new Content() { Text = $"{request.Params.Name}Result", Type = "text" }],
};
default:
throw new McpException($"Unknown tool: '{request.Params?.Name}'", McpErrorCode.InvalidParams);
}
})
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options);
services.AddSingleton(new ObjectWithId());
}
[Fact]
public void Adds_Tools_To_Server()
{
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var tools = serverOptions.Capabilities?.Tools?.ToolCollection;
Assert.NotNull(tools);
Assert.NotEmpty(tools);
}
[Fact]
public async Task Can_List_Registered_Tools()
{
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, 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);
}
[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();
await using var transport = new StreamServerTransport(stdinPipe.Reader.AsStream(), stdoutPipe.Writer.AsStream());
await using var server = McpServerFactory.Create(transport, options, loggerFactory, ServiceProvider);
var serverRunTask = server.RunAsync(TestContext.Current.CancellationToken);
await using (var client = await McpClientFactory.CreateAsync(
new StreamClientTransport(
serverInput: stdinPipe.Writer.AsStream(),
serverOutput: stdoutPipe.Reader.AsStream(),
LoggerFactory),
loggerFactory: LoggerFactory,
cancellationToken: TestContext.Current.CancellationToken))
{
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, 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);
}
stdinPipe.Writer.Complete();
await serverRunTask;
stdoutPipe.Writer.Complete();
}
}
[Fact]
public async Task Can_Be_Notified_Of_Tool_Changes()
{
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, tools.Count);
Channel<JsonRpcNotification> listChanged = Channel.CreateUnbounded<JsonRpcNotification>();
var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.False(notificationRead.IsCompleted);
var serverOptions = ServiceProvider.GetRequiredService<IOptions<McpServerOptions>>().Value;
var serverTools = serverOptions.Capabilities?.Tools?.ToolCollection;
Assert.NotNull(serverTools);
var newTool = McpServerTool.Create([McpServerTool(Name = "NewTool")] () => "42");
await using (client.RegisterNotificationHandler(NotificationMethods.ToolListChangedNotification, (notification, cancellationToken) =>
{
listChanged.Writer.TryWrite(notification);
return default;
}))
{
serverTools.Add(newTool);
await notificationRead;
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(17, tools.Count);
Assert.Contains(tools, t => t.Name == "NewTool");
notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
Assert.False(notificationRead.IsCompleted);
serverTools.Remove(newTool);
await notificationRead;
}
tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.Equal(16, tools.Count);
Assert.DoesNotContain(tools, t => t.Name == "NewTool");
}
[Fact]
public async Task Can_Call_Registered_Tool()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"Echo",
new Dictionary<string, object?>() { ["message"] = "Peter" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("hello Peter", result.Content[0].Text);
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Array_Result()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"EchoArray",
new Dictionary<string, object?>() { ["message"] = "Peter" },
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("hello Peter", result.Content[0].Text);
Assert.Equal("hello2 Peter", result.Content[1].Text);
result = await client.CallToolAsync(
"SecondCustomTool",
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("SecondCustomToolResult", result.Content[0].Text);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Null_Result()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnNull",
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.Empty(result.Content);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Json_Result()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnJson",
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("""{"SomeProp":false}""", Regex.Replace(result.Content[0].Text ?? string.Empty, "\\s+", ""));
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Int_Result()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ReturnInteger",
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("5", result.Content[0].Text);
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task Can_Call_Registered_Tool_And_Pass_ComplexType()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"EchoComplex",
new Dictionary<string, object?>() { ["complex"] = JsonDocument.Parse("""{"Name": "Peter", "Age": 25}""").RootElement },
cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Equal("Peter", result.Content[0].Text);
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task Can_Call_Registered_Tool_With_Instance_Method()
{
await using 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()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"ThrowException",
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.IsError);
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
Assert.Contains("An error occurred", result.Content[0].Text);
}
[Fact]
public async Task Throws_Exception_On_Unknown_Tool()
{
await using IMcpClient client = await CreateMcpClientForServer();
var e = await Assert.ThrowsAsync<McpException>(async () => await client.CallToolAsync(
"NotRegisteredTool",
cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("'NotRegisteredTool'", e.Message);
}
[Fact]
public async Task Returns_IsError_Missing_Parameter()
{
await using IMcpClient client = await CreateMcpClientForServer();
var result = await client.CallToolAsync(
"Echo",
cancellationToken: TestContext.Current.CancellationToken);
Assert.True(result.IsError);
}
[Fact]
public void WithTools_InvalidArgs_Throws()
{
IMcpServerBuilder builder = new ServiceCollection().AddMcpServer();
Assert.Throws<ArgumentNullException>("tools", () => builder.WithTools((IEnumerable<McpServerTool>)null!));
Assert.Throws<ArgumentNullException>("toolTypes", () => builder.WithTools((IEnumerable<Type>)null!));
IMcpServerBuilder nullBuilder = null!;
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithTools<object>());
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithTools(Array.Empty<Type>()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithToolsFromAssembly());
}
[Fact]
public void Empty_Enumerables_Is_Allowed()
{
IMcpServerBuilder builder = new ServiceCollection().AddMcpServer();
builder.WithTools(tools: []); // no exception
builder.WithTools(toolTypes: []); // no exception
builder.WithTools<object>(); // no exception even though no tools exposed
builder.WithToolsFromAssembly(typeof(AIFunction).Assembly); // no exception even though no tools exposed
}
[Fact]
public void Register_Tools_From_Current_Assembly()
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
return;
}
ServiceCollection sc = new();
sc.AddMcpServer().WithToolsFromAssembly();
IServiceProvider services = sc.BuildServiceProvider();
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "Echo");
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void WithTools_Parameters_Satisfiable_From_DI(bool parameterInServices)
{
ServiceCollection sc = new();
if (parameterInServices)
{
sc.AddSingleton(new ComplexObject());
}
sc.AddMcpServer().WithTools([typeof(EchoTool)], BuilderToolsJsonContext.Default.Options);
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = services.GetServices<McpServerTool>().First(t => t.ProtocolTool.Name == "EchoComplex");
if (parameterInServices)
{
Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, AIJsonUtilities.DefaultOptions));
}
else
{
Assert.Contains("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, AIJsonUtilities.DefaultOptions));
}
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
[InlineData(null)]
public void WithToolsFromAssembly_Parameters_Satisfiable_From_DI(ServiceLifetime? lifetime)
{
if (!JsonSerializer.IsReflectionEnabledByDefault)
{
return;
}
ServiceCollection sc = new();
switch (lifetime)
{
case ServiceLifetime.Singleton:
sc.AddSingleton(new ComplexObject());
break;
case ServiceLifetime.Scoped:
sc.AddScoped(_ => new ComplexObject());
break;
case ServiceLifetime.Transient:
sc.AddTransient(_ => new ComplexObject());
break;
}
sc.AddMcpServer().WithToolsFromAssembly();
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = services.GetServices<McpServerTool>().First(t => t.ProtocolTool.Name == "EchoComplex");
if (lifetime is not null)
{
Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, AIJsonUtilities.DefaultOptions));
}
else
{
Assert.Contains("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, AIJsonUtilities.DefaultOptions));
}
}
[Fact]
public async Task Recognizes_Parameter_Types()
{
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tools);
Assert.NotEmpty(tools);
var tool = tools.First(t => t.Name == "TestTool");
Assert.Equal("TestTool", tool.Name);
Assert.Empty(tool.Description!);
Assert.Equal("object", tool.JsonSchema.GetProperty("type").GetString());
Assert.Contains("integer", tool.JsonSchema.GetProperty("properties").GetProperty("number").GetProperty("type").GetString());
Assert.Contains("number", tool.JsonSchema.GetProperty("properties").GetProperty("otherNumber").GetProperty("type").GetString());
Assert.Contains("boolean", tool.JsonSchema.GetProperty("properties").GetProperty("someCheck").GetProperty("type").GetString());
Assert.Contains("string", tool.JsonSchema.GetProperty("properties").GetProperty("someDate").GetProperty("type").GetString());
Assert.Contains("string", tool.JsonSchema.GetProperty("properties").GetProperty("someOtherDate").GetProperty("type").GetString());
Assert.Contains("array", tool.JsonSchema.GetProperty("properties").GetProperty("data").GetProperty("type").GetString());
Assert.Contains("object", tool.JsonSchema.GetProperty("properties").GetProperty("complexObject").GetProperty("type").GetString());
}
[Fact]
public void Register_Tools_From_Multiple_Sources()
{
ServiceCollection sc = new();
sc.AddMcpServer()
.WithTools<EchoTool>(serializerOptions: BuilderToolsJsonContext.Default.Options)
.WithTools<AnotherToolType>(serializerOptions: BuilderToolsJsonContext.Default.Options)
.WithTools([typeof(ToolTypeWithNoAttribute)], BuilderToolsJsonContext.Default.Options)
.WithTools([McpServerTool.Create(() => "42", new() { Name = "Returns42" })]);
IServiceProvider services = sc.BuildServiceProvider();
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "double_echo");
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "DifferentName");
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "MethodB");
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "MethodC");
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "MethodD");
Assert.Contains(services.GetServices<McpServerTool>(), t => t.ProtocolTool.Name == "Returns42");
}
[Fact]
public void Create_ExtractsToolAnnotations_AllSet()
{
var tool = McpServerTool.Create(EchoTool.ReturnInteger);
Assert.NotNull(tool);
Assert.NotNull(tool.ProtocolTool);
var annotations = tool.ProtocolTool.Annotations;
Assert.NotNull(annotations);
Assert.Equal("Return An Integer", annotations.Title);
Assert.False(annotations.DestructiveHint);
Assert.True(annotations.IdempotentHint);
Assert.False(annotations.OpenWorldHint);
Assert.True(annotations.ReadOnlyHint);
}
[Fact]
public void Create_ExtractsToolAnnotations_SomeSet()
{
var tool = McpServerTool.Create(EchoTool.ReturnJson);
Assert.NotNull(tool);
Assert.NotNull(tool.ProtocolTool);
var annotations = tool.ProtocolTool.Annotations;
Assert.NotNull(annotations);
Assert.Null(annotations.Title);
Assert.Null(annotations.DestructiveHint);
Assert.False(annotations.IdempotentHint);
Assert.Null(annotations.OpenWorldHint);
Assert.Null(annotations.ReadOnlyHint);
}
[Fact]
public async Task HandlesIProgressParameter()
{
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tools);
Assert.NotEmpty(tools);
McpClientTool progressTool = tools.First(t => t.Name == nameof(EchoTool.SendsProgressNotifications));
TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously);
int remainingNotifications = 10;
ConcurrentQueue<ProgressNotification> notifications = new();
await using (client.RegisterNotificationHandler(NotificationMethods.ProgressNotification, (notification, cancellationToken) =>
{
if (JsonSerializer.Deserialize<ProgressNotification>(notification.Params, McpJsonUtilities.DefaultOptions) is { } pn &&
pn.ProgressToken == new ProgressToken("abc123"))
{
notifications.Enqueue(pn);
if (Interlocked.Decrement(ref remainingNotifications) == 0)
{
tcs.SetResult();
}
}
return default;
}))
{
var result = await client.SendRequestAsync<CallToolRequestParams, CallToolResponse>(
RequestMethods.ToolsCall,
new CallToolRequestParams
{
Name = progressTool.ProtocolTool.Name,
Meta = new() { ProgressToken = new("abc123") },
},
cancellationToken: TestContext.Current.CancellationToken);
await tcs.Task;
Assert.Contains("done", JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions));
}
ProgressNotification[] array = notifications.OrderBy(n => n.Progress.Progress).ToArray();
Assert.Equal(10, array.Length);
for (int i = 0; i < array.Length; i++)
{
Assert.Equal("abc123", array[i].ProgressToken.ToString());
Assert.Equal(i, array[i].Progress.Progress);
Assert.Equal(10, array[i].Progress.Total);
Assert.Equal($"Progress {i}", array[i].Progress.Message);
}
}
[Fact]
public async Task CancellationNotificationsPropagateToToolTokens()
{
await using IMcpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
Assert.NotNull(tools);
Assert.NotEmpty(tools);
McpClientTool cancelableTool = tools.First(t => t.Name == nameof(EchoTool.InfiniteCancelableOperation));
var requestId = new RequestId(Guid.NewGuid().ToString());
var invokeTask = client.SendRequestAsync<CallToolRequestParams, CallToolResponse>(
RequestMethods.ToolsCall,
new CallToolRequestParams { Name = cancelableTool.ProtocolTool.Name },
requestId: requestId,
cancellationToken: TestContext.Current.CancellationToken);
await client.SendNotificationAsync(
NotificationMethods.CancelledNotification,
parameters: new CancelledNotification()
{
RequestId = requestId,
},
cancellationToken: TestContext.Current.CancellationToken);
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () => await invokeTask);
}
[McpServerToolType]
public sealed class EchoTool(ObjectWithId objectFromDI)
{
private readonly 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)
{
return "hello " + message;
}
[McpServerTool(Name = "double_echo"), Description("Echoes the input back to the client.")]
public static string Echo2(string message)
{
return "hello hello" + message;
}
[McpServerTool]
public static string TestTool(int number, double otherNumber, bool someCheck, DateTime someDate, DateTimeOffset someOtherDate, string[] data, ComplexObject complexObject)
{
return "hello hello";
}
[McpServerTool]
public static string[] EchoArray(string message)
{
return ["hello " + message, "hello2 " + message];
}
[McpServerTool]
public static string? ReturnNull()
{
return null;
}
[McpServerTool(Idempotent = false)]
public static JsonElement ReturnJson()
{
return JsonDocument.Parse("{\"SomeProp\": false}").RootElement;
}
[McpServerTool(Title = "Return An Integer", Destructive = false, Idempotent = true, OpenWorld = false, ReadOnly = true)]
public static int ReturnInteger()
{
return 5;
}
[McpServerTool]
public static string ThrowException()
{
throw new InvalidOperationException("Test error");
}
[McpServerTool]
public static int ReturnCancellationToken(CancellationToken cancellationToken)
{
return cancellationToken.GetHashCode();
}
[McpServerTool]
public static string EchoComplex(ComplexObject complex)
{
return complex.Name!;
}
[McpServerTool]
public static async Task<string> InfiniteCancelableOperation(CancellationToken cancellationToken)
{
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (Exception)
{
return "canceled";
}
return "unreachable";
}
[McpServerTool]
public string GetCtorParameter() => $"{_randomValue}:{objectFromDI.Id}";
[McpServerTool]
public string SendsProgressNotifications(IProgress<ProgressNotificationValue> progress)
{
for (int i = 0; i < 10; i++)
{
progress.Report(new() { Progress = i, Total = 10, Message = $"Progress {i}" });
}
return "done";
}
}
[McpServerToolType]
internal class AnotherToolType
{
[McpServerTool(Name = "DifferentName")]
private static string MethodA(int a) => a.ToString();
[McpServerTool]
internal static string MethodB(string b) => b.ToString();
[McpServerTool]
protected static string MethodC(long c) => c.ToString();
}
internal class ToolTypeWithNoAttribute
{
[McpServerTool]
public static string MethodD(string d) => d.ToString();
}
public class ObjectWithId
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
}
public class ComplexObject
{
public string? Name { get; set; }
public int Age { get; set; }
}
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(long))]
[JsonSerializable(typeof(double))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(DateTimeOffset))]
[JsonSerializable(typeof(ComplexObject))]
[JsonSerializable(typeof(string[]))]
[JsonSerializable(typeof(JsonElement))]
partial class BuilderToolsJsonContext : JsonSerializerContext;
}