diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
index e91bdd206..700d9d26d 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 (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 @@ schema.ValueKind is not JsonValueKind.Object ||
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;
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
index 21a227e8f..d67bac18c 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
@@ -265,6 +265,27 @@ public bool ReadOnly
///
public bool UseStructuredContent { get; set; }
+ ///
+ /// Gets or sets a from which to generate the tool's output schema.
+ ///
+ ///
+ /// The default is , which means the output schema is inferred from the return type.
+ ///
+ ///
+ ///
+ /// When set, a JSON schema is generated from the specified and used as the
+ /// instead of the schema inferred from the tool method's return type.
+ /// This is particularly useful when a tool method returns directly
+ /// (to control properties like , ,
+ /// or ) but still needs to advertise a meaningful output
+ /// schema to clients.
+ ///
+ ///
+ /// must also be set to for this property to take effect.
+ ///
+ ///
+ 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..88d718d13 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
@@ -129,6 +129,26 @@ public sealed class McpServerToolCreateOptions
///
public bool UseStructuredContent { get; set; }
+ ///
+ /// Gets or sets an explicit JSON schema to use as the tool's output schema.
+ ///
+ ///
+ /// The default is , which means the output schema is inferred from the return type.
+ ///
+ ///
+ ///
+ /// When set, this schema is used as the instead of the schema
+ /// inferred from the tool method's return type. This is particularly useful when a tool method
+ /// returns directly (to control properties like ,
+ /// , or ) but still
+ /// needs to advertise a meaningful output schema to clients.
+ ///
+ ///
+ /// must also be set to for this property to take effect.
+ ///
+ ///
+ public JsonElement? OutputSchema { get; set; }
+
///
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
///
@@ -209,6 +229,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 fd62a05c7..045a5d72f 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
@@ -533,6 +533,273 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema(T value)
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();
+ var request = new RequestContext(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);
+
+ 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);
+
+ Assert.NotNull(tool.ProtocolTool.OutputSchema);
+ Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
+
+ var mockServer = new Mock();
+ var request = new RequestContext(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);
+
+ 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();
+ var request = new RequestContext(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();
+ var request = new RequestContext(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, McpJsonUtilities.DefaultOptions),
+ };
+ }
+
+ [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)]