Skip to content

Commit b111284

Browse files
Copilotstephentoub
andcommitted
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>
1 parent 301550d commit b111284

4 files changed

Lines changed: 203 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
206206

207207
newOptions.UseStructuredContent = toolAttr.UseStructuredContent;
208208

209+
if (newOptions.OutputSchema is null && toolAttr.OutputSchemaType is { } outputSchemaType)
210+
{
211+
newOptions.OutputSchema = AIJsonUtilities.CreateJsonSchema(outputSchemaType,
212+
serializerOptions: newOptions.SerializerOptions ?? McpJsonUtilities.DefaultOptions,
213+
inferenceOptions: newOptions.SchemaCreateOptions);
214+
}
215+
209216
if (toolAttr._taskSupport is { } taskSupport)
210217
{
211218
newOptions.Execution ??= new ToolExecution();
@@ -441,7 +448,7 @@ private static void ValidateToolName(string name)
441448
string? description = options?.Description ?? function.Description;
442449

443450
// If structured content is enabled, the return description will be in the output schema
444-
if (options?.UseStructuredContent is true)
451+
if (options?.UseStructuredContent is true || options?.OutputSchema is not null)
445452
{
446453
return description;
447454
}
@@ -483,6 +490,12 @@ schema.ValueKind is not JsonValueKind.Object ||
483490
{
484491
structuredOutputRequiresWrapping = false;
485492

493+
// If an explicit OutputSchema is provided, use it directly and force UseStructuredContent.
494+
if (toolCreateOptions?.OutputSchema is JsonElement explicitSchema)
495+
{
496+
return explicitSchema;
497+
}
498+
486499
if (toolCreateOptions?.UseStructuredContent is not true)
487500
{
488501
return null;

src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,34 @@ public bool ReadOnly
240240
/// The default is <see langword="false"/>.
241241
/// </value>
242242
/// <remarks>
243+
/// <para>
243244
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
244245
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
246+
/// </para>
247+
/// <para>
248+
/// Setting <see cref="OutputSchemaType"/> will automatically enable structured content.
249+
/// </para>
245250
/// </remarks>
246251
public bool UseStructuredContent { get; set; }
247252

253+
/// <summary>
254+
/// Gets or sets the type to use for generating the tool's output schema.
255+
/// </summary>
256+
/// <remarks>
257+
/// <para>
258+
/// When set, the SDK generates the <see cref="Tool.OutputSchema"/> from this type instead of
259+
/// inferring it from the method's return type. This is particularly useful when the method
260+
/// returns <see cref="CallToolResult"/> directly (for example, to control
261+
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
262+
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
263+
/// </para>
264+
/// <para>
265+
/// Setting this property automatically enables <see cref="UseStructuredContent"/>.
266+
/// </para>
267+
/// </remarks>
268+
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)]
269+
public Type? OutputSchemaType { get; set; }
270+
248271
/// <summary>
249272
/// Gets or sets the source URI for the tool's icon.
250273
/// </summary>

src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,36 @@ public sealed class McpServerToolCreateOptions
124124
/// The default is <see langword="false"/>.
125125
/// </value>
126126
/// <remarks>
127+
/// <para>
127128
/// When enabled, the tool will attempt to populate the <see cref="Tool.OutputSchema"/>
128129
/// and provide structured content in the <see cref="CallToolResult.StructuredContent"/> property.
130+
/// </para>
131+
/// <para>
132+
/// Setting <see cref="OutputSchema"/> will automatically enable structured content.
133+
/// </para>
129134
/// </remarks>
130135
public bool UseStructuredContent { get; set; }
131136

137+
/// <summary>
138+
/// Gets or sets a JSON Schema object to use as the tool's output schema.
139+
/// </summary>
140+
/// <remarks>
141+
/// <para>
142+
/// When set, this schema is used directly as the <see cref="Tool.OutputSchema"/> instead of
143+
/// inferring it from the method's return type. This is particularly useful when the method
144+
/// returns <see cref="CallToolResult"/> directly (for example, to control
145+
/// <see cref="CallToolResult.IsError"/>), but the tool should still advertise a meaningful
146+
/// output schema describing the shape of <see cref="CallToolResult.StructuredContent"/>.
147+
/// </para>
148+
/// <para>
149+
/// Setting this property automatically enables <see cref="UseStructuredContent"/>.
150+
/// </para>
151+
/// <para>
152+
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
153+
/// </para>
154+
/// </remarks>
155+
public JsonElement? OutputSchema { get; set; }
156+
132157
/// <summary>
133158
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
134159
/// </summary>
@@ -209,6 +234,7 @@ internal McpServerToolCreateOptions Clone() =>
209234
OpenWorld = OpenWorld,
210235
ReadOnly = ReadOnly,
211236
UseStructuredContent = UseStructuredContent,
237+
OutputSchema = OutputSchema,
212238
SerializerOptions = SerializerOptions,
213239
SchemaCreateOptions = SchemaCreateOptions,
214240
Metadata = Metadata,

tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,146 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(T value)
533533
Assert.Null(result.StructuredContent);
534534
}
535535

536+
[Fact]
537+
public void OutputSchema_ViaOptions_SetsSchemaDirectly()
538+
{
539+
var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}}}""");
540+
McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new()
541+
{
542+
OutputSchema = schemaDoc.RootElement,
543+
});
544+
545+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
546+
Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value));
547+
}
548+
549+
[Fact]
550+
public void OutputSchema_ViaOptions_ForcesStructuredContentEvenIfDisabled()
551+
{
552+
var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"n":{"type":"string"},"a":{"type":"integer"}},"required":["n","a"]}""");
553+
McpServerTool tool = McpServerTool.Create((string input) => new CallToolResult() { Content = [] }, new()
554+
{
555+
UseStructuredContent = false,
556+
OutputSchema = schemaDoc.RootElement,
557+
});
558+
559+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
560+
Assert.True(JsonElement.DeepEquals(schemaDoc.RootElement, tool.ProtocolTool.OutputSchema.Value));
561+
}
562+
563+
[Fact]
564+
public void OutputSchema_ViaOptions_TakesPrecedenceOverReturnTypeSchema()
565+
{
566+
var overrideDoc = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}}}""");
567+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
568+
McpServerTool tool = McpServerTool.Create(() => new Person("Alice", 30), new()
569+
{
570+
UseStructuredContent = true,
571+
OutputSchema = overrideDoc.RootElement,
572+
SerializerOptions = serOpts,
573+
});
574+
575+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
576+
Assert.True(JsonElement.DeepEquals(overrideDoc.RootElement, tool.ProtocolTool.OutputSchema.Value));
577+
}
578+
579+
[Fact]
580+
public void OutputSchemaType_ViaAttribute_ProducesExpectedSchema()
581+
{
582+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
583+
McpServerTool tool = McpServerTool.Create(
584+
typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!,
585+
target: null,
586+
new() { SerializerOptions = serOpts });
587+
588+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
589+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
590+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props));
591+
Assert.True(props.TryGetProperty("Name", out _));
592+
Assert.True(props.TryGetProperty("Age", out _));
593+
}
594+
595+
[Fact]
596+
public async Task OutputSchemaType_ViaAttribute_ReturningCallToolResult_WorksCorrectly()
597+
{
598+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
599+
McpServerTool tool = McpServerTool.Create(
600+
typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!,
601+
target: null,
602+
new() { SerializerOptions = serOpts });
603+
604+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
605+
606+
Mock<McpServer> srv = new();
607+
var ctx = new RequestContext<CallToolRequestParams>(srv.Object, CreateTestJsonRpcRequest())
608+
{
609+
Params = new CallToolRequestParams { Name = "tool_returning_call_tool_result_with_schema_type" },
610+
};
611+
612+
var toolResult = await tool.InvokeAsync(ctx, TestContext.Current.CancellationToken);
613+
Assert.Equal("hello", Assert.IsType<TextContentBlock>(toolResult.Content[0]).Text);
614+
}
615+
616+
[Fact]
617+
public void OutputSchemaType_ViaAttribute_WithUseStructuredContent_ProducesExpectedSchema()
618+
{
619+
JsonSerializerOptions serOpts = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
620+
McpServerTool tool = McpServerTool.Create(
621+
typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolWithBothOutputSchemaTypeAndStructuredContent))!,
622+
target: null,
623+
new() { SerializerOptions = serOpts });
624+
625+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
626+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
627+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props));
628+
Assert.True(props.TryGetProperty("Name", out _));
629+
}
630+
631+
[Fact]
632+
public void OutputSchema_ViaOptions_OverridesAttributeOutputSchemaType()
633+
{
634+
var customDoc = JsonDocument.Parse("""{"type":"object","properties":{"overridden":{"type":"string"}}}""");
635+
McpServerTool tool = McpServerTool.Create(
636+
typeof(OutputSchemaTypeTools).GetMethod(nameof(OutputSchemaTypeTools.ToolReturningCallToolResultWithSchemaType))!,
637+
target: null,
638+
new() { OutputSchema = customDoc.RootElement });
639+
640+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
641+
Assert.True(JsonElement.DeepEquals(customDoc.RootElement, tool.ProtocolTool.OutputSchema.Value));
642+
}
643+
644+
[Fact]
645+
public void OutputSchema_IsPreservedWhenCopyingOptions()
646+
{
647+
var schemaDoc = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}""");
648+
649+
// Verify OutputSchema works correctly when used via tool creation (which clones internally)
650+
McpServerTool tool1 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new()
651+
{
652+
OutputSchema = schemaDoc.RootElement,
653+
});
654+
McpServerTool tool2 = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new()
655+
{
656+
OutputSchema = schemaDoc.RootElement,
657+
});
658+
659+
Assert.NotNull(tool1.ProtocolTool.OutputSchema);
660+
Assert.NotNull(tool2.ProtocolTool.OutputSchema);
661+
Assert.True(JsonElement.DeepEquals(tool1.ProtocolTool.OutputSchema.Value, tool2.ProtocolTool.OutputSchema.Value));
662+
}
663+
664+
private class OutputSchemaTypeTools
665+
{
666+
[McpServerTool(OutputSchemaType = typeof(Person))]
667+
public static CallToolResult ToolReturningCallToolResultWithSchemaType()
668+
=> new() { Content = [new TextContentBlock { Text = "hello" }] };
669+
670+
[McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))]
671+
public static CallToolResult ToolWithBothOutputSchemaTypeAndStructuredContent()
672+
=> new() { Content = [new TextContentBlock { Text = "world" }] };
673+
}
674+
675+
536676
[Theory]
537677
[InlineData(JsonNumberHandling.Strict)]
538678
[InlineData(JsonNumberHandling.AllowReadingFromString)]

0 commit comments

Comments
 (0)