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 System.Collections.Concurrent; using System.ComponentModel; using System.IO.Pipelines; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading.Channels; #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously namespace ModelContextProtocol.Tests.Configuration; public 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(""" { "type": "object", "properties": {}, "required": [] } """), }], }; case "abc": return new() { NextCursor = "def", Tools = [new() { Name = "SecondCustomTool", Description = "Second tool returned by custom handler", InputSchema = JsonSerializer.Deserialize(""" { "type": "object", "properties": {}, "required": [] } """), }], }; case "def": return new() { NextCursor = null, Tools = [new() { Name = "FinalCustomTool", Description = "Third tool returned by custom handler", InputSchema = JsonSerializer.Deserialize(""" { "type": "object", "properties": {}, "required": [] } """), }], }; default: throw new Exception("Unexpected cursor"); } }) .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 Exception($"Unknown tool '{request.Params?.Name}'"); } }) .WithTools(); services.AddSingleton(new ObjectWithId()); } [Fact] public void Adds_Tools_To_Server() { var serverOptions = ServiceProvider.GetRequiredService>().Value; var tools = serverOptions.Capabilities?.Tools?.ToolCollection; Assert.NotNull(tools); Assert.NotEmpty(tools); } [Fact] public async Task Can_List_Registered_Tools() { 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>().Value; var loggerFactory = ServiceProvider.GetRequiredService(); 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() { IMcpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(16, tools.Count); Channel listChanged = Channel.CreateUnbounded(); var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken); Assert.False(notificationRead.IsCompleted); var serverOptions = ServiceProvider.GetRequiredService>().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 Task.CompletedTask; })) { 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() { IMcpClient client = await CreateMcpClientForServer(); var result = await client.CallToolAsync( "Echo", new Dictionary() { ["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() { IMcpClient client = await CreateMcpClientForServer(); var result = await client.CallToolAsync( "EchoArray", new Dictionary() { ["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() { 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() { 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() { 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() { IMcpClient client = await CreateMcpClientForServer(); var result = await client.CallToolAsync( "EchoComplex", new Dictionary() { ["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() { 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() { 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() { IMcpClient client = await CreateMcpClientForServer(); var e = await Assert.ThrowsAsync(async () => await client.CallToolAsync( "NotRegisteredTool", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("'NotRegisteredTool'", e.Message); } [Fact] public async Task Returns_IsError_Missing_Parameter() { 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("toolTypes", () => builder.WithTools((IEnumerable)null!)); IMcpServerBuilder nullBuilder = null!; Assert.Throws("builder", () => nullBuilder.WithTools()); Assert.Throws("builder", () => nullBuilder.WithTools(Array.Empty())); Assert.Throws("builder", () => nullBuilder.WithToolsFromAssembly()); } [Fact] public void Empty_Enumerables_Is_Allowed() { IMcpServerBuilder builder = new ServiceCollection().AddMcpServer(); builder.WithTools(toolTypes: []); // no exception builder.WithTools(); // 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() { ServiceCollection sc = new(); sc.AddMcpServer().WithToolsFromAssembly(); IServiceProvider services = sc.BuildServiceProvider(); Assert.Contains(services.GetServices(), 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)); IServiceProvider services = sc.BuildServiceProvider(); McpServerTool tool = services.GetServices().First(t => t.ProtocolTool.Name == "EchoComplex"); if (parameterInServices) { Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema)); } else { Assert.Contains("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema)); } } [Theory] [InlineData(ServiceLifetime.Singleton)] [InlineData(ServiceLifetime.Scoped)] [InlineData(ServiceLifetime.Transient)] [InlineData(null)] public void WithToolsFromAssembly_Parameters_Satisfiable_From_DI(ServiceLifetime? lifetime) { 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().First(t => t.ProtocolTool.Name == "EchoComplex"); if (lifetime is not null) { Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema)); } else { Assert.Contains("\"complex\"", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema)); } } [Fact] public async Task Recognizes_Parameter_Types() { 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() .WithTools() .WithTools(typeof(ToolTypeWithNoAttribute)); IServiceProvider services = sc.BuildServiceProvider(); Assert.Contains(services.GetServices(), t => t.ProtocolTool.Name == "double_echo"); Assert.Contains(services.GetServices(), t => t.ProtocolTool.Name == "DifferentName"); Assert.Contains(services.GetServices(), t => t.ProtocolTool.Name == "MethodB"); Assert.Contains(services.GetServices(), t => t.ProtocolTool.Name == "MethodC"); Assert.Contains(services.GetServices(), t => t.ProtocolTool.Name == "MethodD"); } [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() { 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)); ConcurrentQueue notifications = new(); await using (client.RegisterNotificationHandler(NotificationMethods.ProgressNotification, (notification, cancellationToken) => { ProgressNotification pn = JsonSerializer.Deserialize(notification.Params)!; notifications.Enqueue(pn); return Task.CompletedTask; })) { var result = await client.SendRequestAsync( RequestMethods.ToolsCall, new CallToolRequestParams { Name = progressTool.ProtocolTool.Name, Meta = new() { ProgressToken = new("abc123") }, }, cancellationToken: TestContext.Current.CancellationToken); Assert.Contains("done", JsonSerializer.Serialize(result)); SpinWait.SpinUntil(() => notifications.Count == 10, TimeSpan.FromSeconds(10)); } 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() { 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( 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(() => 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 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 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 ComplexObject { public string? Name { get; set; } public int Age { get; set; } } public class ObjectWithId { public string Id { get; set; } = Guid.NewGuid().ToString("N"); } }