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
///
- /// 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