csharp-sdk/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
Eirik Tsarpalis 10e4f5b20f
Implement MCP task support (SEP-1686) (#1170)
Co-authored-by: Stephen Halter <halter73@gmail.com>
2026-01-27 19:21:02 -08:00

932 lines
36 KiB
C#

using Json.Schema;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.ComponentModel;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using Xunit.Sdk;
namespace ModelContextProtocol.Tests.Server;
public partial class McpServerToolTests
{
private static JsonRpcRequest CreateTestJsonRpcRequest()
{
return new JsonRpcRequest
{
Id = new RequestId("test-id"),
Method = "test/method",
Params = null
};
}
public McpServerToolTests()
{
#if !NET
Assert.SkipWhen(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "https://github.com/modelcontextprotocol/csharp-sdk/issues/587");
#endif
}
[Fact]
public void Create_InvalidArgs_Throws()
{
Assert.Throws<ArgumentNullException>("function", () => McpServerTool.Create((AIFunction)null!));
Assert.Throws<ArgumentNullException>("method", () => McpServerTool.Create((MethodInfo)null!));
Assert.Throws<ArgumentNullException>("method", () => McpServerTool.Create((MethodInfo)null!, typeof(object)));
Assert.Throws<ArgumentNullException>("createTargetFunc", () => McpServerTool.Create(typeof(McpServerToolTests).GetMethod(nameof(Create_InvalidArgs_Throws))!, null!));
Assert.Throws<ArgumentNullException>("method", () => McpServerTool.Create((Delegate)null!));
Assert.NotNull(McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!, new DisposableToolType()));
Assert.NotNull(McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.StaticMethod))!));
Assert.Throws<ArgumentNullException>("target", () => McpServerTool.Create(typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!, target: null!));
}
[Fact]
public async Task SupportsMcpServer()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return "42";
});
Assert.DoesNotContain("server", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, McpJsonUtilities.DefaultOptions));
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsCtorInjection()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
Mock<McpServer> mockServer = new();
mockServer.SetupGet(s => s.Services).Returns(services);
MethodInfo? testMethod = typeof(HasCtorWithSpecialParameters).GetMethod(nameof(HasCtorWithSpecialParameters.TestTool));
Assert.NotNull(testMethod);
McpServerTool tool = McpServerTool.Create(testMethod, r =>
{
Assert.NotNull(r.Services);
return ActivatorUtilities.CreateInstance(r.Services, typeof(HasCtorWithSpecialParameters));
}, new() { Services = services });
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.NotNull(result);
Assert.NotNull(result.Content);
Assert.Single(result.Content);
Assert.Equal("True True True True", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
private sealed class HasCtorWithSpecialParameters
{
private readonly MyService _ms;
private readonly McpServer _server;
private readonly RequestContext<CallToolRequestParams> _request;
private readonly IProgress<ProgressNotificationValue> _progress;
public HasCtorWithSpecialParameters(MyService ms, McpServer server, RequestContext<CallToolRequestParams> request, IProgress<ProgressNotificationValue> progress)
{
Assert.NotNull(ms);
Assert.NotNull(server);
Assert.NotNull(request);
Assert.NotNull(progress);
_ms = ms;
_server = server;
_request = request;
_progress = progress;
}
public string TestTool() => $"{_ms is not null} {_server is not null} {_request is not null} {_progress is not null}";
}
[Theory]
[InlineData(ServiceLifetime.Singleton)]
[InlineData(ServiceLifetime.Scoped)]
[InlineData(ServiceLifetime.Transient)]
public async Task SupportsServiceFromDI(ServiceLifetime injectedArgumentLifetime)
{
MyService singletonService = new();
ServiceCollection sc = new();
switch (injectedArgumentLifetime)
{
case ServiceLifetime.Singleton:
sc.AddSingleton(singletonService);
break;
case ServiceLifetime.Scoped:
sc.AddScoped(_ => new MyService());
break;
case ServiceLifetime.Transient:
sc.AddTransient(_ => new MyService());
break;
}
sc.AddSingleton(services =>
{
return McpServerTool.Create((MyService actualMyService) =>
{
Assert.NotNull(actualMyService);
if (injectedArgumentLifetime == ServiceLifetime.Singleton)
{
Assert.Same(singletonService, actualMyService);
}
return "42";
}, new() { Services = services });
});
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = services.GetRequiredService<McpServerTool>();
Assert.DoesNotContain("actualMyService", JsonSerializer.Serialize(tool.ProtocolTool.InputSchema, McpJsonUtilities.DefaultOptions));
Mock<McpServer> mockServer = new();
var ex = await Assert.ThrowsAsync<ArgumentException>(async () => await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken));
mockServer.SetupGet(s => s.Services).Returns(services);
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()) { Services = services },
TestContext.Current.CancellationToken);
Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsOptionalServiceFromDI()
{
MyService expectedMyService = new();
ServiceCollection sc = new();
sc.AddSingleton(expectedMyService);
IServiceProvider services = sc.BuildServiceProvider();
McpServerTool tool = McpServerTool.Create((MyService? actualMyService = null) =>
{
Assert.Null(actualMyService);
return "42";
}, new() { Services = services });
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal("42", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsDisposingInstantiatedDisposableTargets()
{
McpServerToolCreateOptions options = new() { SerializerOptions = JsonContext2.Default.Options };
McpServerTool tool1 = McpServerTool.Create(
typeof(DisposableToolType).GetMethod(nameof(DisposableToolType.InstanceMethod))!,
_ => new DisposableToolType(),
options);
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal("""{"disposals":1}""", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsAsyncDisposingInstantiatedAsyncDisposableTargets()
{
McpServerToolCreateOptions options = new() { SerializerOptions = JsonContext2.Default.Options };
McpServerTool tool1 = McpServerTool.Create(
typeof(AsyncDisposableToolType).GetMethod(nameof(AsyncDisposableToolType.InstanceMethod))!,
_ => new AsyncDisposableToolType(),
options);
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal("""{"asyncDisposals":1}""", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task SupportsAsyncDisposingInstantiatedAsyncDisposableAndDisposableTargets()
{
ServiceCollection sc = new();
sc.AddSingleton<MyService>();
IServiceProvider services = sc.BuildServiceProvider();
McpServerToolCreateOptions options = new() { SerializerOptions = JsonContext2.Default.Options };
McpServerTool tool1 = McpServerTool.Create(
typeof(AsyncDisposableAndDisposableToolType).GetMethod(nameof(AsyncDisposableAndDisposableToolType.InstanceMethod))!,
static r => ActivatorUtilities.CreateInstance(r.Services!, typeof(AsyncDisposableAndDisposableToolType)),
options);
var result = await tool1.InvokeAsync(
new RequestContext<CallToolRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()) { Services = services },
TestContext.Current.CancellationToken);
Assert.Equal("""{"asyncDisposals":1,"disposals":0}""", (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task CanReturnCollectionOfAIContent()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new List<AIContent> {
new TextContent("text"),
new DataContent("data:image/png;base64,1234"),
new DataContent("data:audio/wav;base64,1234")
};
}, new() { SerializerOptions = JsonContext2.Default.Options });
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal(3, result.Content.Count);
Assert.Equal("text", (result.Content[0] as TextContentBlock)?.Text);
Assert.Equal("1234", (result.Content[1] as ImageContentBlock)?.Data);
Assert.Equal("image/png", (result.Content[1] as ImageContentBlock)?.MimeType);
Assert.Equal("1234", (result.Content[2] as AudioContentBlock)?.Data);
Assert.Equal("audio/wav", (result.Content[2] as AudioContentBlock)?.MimeType);
}
[Theory]
[InlineData("text", "text")]
[InlineData("data:image/png;base64,1234", "image")]
[InlineData("data:audio/wav;base64,1234", "audio")]
public async Task CanReturnSingleAIContent(string data, string type)
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return type switch
{
"text" => (AIContent)new TextContent(data),
"image" => new DataContent(data),
"audio" => new DataContent(data),
_ => throw new ArgumentException("Invalid type")
};
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Single(result.Content);
Assert.Equal(type, result.Content[0].Type);
if (result.Content[0] is TextContentBlock tc)
{
Assert.Equal(data, tc.Text);
}
else if (result.Content[0] is ImageContentBlock ic)
{
Assert.Equal(data.Split(',').Last(), ic.Data);
Assert.Equal("image/png", ic.MimeType);
}
else if (result.Content[0] is AudioContentBlock ac)
{
Assert.Equal(data.Split(',').Last(), ac.Data);
Assert.Equal("audio/wav", ac.MimeType);
}
else
{
throw new XunitException($"Unexpected content type: {result.Content[0].GetType()}");
}
}
[Fact]
public async Task CanReturnNullAIContent()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return (string?)null;
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Empty(result.Content);
}
[Fact]
public async Task CanReturnString()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return "42";
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Single(result.Content);
Assert.Equal("42", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CanReturnCollectionOfStrings()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new List<string> { "42", "43" };
}, new() { SerializerOptions = JsonContext2.Default.Options });
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Single(result.Content);
Assert.Equal("""["42","43"]""", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
}
[Fact]
public async Task CanReturnMcpContent()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return new TextContentBlock { Text = "42" };
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Single(result.Content);
Assert.Equal("42", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
Assert.Equal("text", result.Content[0].Type);
}
[Fact]
public async Task CanReturnCollectionOfMcpContent()
{
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return (IList<ContentBlock>)
[
new TextContentBlock { Text = "42" },
new ImageContentBlock { Data = "1234", MimeType = "image/png" }
];
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Equal(2, result.Content.Count);
Assert.Equal("42", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
Assert.Equal("1234", Assert.IsType<ImageContentBlock>(result.Content[1]).Data);
Assert.Equal("image/png", Assert.IsType<ImageContentBlock>(result.Content[1]).MimeType);
}
[Fact]
public async Task CanReturnCallToolResult()
{
CallToolResult response = new()
{
Content = [new TextContentBlock { Text = "text" }, new ImageContentBlock { Data = "1234", MimeType = "image/png" }]
};
Mock<McpServer> mockServer = new();
McpServerTool tool = McpServerTool.Create((McpServer server) =>
{
Assert.Same(mockServer.Object, server);
return response;
});
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest()),
TestContext.Current.CancellationToken);
Assert.Same(response, result);
Assert.Equal(2, result.Content.Count);
Assert.Equal("text", Assert.IsType<TextContentBlock>(result.Content[0]).Text);
Assert.Equal("1234", Assert.IsType<ImageContentBlock>(result.Content[1]).Data);
}
[Fact]
public async Task SupportsSchemaCreateOptions()
{
AIJsonSchemaCreateOptions schemaCreateOptions = new()
{
TransformSchemaNode = (context, node) =>
{
node["text"] = "1234";
return node;
},
};
McpServerTool tool = McpServerTool.Create((int num, string str) =>
{
return "42";
}, new() { SchemaCreateOptions = schemaCreateOptions });
Assert.All(
tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject(),
x => Assert.True(x.Value.TryGetProperty("text", out JsonElement value) && value.ToString() == "1234")
);
}
[Theory]
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(T value)
{
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.NotNull(result.StructuredContent);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[Fact]
public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema()
{
McpServerTool tool = McpServerTool.Create(() => { });
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
tool = McpServerTool.Create(() => Task.CompletedTask);
request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
tool = McpServerTool.Create(() => default(ValueTask));
request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
}
[Theory]
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(T value)
{
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { UseStructuredContent = false, SerializerOptions = options });
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.Null(tool.ProtocolTool.OutputSchema);
Assert.Null(result.StructuredContent);
}
[Theory]
[InlineData(JsonNumberHandling.Strict)]
[InlineData(JsonNumberHandling.AllowReadingFromString)]
public async Task ToolWithNullableParameters_ReturnsExpectedSchema(JsonNumberHandling nunmberHandling)
{
JsonSerializerOptions options = new(JsonContext2.Default.Options) { NumberHandling = nunmberHandling };
McpServerTool tool = McpServerTool.Create((int? x = 42, DateTimeOffset? y = null) => { }, new() { SerializerOptions = options });
JsonElement expectedSchema = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"x": { "type": ["integer", "null"], "default": 42 },
"y": { "type": ["string", "null"], "format": "date-time", "default": null }
}
}
""").RootElement;
Assert.True(JsonElement.DeepEquals(expectedSchema, tool.ProtocolTool.InputSchema));
}
public static IEnumerable<object[]> StructuredOutput_ReturnsExpectedSchema_Inputs()
{
yield return new object[] { "string" };
yield return new object[] { 42 };
yield return new object[] { 3.14 };
yield return new object[] { true };
yield return new object[] { new() };
yield return new object[] { new List<string> { "item1", "item2" } };
yield return new object[] { new Dictionary<string, int> { ["key1"] = 1, ["key2"] = 2 } };
yield return new object[] { new Person("John", 27) };
}
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;
}
public static object StaticMethod()
{
return "42";
}
}
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
{
public AsyncDisposableAndDisposableToolType(MyService service)
{
Assert.NotNull(service);
}
[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;
}
}
private static void AssertMatchesJsonSchema(JsonElement schemaDoc, JsonNode? value)
{
JsonSchema schema = JsonSerializer.Deserialize(schemaDoc, JsonContext2.Default.JsonSchema)!;
EvaluationOptions options = new() { OutputFormat = OutputFormat.List };
EvaluationResults results = schema.Evaluate(JsonSerializer.SerializeToElement(value, JsonContext2.Default.JsonNode), options);
if (!results.IsValid)
{
IEnumerable<string> errors = (results.Details ?? [])
.Where(d => d.Errors?.Count > 0)
.SelectMany(d => d.Errors!.Select(error => $"Path:${d.InstanceLocation} {error.Key}:{error.Value}"));
throw new XunitException($"""
Instance JSON document does not match the specified schema.
Schema:
{JsonSerializer.Serialize(schema)}
Instance:
{value?.ToJsonString() ?? "null"}
Errors:
{string.Join(Environment.NewLine, errors)}
""");
}
}
record Person(string Name, int Age);
[Fact]
public void SupportsIconsInCreateOptions()
{
var icons = new List<Icon>
{
new() { Source = "https://example.com/icon.png", MimeType = "image/png", Sizes = ["48x48"] },
new() { Source = "https://example.com/icon.svg", MimeType = "image/svg+xml", Sizes = ["any"] }
};
McpServerTool tool = McpServerTool.Create(() => "test", new McpServerToolCreateOptions
{
Icons = icons
});
Assert.NotNull(tool.ProtocolTool.Icons);
Assert.Equal(2, tool.ProtocolTool.Icons.Count);
Assert.Equal("https://example.com/icon.png", tool.ProtocolTool.Icons[0].Source);
Assert.Equal("image/png", tool.ProtocolTool.Icons[0].MimeType);
Assert.NotNull(tool.ProtocolTool.Icons[0].Sizes);
Assert.Single(tool.ProtocolTool.Icons[0].Sizes!);
Assert.Equal("48x48", tool.ProtocolTool.Icons[0].Sizes![0]);
Assert.Equal("https://example.com/icon.svg", tool.ProtocolTool.Icons[1].Source);
Assert.Equal("image/svg+xml", tool.ProtocolTool.Icons[1].MimeType);
Assert.NotNull(tool.ProtocolTool.Icons[1].Sizes);
Assert.Single(tool.ProtocolTool.Icons[1].Sizes!);
Assert.Equal("any", tool.ProtocolTool.Icons[1].Sizes![0]);
}
[Fact]
public void SupportsIconSourceInAttribute()
{
McpServerTool tool = McpServerTool.Create([McpServerTool(IconSource = "https://example.com/tool-icon.png")] () => "result");
var icon = Assert.Single(tool.ProtocolTool.Icons!);
Assert.Equal("https://example.com/tool-icon.png", icon.Source);
Assert.Null(icon.MimeType);
Assert.Null(icon.Sizes);
}
[Fact]
public void CreateOptionsIconsOverrideAttributeIconSource()
{
var optionsIcons = new List<Icon>
{
new() { Source = "https://example.com/override-icon.svg", MimeType = "image/svg+xml" }
};
McpServerTool tool = McpServerTool.Create([McpServerTool(IconSource = "https://example.com/tool-icon.png")] () => "result", new McpServerToolCreateOptions
{
Icons = optionsIcons
});
var icon = Assert.Single(tool.ProtocolTool.Icons!);
Assert.Equal("https://example.com/override-icon.svg", icon.Source);
Assert.Equal("image/svg+xml", icon.MimeType);
}
[Fact]
public void SupportsToolWithoutIcons()
{
McpServerTool tool = McpServerTool.Create([McpServerTool] () => "result");
Assert.Null(tool.ProtocolTool.Icons);
}
[Fact]
public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription()
{
// When UseStructuredContent is false (default), return description should be appended to tool description
McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription);
Assert.Equal("Tool that returns data.\nReturns: The computed result", tool.ProtocolTool.Description);
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void ReturnDescription_StructuredOutputEnabled_NotIncludedInToolDescription()
{
// When UseStructuredContent is true, return description should be in the output schema, not in tool description
McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { UseStructuredContent = true });
Assert.Equal("Tool that returns data.", tool.ProtocolTool.Description);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
// Verify the output schema contains the description
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("result", out var result));
Assert.True(result.TryGetProperty("description", out var description));
Assert.Equal("The computed result", description.GetString());
}
[Fact]
public void ReturnDescription_NoFunctionDescription_OnlyReturnsDescription()
{
// When there's no function description but there's a return description
McpServerTool tool = McpServerTool.Create(ToolWithOnlyReturnDescription);
Assert.Equal("Returns: The computed result", tool.ProtocolTool.Description);
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void ReturnDescription_ExplicitDescriptionOption_SynthesizesWithReturnDescription()
{
// When Description is explicitly set in options and there's a return description,
// the return description should be appended since UseStructuredContent is false
McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { Description = "Custom description" });
Assert.Equal("Custom description\nReturns: The computed result", tool.ProtocolTool.Description);
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void ReturnDescription_NoReturnDescription_NoChange()
{
// When there's no return description, the tool description should remain unchanged
McpServerTool tool = McpServerTool.Create(ToolWithoutReturnDescription);
Assert.Equal("Tool without return description.", tool.ProtocolTool.Description);
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void ReturnDescription_StructuredOutputEnabled_WithExplicitDescription_NoSynthesis()
{
// When UseStructuredContent is true and Description is set, return description goes to output schema
McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new()
{
Description = "Custom description",
UseStructuredContent = true
});
// Description should not have the return description appended
Assert.Equal("Custom description", tool.ProtocolTool.Description);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
}
[Fact]
public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTransportIsNotStreamableHttpPost()
{
// Arrange
Mock<McpServer> mockServer = new();
var jsonRpcRequest = CreateTestJsonRpcRequest();
// The JsonRpcRequest has no Context, so RelatedTransport will be null
var requestContext = new RequestContext<CallToolRequestParams>(mockServer.Object, jsonRpcRequest);
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
() => requestContext.EnablePollingAsync(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken).AsTask());
Assert.Contains("Streamable HTTP", exception.Message);
}
[Fact]
public void AsyncTool_AutomaticallyMarkedWithTaskSupport()
{
// Async tools should automatically get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningTask);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_ValueTask_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning ValueTask should also get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTask);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_TaskOfT_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning Task<T> should get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningTaskOfT);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void AsyncTool_ValueTaskOfT_AutomaticallyMarkedWithTaskSupport()
{
// Async tools returning ValueTask<T> should get TaskSupport = Optional
McpServerTool tool = McpServerTool.Create(AsyncToolReturningValueTaskOfT);
Assert.NotNull(tool.ProtocolTool.Execution);
Assert.Equal(ToolTaskSupport.Optional, tool.ProtocolTool.Execution.TaskSupport);
}
[Fact]
public void SyncTool_NotMarkedWithTaskSupport()
{
// Synchronous tools should not have TaskSupport set
McpServerTool tool = McpServerTool.Create(SyncTool);
Assert.Null(tool.ProtocolTool.Execution);
}
private static async Task AsyncToolReturningTask()
{
await Task.Yield();
}
private static async ValueTask AsyncToolReturningValueTask()
{
await Task.Yield();
}
private static async Task<string> AsyncToolReturningTaskOfT()
{
await Task.Yield();
return "result";
}
private static async ValueTask<string> AsyncToolReturningValueTaskOfT()
{
await Task.Yield();
return "result";
}
private static string SyncTool()
{
return "sync result";
}
[Description("Tool that returns data.")]
[return: Description("The computed result")]
private static string ToolWithReturnDescription() => "result";
[return: Description("The computed result")]
private static string ToolWithOnlyReturnDescription() => "result";
[Description("Tool without return description.")]
private static string ToolWithoutReturnDescription() => "result";
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(JsonNode))]
[JsonSerializable(typeof(DisposableToolType))]
[JsonSerializable(typeof(AsyncDisposableToolType))]
[JsonSerializable(typeof(AsyncDisposableAndDisposableToolType))]
[JsonSerializable(typeof(JsonSchema))]
[JsonSerializable(typeof(List<AIContent>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(int?))]
[JsonSerializable(typeof(DateTimeOffset?))]
partial class JsonContext2 : JsonSerializerContext;
}