Support specifying OutputSchema independently of return type for tools returning CallToolResult (#1425)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot 2026-03-22 21:19:29 -04:00 • committed by GitHub
commit 2958a75894
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 335 additions and 1 deletions

View file

@ -206,6 +206,13 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
if (toolAttr.OutputSchemaType is Type outputSchemaType)
{
newOptions.OutputSchema ??= AIJsonUtilities.CreateJsonSchema(outputSchemaType,
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
inferenceOptions: newOptions.SchemaCreateOptions);
}
if (toolAttr._taskSupport is { } taskSupport)
{
newOptions.Execution ??= new ToolExecution();
@ -487,7 +494,17 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
return null;
}
if (function.ReturnJsonSchema is not JsonElement outputSchema)
// Explicit OutputSchema takes precedence over AIFunction's return schema.
JsonElement outputSchema;
if (toolCreateOptions.OutputSchema is { } explicitSchema)
{
outputSchema = explicitSchema;
}
else if (function.ReturnJsonSchema is { } returnSchema)
{
outputSchema = returnSchema;
}
else
{
return null;
}

View file

@ -265,6 +265,27 @@ public sealed class McpServerToolAttribute : Attribute
/// </remarks>
public bool UseStructuredContent { get; set; }
/// <summary>
/// Gets or sets a <see cref="Type"/> from which to generate the tool's output schema.
/// </summary>
/// <value>
/// The default is <see langword="null"/>, which means the output schema is inferred from the return type.
/// </value>
/// <remarks>
/// <para>
/// When set, a JSON schema is generated from the specified <see cref="Type"/> and used as the
/// <see cref="Tool.OutputSchema"/> instead of the schema inferred from the tool method's return type.
/// This is particularly useful when a tool method returns <see cref="CallToolResult"/> directly
/// (to control properties like <see cref="Result.Meta"/>, <see cref="CallToolResult.IsError"/>,
/// or <see cref="CallToolResult.StructuredContent"/>) but still needs to advertise a meaningful output
/// schema to clients.
/// </para>
/// <para>
/// <see cref="UseStructuredContent"/> must also be set to <see langword="true"/> for this property to take effect.
/// </para>
/// </remarks>
public Type? OutputSchemaType { get; set; }
/// <summary>
/// Gets or sets the source URI for the tool's icon.
/// </summary>

View file

@ -129,6 +129,26 @@ public sealed class McpServerToolCreateOptions
/// </remarks>
public bool UseStructuredContent { get; set; }
/// <summary>
/// Gets or sets an explicit JSON schema to use as the tool's output schema.
/// </summary>
/// <value>
/// The default is <see langword="null"/>, which means the output schema is inferred from the return type.
/// </value>
/// <remarks>
/// <para>
/// When set, this schema is used as the <see cref="Tool.OutputSchema"/> instead of the schema
/// inferred from the tool method's return type. This is particularly useful when a tool method
/// returns <see cref="CallToolResult"/> directly (to control properties like <see cref="Result.Meta"/>,
/// <see cref="CallToolResult.IsError"/>, or <see cref="CallToolResult.StructuredContent"/>) but still
/// needs to advertise a meaningful output schema to clients.
/// </para>
/// <para>
/// <see cref="UseStructuredContent"/> must also be set to <see langword="true"/> for this property to take effect.
/// </para>
/// </remarks>
public JsonElement? OutputSchema { get; set; }
/// <summary>
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
/// </summary>
@ -209,6 +229,7 @@ public sealed class McpServerToolCreateOptions
OpenWorld = OpenWorld,
ReadOnly = ReadOnly,
UseStructuredContent = UseStructuredContent,
OutputSchema = OutputSchema,
SerializerOptions = SerializerOptions,
SchemaCreateOptions = SchemaCreateOptions,
Metadata = Metadata,

View file

@ -533,6 +533,273 @@ public partial class McpServerToolTests
Assert.Null(result.StructuredContent);
}
[Fact]
public void OutputSchema_Options_OverridesReturnTypeSchema()
{
// When OutputSchema is set on options, it should be used instead of the return type's schema
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
UseStructuredContent = true,
OutputSchema = outputSchema,
});
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("name", out _));
Assert.True(properties.TryGetProperty("age", out _));
}
[Fact]
public void OutputSchema_Options_WithCallToolResultReturn()
{
// When the tool returns CallToolResult, OutputSchema on options provides the advertised schema
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}},"required":["result"]}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new()
{
UseStructuredContent = true,
OutputSchema = outputSchema,
});
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 properties));
Assert.True(properties.TryGetProperty("result", out _));
}
[Fact]
public async Task OutputSchema_Options_CallToolResult_PreservesStructuredContent()
{
// When tool returns CallToolResult with StructuredContent, it's preserved in the response
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""").RootElement;
JsonElement structuredContent = JsonDocument.Parse("""{"value":42}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => new CallToolResult()
{
Content = [new TextContentBlock { Text = "42" }],
StructuredContent = structuredContent,
}, new()
{
Name = "tool",
UseStructuredContent = true,
OutputSchema = outputSchema,
});
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.NotNull(result.StructuredContent);
Assert.Equal(42, result.StructuredContent.Value.GetProperty("value").GetInt32());
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[Fact]
public void OutputSchema_Options_RequiresUseStructuredContent()
{
// OutputSchema without UseStructuredContent=true should not produce an output schema
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"name":{"type":"string"}}}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
UseStructuredContent = false,
OutputSchema = outputSchema,
});
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void OutputSchema_Options_NonObjectSchema_GetsWrapped()
{
// Non-object output schema should be wrapped in a "result" property envelope
JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
UseStructuredContent = true,
OutputSchema = outputSchema,
});
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 properties));
Assert.True(properties.TryGetProperty("result", out var resultProp));
Assert.Equal("string", resultProp.GetProperty("type").GetString());
}
[Fact]
public void OutputSchema_Options_NullableObjectSchema_BecomesObject()
{
// ["object", "null"] type should be simplified to just "object"
JsonElement outputSchema = JsonDocument.Parse("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
UseStructuredContent = true,
OutputSchema = outputSchema,
});
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
}
[Fact]
public void OutputSchema_Attribute_WithType_GeneratesSchema()
{
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
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 properties));
Assert.True(properties.TryGetProperty("name", out _));
Assert.True(properties.TryGetProperty("age", out _));
}
[Fact]
public async Task OutputSchema_Attribute_CallToolResult_PreservesStructuredContent()
{
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(result.StructuredContent);
Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString());
Assert.Equal(27, result.StructuredContent.Value.GetProperty("age").GetInt32());
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[Fact]
public void OutputSchema_Attribute_WithoutUseStructuredContent_NoSchema()
{
// If UseStructuredContent is false but OutputSchema type is set, no output schema should be generated
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaButNoStructuredContent, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
Assert.Null(tool.ProtocolTool.OutputSchema);
}
[Fact]
public void OutputSchema_Options_TakesPrecedenceOverAttribute()
{
// Options.OutputSchema should take precedence over attribute-derived schema
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}},"required":["custom"]}""").RootElement;
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new()
{
OutputSchema = outputSchema,
});
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
Assert.True(properties.TryGetProperty("custom", out _));
// Should not have Person's properties
Assert.False(properties.TryGetProperty("name", out _));
}
[Fact]
public void OutputSchema_Options_Clone_PreservesValue()
{
// Verify that Clone() preserves the OutputSchema property
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}""").RootElement;
McpServerTool tool1 = McpServerTool.Create(() => "result", new()
{
Name = "tool1",
UseStructuredContent = true,
OutputSchema = outputSchema,
});
// The output schema should be present since we set it
Assert.NotNull(tool1.ProtocolTool.OutputSchema);
Assert.True(tool1.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props));
Assert.True(props.TryGetProperty("x", out _));
}
[Fact]
public async Task OutputSchema_Options_PersonType_WithCallToolResult()
{
// Create output schema from Person type, tool returns CallToolResult with matching structured content
JsonSerializerOptions serializerOptions = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(typeof(Person), serializerOptions: serializerOptions);
Person person = new("Alice", 30);
JsonElement structuredContent = JsonSerializer.SerializeToElement(person, serializerOptions);
McpServerTool tool = McpServerTool.Create(() => new CallToolResult()
{
Content = [new TextContentBlock { Text = "Alice, 30" }],
StructuredContent = structuredContent,
}, new()
{
Name = "tool",
UseStructuredContent = true,
OutputSchema = outputSchema,
SerializerOptions = serializerOptions,
});
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.NotNull(result.StructuredContent);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[Fact]
public async Task OutputSchema_Options_OverridesReturnTypeSchema_InvokeAndValidate()
{
// OutputSchema overrides return type schema; result should match the original return type, but schema is the override
JsonSerializerOptions serializerOptions = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(typeof(Person), serializerOptions: serializerOptions);
McpServerTool tool = McpServerTool.Create(() => new Person("Bob", 25), new()
{
Name = "tool",
UseStructuredContent = true,
OutputSchema = outputSchema,
SerializerOptions = serializerOptions,
});
var mockServer = new Mock<McpServer>();
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
{
Params = new CallToolRequestParams { Name = "tool" },
};
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.NotNull(result.StructuredContent);
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
[McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))]
private static CallToolResult ToolWithOutputSchemaAttribute()
{
var person = new Person("John", 27);
return new CallToolResult()
{
Content = [new TextContentBlock { Text = $"{person.Name}, {person.Age}" }],
StructuredContent = JsonSerializer.SerializeToElement(person, JsonContext2.Default.Person),
};
}
[McpServerTool(UseStructuredContent = false, OutputSchemaType = typeof(Person))]
private static CallToolResult ToolWithOutputSchemaButNoStructuredContent()
{
return new CallToolResult()
{
Content = [new TextContentBlock { Text = "result" }],
};
}
[Theory]
[InlineData(JsonNumberHandling.Strict)]
[InlineData(JsonNumberHandling.AllowReadingFromString)]
@ -679,6 +946,13 @@ public partial class McpServerToolTests
record Person(string Name, int Age);
private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
{
JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);
options.TypeInfoResolverChain.Add(JsonContext2.Default);
return options;
}
[Fact]
public void SupportsIconsInCreateOptions()
{
@ -928,5 +1202,6 @@ public partial class McpServerToolTests
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(int?))]
[JsonSerializable(typeof(DateTimeOffset?))]
[JsonSerializable(typeof(Person))]
partial class JsonContext2 : JsonSerializerContext;
}