Skip to content

Commit 2958a75

Browse files
CopilotstephentoubCopilot
authored
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 4140c6d commit 2958a75

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
@@ -533,6 +533,273 @@ public async Task StructuredOutput_Disabled_ReturnsExpectedSchema<T>(T value)
533533
Assert.Null(result.StructuredContent);
534534
}
535535

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

680947
record Person(string Name, int Age);
681948

949+
private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
950+
{
951+
JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);
952+
options.TypeInfoResolverChain.Add(JsonContext2.Default);
953+
return options;
954+
}
955+
682956
[Fact]
683957
public void SupportsIconsInCreateOptions()
684958
{
@@ -928,5 +1202,6 @@ private static string SyncTool()
9281202
[JsonSerializable(typeof(List<string>))]
9291203
[JsonSerializable(typeof(int?))]
9301204
[JsonSerializable(typeof(DateTimeOffset?))]
1205+
[JsonSerializable(typeof(Person))]
9311206
partial class JsonContext2 : JsonSerializerContext;
9321207
}

0 commit comments

Comments
 (0)