From 301550d74bba14c400d0f299e51c83d8b0f4079b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:29:02 +0000 Subject: [PATCH 01/14] Initial plan From b1112845afca25c2e7aad42c627a11b4330d0601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:40:53 +0000 Subject: [PATCH 02/14] Add OutputSchemaType to McpServerToolAttribute and OutputSchema to McpServerToolCreateOptions - Added Type? OutputSchemaType property to McpServerToolAttribute - Added JsonElement? OutputSchema property to McpServerToolCreateOptions - Updated DeriveOptions to generate schema from OutputSchemaType via AIJsonUtilities.CreateJsonSchema - Updated CreateOutputSchema to use explicit OutputSchema when provided (takes precedence) - OutputSchema forces structured content behavior even if UseStructuredContent is false - Updated Clone() to preserve OutputSchema - Added 8 new tests covering various scenarios - Updated XML documentation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 15 +- .../Server/McpServerToolAttribute.cs | 23 +++ .../Server/McpServerToolCreateOptions.cs | 26 ++++ .../Server/McpServerToolTests.cs | 140 ++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f72e5a483..9bd8bba01 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -206,6 +206,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.UseStructuredContent = toolAttr.UseStructuredContent; + if (newOptions.OutputSchema is null && toolAttr.OutputSchemaType is { } outputSchemaType) + { + newOptions.OutputSchema = AIJsonUtilities.CreateJsonSchema(outputSchemaType, + serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + inferenceOptions: newOptions.SchemaCreateOptions); + } + if (toolAttr._taskSupport is { } taskSupport) { newOptions.Execution ??= new ToolExecution(); @@ -441,7 +448,7 @@ private static void ValidateToolName(string name) 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 (options?.UseStructuredContent is true || options?.OutputSchema is not null) { return description; } @@ -483,6 +490,12 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; + // If an explicit OutputSchema is provided, use it directly and force UseStructuredContent. + if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema) + { + return explicitSchema; + } + if (toolCreateOptions?.UseStructuredContent is not true) { return null; diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index bde097100..e1af5741f 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -240,11 +240,34 @@ public bool ReadOnly /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// Setting will automatically enable structured content. + /// /// public bool UseStructuredContent { get; set; } + /// + /// Gets or sets the type to use for generating the tool's output schema. + /// + /// + /// + /// When set, the SDK generates the from this type 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 automatically enables . + /// + /// + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] + public Type? OutputSchemaType { get; set; } + /// /// Gets or sets the source URI for the tool's icon. /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 3bf0c5305..4d60b87d3 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -124,11 +124,36 @@ public sealed class McpServerToolCreateOptions /// The default is . /// /// + /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. + /// + /// + /// Setting will automatically enable structured content. + /// /// public bool UseStructuredContent { get; set; } + /// + /// Gets or sets a JSON Schema object to use as the tool's output schema. + /// + /// + /// + /// 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 automatically enables . + /// + /// + /// The schema must be a valid JSON Schema object with the "type" property set to "object". + /// + /// + public JsonElement? OutputSchema { get; set; } + /// /// Gets or sets the JSON serializer options to use when marshalling data to/from JSON. /// @@ -209,6 +234,7 @@ internal McpServerToolCreateOptions Clone() => OpenWorld = OpenWorld, ReadOnly = ReadOnly, UseStructuredContent = UseStructuredContent, + OutputSchema = OutputSchema, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2e826d591..bb2cbcba1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -533,6 +533,146 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) Assert.Null(result.StructuredContent); } + [Fact] + public void OutputSchema_ViaOptions_SetsSchemaDirectly() + { + 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_ForcesStructuredContentEvenIfDisabled() + { + 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() + { + UseStructuredContent = false, + OutputSchema = schemaDoc.RootElement, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() + { + 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() + { + UseStructuredContent = true, + OutputSchema = overrideDoc.RootElement, + SerializerOptions = serOpts, + }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(overrideDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchemaType_ViaAttribute_ProducesExpectedSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, + target: null, + 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 OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCorrectly() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, + target: null, + new() { SerializerOptions = serOpts }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + + Mock srv = new(); + var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) + { + Params = new CallToolRequestParams { Name = "tool_returning_call_tool_result_with_schema_type" }, + }; + + var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); + Assert.Equal("hello", Assert.IsType(toolResult.Content[0]).Text); + } + + [Fact] + public void OutputSchemaType_ViaAttribute_WithUseStructuredContent_ProducesExpectedSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolWithBothOutputSchemaTypeAndStructuredContent))!, + target: null, + 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 _)); + } + + [Fact] + public void OutputSchema_ViaOptions_OverridesAttributeOutputSchemaType() + { + var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}"""); + McpServerTool tool = McpServerTool.Create( + typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, + target: null, + new() { OutputSchema = customDoc.RootElement }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.True(JsonElement.DeepEquals(customDoc.RootElement, tool.ProtocolTool.OutputSchema.Value)); + } + + [Fact] + public void OutputSchema_IsPreservedWhenCopyingOptions() + { + 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)); + } + + private class OutputSchemaTypeTools + { + [McpServerTool(OutputSchemaType = typeof(Person))] + public static CallToolResult ToolReturningCallToolResultWithSchemaType() + => new() { Content = [new TextContentBlock { Text = "hello" }] }; + + [McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))] + public static CallToolResult ToolWithBothOutputSchemaTypeAndStructuredContent() + => new() { Content = [new TextContentBlock { Text = "world" }] }; + } + + [Theory] [InlineData(JsonNumberHandling.Strict)] [InlineData(JsonNumberHandling.AllowReadingFromString)] From 5c82239b7782a6d36f80acbfc7a3f3aeeaf4a563 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:14:47 +0000 Subject: [PATCH 03/14] Refactor OutputSchemaType check to use ??= pattern Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 9bd8bba01..894e2895e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -206,9 +206,9 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.UseStructuredContent = toolAttr.UseStructuredContent; - if (newOptions.OutputSchema is null && toolAttr.OutputSchemaType is { } outputSchemaType) + if (toolAttr.OutputSchemaType is { } outputSchemaType) { - newOptions.OutputSchema = AIJsonUtilities.CreateJsonSchema(outputSchemaType, + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); } From 444dcfa3950e6c351656708b2c1cd1bc63222a2a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:20:26 +0000 Subject: [PATCH 04/14] Set UseStructuredContent=true when OutputSchemaType is set, remove DynamicallyAccessedMembers - In DeriveOptions, force UseStructuredContent=true when OutputSchemaType is set - Simplify GetToolDescription to only check UseStructuredContent (no longer needs OutputSchema check) - Remove unnecessary [DynamicallyAccessedMembers] from OutputSchemaType property Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 5 +++-- .../Server/McpServerToolAttribute.cs | 1 - 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 894e2895e..d963e36a0 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -208,6 +208,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe if (toolAttr.OutputSchemaType is { } outputSchemaType) { + newOptions.UseStructuredContent = true; newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); @@ -448,7 +449,7 @@ private static void ValidateToolName(string name) 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 || options?.OutputSchema is not null) + if (options?.UseStructuredContent is true) { return description; } @@ -490,7 +491,7 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; - // If an explicit OutputSchema is provided, use it directly and force UseStructuredContent. + // If an explicit OutputSchema is provided, use it directly. if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema) { return explicitSchema; diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index e1af5741f..2fe75f7f6 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -265,7 +265,6 @@ public bool ReadOnly /// Setting this property automatically enables . /// /// - [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] public Type? OutputSchemaType { get; set; } /// From a6b7ef5f0f0221551c6ebd3c1c84d96eb608cb70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Feb 2026 15:16:27 +0000 Subject: [PATCH 05/14] Address review feedback: assert UseStructuredContent, use lambdas in tests, add using for JsonDocument - Add Debug.Assert in CreateOutputSchema to verify UseStructuredContent is true when OutputSchema is set - Force UseStructuredContent=true in DeriveOptions when OutputSchema is set via options - Update docs to say "to a non-null value" for UseStructuredContent auto-enable - Add using statements for all JsonDocument.Parse calls in tests - Rewrite attribute-based tests to use lambdas instead of reflection - Add test for OutputSchemaType with UseStructuredContent=false to verify it's overridden - Remove OutputSchemaTypeTools helper class (no longer needed) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 7 +++ .../Server/McpServerToolAttribute.cs | 4 +- .../Server/McpServerToolCreateOptions.cs | 4 +- .../Server/McpServerToolTests.cs | 51 ++++++++++--------- 4 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d963e36a0..0fc70c727 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -229,6 +229,12 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe // Set metadata if not already provided newOptions.Metadata ??= CreateMetadata(method); + // Force UseStructuredContent when OutputSchema is explicitly provided + if (newOptions.OutputSchema is not null) + { + newOptions.UseStructuredContent = true; + } + return newOptions; } @@ -494,6 +500,7 @@ schema.ValueKind is not JsonValueKind.Object || // If an explicit OutputSchema is provided, use it directly. if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema) { + Debug.Assert(toolCreateOptions.UseStructuredContent, "UseStructuredContent should be true when OutputSchema is set."); return explicitSchema; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 2fe75f7f6..8dc3a82cf 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -245,7 +245,7 @@ public bool ReadOnly /// and provide structured content in the property. /// /// - /// Setting will automatically enable structured content. + /// Setting to a non- value will automatically enable structured content. /// /// public bool UseStructuredContent { get; set; } @@ -262,7 +262,7 @@ public bool ReadOnly /// output schema describing the shape of . /// /// - /// Setting this property automatically enables . + /// Setting this property to a non- value will automatically enable . /// /// public Type? OutputSchemaType { get; set; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 4d60b87d3..c702953f4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -129,7 +129,7 @@ public sealed class McpServerToolCreateOptions /// and provide structured content in the property. /// /// - /// Setting will automatically enable structured content. + /// Setting to a non- value will automatically enable structured content. /// /// public bool UseStructuredContent { get; set; } @@ -146,7 +146,7 @@ public sealed class McpServerToolCreateOptions /// output schema describing the shape of . /// /// - /// Setting this property automatically enables . + /// Setting this property to a non- value will automatically enable . /// /// /// The schema must be a valid JSON Schema object with the "type" property set to "object". diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index bb2cbcba1..9a49e72f9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -536,7 +536,7 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value) [Fact] public void OutputSchema_ViaOptions_SetsSchemaDirectly() { - var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}}}"""); + using var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}}}"""); McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new() { OutputSchema = schemaDoc.RootElement, @@ -549,7 +549,7 @@ public void OutputSchema_ViaOptions_SetsSchemaDirectly() [Fact] public void OutputSchema_ViaOptions_ForcesStructuredContentEvenIfDisabled() { - var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"n":{"type":"string"},"a":{"type":"integer"}},"required":["n","a"]}"""); + 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() { UseStructuredContent = false, @@ -563,7 +563,7 @@ public void OutputSchema_ViaOptions_ForcesStructuredContentEvenIfDisabled() [Fact] public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() { - var overrideDoc = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}}}"""); + 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() { @@ -581,8 +581,7 @@ public void OutputSchemaType_ViaAttribute_ProducesExpectedSchema() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, - target: null, + [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, new() { SerializerOptions = serOpts }); Assert.NotNull(tool.ProtocolTool.OutputSchema); @@ -597,8 +596,7 @@ public async Task OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCor { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, - target: null, + [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [new TextContentBlock { Text = "hello" }] }, new() { SerializerOptions = serOpts }); Assert.NotNull(tool.ProtocolTool.OutputSchema); @@ -606,7 +604,7 @@ public async Task OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCor Mock srv = new(); var ctx = new RequestContext(srv.Object, CreateTestJsonRpcRequest()) { - Params = new CallToolRequestParams { Name = "tool_returning_call_tool_result_with_schema_type" }, + Params = new CallToolRequestParams { Name = "tool" }, }; var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); @@ -618,23 +616,37 @@ public void OutputSchemaType_ViaAttribute_WithUseStructuredContent_ProducesExpec { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolWithBothOutputSchemaTypeAndStructuredContent))!, - target: null, + [McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, + 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 _)); + } + + [Fact] + public void OutputSchemaType_ViaAttribute_WithUseStructuredContentFalse_StillProducesSchema() + { + JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; + McpServerTool tool = McpServerTool.Create( + [McpServerTool(UseStructuredContent = false, OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, new() { SerializerOptions = serOpts }); + // OutputSchemaType forces UseStructuredContent to true even if explicitly set to false 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 void OutputSchema_ViaOptions_OverridesAttributeOutputSchemaType() { - var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}"""); + using var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}"""); McpServerTool tool = McpServerTool.Create( - typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!, - target: null, + [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, new() { OutputSchema = customDoc.RootElement }); Assert.NotNull(tool.ProtocolTool.OutputSchema); @@ -644,7 +656,7 @@ public void OutputSchema_ViaOptions_OverridesAttributeOutputSchemaType() [Fact] public void OutputSchema_IsPreservedWhenCopyingOptions() { - var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}"""); + 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() @@ -661,17 +673,6 @@ public void OutputSchema_IsPreservedWhenCopyingOptions() Assert.True(JsonElement.DeepEquals(tool1.ProtocolTool.OutputSchema.Value, tool2.ProtocolTool.OutputSchema.Value)); } - private class OutputSchemaTypeTools - { - [McpServerTool(OutputSchemaType = typeof(Person))] - public static CallToolResult ToolReturningCallToolResultWithSchemaType() - => new() { Content = [new TextContentBlock { Text = "hello" }] }; - - [McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))] - public static CallToolResult ToolWithBothOutputSchemaTypeAndStructuredContent() - => new() { Content = [new TextContentBlock { Text = "world" }] }; - } - [Theory] [InlineData(JsonNumberHandling.Strict)] From dcfff8ebedf6b91c848a350cebdaec9a13be9732 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 10:49:06 +0000 Subject: [PATCH 06/14] Add CallToolResult and CallToolAsync, remove OutputSchemaType from attribute - Remove OutputSchemaType from McpServerToolAttribute (keep OutputSchema on options) - Add sealed CallToolResult class as a peer of CallToolResult with T Content - Add ICallToolResultTyped internal interface for generic pattern matching - Update AIFunctionMcpServerTool to recognize CallToolResult return type: - DeriveOptions detects CallToolResult and generates OutputSchema from T - InvokeAsync handles ICallToolResultTyped to convert to CallToolResult - Add CallToolAsync to McpClient that deserializes StructuredContent/Content as T - Update XML doc comments on McpServerTool and McpServerToolAttribute Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 62 +++++++++++++ .../Protocol/CallToolResultOfT.cs | 87 +++++++++++++++++++ .../Server/AIFunctionMcpServerTool.cs | 46 ++++++++-- .../Server/McpServerTool.cs | 9 ++ .../Server/McpServerToolAttribute.cs | 31 ++----- 5 files changed, 205 insertions(+), 30 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 4b80c6e56..4e08cdcae 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,67 @@ public ValueTask CallToolAsync( cancellationToken: cancellationToken); } + /// + /// Invokes a tool on the server and deserializes the result as a strongly-typed . + /// + /// 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 result from the tool execution. + /// is . + /// + /// The request failed, the server returned an error response, or was . + /// + /// 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 + /// with the error content details. + /// + /// + 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.Count > 0 && result.Content[0] is TextContentBlock textBlock + ? textBlock.Text + : "The tool call returned an error."; + 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.Count > 0 && result.Content[0] is TextContentBlock textContent) + { + return JsonSerializer.Deserialize(textContent.Text, typeInfo)!; + } + + throw new McpException("The tool call did not return any content that could be deserialized."); + } + /// /// 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..848a72513 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -0,0 +1,87 @@ +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 : 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; } + + /// + /// Gets or sets metadata reserved by MCP for protocol-level metadata. + /// + /// + /// Implementations must not make assumptions about its contents. + /// + public JsonObject? Meta { get; set; } + + /// + CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) + { + var typeInfo = serializerOptions.GetTypeInfo(typeof(T)); + + string json = JsonSerializer.Serialize(Content, typeInfo); + JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, typeInfo); + + return new() + { + Content = [new TextContentBlock { Text = json }], + 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 0fc70c727..d71cfff4c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -206,14 +206,6 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.UseStructuredContent = toolAttr.UseStructuredContent; - if (toolAttr.OutputSchemaType is { } outputSchemaType) - { - newOptions.UseStructuredContent = true; - newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, - serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - inferenceOptions: newOptions.SchemaCreateOptions); - } - if (toolAttr._taskSupport is { } taskSupport) { newOptions.Execution ??= new ToolExecution(); @@ -235,6 +227,15 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.UseStructuredContent = true; } + // If the method returns CallToolResult, automatically use T for the output schema + if (GetCallToolResultContentType(method) is { } contentType) + { + newOptions.UseStructuredContent = true; + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(contentType, + serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + inferenceOptions: newOptions.SchemaCreateOptions); + } + return newOptions; } @@ -319,6 +320,8 @@ public override async ValueTask InvokeAsync( CallToolResult callToolResponse => callToolResponse, + ICallToolResultTyped typedResult => typedResult.ToCallToolResult(AIFunction.JsonSerializerOptions), + _ => new() { Content = [new TextContentBlock { Text = JsonSerializer.Serialize(result, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))) }], @@ -375,6 +378,33 @@ private static bool IsAsyncMethod(MethodInfo method) return false; } + /// + /// If the method's return type is (possibly wrapped in + /// or ), returns the T type argument. Otherwise, returns . + /// + private static Type? GetCallToolResultContentType(MethodInfo method) + { + Type t = method.ReturnType; + + // Unwrap Task or ValueTask + if (t.IsGenericType) + { + Type genericDef = t.GetGenericTypeDefinition(); + if (genericDef == typeof(Task<>) || genericDef == typeof(ValueTask<>)) + { + t = t.GetGenericArguments()[0]; + } + } + + // Check if the unwrapped type is CallToolResult + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(CallToolResult<>)) + { + return t.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) { diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index f2e75387d..379187de9 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 8dc3a82cf..79e22d1f3 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". /// @@ -240,33 +249,11 @@ public bool ReadOnly /// The default is . /// /// - /// /// When enabled, the tool will attempt to populate the /// and provide structured content in the property. - /// - /// - /// Setting to a non- value will automatically enable structured content. - /// /// public bool UseStructuredContent { get; set; } - /// - /// Gets or sets the type to use for generating the tool's output schema. - /// - /// - /// - /// When set, the SDK generates the from this type 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 automatically enable . - /// - /// - public Type? OutputSchemaType { get; set; } - /// /// Gets or sets the source URI for the tool's icon. /// From b10972839dabb9c485e4f38d79c59b024c949c3e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:03:05 +0000 Subject: [PATCH 07/14] Add tests for CallToolResult, CallToolAsync, and update existing tests - Replace OutputSchemaType attribute tests with CallToolResult unit tests - Add 8 new unit tests for CallToolResult in McpServerToolTests - Add 6 new integration tests in CallToolResultOfTTests - Refactor CreateOutputSchema to run wrapping logic on explicit OutputSchema too - All 1465 core tests + 267 AspNetCore tests + 57 analyzer tests pass Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 79 +++++---- .../Server/CallToolResultOfTTests.cs | 167 ++++++++++++++++++ .../Server/McpServerToolTests.cs | 100 +++++++++-- 3 files changed, 296 insertions(+), 50 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d71cfff4c..f351696d6 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -527,58 +527,71 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; - // If an explicit OutputSchema is provided, use it directly. + // Determine the raw output schema to use. + JsonElement? rawSchema = null; + if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema) { Debug.Assert(toolCreateOptions.UseStructuredContent, "UseStructuredContent should be true when OutputSchema is set."); - return explicitSchema; + rawSchema = explicitSchema; } - - if (toolCreateOptions?.UseStructuredContent is not true) + else if (toolCreateOptions?.UseStructuredContent is not true) { return null; } + else + { + rawSchema = function.ReturnJsonSchema; + } - if (function.ReturnJsonSchema is not JsonElement outputSchema) + if (rawSchema is not JsonElement outputSchema) { return null; } - if (outputSchema.ValueKind is not JsonValueKind.Object || - !outputSchema.TryGetProperty("type", out JsonElement typeProperty) || - typeProperty.ValueKind is not JsonValueKind.String || - typeProperty.GetString() is not "object") + return EnsureObjectSchema(outputSchema, ref structuredOutputRequiresWrapping); + } + + /// + /// Ensures the schema is a valid MCP output schema (type "object"). Wraps non-object schemas in an envelope. + /// + private static JsonElement EnsureObjectSchema(JsonElement outputSchema, ref bool structuredOutputRequiresWrapping) + { + if (outputSchema.ValueKind is JsonValueKind.Object && + outputSchema.TryGetProperty("type", out JsonElement typeProperty) && + typeProperty.ValueKind is JsonValueKind.String && + typeProperty.GetString() is "object") { - // If the output schema is not an object, need to modify to be a valid MCP output schema. - JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + return outputSchema; + } - if (schemaNode is JsonObject objSchema && - objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && - typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) - { - // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. - objSchema["type"] = "object"; - } - else + // If the output schema is not an object, need to modify to be a valid MCP output schema. + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + + if (schemaNode is JsonObject objSchema && + objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && + typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) + { + // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. + objSchema["type"] = "object"; + } + else + { + // For anything else, wrap the schema in an envelope with a "result" property. + schemaNode = new JsonObject { - // For anything else, wrap the schema in an envelope with a "result" property. - schemaNode = new JsonObject + ["type"] = "object", + ["properties"] = new JsonObject { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["result"] = schemaNode - }, - ["required"] = new JsonArray { (JsonNode)"result" } - }; - - structuredOutputRequiresWrapping = true; - } + ["result"] = schemaNode + }, + ["required"] = new JsonArray { (JsonNode)"result" } + }; - outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); + structuredOutputRequiresWrapping = true; } - return outputSchema; + return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); } private JsonNode? CreateStructuredResponse(object? aiFunctionResult) diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs new file mode 100644 index 000000000..525972014 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs @@ -0,0 +1,167 @@ +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", UseStructuredContent = true, 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 9a49e72f9..87d5bdc6b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -577,11 +577,11 @@ public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() } [Fact] - public void OutputSchemaType_ViaAttribute_ProducesExpectedSchema() + public void CallToolResultOfT_ProducesExpectedOutputSchema() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, + () => new CallToolResult { Content = new Person("Alice", 30) }, new() { SerializerOptions = serOpts }); Assert.NotNull(tool.ProtocolTool.OutputSchema); @@ -592,11 +592,11 @@ public void OutputSchemaType_ViaAttribute_ProducesExpectedSchema() } [Fact] - public async Task OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCorrectly() + public async Task CallToolResultOfT_SerializesContentCorrectly() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [new TextContentBlock { Text = "hello" }] }, + () => new CallToolResult { Content = new Person("Alice", 30) }, new() { SerializerOptions = serOpts }); Assert.NotNull(tool.ProtocolTool.OutputSchema); @@ -608,51 +608,117 @@ public async Task OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCor }; var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken); - Assert.Equal("hello", Assert.IsType(toolResult.Content[0]).Text); + + // 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 void OutputSchemaType_ViaAttribute_WithUseStructuredContent_ProducesExpectedSchema() + public async Task CallToolResultOfT_PropagatesIsError() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - [McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, + () => new CallToolResult { Content = "something went wrong", IsError = true }, 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 _)); + 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 void OutputSchemaType_ViaAttribute_WithUseStructuredContentFalse_StillProducesSchema() + public async Task CallToolResultOfT_AsyncMethod_ProducesExpectedSchema() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create( - [McpServerTool(UseStructuredContent = false, OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, + async () => + { + await Task.CompletedTask; + return new CallToolResult { Content = new Person("Charlie", 35) }; + }, new() { SerializerOptions = serOpts }); - // OutputSchemaType forces UseStructuredContent to true even if explicitly set to false 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 OutputSchema_ViaOptions_OverridesAttributeOutputSchemaType() + 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( - [McpServerTool(OutputSchemaType = typeof(Person))] () => new CallToolResult() { Content = [] }, - new() { OutputSchema = customDoc.RootElement }); + () => 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() { From dd3e73b0997338acba1aca6659e88cb490132ecc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:28:48 +0000 Subject: [PATCH 08/14] Use last content block instead of first in CallToolAsync Use result.Content[^1] instead of result.Content[0] for both error message extraction and text content fallback deserialization. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 4e08cdcae..614ebf4eb 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -933,7 +933,7 @@ public ValueTask CallToolAsync( /// /// 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 + /// as . Otherwise, if the result has text content, the text of the last /// is deserialized as . /// /// @@ -952,7 +952,7 @@ public async ValueTask CallToolAsync( if (result.IsError is true) { - string errorMessage = result.Content.Count > 0 && result.Content[0] is TextContentBlock textBlock + string errorMessage = result.Content.Count > 0 && result.Content[^1] is TextContentBlock textBlock ? textBlock.Text : "The tool call returned an error."; throw new McpException(errorMessage); @@ -967,7 +967,7 @@ public async ValueTask CallToolAsync( return JsonSerializer.Deserialize(structuredContent, typeInfo)!; } - if (result.Content.Count > 0 && result.Content[0] is TextContentBlock textContent) + if (result.Content.Count > 0 && result.Content[^1] is TextContentBlock textContent) { return JsonSerializer.Deserialize(textContent.Text, typeInfo)!; } From 8930269a5d2dc52d18555c1359bd71d998b36de8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:34:04 +0000 Subject: [PATCH 09/14] Remove ICallToolResultTyped, move conversion logic to AIFunctionMcpServerTool Replace the ICallToolResultTyped interface (which contained serialization logic) with a minimal ICallToolResultTypedContent interface that only exposes raw content data. The serialization/conversion logic now lives in AIFunctionMcpServerTool's ConvertCallToolResultOfT method where it's consumed. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Protocol/CallToolResultOfT.cs | 30 +++++------------- .../Server/AIFunctionMcpServerTool.cs | 31 ++++++++++++++++++- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs index 848a72513..e6c74063c 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Protocol; @@ -23,7 +22,7 @@ namespace ModelContextProtocol.Protocol; /// the SDK to handle serialization of a strongly-typed result. /// /// -public sealed class CallToolResult : ICallToolResultTyped +public sealed class CallToolResult : ICallToolResultTypedContent { /// /// Gets or sets the typed content returned by the tool. @@ -58,30 +57,15 @@ public sealed class CallToolResult : ICallToolResultTyped public JsonObject? Meta { get; set; } /// - CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) - { - var typeInfo = serializerOptions.GetTypeInfo(typeof(T)); - - string json = JsonSerializer.Serialize(Content, typeInfo); - JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, typeInfo); - - return new() - { - Content = [new TextContentBlock { Text = json }], - StructuredContent = structuredContent, - IsError = IsError, - Meta = Meta, - }; - } + (object? Content, Type ContentType, bool? IsError, JsonObject? Meta) ICallToolResultTypedContent.GetContent() => + (Content, typeof(T), IsError, Meta); } /// -/// Internal interface for converting strongly-typed tool results to . +/// Internal interface for accessing the content of a without reflection. /// -internal interface ICallToolResultTyped +internal interface ICallToolResultTypedContent { - /// - /// Converts the strongly-typed result to a . - /// - CallToolResult ToCallToolResult(JsonSerializerOptions serializerOptions); + /// Gets the content, its type, the error flag, and metadata. + (object? Content, Type ContentType, bool? IsError, JsonObject? Meta) GetContent(); } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index f351696d6..497cf8ebf 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -320,7 +320,7 @@ public override async ValueTask InvokeAsync( CallToolResult callToolResponse => callToolResponse, - ICallToolResultTyped typedResult => typedResult.ToCallToolResult(AIFunction.JsonSerializerOptions), + _ when ConvertCallToolResultOfT(result, AIFunction.JsonSerializerOptions) is { } converted => converted, _ => new() { @@ -644,4 +644,33 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer IsError = allErrorContent && hasAny }; } + + /// + /// If is a , converts it to a . + /// + private static CallToolResult? ConvertCallToolResultOfT(object result, JsonSerializerOptions serializerOptions) + { + Type type = result.GetType(); + if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(CallToolResult<>)) + { + return null; + } + + // Use the internal accessor to get the untyped content, IsError, and Meta + // without reflection, avoiding trimming warnings. + var (content, contentType, isError, meta) = ((ICallToolResultTypedContent)result).GetContent(); + + var typeInfo = serializerOptions.GetTypeInfo(contentType); + + string json = JsonSerializer.Serialize(content, typeInfo); + JsonNode? structuredContent = JsonSerializer.SerializeToNode(content, typeInfo); + + return new() + { + Content = [new TextContentBlock { Text = json }], + StructuredContent = structuredContent, + IsError = isError, + Meta = meta, + }; + } } \ No newline at end of file From 6db528767d59cda96cccea526eb7c05e68a2194e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 11:44:49 +0000 Subject: [PATCH 10/14] Remove UseStructuredContent from McpServerToolCreateOptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the presence of OutputSchema to indicate structured output. Keep UseStructuredContent on the attribute — when set, DeriveOptions generates the OutputSchema from the method's return type. Tests updated accordingly. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 44 ++++++------------- .../Server/McpServerToolCreateOptions.cs | 22 ++-------- .../Server/CallToolResultOfTTests.cs | 2 +- .../Server/McpServerToolTests.cs | 29 ++++++------ 4 files changed, 30 insertions(+), 67 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 497cf8ebf..adbc6766f 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -204,13 +204,19 @@ 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; } + + // When the attribute enables structured content, generate the output schema from the return type + if (toolAttr.UseStructuredContent) + { + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(method.ReturnType, + serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, + inferenceOptions: newOptions.SchemaCreateOptions); + } } if (method.GetCustomAttribute() is { } descAttr) @@ -221,16 +227,9 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe // Set metadata if not already provided newOptions.Metadata ??= CreateMetadata(method); - // Force UseStructuredContent when OutputSchema is explicitly provided - if (newOptions.OutputSchema is not null) - { - newOptions.UseStructuredContent = true; - } - // If the method returns CallToolResult, automatically use T for the output schema if (GetCallToolResultContentType(method) is { } contentType) { - newOptions.UseStructuredContent = true; newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(contentType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); @@ -476,16 +475,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; } @@ -527,24 +526,7 @@ schema.ValueKind is not JsonValueKind.Object || { structuredOutputRequiresWrapping = false; - // Determine the raw output schema to use. - JsonElement? rawSchema = null; - - if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema) - { - Debug.Assert(toolCreateOptions.UseStructuredContent, "UseStructuredContent should be true when OutputSchema is set."); - rawSchema = explicitSchema; - } - else if (toolCreateOptions?.UseStructuredContent is not true) - { - return null; - } - else - { - rawSchema = function.ReturnJsonSchema; - } - - if (rawSchema is not JsonElement outputSchema) + if (toolCreateOptions?.OutputSchema is not JsonElement outputSchema) { return null; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index c702953f4..b3a6b5c83 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -117,23 +117,6 @@ 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. - /// - /// - /// The default is . - /// - /// - /// - /// When enabled, the tool will attempt to populate the - /// and provide structured content in the property. - /// - /// - /// Setting to a non- value will automatically enable structured content. - /// - /// - public bool UseStructuredContent { get; set; } - /// /// Gets or sets a JSON Schema object to use as the tool's output schema. /// @@ -146,7 +129,9 @@ public sealed class McpServerToolCreateOptions /// output schema describing the shape of . /// /// - /// Setting this property to a non- value will automatically enable . + /// 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". @@ -233,7 +218,6 @@ internal McpServerToolCreateOptions Clone() => Idempotent = Idempotent, OpenWorld = OpenWorld, ReadOnly = ReadOnly, - UseStructuredContent = UseStructuredContent, OutputSchema = OutputSchema, SerializerOptions = SerializerOptions, SchemaCreateOptions = SchemaCreateOptions, diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs index 525972014..ba18c3a4a 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs @@ -73,7 +73,7 @@ public async Task CallToolAsyncOfT_FallsBackToTextContent() JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; _toolCollection.Add(McpServerTool.Create( () => new PersonData { Name = "Bob", Age = 25 }, - new() { Name = "text_tool", UseStructuredContent = true, SerializerOptions = serOpts })); + new() { Name = "text_tool", SerializerOptions = serOpts })); StartServer(); var client = await CreateMcpClientForServer(); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 87d5bdc6b..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()) { @@ -547,12 +547,11 @@ public void OutputSchema_ViaOptions_SetsSchemaDirectly() } [Fact] - public void OutputSchema_ViaOptions_ForcesStructuredContentEvenIfDisabled() + 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() { - UseStructuredContent = false, OutputSchema = schemaDoc.RootElement, }); @@ -567,7 +566,6 @@ public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema() JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create(() => new Person("Alice", 30), new() { - UseStructuredContent = true, OutputSchema = overrideDoc.RootElement, SerializerOptions = serOpts, }); @@ -965,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] @@ -1011,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); From 84dcb4696299977ad7163cb50dd0cbd2b8882a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 12:02:14 +0000 Subject: [PATCH 11/14] Revert to ICallToolResultTyped with ToCallToolResult conversion method Restore the previous interface design where CallToolResult implements ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions) with the serialization logic inside the generic type. Remove the data-only ICallToolResultTypedContent interface and ConvertCallToolResultOfT helper. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Protocol/CallToolResultOfT.cs | 30 +++++++++++++----- .../Server/AIFunctionMcpServerTool.cs | 31 +------------------ 2 files changed, 24 insertions(+), 37 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs index e6c74063c..848a72513 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Protocol; @@ -22,7 +23,7 @@ namespace ModelContextProtocol.Protocol; /// the SDK to handle serialization of a strongly-typed result. /// /// -public sealed class CallToolResult : ICallToolResultTypedContent +public sealed class CallToolResult : ICallToolResultTyped { /// /// Gets or sets the typed content returned by the tool. @@ -57,15 +58,30 @@ public sealed class CallToolResult : ICallToolResultTypedContent public JsonObject? Meta { get; set; } /// - (object? Content, Type ContentType, bool? IsError, JsonObject? Meta) ICallToolResultTypedContent.GetContent() => - (Content, typeof(T), IsError, Meta); + CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) + { + var typeInfo = serializerOptions.GetTypeInfo(typeof(T)); + + string json = JsonSerializer.Serialize(Content, typeInfo); + JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, typeInfo); + + return new() + { + Content = [new TextContentBlock { Text = json }], + StructuredContent = structuredContent, + IsError = IsError, + Meta = Meta, + }; + } } /// -/// Internal interface for accessing the content of a without reflection. +/// Internal interface for converting strongly-typed tool results to . /// -internal interface ICallToolResultTypedContent +internal interface ICallToolResultTyped { - /// Gets the content, its type, the error flag, and metadata. - (object? Content, Type ContentType, bool? IsError, JsonObject? Meta) GetContent(); + /// + /// 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 adbc6766f..5f99062e0 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -319,7 +319,7 @@ public override async ValueTask InvokeAsync( CallToolResult callToolResponse => callToolResponse, - _ when ConvertCallToolResultOfT(result, AIFunction.JsonSerializerOptions) is { } converted => converted, + _ when result is ICallToolResultTyped typed => typed.ToCallToolResult(AIFunction.JsonSerializerOptions), _ => new() { @@ -626,33 +626,4 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer IsError = allErrorContent && hasAny }; } - - /// - /// If is a , converts it to a . - /// - private static CallToolResult? ConvertCallToolResultOfT(object result, JsonSerializerOptions serializerOptions) - { - Type type = result.GetType(); - if (!type.IsGenericType || type.GetGenericTypeDefinition() != typeof(CallToolResult<>)) - { - return null; - } - - // Use the internal accessor to get the untyped content, IsError, and Meta - // without reflection, avoiding trimming warnings. - var (content, contentType, isError, meta) = ((ICallToolResultTypedContent)result).GetContent(); - - var typeInfo = serializerOptions.GetTypeInfo(contentType); - - string json = JsonSerializer.Serialize(content, typeInfo); - JsonNode? structuredContent = JsonSerializer.SerializeToNode(content, typeInfo); - - return new() - { - Content = [new TextContentBlock { Text = json }], - StructuredContent = structuredContent, - IsError = isError, - Meta = meta, - }; - } } \ No newline at end of file From 17c3e9f24a5f187a943f6bf5dd8ed0775259ef02 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:27:17 +0000 Subject: [PATCH 12/14] Address review feedback: serialize once, fix schema inference order, minimize diff - Serialize to JsonNode once, use ToJsonString for text (avoid double serialization) - Fix UseStructuredContent + CallToolResult: use T for schema, not full return type - Simplify pattern match: ICallToolResultTyped typed => instead of _ when - Change GetCallToolResultContentType to accept Type instead of MethodInfo - Revert CreateOutputSchema back to original structure (remove EnsureObjectSchema helper) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Protocol/CallToolResultOfT.cs | 7 +- .../Server/AIFunctionMcpServerTool.cs | 92 +++++++++---------- 2 files changed, 43 insertions(+), 56 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs index 848a72513..09a20a74e 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -60,14 +60,11 @@ public sealed class CallToolResult : ICallToolResultTyped /// CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) { - var typeInfo = serializerOptions.GetTypeInfo(typeof(T)); - - string json = JsonSerializer.Serialize(Content, typeInfo); - JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, typeInfo); + JsonNode? structuredContent = JsonSerializer.SerializeToNode(Content, serializerOptions.GetTypeInfo(typeof(T))); return new() { - Content = [new TextContentBlock { Text = json }], + Content = [new TextContentBlock { Text = structuredContent?.ToJsonString(serializerOptions) ?? "null" }], StructuredContent = structuredContent, IsError = IsError, Meta = Meta, diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 5f99062e0..38c412c17 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -210,10 +210,12 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Execution.TaskSupport ??= taskSupport; } - // When the attribute enables structured content, generate the output schema from the return type + // When the attribute enables structured content, generate the output schema from the return type. + // If the return type is CallToolResult, use T rather than the full return type. if (toolAttr.UseStructuredContent) { - newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(method.ReturnType, + Type outputType = GetCallToolResultContentType(method.ReturnType) ?? method.ReturnType; + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); } @@ -228,7 +230,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Metadata ??= CreateMetadata(method); // If the method returns CallToolResult, automatically use T for the output schema - if (GetCallToolResultContentType(method) is { } contentType) + if (GetCallToolResultContentType(method.ReturnType) is { } contentType) { newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(contentType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, @@ -319,7 +321,7 @@ public override async ValueTask InvokeAsync( CallToolResult callToolResponse => callToolResponse, - _ when result is ICallToolResultTyped typed => typed.ToCallToolResult(AIFunction.JsonSerializerOptions), + ICallToolResultTyped typed => typed.ToCallToolResult(AIFunction.JsonSerializerOptions), _ => new() { @@ -378,27 +380,23 @@ private static bool IsAsyncMethod(MethodInfo method) } /// - /// If the method's return type is (possibly wrapped in + /// If the specified type is (possibly wrapped in /// or ), returns the T type argument. Otherwise, returns . /// - private static Type? GetCallToolResultContentType(MethodInfo method) + private static Type? GetCallToolResultContentType(Type returnType) { - Type t = method.ReturnType; - - // Unwrap Task or ValueTask - if (t.IsGenericType) + if (returnType.IsGenericType) { - Type genericDef = t.GetGenericTypeDefinition(); + Type genericDef = returnType.GetGenericTypeDefinition(); if (genericDef == typeof(Task<>) || genericDef == typeof(ValueTask<>)) { - t = t.GetGenericArguments()[0]; + returnType = returnType.GetGenericArguments()[0]; } } - // Check if the unwrapped type is CallToolResult - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(CallToolResult<>)) + if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(CallToolResult<>)) { - return t.GetGenericArguments()[0]; + return returnType.GetGenericArguments()[0]; } return null; @@ -531,49 +529,41 @@ schema.ValueKind is not JsonValueKind.Object || return null; } - return EnsureObjectSchema(outputSchema, ref structuredOutputRequiresWrapping); - } - - /// - /// Ensures the schema is a valid MCP output schema (type "object"). Wraps non-object schemas in an envelope. - /// - private static JsonElement EnsureObjectSchema(JsonElement outputSchema, ref bool structuredOutputRequiresWrapping) - { - if (outputSchema.ValueKind is JsonValueKind.Object && - outputSchema.TryGetProperty("type", out JsonElement typeProperty) && - typeProperty.ValueKind is JsonValueKind.String && - typeProperty.GetString() is "object") + if (outputSchema.ValueKind is not JsonValueKind.Object || + !outputSchema.TryGetProperty("type", out JsonElement typeProperty) || + typeProperty.ValueKind is not JsonValueKind.String || + typeProperty.GetString() is not "object") { - return outputSchema; - } - - // If the output schema is not an object, need to modify to be a valid MCP output schema. - JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + // If the output schema is not an object, need to modify to be a valid MCP output schema. + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - if (schemaNode is JsonObject objSchema && - objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && - typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) - { - // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. - objSchema["type"] = "object"; - } - else - { - // For anything else, wrap the schema in an envelope with a "result" property. - schemaNode = new JsonObject + if (schemaNode is JsonObject objSchema && + objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && + typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) + { + // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. + objSchema["type"] = "object"; + } + else { - ["type"] = "object", - ["properties"] = new JsonObject + // For anything else, wrap the schema in an envelope with a "result" property. + schemaNode = new JsonObject { - ["result"] = schemaNode - }, - ["required"] = new JsonArray { (JsonNode)"result" } - }; + ["type"] = "object", + ["properties"] = new JsonObject + { + ["result"] = schemaNode + }, + ["required"] = new JsonArray { (JsonNode)"result" } + }; + + structuredOutputRequiresWrapping = true; + } - structuredOutputRequiresWrapping = true; + outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); } - return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); + return outputSchema; } private JsonNode? CreateStructuredResponse(object? aiFunctionResult) From f3d00f07430207c476ea922d9cd08ef1c11c9795 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:04:03 +0000 Subject: [PATCH 13/14] Return CallToolResult from CallToolAsync, use ToString and FirstOrDefault - CallToolAsync now returns CallToolResult instead of T - No longer throws on IsError; caller can inspect IsError on the result - Use OfType().FirstOrDefault() instead of indexing - Use ToString() instead of ToJsonString(serializerOptions) in ToCallToolResult - Updated tests accordingly Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 53 +++++++++---------- .../Protocol/CallToolResultOfT.cs | 2 +- .../Server/CallToolResultOfTTests.cs | 37 +++++++------ 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 614ebf4eb..1e5a06785 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -915,7 +915,7 @@ public ValueTask CallToolAsync( } /// - /// Invokes a tool on the server and deserializes the result as a strongly-typed . + /// Invokes a tool on the server and deserializes the result as a strongly-typed . /// /// The type to deserialize the tool's structured content or text content into. /// The name of the tool to call on the server. @@ -923,25 +923,19 @@ public ValueTask CallToolAsync( /// 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 result from the tool execution. + /// A containing the deserialized content, error state, and metadata. /// is . - /// - /// The request failed, the server returned an error response, or was . - /// + /// The request failed or the server returned an error response. /// 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 last + /// as . Otherwise, if the result has text content, the text of the first /// is deserialized as . /// - /// - /// If is , an is thrown - /// with the error content details. - /// /// - public async ValueTask CallToolAsync( + public async ValueTask> CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, IProgress? progress = null, @@ -950,29 +944,30 @@ public async ValueTask CallToolAsync( { CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false); - if (result.IsError is true) - { - string errorMessage = result.Content.Count > 0 && result.Content[^1] is TextContentBlock textBlock - ? textBlock.Text - : "The tool call returned an error."; - throw new McpException(errorMessage); - } - - var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - JsonTypeInfo typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T)); + T? content = default; - // Prefer StructuredContent if available, otherwise fall back to text content - if (result.StructuredContent is { } structuredContent) + if (result.IsError is not true) { - return JsonSerializer.Deserialize(structuredContent, typeInfo)!; - } + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; + JsonTypeInfo typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T)); - if (result.Content.Count > 0 && result.Content[^1] is TextContentBlock textContent) - { - return JsonSerializer.Deserialize(textContent.Text, typeInfo)!; + // Prefer StructuredContent if available, otherwise fall back to text content + if (result.StructuredContent is { } structuredContent) + { + content = JsonSerializer.Deserialize(structuredContent, typeInfo); + } + else if (result.Content.OfType().FirstOrDefault() is { } textContent) + { + content = JsonSerializer.Deserialize(textContent.Text, typeInfo); + } } - throw new McpException("The tool call did not return any content that could be deserialized."); + return new() + { + Content = content, + IsError = result.IsError, + Meta = result.Meta, + }; } /// diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs index 09a20a74e..eb96faea3 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -64,7 +64,7 @@ CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions seria return new() { - Content = [new TextContentBlock { Text = structuredContent?.ToJsonString(serializerOptions) ?? "null" }], + Content = [new TextContentBlock { Text = structuredContent?.ToString() ?? "null" }], StructuredContent = structuredContent, IsError = IsError, Meta = Meta, diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs index ba18c3a4a..1ecb91d02 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs @@ -41,13 +41,13 @@ public async Task CallToolAsyncOfT_DeserializesStructuredContent() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.Equal("Alice", result.Name); - Assert.Equal(30, result.Age); + Assert.NotNull(result.Content); + Assert.Equal("Alice", result.Content.Name); + Assert.Equal(30, result.Content.Age); } [Fact] - public async Task CallToolAsyncOfT_ThrowsOnIsError() + public async Task CallToolAsyncOfT_PropagatesIsError() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; _toolCollection.Add(McpServerTool.Create( @@ -57,13 +57,12 @@ public async Task CallToolAsyncOfT_ThrowsOnIsError() StartServer(); var client = await CreateMcpClientForServer(); - var ex = await Assert.ThrowsAsync( - () => client.CallToolAsync( - "error_tool", - options: new() { JsonSerializerOptions = serOpts }, - cancellationToken: TestContext.Current.CancellationToken).AsTask()); + var result = await client.CallToolAsync( + "error_tool", + options: new() { JsonSerializerOptions = serOpts }, + cancellationToken: TestContext.Current.CancellationToken); - Assert.Contains("something went wrong", ex.Message); + Assert.True(result.IsError); } [Fact] @@ -83,9 +82,9 @@ public async Task CallToolAsyncOfT_FallsBackToTextContent() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.Equal("Bob", result.Name); - Assert.Equal(25, result.Age); + Assert.NotNull(result.Content); + Assert.Equal("Bob", result.Content.Name); + Assert.Equal(25, result.Content.Age); } [Fact] @@ -129,9 +128,9 @@ public async Task CallToolAsyncOfT_WithAsyncTool_Works() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.Equal("Charlie", result.Name); - Assert.Equal(35, result.Age); + Assert.NotNull(result.Content); + Assert.Equal("Charlie", result.Content.Name); + Assert.Equal(35, result.Content.Age); } [Fact] @@ -154,9 +153,9 @@ public async Task CallToolAsyncOfT_WithArguments_Works() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result); - Assert.Equal("Diana", result.Name); - Assert.Equal(28, result.Age); + Assert.NotNull(result.Content); + Assert.Equal("Diana", result.Content.Name); + Assert.Equal(28, result.Content.Age); } private class PersonData From 24bbac3dfb92205572b8e8861ae69bc29124480c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:56:26 +0000 Subject: [PATCH 14/14] CallToolResult derives from Result, CallToolAsync returns T and throws on IsError, consolidate schema handling - CallToolResult now derives from Result (inherits Meta), removed duplicate Meta property - CallToolAsync returns T? instead of CallToolResult, throws McpException on IsError - Consolidated schema handling in DeriveOptions: unified UseStructuredContent and CallToolResult auto-detection into a single block - Updated XML docs for cref changes (Result.Meta) Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Client/McpClient.Methods.cs | 47 ++++++++++--------- .../Protocol/CallToolResultOfT.cs | 10 +--- .../Server/AIFunctionMcpServerTool.cs | 25 +++++----- .../Server/McpServerTool.cs | 2 +- .../Server/McpServerToolAttribute.cs | 2 +- .../Server/CallToolResultOfTTests.cs | 32 ++++++------- 6 files changed, 56 insertions(+), 62 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 1e5a06785..d1eab68ef 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -915,7 +915,7 @@ public ValueTask CallToolAsync( } /// - /// Invokes a tool on the server and deserializes the result as a strongly-typed . + /// 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. @@ -923,9 +923,9 @@ public ValueTask CallToolAsync( /// 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 . - /// A containing the deserialized content, error state, and metadata. + /// The deserialized content of the tool result. /// is . - /// The request failed or the server returned an error response. + /// The request failed, the server returned an error response, or is . /// The result content could not be deserialized as . /// /// @@ -934,8 +934,12 @@ public ValueTask CallToolAsync( /// 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( + public async ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, IProgress? progress = null, @@ -944,30 +948,27 @@ public async ValueTask> CallToolAsync( { CallToolResult result = await CallToolAsync(toolName, arguments, progress, options, cancellationToken).ConfigureAwait(false); - T? content = default; - - if (result.IsError is not true) + if (result.IsError is true) { - var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - JsonTypeInfo typeInfo = (JsonTypeInfo)serializerOptions.GetTypeInfo(typeof(T)); + string errorMessage = result.Content.OfType().FirstOrDefault()?.Text ?? "Tool call failed."; + throw new McpException(errorMessage); + } - // Prefer StructuredContent if available, otherwise fall back to text content - if (result.StructuredContent is { } structuredContent) - { - content = JsonSerializer.Deserialize(structuredContent, typeInfo); - } - else if (result.Content.OfType().FirstOrDefault() is { } textContent) - { - content = JsonSerializer.Deserialize(textContent.Text, typeInfo); - } + 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); } - return new() + if (result.Content.OfType().FirstOrDefault() is { } textContent) { - Content = content, - IsError = result.IsError, - Meta = result.Meta, - }; + return JsonSerializer.Deserialize(textContent.Text, typeInfo); + } + + return default; } /// diff --git a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs index eb96faea3..a3c01c719 100644 --- a/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs +++ b/src/ModelContextProtocol.Core/Protocol/CallToolResultOfT.cs @@ -23,7 +23,7 @@ namespace ModelContextProtocol.Protocol; /// the SDK to handle serialization of a strongly-typed result. /// /// -public sealed class CallToolResult : ICallToolResultTyped +public sealed class CallToolResult : Result, ICallToolResultTyped { /// /// Gets or sets the typed content returned by the tool. @@ -49,14 +49,6 @@ public sealed class CallToolResult : ICallToolResultTyped /// public bool? IsError { get; set; } - /// - /// Gets or sets metadata reserved by MCP for protocol-level metadata. - /// - /// - /// Implementations must not make assumptions about its contents. - /// - public JsonObject? Meta { get; set; } - /// CallToolResult ICallToolResultTyped.ToCallToolResult(JsonSerializerOptions serializerOptions) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 38c412c17..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; @@ -210,15 +211,7 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Execution.TaskSupport ??= taskSupport; } - // When the attribute enables structured content, generate the output schema from the return type. - // If the return type is CallToolResult, use T rather than the full return type. - if (toolAttr.UseStructuredContent) - { - Type outputType = GetCallToolResultContentType(method.ReturnType) ?? method.ReturnType; - newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputType, - serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - inferenceOptions: newOptions.SchemaCreateOptions); - } + useStructuredContent = toolAttr.UseStructuredContent; } if (method.GetCustomAttribute() is { } descAttr) @@ -229,10 +222,18 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe // Set metadata if not already provided newOptions.Metadata ??= CreateMetadata(method); - // If the method returns CallToolResult, automatically use T for the output schema - if (GetCallToolResultContentType(method.ReturnType) is { } contentType) + // 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(contentType, + newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType, serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions, inferenceOptions: newOptions.SchemaCreateOptions); } diff --git a/src/ModelContextProtocol.Core/Server/McpServerTool.cs b/src/ModelContextProtocol.Core/Server/McpServerTool.cs index 379187de9..69af7dfe4 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerTool.cs @@ -126,7 +126,7 @@ namespace ModelContextProtocol.Server; /// /// The T content is serialized to JSON and used as both a /// and as the . The -/// and properties are propagated to the resulting . +/// and properties are propagated to the resulting . /// The T type argument is also used to infer the . /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index 79e22d1f3..cf1026b7a 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -122,7 +122,7 @@ namespace ModelContextProtocol.Server; /// /// The T content is serialized to JSON and used as both a /// and as the . The -/// and properties are propagated to the resulting . +/// and properties are propagated to the resulting . /// The T type argument is also used to infer the . /// /// diff --git a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs index 1ecb91d02..3b104ed52 100644 --- a/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/CallToolResultOfTTests.cs @@ -41,13 +41,13 @@ public async Task CallToolAsyncOfT_DeserializesStructuredContent() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result.Content); - Assert.Equal("Alice", result.Content.Name); - Assert.Equal(30, result.Content.Age); + Assert.NotNull(result); + Assert.Equal("Alice", result.Name); + Assert.Equal(30, result.Age); } [Fact] - public async Task CallToolAsyncOfT_PropagatesIsError() + public async Task CallToolAsyncOfT_ThrowsOnIsError() { JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; _toolCollection.Add(McpServerTool.Create( @@ -57,12 +57,12 @@ public async Task CallToolAsyncOfT_PropagatesIsError() StartServer(); var client = await CreateMcpClientForServer(); - var result = await client.CallToolAsync( + var ex = await Assert.ThrowsAsync(() => client.CallToolAsync( "error_tool", options: new() { JsonSerializerOptions = serOpts }, - cancellationToken: TestContext.Current.CancellationToken); + cancellationToken: TestContext.Current.CancellationToken).AsTask()); - Assert.True(result.IsError); + Assert.Contains("something went wrong", ex.Message); } [Fact] @@ -82,9 +82,9 @@ public async Task CallToolAsyncOfT_FallsBackToTextContent() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result.Content); - Assert.Equal("Bob", result.Content.Name); - Assert.Equal(25, result.Content.Age); + Assert.NotNull(result); + Assert.Equal("Bob", result.Name); + Assert.Equal(25, result.Age); } [Fact] @@ -128,9 +128,9 @@ public async Task CallToolAsyncOfT_WithAsyncTool_Works() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result.Content); - Assert.Equal("Charlie", result.Content.Name); - Assert.Equal(35, result.Content.Age); + Assert.NotNull(result); + Assert.Equal("Charlie", result.Name); + Assert.Equal(35, result.Age); } [Fact] @@ -153,9 +153,9 @@ public async Task CallToolAsyncOfT_WithArguments_Works() options: new() { JsonSerializerOptions = serOpts }, cancellationToken: TestContext.Current.CancellationToken); - Assert.NotNull(result.Content); - Assert.Equal("Diana", result.Content.Name); - Assert.Equal(28, result.Content.Age); + Assert.NotNull(result); + Assert.Equal("Diana", result.Name); + Assert.Equal(28, result.Age); } private class PersonData