diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 4b80c6e56..d1eab68ef 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Client; @@ -913,6 +914,63 @@ public ValueTask CallToolAsync( cancellationToken: cancellationToken); } + /// + /// Invokes a tool on the server and deserializes the result as . + /// + /// The type to deserialize the tool's structured content or text content into. + /// The name of the tool to call on the server. + /// An optional dictionary of arguments to pass to the tool. + /// An optional progress reporter for server notifications. + /// Optional request options including metadata, serialization settings, and progress tracking. + /// The to monitor for cancellation requests. The default is . + /// The deserialized content of the tool result. + /// is . + /// The request failed, the server returned an error response, or is . + /// The result content could not be deserialized as . + /// + /// + /// This method calls the existing + /// and then deserializes the result. If the result has , that is deserialized + /// as . Otherwise, if the result has text content, the text of the first + /// is deserialized as . + /// + /// + /// If is , an is thrown. To inspect + /// error details without an exception, use the non-generic overload instead. + /// + /// + public async ValueTask CallToolAsync( + string toolName, + IReadOnlyDictionary? arguments = null, + IProgress? progress = null, + RequestOptions? options = null, + CancellationToken cancellationToken = default) + { + CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false); + + if (result.IsError is true) + { + string errorMessage = result.Content.OfType().FirstOrDefault()?.Text ?? "Tool call failed."; + throw new McpException(errorMessage); + } + + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; + JsonTypeInfo typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T)); + + // Prefer StructuredContent if available, otherwise fall back to text content + if (result.StructuredContent is { } structuredContent) + { + return JsonSerializer.Deserialize(structuredContent, typeInfo); + } + + if (result.Content.OfType().FirstOrDefault() is { } textContent) + { + return JsonSerializer.Deserialize(textContent.Text, typeInfo); + } + + return default; + } + /// /// Invokes a tool on the server as a task for long-running operations. /// diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs new file mode 100644 index 000000000..a3c01c719 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Protocol; + +/// +/// Represents a strongly-typed result of a request. +/// +/// +/// The type of the structured content returned by the tool. This type is used to infer the +/// advertised by the tool. +/// +/// +/// +/// provides a way to return strongly-typed structured content from a tool +/// while still providing access to and . When a tool method +/// returns , the SDK uses to infer the output schema +/// and serializes as both the text content and structured content of the response. +/// +/// +/// This type is a peer of , not a subclass. Use when +/// you need full control over individual content blocks, and when you want +/// the SDK to handle serialization of a strongly-typed result. +/// +/// +public sealed class CallToolResult : Result, ICallToolResultTyped +{ + /// + /// Gets or sets the typed content returned by the tool. + /// + public T? Content { get; set; } + + /// + /// Gets or sets a value that indicates whether the tool call was unsuccessful. + /// + /// + /// to signify that the tool execution failed; if it was successful. + /// + /// + /// + /// Tool execution errors (including input validation errors, API failures, and business logic errors) + /// are reported with this property set to and details in the + /// property, rather than as protocol-level errors. + /// + /// + /// This design allows language models to receive detailed error feedback and potentially self-correct + /// in subsequent requests. + /// + /// + public bool? IsError { get; set; } + + /// + CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) + { + JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, serializerOptions.GetTypeInfo(typeof(T))); + + return new() + { + Content = [new TextContentBlock { Text = structuredContent?.ToString() ?? "null" }], + StructuredContent = structuredContent, + IsError = IsError, + Meta = Meta, + }; + } +} + +/// +/// Internal interface for converting strongly-typed tool results to . +/// +internal interface ICallToolResultTyped +{ + /// + /// Converts the strongly-typed result to a . + /// + CallToolResult ToCallToolResult(JsonSerializerOptions serializerOptions); +} diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f72e5a483..2784a53c2 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -174,6 +174,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe { McpServerToolCreateOptions newOptions = options?.Clone() ?? new(); + bool useStructuredContent = false; if (method.GetCustomAttribute() is { } toolAttr) { newOptions.Name ??= toolAttr.Name; @@ -204,13 +205,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Icons = [new() { Source = iconSource }]; } - newOptions.UseStructuredContent = toolAttr.UseStructuredContent; - if (toolAttr._taskSupport is { } taskSupport) { newOptions.Execution ??= new ToolExecution(); newOptions.Execution.TaskSupport ??= taskSupport; } + + useStructuredContent = toolAttr.UseStructuredContent; } if (method.GetCustomAttribute() is { } descAttr) @@ -221,6 +222,22 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe // Set metadata if not already provided newOptions.Metadata ??= CreateMetadata(method); + // Generate the output schema from the return type if needed. + // UseStructuredContent on the attribute uses T from CallToolResult or the return type directly. + // CallToolResult return types automatically infer the schema from T even without UseStructuredContent. + Type? outputSchemaType = GetCallToolResultContentType(method.ReturnType); + if (outputSchemaType is null && useStructuredContent) + { + outputSchemaType = method.ReturnType; + } + + if (outputSchemaType is not null) + { + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, + serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + inferenceOptions: newOptions.SchemaCreateOptions); + } + return newOptions; } @@ -305,6 +322,8 @@ public override async ValueTask InvokeAsync( CallToolResult callToolResponse => callToolResponse, + ICallToolResultTyped typed => typed.ToCallToolResult(AIFunction.JsonSerializerOptions), + _ => new() { Content = [new TextContentBlock { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))) }], @@ -361,6 +380,29 @@ private static bool IsAsyncMethod(MethodInfo method) return false; } + /// + /// If the specified type is (possibly wrapped in + /// or ), returns the T type argument. Otherwise, returns . + /// + private static Type? GetCallToolResultContentType(Type returnType) + { + if (returnType.IsGenericType) + { + Type genericDef = returnType.GetGenericTypeDefinition(); + if (genericDef == typeof(Task<>) || genericDef == typeof(ValueTask<>)) + { + returnType = returnType.GetGenericArguments()[0]; + } + } + + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(CallToolResult<>)) + { + return returnType.GetGenericArguments()[0]; + } + + return null; + } + /// Creates metadata from attributes on the specified method and its declaring class, with the MethodInfo as the first item. internal static IReadOnlyList CreateMetadata(MethodInfo method) { @@ -432,16 +474,16 @@ private static void ValidateToolName(string name) /// Gets the tool description, synthesizing from both the function description and return description when appropriate. /// /// - /// When UseStructuredContent is true, the return description is included in the output schema. - /// When UseStructuredContent is false (default), if there's a return description in the ReturnJsonSchema, + /// When an output schema is present, the return description is included in the output schema. + /// When no output schema is present (default), if there's a return description in the ReturnJsonSchema, /// it will be appended to the tool description so the information is still available to consumers. /// private static string? GetToolDescription(AIFunction function, McpServerToolCreateOptions? options) { string? description = options?.Description ?? function.Description; - // If structured content is enabled, the return description will be in the output schema - if (options?.UseStructuredContent is true) + // If structured content is enabled (output schema present), the return description will be in the output schema + if (options?.OutputSchema is not null) { return description; } @@ -483,12 +525,7 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; - if (toolCreateOptions?.UseStructuredContent is not true) - { - return null; - } - - if (function.ReturnJsonSchema is not JsonElement outputSchema) + if (toolCreateOptions?.OutputSchema is not JsonElement outputSchema) { return null; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index f2e75387d..69af7dfe4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -122,6 +122,15 @@ namespace ModelContextProtocol.Server; /// Returned directly without modification. /// /// +/// +/// +/// The T content is serialized to JSON and used as both a +/// and as the . The +/// and properties are propagated to the resulting . +/// The T type argument is also used to infer the . +/// +/// +/// /// Other types /// Serialized to JSON and returned as a single object with set to "text". /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index bde097100..cf1026b7a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -118,6 +118,15 @@ namespace ModelContextProtocol.Server; /// Returned directly without modification. /// /// +/// +/// +/// The T content is serialized to JSON and used as both a +/// and as the . The +/// and properties are propagated to the resulting . +/// The T type argument is also used to infer the . +/// +/// +/// /// Other types /// Serialized to JSON and returned as a single object with set to "text". /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 3bf0c5305..b3a6b5c83 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -118,16 +118,26 @@ public sealed class McpServerToolCreateOptions public bool? ReadOnly { get; set; } /// - /// Gets or sets a value that indicates whether the tool should report an output schema for structured content. + /// Gets or sets a JSON Schema object to use as the tool's output schema. /// - /// - /// The default is . - /// /// - /// When enabled, the tool will attempt to populate the - /// and provide structured content in the property. + /// + /// When set, this schema is used directly as the instead of + /// inferring it from the method's return type. This is particularly useful when the method + /// returns directly (for example, to control + /// ), but the tool should still advertise a meaningful + /// output schema describing the shape of . + /// + /// + /// Setting this property to a non- value will enable structured content + /// for the tool, causing the tool to populate both and + /// . + /// + /// + /// The schema must be a valid JSON Schema object with the "type" property set to "object". + /// /// - public bool UseStructuredContent { get; set; } + public JsonElement? OutputSchema { get; set; } /// /// Gets or sets the JSON serializer options to use when marshalling data to/from JSON. @@ -208,7 +218,7 @@ internal McpServerToolCreateOptions Clone() => Idempotent = Idempotent, OpenWorld = OpenWorld, ReadOnly = ReadOnly, - UseStructuredContent = UseStructuredContent, + OutputSchema = OutputSchema, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs new file mode 100644 index 000000000..3b104ed52 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Tests.Server; + +public class CallToolResultOfTTests : ClientServerTestBase +{ + private McpServerPrimitiveCollection _toolCollection = []; + + public CallToolResultOfTTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.Services.Configure(options => + { + options.ToolCollection = _toolCollection; + }); + } + + [Fact] + public async Task CallToolAsyncOfT_DeserializesStructuredContent() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + () => new CallToolResult { Content = new PersonData { Name = "Alice", Age = 30 } }, + new() { Name = "get_person", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "get_person", + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("Alice", result.Name); + Assert.Equal(30, result.Age); + } + + [Fact] + public async Task CallToolAsyncOfT_ThrowsOnIsError() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + () => new CallToolResult { Content = "something went wrong", IsError = true }, + new() { Name = "error_tool", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var ex = await Assert.ThrowsAsync(() => client.CallToolAsync( + "error_tool", + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken).AsTask()); + + Assert.Contains("something went wrong", ex.Message); + } + + [Fact] + public async Task CallToolAsyncOfT_FallsBackToTextContent() + { + // Use a regular tool that returns structured text, not CallToolResult + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + () => new PersonData { Name = "Bob", Age = 25 }, + new() { Name = "text_tool", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "text_tool", + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("Bob", result.Name); + Assert.Equal(25, result.Age); + } + + [Fact] + public async Task CallToolResultOfT_AdvertisesOutputSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + () => new CallToolResult { Content = new PersonData { Name = "Alice", Age = 30 } }, + new() { Name = "schema_tool", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = Assert.Single(tools); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("Name", out _)); + Assert.True(props.TryGetProperty("Age", out _)); + } + + [Fact] + public async Task CallToolAsyncOfT_WithAsyncTool_Works() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + async () => + { + await Task.CompletedTask; + return new CallToolResult { Content = new PersonData { Name = "Charlie", Age = 35 } }; + }, + new() { Name = "async_tool", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "async_tool", + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("Charlie", result.Name); + Assert.Equal(35, result.Age); + } + + [Fact] + public async Task CallToolAsyncOfT_WithArguments_Works() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + _toolCollection.Add(McpServerTool.Create( + (string name, int age) => new CallToolResult + { + Content = new PersonData { Name = name, Age = age } + }, + new() { Name = "create_person", SerializerOptions = serOpts })); + + StartServer(); + var client = await CreateMcpClientForServer(); + + var result = await client.CallToolAsync( + "create_person", + new Dictionary { ["name"] = "Diana", ["age"] = 28 }, + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Equal("Diana", result.Name); + Assert.Equal(28, result.Age); + } + + private class PersonData + { + public string? Name { get; set; } + public int Age { get; set; } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2e826d591..a1f531951 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -462,7 +462,7 @@ public async Task SupportsSchemaCreateOptions() public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) { JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); + McpServerTool tool = McpServerTool.Create([McpServerTool(UseStructuredContent = true)] () => value, new() { Name = "tool", SerializerOptions = options }); var mockServer = new Mock(); var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) { @@ -520,7 +520,7 @@ public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSch public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) { JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; - McpServerTool tool = McpServerTool.Create(() => value, new() { UseStructuredContent = false, SerializerOptions = options }); + McpServerTool tool = McpServerTool.Create(() => value, new() { SerializerOptions = options }); var mockServer = new Mock(); var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest()) { @@ -533,6 +533,211 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) Assert.Null(result.StructuredContent); } + [Fact] + public void OutputSchema_ViaOptions_SetsSchemaDirectly() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}}}"""); + McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_ViaOptions_EnablesStructuredContent() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"n":{"type":"string"},"a":{"type":"integer"}},"required":["n","a"]}"""); + McpServerTool tool = McpServerTool.Create((string input) => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() + { + using var overrideDoc = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}}}"""); + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create(() => new Person("Alice", 30), new() + { + OutputSchema = overrideDoc.RootElement, + SerializerOptions = serOpts, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(overrideDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void CallToolResultOfT_ProducesExpectedOutputSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = new Person("Alice", 30) }, + new() { SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("Name", out _)); + Assert.True(props.TryGetProperty("Age", out _)); + } + + [Fact] + public async Task CallToolResultOfT_SerializesContentCorrectly() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = new Person("Alice", 30) }, + new() { SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + + // Content should be serialized as text content block + Assert.Single(toolResult.Content); + var textBlock = Assert.IsType(toolResult.Content[0]); + Assert.Contains("Alice", textBlock.Text); + Assert.Contains("30", textBlock.Text); + + // StructuredContent should be populated + Assert.NotNull(toolResult.StructuredContent); + } + + [Fact] + public async Task CallToolResultOfT_PropagatesIsError() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = "something went wrong", IsError = true }, + new() { SerializerOptions = serOpts }); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.True(toolResult.IsError); + } + + [Fact] + public async Task CallToolResultOfT_PropagatesMeta() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + var meta = new JsonObject { ["traceId"] = "abc-123" }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = new Person("Bob", 25), Meta = meta }, + new() { SerializerOptions = serOpts }); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.NotNull(toolResult.Meta); + Assert.Equal("abc-123", toolResult.Meta["traceId"]?.GetValue()); + } + + [Fact] + public async Task CallToolResultOfT_AsyncMethod_ProducesExpectedSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + async () => + { + await Task.CompletedTask; + return new CallToolResult { Content = new Person("Charlie", 35) }; + }, + new() { SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props)); + Assert.True(props.TryGetProperty("Name", out _)); + Assert.True(props.TryGetProperty("Age", out _)); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.NotNull(toolResult.StructuredContent); + Assert.Single(toolResult.Content); + } + + [Fact] + public void CallToolResultOfT_ExplicitOutputSchemaOverrides() + { + using var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}"""); + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = new Person("Alice", 30) }, + new() { OutputSchema = customDoc.RootElement, SerializerOptions = serOpts }); + + // Explicit OutputSchema from options should override the inferred one from T + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(customDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public async Task CallToolResultOfT_NullContent_HandledGracefully() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + () => new CallToolResult { Content = null }, + new() { SerializerOptions = serOpts }); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.Single(toolResult.Content); + Assert.Equal("null", Assert.IsType(toolResult.Content[0]).Text); + } + + [Fact] + public void OutputSchema_IsPreservedWhenCopyingOptions() + { + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}"""); + + // Verify OutputSchema works correctly when used via tool creation (which clones internally) + McpServerTool tool1 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + McpServerTool tool2 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() + { + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool1.ProtocolTool.OutputSchema); + Assert.NotNull(tool2.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(tool1.ProtocolTool.OutputSchema.Value, tool2.ProtocolTool.OutputSchema.Value)); + } + + [Theory] [InlineData(JsonNumberHandling.Strict)] [InlineData(JsonNumberHandling.AllowReadingFromString)] @@ -758,15 +963,14 @@ public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription 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 }); + McpServerTool tool = McpServerTool.Create( + [McpServerTool(UseStructuredContent = true)] + [Description("Tool that returns data.")] + [return: Description("The computed result")] + static () => "result"); 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] @@ -804,11 +1008,11 @@ public void ReturnDescription_NoReturnDescription_NoChange() 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 - }); + McpServerTool tool = McpServerTool.Create( + [McpServerTool(UseStructuredContent = true)] + [return: Description("The computed result")] + static () => "result", + new() { Description = "Custom description" }); // Description should not have the return description appended Assert.Equal("Custom description", tool.ProtocolTool.Description);