Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
301550d
Initial plan
Copilot Feb 14, 2026
b111284
Add OutputSchemaType to McpServerToolAttribute and OutputSchema to Mc…
Copilot Feb 14, 2026
5c82239
Refactor OutputSchemaType check to use ??= pattern
Copilot Feb 14, 2026
444dcfa
Set UseStructuredContent=true when OutputSchemaType is set, remove Dy…
Copilot Feb 14, 2026
a6b7ef5
Address review feedback: assert UseStructuredContent, use lambdas in …
Copilot Feb 14, 2026
1d9f40c
Merge branch 'main' into copilot/support-output-schema-independently
stephentoub Feb 14, 2026
dcfff8e
Add CallToolResult<T> and CallToolAsync<T>, remove OutputSchemaType f…
Copilot Feb 18, 2026
b109728
Add tests for CallToolResult<T>, CallToolAsync<T>, and update existin…
Copilot Feb 18, 2026
dd3e73b
Use last content block instead of first in CallToolAsync<T>
Copilot Feb 18, 2026
8930269
Remove ICallToolResultTyped, move conversion logic to AIFunctionMcpSe…
Copilot Feb 18, 2026
6db5287
Remove UseStructuredContent from McpServerToolCreateOptions
Copilot Feb 18, 2026
84dcb46
Revert to ICallToolResultTyped with ToCallToolResult conversion method
Copilot Feb 18, 2026
17c3e9f
Address review feedback: serialize once, fix schema inference order, …
Copilot Feb 18, 2026
f3d00f0
Return CallToolResult<T> from CallToolAsync<T>, use ToString and Firs…
Copilot Feb 18, 2026
24bbac3
CallToolResult<T> derives from Result, CallToolAsync<T> returns T and…
Copilot Feb 18, 2026
c45278b
Merge branch 'main' into copilot/support-output-schema-independently
stephentoub Feb 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 23 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -240,11 +240,34 @@ public bool ReadOnly
/// The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// <para>
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
/// </para>
/// <para>
/// Setting <see cref="OutputSchemaType"/> will automatically enable structured content.
/// </para>
/// </remarks>
public bool UseStructuredContent { get; set; }

/// <summary>
/// Gets or sets the type to use for generating the tool's output schema.
/// </summary>
/// <remarks>
/// <para>
/// When set, the SDK generates the <see cref="Tool.OutputSchema"/> from this type instead of
/// inferring it from the method's return type. This is particularly useful when the method
/// returns <see cref="CallToolResult"/> directly (for example, to control
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
/// </para>
/// <para>
/// Setting this property automatically enables <see cref="UseStructuredContent"/>.
/// </para>
/// </remarks>
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
public Type? OutputSchemaType { get; set; }

/// <summary>
/// Gets or sets the source URI for the tool's icon.
/// </summary>
Expand Down
26 changes: 26 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,36 @@ public sealed class McpServerToolCreateOptions
/// The default is <see langword="false"/>.
/// </value>
/// <remarks>
/// <para>
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
/// </para>
/// <para>
/// Setting <see cref="OutputSchema"/> will automatically enable structured content.
/// </para>
/// </remarks>
public bool UseStructuredContent { get; set; }

/// <summary>
/// Gets or sets a JSON Schema object to use as the tool's output schema.
/// </summary>
/// <remarks>
/// <para>
/// When set, this schema is used directly as the <see cref="Tool.OutputSchema"/> instead of
/// inferring it from the method's return type. This is particularly useful when the method
/// returns <see cref="CallToolResult"/> directly (for example, to control
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
/// </para>
/// <para>
/// Setting this property automatically enables <see cref="UseStructuredContent"/>.
/// </para>
/// <para>
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
/// </para>
/// </remarks>
public JsonElement? OutputSchema { get; set; }

/// <summary>
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
/// </summary>
Expand Down Expand Up @@ -209,6 +234,7 @@ internal McpServerToolCreateOptions Clone() =>
OpenWorld = OpenWorld,
ReadOnly = ReadOnly,
UseStructuredContent = UseStructuredContent,
OutputSchema = OutputSchema,
SerializerOptions = SerializerOptions,
SchemaCreateOptions = SchemaCreateOptions,
Metadata = Metadata,
Expand Down
140 changes: 140 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,146 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(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<McpServer> srv = new();
var ctx = new RequestContext<CallToolRequestParams>(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<TextContentBlock>(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)]
Expand Down