Skip to content

Commit 0663b7c

Browse files
Relax outputSchema to any JSON Schema 2020-12 document per SEP-2106 (#1568)
Co-authored-by: Mike Kistler <mikekistler@microsoft.com>
1 parent c021d0a commit 0663b7c

8 files changed

Lines changed: 839 additions & 260 deletions

File tree

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
8484
return false; // No type keyword found.
8585
}
8686

87+
// Per SEP-2106, a tool's outputSchema may be any valid JSON Schema document — not just
88+
// schemas with type:"object". Validation is therefore reduced to a structural check
89+
// matching JSON Schema 2020-12: a schema may be either a JSON object (the usual form
90+
// with keywords like "type", "properties", etc.) or a boolean (`true` matches anything,
91+
// `false` matches nothing). Stricter keyword-level validation is intentionally not
92+
// performed. Pre-2026-06-30 clients still receive the legacy wrapped wire shape — that
93+
// wiring lives in AIFunctionMcpServerTool.CreateStructuredResponse and McpServerImpl's
94+
// listToolsHandler.
95+
internal static bool IsValidToolOutputSchema(JsonElement element) =>
96+
element.ValueKind is JsonValueKind.Object or JsonValueKind.True or JsonValueKind.False;
97+
8798
// Keep in sync with CreateDefaultOptions above.
8899
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
89100
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,30 @@ internal static bool SupportsPrimingEvent(string? protocolVersion)
7272
return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0;
7373
}
7474

75+
/// <summary>
76+
/// Checks whether the negotiated protocol version permits emitting non-object output
77+
/// schemas and their structured content in their natural shape (per SEP-2106).
78+
/// </summary>
79+
/// <param name="protocolVersion">The negotiated protocol version, or <c>null</c> if
80+
/// negotiation has not completed.</param>
81+
/// <returns><c>true</c> if the version is <c>"2026-06-30"</c> or later (including the
82+
/// in-flight <c>"DRAFT-2026-06-v1"</c>, since <c>'D' &gt; '2'</c> ordinally); <c>false</c>
83+
/// otherwise. A <c>false</c> return signals that the wire emission boundary must apply
84+
/// the <c>{"result": &lt;value&gt;}</c> envelope expected by clients on protocol versions
85+
/// that pre-date SEP-2106's widening of <c>outputSchema</c> to any JSON Schema 2020-12
86+
/// document.</returns>
87+
internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
88+
{
89+
const string MinNaturalOutputSchemasProtocolVersion = "2026-06-30";
90+
91+
if (protocolVersion is null)
92+
{
93+
return false;
94+
}
95+
96+
return string.Compare(protocolVersion, MinNaturalOutputSchemasProtocolVersion, StringComparison.Ordinal) >= 0;
97+
}
98+
7599
private readonly bool _isServer;
76100
private readonly string _transportKind;
77101
private readonly ITransport _transport;

src/ModelContextProtocol.Core/Protocol/Tool.cs

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,17 +81,24 @@ public JsonElement InputSchema
8181
} = McpJsonUtilities.DefaultMcpToolSchema;
8282

8383
/// <summary>
84-
/// Gets or sets a JSON Schema object defining the expected structured outputs for the tool.
84+
/// Gets or sets a JSON Schema document describing the shape of the tool's structured output.
8585
/// </summary>
86-
/// <exception cref="ArgumentException">The value is not a valid MCP tool JSON schema.</exception>
86+
/// <exception cref="ArgumentException">
87+
/// The value is not a valid JSON Schema 2020-12 document — i.e., not a JSON object or a
88+
/// JSON boolean.
89+
/// </exception>
8790
/// <remarks>
8891
/// <para>
89-
/// The schema must be a valid JSON Schema object with the "type" property set to "object".
90-
/// This is enforced by validation in the setter which will throw an <see cref="ArgumentException"/>
91-
/// if an invalid schema is provided.
92+
/// Per SEP-2106 ("Allow valid JSON Schemas in <c>outputSchema</c>"), the schema may describe
93+
/// any JSON value — object, array, string, number, boolean, or <see langword="null"/> — to
94+
/// support tools whose structured output is not an object. The setter only checks that the
95+
/// supplied value is a structurally valid JSON Schema 2020-12 document (a JSON object, or
96+
/// the boolean schemas <c>true</c>/<c>false</c> per §4.3); deeper keyword-level validation
97+
/// is intentionally not performed.
9298
/// </para>
9399
/// <para>
94-
/// The schema should describe the shape of the data as returned in <see cref="CallToolResult.StructuredContent"/>.
100+
/// The schema describes the shape of the value placed in <see cref="CallToolResult.StructuredContent"/>.
101+
/// Unlike <see cref="InputSchema"/>, the top-level <c>type</c> is not required to be <c>"object"</c>.
95102
/// </para>
96103
/// </remarks>
97104
[JsonPropertyName("outputSchema")]
@@ -100,9 +107,9 @@ public JsonElement? OutputSchema
100107
get => field;
101108
set
102109
{
103-
if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value))
110+
if (value is not null && !McpJsonUtilities.IsValidToolOutputSchema(value.Value))
104111
{
105-
throw new ArgumentException("The specified document is not a valid MCP tool output JSON schema.", nameof(OutputSchema));
112+
throw new ArgumentException("The specified document is not a valid JSON Schema 2020-12 document (must be a JSON object or a JSON boolean).", nameof(OutputSchema));
106113
}
107114

108115
field = value;

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Lines changed: 119 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server;
1313
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
1414
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
1515
{
16-
private readonly bool _structuredOutputRequiresWrapping;
1716
private readonly IReadOnlyList<object> _metadata;
1817

1918
/// <summary>
@@ -120,7 +119,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
120119
Name = options?.Name ?? function.Name,
121120
Description = GetToolDescription(function, options),
122121
InputSchema = function.JsonSchema,
123-
OutputSchema = CreateOutputSchema(function, options, out bool structuredOutputRequiresWrapping),
122+
OutputSchema = CreateOutputSchema(function, options),
124123
Icons = options?.Icons,
125124
};
126125

@@ -156,7 +155,7 @@ options.OpenWorld is not null ||
156155
options.Meta;
157156
}
158157

159-
return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []);
158+
return new AIFunctionMcpServerTool(function, tool, options?.Services, options?.Metadata ?? []);
160159
}
161160

162161
private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpServerToolCreateOptions? options)
@@ -218,14 +217,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
218217
internal AIFunction AIFunction { get; }
219218

220219
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
221-
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, bool structuredOutputRequiresWrapping, IReadOnlyList<object> metadata)
220+
private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider? serviceProvider, IReadOnlyList<object> metadata)
222221
{
223222
ValidateToolName(tool.Name);
224223

225224
AIFunction = function;
226225
ProtocolTool = tool;
227226

228-
_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
229227
_metadata = metadata;
230228
}
231229

@@ -235,6 +233,41 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider
235233
/// <inheritdoc />
236234
public override IReadOnlyList<object> Metadata => _metadata;
237235

236+
/// <summary>
237+
/// Returns a <see cref="Tool"/> clone whose <see cref="Tool.OutputSchema"/> is rewritten
238+
/// into the wire shape required by clients on protocol versions older than
239+
/// <c>"2026-06-30"</c>. Those versions require <c>outputSchema.type == "object"</c>;
240+
/// SEP-2106 (negotiated at <c>"2026-06-30"</c> and later) widens that to any JSON
241+
/// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in
242+
/// <c>{"type":"object","properties":{"result":&lt;schema&gt;}}</c> and the
243+
/// <c>type:["object","null"]</c> array form is normalized to plain <c>"object"</c>
244+
/// before emission. Returns <see cref="ProtocolTool"/> unchanged when there is no
245+
/// output schema. Callers must gate the call on the negotiated version — this method
246+
/// is unconditional; the gate lives at the emission site.
247+
/// </summary>
248+
internal Tool BuildLegacyWireProtocolTool()
249+
{
250+
if (ProtocolTool.OutputSchema is not { } natural)
251+
{
252+
return ProtocolTool;
253+
}
254+
255+
JsonElement legacyOutputSchema = TransformOutputSchemaForLegacyWire(natural);
256+
257+
return new Tool
258+
{
259+
Name = ProtocolTool.Name,
260+
Title = ProtocolTool.Title,
261+
Description = ProtocolTool.Description,
262+
InputSchema = ProtocolTool.InputSchema,
263+
OutputSchema = legacyOutputSchema,
264+
Annotations = ProtocolTool.Annotations,
265+
Execution = ProtocolTool.Execution,
266+
Icons = ProtocolTool.Icons,
267+
Meta = ProtocolTool.Meta,
268+
};
269+
}
270+
238271
/// <inheritdoc />
239272
public override async ValueTask<CallToolResult> InvokeAsync(
240273
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
@@ -256,7 +289,7 @@ public override async ValueTask<CallToolResult> InvokeAsync(
256289
object? result;
257290
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
258291

259-
JsonElement? structuredContent = CreateStructuredResponse(result);
292+
JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion);
260293
return result switch
261294
{
262295
AIContent aiContent => new()
@@ -468,48 +501,102 @@ schema.ValueKind is not JsonValueKind.Object ||
468501
return descriptionElement.GetString();
469502
}
470503

471-
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions, out bool structuredOutputRequiresWrapping)
504+
private static JsonElement? CreateOutputSchema(AIFunction function, McpServerToolCreateOptions? toolCreateOptions)
472505
{
473-
structuredOutputRequiresWrapping = false;
474-
475506
if (toolCreateOptions?.UseStructuredContent is not true)
476507
{
477508
return null;
478509
}
479510

511+
// Per SEP-2106, any valid JSON Schema document is acceptable for outputSchema —
512+
// arrays, primitives, compositions, and nullable types pass through unchanged.
480513
// Explicit OutputSchema takes precedence over AIFunction's return schema.
481-
JsonElement outputSchema;
514+
// Back-compat for pre-2026-06-30 clients is applied at the wire emission sites
515+
// (CreateStructuredResponse for tools/call, listToolsHandler for tools/list).
482516
if (toolCreateOptions.OutputSchema is { } explicitSchema)
483517
{
484-
outputSchema = explicitSchema;
518+
return explicitSchema;
485519
}
486-
else if (function.ReturnJsonSchema is { } returnSchema)
520+
521+
if (function.ReturnJsonSchema is { } returnSchema)
487522
{
488-
outputSchema = returnSchema;
523+
return returnSchema;
489524
}
490-
else
525+
526+
return null;
527+
}
528+
529+
/// <summary>
530+
/// Returns <see langword="true"/> iff the structured-content value must be wrapped in
531+
/// the <c>{"result": &lt;value&gt;}</c> envelope on the wire — i.e., the output schema
532+
/// is neither plain object-typed (<c>type:"object"</c>) nor the
533+
/// <c>type:["object","null"]</c> array form. Used by <see cref="CreateStructuredResponse"/>
534+
/// to decide whether to apply the envelope when emitting to a client that negotiated a
535+
/// protocol version older than <c>"2026-06-30"</c> (those versions pre-date SEP-2106's
536+
/// allowance of non-object output schemas). The inner <c>type:["object","null"]</c>
537+
/// check is hoisted into a named bool to keep the surrounding control flow free of
538+
/// empty branches.
539+
/// </summary>
540+
internal static bool ShouldWrapValueForLegacyWire(JsonElement schema)
541+
{
542+
bool structuredOutputRequiresWrapping = false;
543+
544+
if (schema.ValueKind is not JsonValueKind.Object ||
545+
!schema.TryGetProperty("type", out JsonElement typeProperty) ||
546+
typeProperty.ValueKind is not JsonValueKind.String ||
547+
typeProperty.GetString() is not "object")
491548
{
492-
return null;
549+
JsonNode? schemaNode = JsonSerializer.SerializeToNode(schema, McpJsonUtilities.JsonContext.Default.JsonElement);
550+
551+
bool isNullableObjectArray =
552+
schemaNode is JsonObject objSchema &&
553+
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
554+
typeNode is JsonArray { Count: 2 } typeArray &&
555+
typeArray.Any(type => (string?)type is "object") &&
556+
typeArray.Any(type => (string?)type is "null");
557+
558+
if (!isNullableObjectArray)
559+
{
560+
structuredOutputRequiresWrapping = true;
561+
}
493562
}
494563

495-
if (outputSchema.ValueKind is not JsonValueKind.Object ||
496-
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
564+
return structuredOutputRequiresWrapping;
565+
}
566+
567+
/// <summary>
568+
/// Transforms <paramref name="naturalSchema"/> into the wire shape required by clients
569+
/// on protocol versions older than <c>"2026-06-30"</c>: non-object schemas are wrapped
570+
/// in <c>{"type":"object","properties":{"result":&lt;schema&gt;},"required":["result"]}</c>,
571+
/// the <c>type:["object","null"]</c> array form is normalized to plain <c>"object"</c>,
572+
/// and plain object-typed schemas pass through unchanged. SEP-2106 clients
573+
/// (<c>"2026-06-30"</c>+) see the natural schema and never need this transform.
574+
/// Dispatches on <see cref="ShouldWrapValueForLegacyWire"/> so the wrap decision lives
575+
/// in one place.
576+
/// </summary>
577+
/// <param name="naturalSchema">The natural JSON Schema 2020-12 document.</param>
578+
internal static JsonElement TransformOutputSchemaForLegacyWire(JsonElement naturalSchema)
579+
{
580+
if (naturalSchema.ValueKind is not JsonValueKind.Object ||
581+
!naturalSchema.TryGetProperty("type", out JsonElement typeProperty) ||
497582
typeProperty.ValueKind is not JsonValueKind.String ||
498583
typeProperty.GetString() is not "object")
499584
{
500-
// If the output schema is not an object, need to modify to be a valid MCP output schema.
501-
JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
585+
JsonNode? schemaNode = JsonSerializer.SerializeToNode(naturalSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
502586

503587
if (schemaNode is JsonObject objSchema &&
504588
objSchema.TryGetPropertyValue("type", out JsonNode? typeNode) &&
505-
typeNode is JsonArray { Count: 2 } typeArray && typeArray.Any(type => (string?)type is "object") && typeArray.Any(type => (string?)type is "null"))
589+
typeNode is JsonArray { Count: 2 } typeArray &&
590+
typeArray.Any(type => (string?)type is "object") &&
591+
typeArray.Any(type => (string?)type is "null"))
506592
{
507-
// For schemas that are of type ["object", "null"], replace with just "object" to be conformant.
593+
// type:["object","null"] → normalize to plain "object". No envelope.
508594
objSchema["type"] = "object";
509595
}
510596
else
511597
{
512-
// For anything else, wrap the schema in an envelope with a "result" property.
598+
// Anything else (string, integer, array, boolean schemas, missing type,
599+
// compositions). Wrap in the {"result": <schema>} envelope.
513600
schemaNode = new JsonObject
514601
{
515602
["type"] = "object",
@@ -524,14 +611,12 @@ typeProperty.ValueKind is not JsonValueKind.String ||
524611
// paths (e.g., "#/items/..." or "#") are now invalid because the original schema
525612
// has moved under "#/properties/result". Rewrite them to account for the new location.
526613
RewriteRefPointers(schemaNode["properties"]!["result"]);
527-
528-
structuredOutputRequiresWrapping = true;
529614
}
530615

531-
outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
616+
return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
532617
}
533618

534-
return outputSchema;
619+
return naturalSchema;
535620
}
536621

537622
/// <summary>
@@ -580,7 +665,7 @@ private static void RewriteRefPointers(JsonNode? node)
580665
}
581666
}
582667

583-
private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
668+
private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion)
584669
{
585670
if (ProtocolTool.OutputSchema is null)
586671
{
@@ -596,10 +681,14 @@ private static void RewriteRefPointers(JsonNode? node)
596681
_ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
597682
};
598683

599-
if (_structuredOutputRequiresWrapping)
684+
// Pre-SEP-2106 clients expect the {"result": <value>} envelope for non-object
685+
// schemas. SEP-2106 clients see the natural shape. The classification is decided
686+
// fresh per request from the stored natural schema.
687+
if (!McpSessionHandler.SupportsNaturalOutputSchemas(negotiatedProtocolVersion) &&
688+
ShouldWrapValueForLegacyWire(ProtocolTool.OutputSchema.Value))
600689
{
601-
JsonNode? resultNode = elementResult is { } je
602-
? JsonSerializer.SerializeToNode(je, McpJsonUtilities.JsonContext.Default.JsonElement)
690+
JsonNode? resultNode = elementResult is { } v
691+
? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement)
603692
: null;
604693
return JsonSerializer.SerializeToElement(new JsonObject
605694
{

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -825,9 +825,22 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
825825

826826
if (request.Params?.Cursor is null)
827827
{
828+
// SEP-2106 wire shaping: clients on protocol versions older than
829+
// 2026-06-30 require outputSchema.type == "object", so the natural
830+
// schema is reshaped before emission (type:["object","null"] normalized
831+
// to "object", any other non-object schema wrapped in
832+
// {"type":"object","properties":{"result":<schema>}}). Clients on
833+
// 2026-06-30+ receive the natural JSON Schema 2020-12 document stored
834+
// on Tool.OutputSchema. Only AIFunctionMcpServerTool tools go through
835+
// reshaping; custom McpServerTool subclasses build their Tool directly
836+
// and pass through unchanged at every protocol version.
837+
bool useNaturalSchemas = McpSessionHandler.SupportsNaturalOutputSchemas(request.Server.NegotiatedProtocolVersion);
828838
foreach (var t in tools)
829839
{
830-
result.Tools.Add(t.ProtocolTool);
840+
Tool wireTool = useNaturalSchemas || t is not AIFunctionMcpServerTool aiFunctionTool
841+
? t.ProtocolTool
842+
: aiFunctionTool.BuildLegacyWireProtocolTool();
843+
result.Tools.Add(wireTool);
831844
}
832845
}
833846

0 commit comments

Comments
 (0)