@@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server;
1313/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
1414internal 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":<schema>}}</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": <value>}</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":<schema>},"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 {
0 commit comments