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());
}