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)]