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:
parent
4140c6d6e4
commit
2958a75894
4 changed files with 335 additions and 1 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue