Skip to content

Commit bbe96fa

Browse files
CopilotstephentoubCopilot
committed
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>
1 parent a2027b5 commit bbe96fa

4 files changed

Lines changed: 335 additions & 1 deletion

File tree

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 18 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 (toolAttr.OutputSchemaType is Type 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();
@@ -487,7 +494,17 @@ schema.ValueKind is not JsonValueKind.Object ||
487494
return null;
488495
}
489496

490-
if (function.ReturnJsonSchema is not JsonElement outputSchema)
497+
// Explicit OutputSchema takes precedence over AIFunction's return schema.
498+
JsonElement outputSchema;
499+
if (toolCreateOptions.OutputSchema is { } explicitSchema)
500+
{
501+
outputSchema = explicitSchema;
502+
}
503+
else if (function.ReturnJsonSchema is { } returnSchema)
504+
{
505+
outputSchema = returnSchema;
506+
}
507+
else
491508
{
492509
return null;
493510
}

src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,27 @@ public bool ReadOnly
265265
/// </remarks>
266266
public bool UseStructuredContent { get; set; }
267267

268+
/// <summary>
269+
/// Gets or sets a <see cref="Type"/> from which to generate the tool's output schema.
270+
/// </summary>
271+
/// <value>
272+
/// The default is <see langword="null"/>, which means the output schema is inferred from the return type.
273+
/// </value>
274+
/// <remarks>
275+
/// <para>
276+
/// When set, a JSON schema is generated from the specified <see cref="Type"/> and used as the
277+
/// <see cref="Tool.OutputSchema"/> instead of the schema inferred from the tool method's return type.
278+
/// This is particularly useful when a tool method returns <see cref="CallToolResult"/> directly
279+
/// (to control properties like <see cref="Result.Meta"/>, <see cref="CallToolResult.IsError"/>,
280+
/// or <see cref="CallToolResult.StructuredContent"/>) but still needs to advertise a meaningful output
281+
/// schema to clients.
282+
/// </para>
283+
/// <para>
284+
/// <see cref="UseStructuredContent"/> must also be set to <see langword="true"/> for this property to take effect.
285+
/// </para>
286+
/// </remarks>
287+
public Type? OutputSchemaType { get; set; }
288+
268289
/// <summary>
269290
/// Gets or sets the source URI for the tool's icon.
270291
/// </summary>

src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ public sealed class McpServerToolCreateOptions
129129
/// </remarks>
130130
public bool UseStructuredContent { get; set; }
131131

132+
/// <summary>
133+
/// Gets or sets an explicit JSON schema to use as the tool's output schema.
134+
/// </summary>
135+
/// <value>
136+
/// The default is <see langword="null"/>, which means the output schema is inferred from the return type.
137+
/// </value>
138+
/// <remarks>
139+
/// <para>
140+
/// When set, this schema is used as the <see cref="Tool.OutputSchema"/> instead of the schema
141+
/// inferred from the tool method's return type. This is particularly useful when a tool method
142+
/// returns <see cref="CallToolResult"/> directly (to control properties like <see cref="Result.Meta"/>,
143+
/// <see cref="CallToolResult.IsError"/>, or <see cref="CallToolResult.StructuredContent"/>) but still
144+
/// needs to advertise a meaningful output schema to clients.
145+
/// </para>
146+
/// <para>
147+
/// <see cref="UseStructuredContent"/> must also be set to <see langword="true"/> for this property to take effect.
148+
/// </para>
149+
/// </remarks>
150+
public JsonElement? OutputSchema { get; set; }
151+
132152
/// <summary>
133153
/// Gets or sets the JSON serializer options to use when marshalling data to/from JSON.
134154
/// </summary>
@@ -209,6 +229,7 @@ internal McpServerToolCreateOptions Clone() =>
209229
OpenWorld = OpenWorld,
210230
ReadOnly = ReadOnly,
211231
UseStructuredContent = UseStructuredContent,
232+
OutputSchema = OutputSchema,
212233
SerializerOptions = SerializerOptions,
213234
SchemaCreateOptions = SchemaCreateOptions,
214235
Metadata = Metadata,

tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,273 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(T value)
518518
Assert.Null(result.StructuredContent);
519519
}
520520

521+
[Fact]
522+
public void OutputSchema_Options_OverridesReturnTypeSchema()
523+
{
524+
// When OutputSchema is set on options, it should be used instead of the return type's schema
525+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"name":{"type":"string"},"age":{"type":"integer"}},"required":["name","age"]}""").RootElement;
526+
McpServerTool tool = McpServerTool.Create(() => "result", new()
527+
{
528+
UseStructuredContent = true,
529+
OutputSchema = outputSchema,
530+
});
531+
532+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
533+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
534+
Assert.True(properties.TryGetProperty("name", out _));
535+
Assert.True(properties.TryGetProperty("age", out _));
536+
}
537+
538+
[Fact]
539+
public void OutputSchema_Options_WithCallToolResultReturn()
540+
{
541+
// When the tool returns CallToolResult, OutputSchema on options provides the advertised schema
542+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"result":{"type":"string"}},"required":["result"]}""").RootElement;
543+
McpServerTool tool = McpServerTool.Create(() => new CallToolResult() { Content = [] }, new()
544+
{
545+
UseStructuredContent = true,
546+
OutputSchema = outputSchema,
547+
});
548+
549+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
550+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
551+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
552+
Assert.True(properties.TryGetProperty("result", out _));
553+
}
554+
555+
[Fact]
556+
public async Task OutputSchema_Options_CallToolResult_PreservesStructuredContent()
557+
{
558+
// When tool returns CallToolResult with StructuredContent, it's preserved in the response
559+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"value":{"type":"integer"}},"required":["value"]}""").RootElement;
560+
JsonElement structuredContent = JsonDocument.Parse("""{"value":42}""").RootElement;
561+
McpServerTool tool = McpServerTool.Create(() => new CallToolResult()
562+
{
563+
Content = [new TextContentBlock { Text = "42" }],
564+
StructuredContent = structuredContent,
565+
}, new()
566+
{
567+
Name = "tool",
568+
UseStructuredContent = true,
569+
OutputSchema = outputSchema,
570+
});
571+
var mockServer = new Mock<McpServer>();
572+
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
573+
{
574+
Params = new CallToolRequestParams { Name = "tool" },
575+
};
576+
577+
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
578+
579+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
580+
Assert.NotNull(result.StructuredContent);
581+
Assert.Equal(42, result.StructuredContent.Value.GetProperty("value").GetInt32());
582+
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
583+
}
584+
585+
[Fact]
586+
public void OutputSchema_Options_RequiresUseStructuredContent()
587+
{
588+
// OutputSchema without UseStructuredContent=true should not produce an output schema
589+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"name":{"type":"string"}}}""").RootElement;
590+
McpServerTool tool = McpServerTool.Create(() => "result", new()
591+
{
592+
UseStructuredContent = false,
593+
OutputSchema = outputSchema,
594+
});
595+
596+
Assert.Null(tool.ProtocolTool.OutputSchema);
597+
}
598+
599+
[Fact]
600+
public void OutputSchema_Options_NonObjectSchema_GetsWrapped()
601+
{
602+
// Non-object output schema should be wrapped in a "result" property envelope
603+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement;
604+
McpServerTool tool = McpServerTool.Create(() => "result", new()
605+
{
606+
UseStructuredContent = true,
607+
OutputSchema = outputSchema,
608+
});
609+
610+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
611+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
612+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
613+
Assert.True(properties.TryGetProperty("result", out var resultProp));
614+
Assert.Equal("string", resultProp.GetProperty("type").GetString());
615+
}
616+
617+
[Fact]
618+
public void OutputSchema_Options_NullableObjectSchema_BecomesObject()
619+
{
620+
// ["object", "null"] type should be simplified to just "object"
621+
JsonElement outputSchema = JsonDocument.Parse("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement;
622+
McpServerTool tool = McpServerTool.Create(() => "result", new()
623+
{
624+
UseStructuredContent = true,
625+
OutputSchema = outputSchema,
626+
});
627+
628+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
629+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
630+
}
631+
632+
[Fact]
633+
public void OutputSchema_Attribute_WithType_GeneratesSchema()
634+
{
635+
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
636+
637+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
638+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
639+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
640+
Assert.True(properties.TryGetProperty("name", out _));
641+
Assert.True(properties.TryGetProperty("age", out _));
642+
}
643+
644+
[Fact]
645+
public async Task OutputSchema_Attribute_CallToolResult_PreservesStructuredContent()
646+
{
647+
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
648+
649+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
650+
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
651+
652+
var mockServer = new Mock<McpServer>();
653+
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
654+
{
655+
Params = new CallToolRequestParams { Name = "tool" },
656+
};
657+
658+
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
659+
660+
Assert.NotNull(result.StructuredContent);
661+
Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString());
662+
Assert.Equal(27, result.StructuredContent.Value.GetProperty("age").GetInt32());
663+
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
664+
}
665+
666+
[Fact]
667+
public void OutputSchema_Attribute_WithoutUseStructuredContent_NoSchema()
668+
{
669+
// If UseStructuredContent is false but OutputSchema type is set, no output schema should be generated
670+
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaButNoStructuredContent, new() { SerializerOptions = CreateSerializerOptionsWithPerson() });
671+
672+
Assert.Null(tool.ProtocolTool.OutputSchema);
673+
}
674+
675+
[Fact]
676+
public void OutputSchema_Options_TakesPrecedenceOverAttribute()
677+
{
678+
// Options.OutputSchema should take precedence over attribute-derived schema
679+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"custom":{"type":"boolean"}},"required":["custom"]}""").RootElement;
680+
McpServerTool tool = McpServerTool.Create(ToolWithOutputSchemaAttribute, new()
681+
{
682+
OutputSchema = outputSchema,
683+
});
684+
685+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
686+
Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties));
687+
Assert.True(properties.TryGetProperty("custom", out _));
688+
// Should not have Person's properties
689+
Assert.False(properties.TryGetProperty("name", out _));
690+
}
691+
692+
[Fact]
693+
public void OutputSchema_Options_Clone_PreservesValue()
694+
{
695+
// Verify that Clone() preserves the OutputSchema property
696+
JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"x":{"type":"integer"}}}""").RootElement;
697+
McpServerTool tool1 = McpServerTool.Create(() => "result", new()
698+
{
699+
Name = "tool1",
700+
UseStructuredContent = true,
701+
OutputSchema = outputSchema,
702+
});
703+
704+
// The output schema should be present since we set it
705+
Assert.NotNull(tool1.ProtocolTool.OutputSchema);
706+
Assert.True(tool1.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var props));
707+
Assert.True(props.TryGetProperty("x", out _));
708+
}
709+
710+
[Fact]
711+
public async Task OutputSchema_Options_PersonType_WithCallToolResult()
712+
{
713+
// Create output schema from Person type, tool returns CallToolResult with matching structured content
714+
JsonSerializerOptions serializerOptions = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
715+
JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(typeof(Person), serializerOptions: serializerOptions);
716+
Person person = new("Alice", 30);
717+
JsonElement structuredContent = JsonSerializer.SerializeToElement(person, serializerOptions);
718+
McpServerTool tool = McpServerTool.Create(() => new CallToolResult()
719+
{
720+
Content = [new TextContentBlock { Text = "Alice, 30" }],
721+
StructuredContent = structuredContent,
722+
}, new()
723+
{
724+
Name = "tool",
725+
UseStructuredContent = true,
726+
OutputSchema = outputSchema,
727+
SerializerOptions = serializerOptions,
728+
});
729+
var mockServer = new Mock<McpServer>();
730+
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
731+
{
732+
Params = new CallToolRequestParams { Name = "tool" },
733+
};
734+
735+
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
736+
737+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
738+
Assert.NotNull(result.StructuredContent);
739+
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
740+
}
741+
742+
[Fact]
743+
public async Task OutputSchema_Options_OverridesReturnTypeSchema_InvokeAndValidate()
744+
{
745+
// OutputSchema overrides return type schema; result should match the original return type, but schema is the override
746+
JsonSerializerOptions serializerOptions = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
747+
JsonElement outputSchema = AIJsonUtilities.CreateJsonSchema(typeof(Person), serializerOptions: serializerOptions);
748+
McpServerTool tool = McpServerTool.Create(() => new Person("Bob", 25), new()
749+
{
750+
Name = "tool",
751+
UseStructuredContent = true,
752+
OutputSchema = outputSchema,
753+
SerializerOptions = serializerOptions,
754+
});
755+
var mockServer = new Mock<McpServer>();
756+
var request = new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest())
757+
{
758+
Params = new CallToolRequestParams { Name = "tool" },
759+
};
760+
761+
var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
762+
763+
Assert.NotNull(tool.ProtocolTool.OutputSchema);
764+
Assert.NotNull(result.StructuredContent);
765+
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
766+
}
767+
768+
[McpServerTool(UseStructuredContent = true, OutputSchemaType = typeof(Person))]
769+
private static CallToolResult ToolWithOutputSchemaAttribute()
770+
{
771+
var person = new Person("John", 27);
772+
return new CallToolResult()
773+
{
774+
Content = [new TextContentBlock { Text = $"{person.Name}, {person.Age}" }],
775+
StructuredContent = JsonSerializer.SerializeToElement(person, JsonContext2.Default.Person),
776+
};
777+
}
778+
779+
[McpServerTool(UseStructuredContent = false, OutputSchemaType = typeof(Person))]
780+
private static CallToolResult ToolWithOutputSchemaButNoStructuredContent()
781+
{
782+
return new CallToolResult()
783+
{
784+
Content = [new TextContentBlock { Text = "result" }],
785+
};
786+
}
787+
521788
[Theory]
522789
[InlineData(JsonNumberHandling.Strict)]
523790
[InlineData(JsonNumberHandling.AllowReadingFromString)]
@@ -664,6 +931,13 @@ Instance JSON document does not match the specified schema.
664931

665932
record Person(string Name, int Age);
666933

934+
private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
935+
{
936+
JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);
937+
options.TypeInfoResolverChain.Add(JsonContext2.Default);
938+
return options;
939+
}
940+
667941
[Fact]
668942
public void SupportsIconsInCreateOptions()
669943
{
@@ -913,5 +1187,6 @@ private static string SyncTool()
9131187
[JsonSerializable(typeof(List<string>))]
9141188
[JsonSerializable(typeof(int?))]
9151189
[JsonSerializable(typeof(DateTimeOffset?))]
1190+
[JsonSerializable(typeof(Person))]
9161191
partial class JsonContext2 : JsonSerializerContext;
9171192
}

0 commit comments

Comments
 (0)