From 126d71283eb602f36c03d2000541001824c81e44 Mon Sep 17 00:00:00 2001 From: Jayaraman Venkatesan <112980436+jayaraman-venkatesan@users.noreply.github.com> Date: Fri, 8 May 2026 18:45:51 -0400 Subject: [PATCH] feat(feature/sep-2106-output-schema-relaxation): relax outputSchema to any JSON Schema 2020-12 document per SEP-2106 --- .../McpJsonUtilities.cs | 9 +++ .../Protocol/Tool.cs | 19 +++-- .../Server/AIFunctionMcpServerTool.cs | 77 ++++--------------- .../Protocol/ToolTests.cs | 47 +++++++++++ .../Server/McpServerToolTests.cs | 51 ++++++++---- 5 files changed, 116 insertions(+), 87 deletions(-) diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index abb6d29df..992dfbdbd 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -84,6 +84,15 @@ internal static bool IsValidMcpToolSchema(JsonElement element) return false; // No type keyword found. } + // Per SEP-2106, a tool's outputSchema may be any valid JSON Schema document — not just + // schemas with type:"object". Validation is therefore reduced to a structural check + // matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form + // with keywords like "type", "properties", etc.) or a boolean (`true` matches anything, + // `false` matches nothing). Stricter keyword-level validation is intentionally not + // performed. + internal static bool IsValidJsonSchemaDocument(JsonElement element) => + element.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False; + // Keep in sync with CreateDefaultOptions above. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs index 8abbfd88c..bde86d5f8 100644 --- a/src/ModelContextProtocol.Core/Protocol/Tool.cs +++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs @@ -80,17 +80,20 @@ public JsonElement InputSchema } = McpJsonUtilities.DefaultMcpToolSchema; /// - /// Gets or sets a JSON Schema object defining the expected structured outputs for the tool. + /// Gets or sets a JSON Schema document describing the shape of the tool's structured output. /// - /// The value is not a valid MCP tool JSON schema. + /// The value is not a JSON object. /// /// - /// The schema must be a valid JSON Schema object with the "type" property set to "object". - /// This is enforced by validation in the setter which will throw an - /// if an invalid schema is provided. + /// Per SEP-2106 ("Allow valid JSON Schemas in outputSchema"), the schema may describe + /// any JSON value — object, array, string, number, boolean, or — to + /// support tools whose structured output is not an object. The setter only validates that the + /// supplied value is a JSON object (a schema document); deeper keyword-level validation is + /// intentionally not performed. /// /// - /// The schema should describe the shape of the data as returned in . + /// The schema describes the shape of the value placed in . + /// Unlike , the top-level type is no longer required to be "object". /// /// [JsonPropertyName("outputSchema")] @@ -99,9 +102,9 @@ public JsonElement? OutputSchema get => field; set { - if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value)) + if (value is not null && !McpJsonUtilities.IsValidJsonSchemaDocument(value.Value)) { - throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema)); + throw new ArgumentException("The specified document is not a valid JSON Schema document (must be a JSON object).", nameof(OutputSchema)); } field = value; diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 700d9d26d..020c011e7 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server; /// Provides an that's implemented via an . internal sealed partial class AIFunctionMcpServerTool : McpServerTool { - private readonly bool _structuredOutputRequiresWrapping; private readonly IReadOnlyList _metadata; /// @@ -120,7 +119,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Name = options?.Name ?? function.Name, Description = GetToolDescription(function, options), InputSchema = function.JsonSchema, - OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping), + OutputSchema = CreateOutputSchema(function, options), Icons = options?.Icons, }; @@ -167,7 +166,7 @@ options.OpenWorld is not null || tool.Execution.TaskSupport = ToolTaskSupport.Optional; } - return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); + return new AIFunctionMcpServerTool(function, tool, options?.Services, options?.Metadata ?? []); } private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options) @@ -235,14 +234,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe internal AIFunction AIFunction { get; } /// Initializes a new instance of the class. - private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList metadata) + private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, IReadOnlyList metadata) { ValidateToolName(tool.Name); AIFunction = function; ProtocolTool = tool; - _structuredOutputRequiresWrapping = structuredOutputRequiresWrapping; _metadata = metadata; } @@ -485,65 +483,27 @@ schema.ValueKind is not JsonValueKind.Object || return descriptionElement.GetString(); } - private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping) + private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions) { - structuredOutputRequiresWrapping = false; - if (toolCreateOptions?.UseStructuredContent is not true) { return null; } + // Per SEP-2106, any valid JSON Schema document is acceptable for outputSchema — + // arrays, primitives, compositions, and nullable types pass through unchanged. // 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; + return explicitSchema; } - if (outputSchema.ValueKind is not JsonValueKind.Object || - !outputSchema.TryGetProperty("type", out JsonElement typeProperty) || - typeProperty.ValueKind is not JsonValueKind.String || - typeProperty.GetString() is not "object") + if (function.ReturnJsonSchema is { } returnSchema) { - // If the output schema is not an object, need to modify to be a valid MCP output schema. - JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); - - if (schemaNode is JsonObject objSchema && - objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) && - typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null")) - { - // For schemas that are of type ["object", "null"], replace with just "object" to be conformant. - objSchema["type"] = "object"; - } - else - { - // For anything else, wrap the schema in an envelope with a "result" property. - schemaNode = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["result"] = schemaNode - }, - ["required"] = new JsonArray { (JsonNode)"result" } - }; - - structuredOutputRequiresWrapping = true; - } - - outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement); + return returnSchema; } - return outputSchema; + return null; } private JsonElement? CreateStructuredResponse(object? aiFunctionResult) @@ -554,26 +514,15 @@ typeProperty.ValueKind is not JsonValueKind.String || return null; } - JsonElement? elementResult = aiFunctionResult switch + // Per SEP-2106, the tool's return value flows through to structuredContent unchanged. + // No "result" envelope wrapping — the value's natural JSON shape is what the schema describes. + return aiFunctionResult switch { JsonElement jsonElement => jsonElement, JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), null => null, _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; - - if (_structuredOutputRequiresWrapping) - { - JsonNode? resultNode = elementResult is { } je - ? JsonSerializer.SerializeToNode(je, McpJsonUtilities.JsonContext.Default.JsonElement) - : null; - return JsonSerializer.SerializeToElement(new JsonObject - { - ["result"] = resultNode - }, McpJsonUtilities.JsonContext.Default.JsonObject); - } - - return elementResult; } private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) diff --git a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs index 5b2160571..f4336cacc 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ToolTests.cs @@ -139,4 +139,51 @@ public static void ToolInputSchema_AcceptsValidSchemaDocuments(string validSchem Assert.True(JsonElement.DeepEquals(document.RootElement, tool.InputSchema)); } + + [Theory] + [InlineData("null")] + [InlineData("3.5e3")] + [InlineData("[]")] + [InlineData("\"a-string\"")] + public static void ToolOutputSchema_RejectsInvalidJsonSchemaDocuments(string invalidSchema) + { + // Per SEP-2106 / JSON Schema 2020-12 §4.3, a schema document is either a JSON object + // or a boolean (true/false). Other JSON values — null literals, numbers, strings, + // arrays — are not valid schema documents and are rejected. + using var document = JsonDocument.Parse(invalidSchema); + var tool = new Tool { Name = "test" }; + + Assert.Throws(() => tool.OutputSchema = document.RootElement); + } + + [Theory] + [InlineData("""{"type":"object"}""")] + [InlineData("""{"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}""")] + [InlineData("""{"type":"array","items":{"type":"integer"}}""")] + [InlineData("""{"type":"string"}""")] + [InlineData("""{"type":"number"}""")] + [InlineData("""{"type":"integer","minimum":0}""")] + [InlineData("""{"type":"boolean"}""")] + [InlineData("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""")] + [InlineData("""{}""")] + [InlineData("""{"oneOf":[{"type":"string"},{"type":"integer"}]}""")] + [InlineData("true")] + [InlineData("false")] + public static void ToolOutputSchema_AcceptsAnyValidJsonSchemaDocument(string validSchema) + { + // Per SEP-2106, OutputSchema accepts any valid JSON Schema 2020-12 document — JSON + // objects (with arrays, primitives, compositions, nullable types) plus the boolean + // schemas `true` (matches any value) and `false` (matches nothing). The `true` form + // also appears organically as the auto-derived schema for an unconstrained `object` + // return type. + using var document = JsonDocument.Parse(validSchema); + Tool tool = new() + { + Name = "test", + OutputSchema = document.RootElement, + }; + + Assert.NotNull(tool.OutputSchema); + Assert.True(JsonElement.DeepEquals(document.RootElement, tool.OutputSchema.Value)); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 808ba7efe..fecbcbe70 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -461,6 +461,10 @@ public async Task SupportsSchemaCreateOptions() [MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))] public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) { + // Per SEP-2106 the output schema's top-level "type" matches the natural shape of the + // return value (e.g. "string", "integer", "array") rather than always being "object". + // The strict round-trip check is AssertMatchesJsonSchema below, which proves the + // emitted structuredContent validates against the published schema. JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() }; McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options }); var mockServer = new Mock(); @@ -469,7 +473,6 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value) var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); Assert.NotNull(result.StructuredContent); AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent); } @@ -594,9 +597,11 @@ public void OutputSchema_Options_RequiresUseStructuredContent() } [Fact] - public void OutputSchema_Options_NonObjectSchema_GetsWrapped() + public void OutputSchema_Options_NonObjectSchema_PassesThrough() { - // Non-object output schema should be wrapped in a "result" property envelope + // Per SEP-2106, outputSchema may be any valid JSON Schema document — including + // non-object schemas. The SDK no longer wraps non-object schemas in a + // {"type":"object","properties":{"result":}} envelope. JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement; McpServerTool tool = McpServerTool.Create(() => "result", new() { @@ -605,16 +610,15 @@ public void OutputSchema_Options_NonObjectSchema_GetsWrapped() }); 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()); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _)); } [Fact] - public void OutputSchema_Options_NullableObjectSchema_BecomesObject() + public void OutputSchema_Options_NullableObjectSchema_PassesThrough() { - // ["object", "null"] type should be simplified to just "object" + // Per SEP-2106, the SDK no longer normalizes ["object","null"] type-arrays down + // to just "object". The schema author's intent is preserved on the wire. JsonElement outputSchema = JsonDocument.Parse("""{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement; McpServerTool tool = McpServerTool.Create(() => "result", new() { @@ -623,7 +627,24 @@ public void OutputSchema_Options_NullableObjectSchema_BecomesObject() }); Assert.NotNull(tool.ProtocolTool.OutputSchema); - Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + var typeProperty = tool.ProtocolTool.OutputSchema.Value.GetProperty("type"); + Assert.Equal(JsonValueKind.Array, typeProperty.ValueKind); + Assert.Collection(typeProperty.EnumerateArray(), + t => Assert.Equal("object", t.GetString()), + t => Assert.Equal("null", t.GetString())); + } + + [Fact] + public void OutputSchema_Create_StringReturn_NoEnvelope() + { + // End-to-end check: a tool with a string return type and UseStructuredContent + // produces an outputSchema describing the string directly (no "result" envelope) + // and emits the raw string value as structuredContent. + McpServerTool tool = McpServerTool.Create(() => "hello", new() { UseStructuredContent = true }); + + Assert.NotNull(tool.ProtocolTool.OutputSchema); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.False(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out _)); } [Fact] @@ -1004,15 +1025,15 @@ public void ReturnDescription_StructuredOutputDisabled_IncludedInToolDescription [Fact] public void ReturnDescription_StructuredOutputEnabled_NotIncludedInToolDescription() { - // When UseStructuredContent is true, return description should be in the output schema, not in tool description + // When UseStructuredContent is true, return description should be in the output schema, not in tool description. + // Per SEP-2106 the schema is no longer wrapped in a {"result": } envelope, so the description + // sits directly on the (non-object) output schema. McpServerTool tool = McpServerTool.Create(ToolWithReturnDescription, new() { UseStructuredContent = true }); Assert.Equal("Tool that returns data.", tool.ProtocolTool.Description); Assert.NotNull(tool.ProtocolTool.OutputSchema); - // Verify the output schema contains the description - Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("properties", out var properties)); - Assert.True(properties.TryGetProperty("result", out var result)); - Assert.True(result.TryGetProperty("description", out var description)); + Assert.Equal("string", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString()); + Assert.True(tool.ProtocolTool.OutputSchema.Value.TryGetProperty("description", out var description)); Assert.Equal("The computed result", description.GetString()); }