Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ 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. Pre-2026-06-30 clients still receive the legacy wrapped wire shape — that
// wiring lives in AIFunctionMcpServerTool.CreateStructuredResponse and McpServerImpl's
// listToolsHandler.
internal static bool IsValidToolOutputSchema(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,
Expand Down
24 changes: 24 additions & 0 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,30 @@ internal static bool SupportsPrimingEvent(string? protocolVersion)
return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0;
}

/// <summary>
/// Checks whether the negotiated protocol version permits emitting non-object output
/// schemas and their structured content in their natural shape (per SEP-2106).
/// </summary>
/// <param name="protocolVersion">The negotiated protocol version, or <c>null</c> if
/// negotiation has not completed.</param>
/// <returns><c>true</c> if the version is <c>"2026-06-30"</c> or later (including the
/// in-flight <c>"DRAFT-2026-06-v1"</c>, since <c>'D' &gt; '2'</c> ordinally); <c>false</c>
/// otherwise. A <c>false</c> return signals that the wire emission boundary must apply
/// the <c>{"result": &lt;value&gt;}</c> envelope expected by clients on protocol versions
/// that pre-date SEP-2106's widening of <c>outputSchema</c> to any JSON Schema 2020-12
/// document.</returns>
internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
{
const string MinNaturalOutputSchemasProtocolVersion = "2026-06-30";

if (protocolVersion is null)
{
return false;
}

return string.Compare(protocolVersion, MinNaturalOutputSchemasProtocolVersion, StringComparison.Ordinal) >= 0;
}

private readonly bool _isServer;
private readonly string _transportKind;
private readonly ITransport _transport;
Expand Down
23 changes: 15 additions & 8 deletions src/ModelContextProtocol.Core/Protocol/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,24 @@ public JsonElement InputSchema
} = McpJsonUtilities.DefaultMcpToolSchema;

/// <summary>
/// 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.
/// </summary>
/// <exception cref="ArgumentException">The value is not a valid MCP tool JSON schema.</exception>
/// <exception cref="ArgumentException">
/// The value is not a valid JSON Schema 2020-12 document — i.e., not a JSON object or a
/// JSON boolean.
/// </exception>
/// <remarks>
/// <para>
/// 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 <see cref="ArgumentException"/>
/// if an invalid schema is provided.
/// Per SEP-2106 ("Allow valid JSON Schemas in <c>outputSchema</c>"), the schema may describe
/// any JSON value — object, array, string, number, boolean, or <see langword="null"/> — to
/// support tools whose structured output is not an object. The setter only checks that the
/// supplied value is a structurally valid JSON Schema 2020-12 document (a JSON object, or
/// the boolean schemas <c>true</c>/<c>false</c> per §4.3); deeper keyword-level validation
/// is intentionally not performed.
/// </para>
/// <para>
/// The schema should describe the shape of the data as returned in <see cref="CallToolResult.StructuredContent"/>.
/// The schema describes the shape of the value placed in <see cref="CallToolResult.StructuredContent"/>.
/// Unlike <see cref="InputSchema"/>, the top-level <c>type</c> is no longer required to be <c>"object"</c>.
/// </para>
/// </remarks>
[JsonPropertyName("outputSchema")]
Expand All @@ -99,9 +106,9 @@ public JsonElement? OutputSchema
get => field;
set
{
if (value is not null && !McpJsonUtilities.IsValidMcpToolSchema(value.Value))
if (value is not null && !McpJsonUtilities.IsValidToolOutputSchema(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 2020-12 document (must be a JSON object or a JSON boolean).", nameof(OutputSchema));
}

field = value;
Expand Down
149 changes: 119 additions & 30 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ namespace ModelContextProtocol.Server;
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
private readonly bool _structuredOutputRequiresWrapping;
private readonly IReadOnlyList<object> _metadata;

/// <summary>
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -173,7 +172,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)
Expand Down Expand Up @@ -241,14 +240,13 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
internal AIFunction AIFunction { get; }

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

AIFunction = function;
ProtocolTool = tool;

_structuredOutputRequiresWrapping = structuredOutputRequiresWrapping;
_metadata = metadata;
}

Expand All @@ -258,6 +256,41 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider
/// <inheritdoc />
public override IReadOnlyList<object> Metadata => _metadata;

/// <summary>
/// Returns a <see cref="Tool"/> clone whose <see cref="Tool.OutputSchema"/> is rewritten
/// into the wire shape required by clients on protocol versions older than
/// <c>"2026-06-30"</c>. Those versions require <c>outputSchema.type == "object"</c>;
/// SEP-2106 (negotiated at <c>"2026-06-30"</c> and later) widens that to any JSON
/// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in
/// <c>{"type":"object","properties":{"result":&lt;schema&gt;}}</c> and the
/// <c>type:["object","null"]</c> array form is normalized to plain <c>"object"</c>
/// before emission. Returns <see cref="ProtocolTool"/> unchanged when there is no
/// output schema. Callers must gate the call on the negotiated version — this method
/// is unconditional; the gate lives at the emission site.
/// </summary>
internal Tool BuildLegacyWireProtocolTool()
{
if (ProtocolTool.OutputSchema is not { } natural)
{
return ProtocolTool;
}

JsonElement legacyOutputSchema = TransformOutputSchemaForLegacyWire(natural);

return new Tool
{
Name = ProtocolTool.Name,
Title = ProtocolTool.Title,
Description = ProtocolTool.Description,
InputSchema = ProtocolTool.InputSchema,
OutputSchema = legacyOutputSchema,
Annotations = ProtocolTool.Annotations,
Execution = ProtocolTool.Execution,
Icons = ProtocolTool.Icons,
Meta = ProtocolTool.Meta,
};
}

/// <inheritdoc />
public override async ValueTask<CallToolResult> InvokeAsync(
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
Expand All @@ -279,7 +312,7 @@ public override async ValueTask<CallToolResult> InvokeAsync(
object? result;
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);

JsonElement? structuredContent = CreateStructuredResponse(result);
JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion);
return result switch
{
AIContent aiContent => new()
Expand Down Expand Up @@ -491,48 +524,102 @@ 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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For backward compatibility reasons, I don't think we can completely eliminate the wrapping of non-object schemas, since this will still be needed for clients using 2025-11-25 or earlier protocol versions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapping for non-object schemas is restored in c30725c, just at the wire emission boundary instead of inside CreateOutputSchema. The classification + transformation logic is the same as before SEP-2106; it moved from registration-time storage to per-request emission so a single registered Tool can serve both legacy and SEP-2106 clients off the same server

{
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;
// Back-compat for pre-2026-06-30 clients is applied at the wire emission sites
// (CreateStructuredResponse for tools/call, listToolsHandler for tools/list).
if (toolCreateOptions.OutputSchema is { } explicitSchema)
{
outputSchema = explicitSchema;
return explicitSchema;
}
else if (function.ReturnJsonSchema is { } returnSchema)

if (function.ReturnJsonSchema is { } returnSchema)
{
outputSchema = returnSchema;
return returnSchema;
}
else

return null;
}

/// <summary>
/// Returns <see langword="true"/> iff the structured-content value must be wrapped in
/// the <c>{"result": &lt;value&gt;}</c> envelope on the wire — i.e., the output schema
/// is neither plain object-typed (<c>type:"object"</c>) nor the
/// <c>type:["object","null"]</c> array form. Used by <see cref="CreateStructuredResponse"/>
/// to decide whether to apply the envelope when emitting to a client that negotiated a
/// protocol version older than <c>"2026-06-30"</c> (those versions pre-date SEP-2106's
/// allowance of non-object output schemas). The inner <c>type:["object","null"]</c>
/// check is hoisted into a named bool to keep the surrounding control flow free of
/// empty branches.
/// </summary>
internal static bool ShouldWrapValueForLegacyWire(JsonElement schema)
{
bool structuredOutputRequiresWrapping = false;

if (schema.ValueKind is not JsonValueKind.Object ||
!schema.TryGetProperty("type", out JsonElement typeProperty) ||
typeProperty.ValueKind is not JsonValueKind.String ||
typeProperty.GetString() is not "object")
{
return null;
JsonNode? schemaNode = JsonSerializer.SerializeToNode(schema, McpJsonUtilities.JsonContext.Default.JsonElement);

bool isNullableObjectArray =
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");

if (!isNullableObjectArray)
{
structuredOutputRequiresWrapping = true;
}
}

if (outputSchema.ValueKind is not JsonValueKind.Object ||
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
return structuredOutputRequiresWrapping;
}

/// <summary>
/// Transforms <paramref name="naturalSchema"/> into the wire shape required by clients
/// on protocol versions older than <c>"2026-06-30"</c>: non-object schemas are wrapped
/// in <c>{"type":"object","properties":{"result":&lt;schema&gt;},"required":["result"]}</c>,
/// the <c>type:["object","null"]</c> array form is normalized to plain <c>"object"</c>,
/// and plain object-typed schemas pass through unchanged. SEP-2106 clients
/// (<c>"2026-06-30"</c>+) see the natural schema and never need this transform.
/// Dispatches on <see cref="ShouldWrapValueForLegacyWire"/> so the wrap decision lives
/// in one place.
/// </summary>
/// <param name="naturalSchema">The natural JSON Schema 2020-12 document.</param>
internal static JsonElement TransformOutputSchemaForLegacyWire(JsonElement naturalSchema)
{
if (naturalSchema.ValueKind is not JsonValueKind.Object ||
!naturalSchema.TryGetProperty("type", out JsonElement typeProperty) ||
typeProperty.ValueKind is not JsonValueKind.String ||
typeProperty.GetString() is not "object")
{
// 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);
JsonNode? schemaNode = JsonSerializer.SerializeToNode(naturalSchema, 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"))
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.
// type:["object","null"] → normalize to plain "object". No envelope.
objSchema["type"] = "object";
}
else
{
// For anything else, wrap the schema in an envelope with a "result" property.
// Anything else (string, integer, array, boolean schemas, missing type,
// compositions). Wrap in the {"result": <schema>} envelope.
schemaNode = new JsonObject
{
["type"] = "object",
Expand All @@ -542,17 +629,15 @@ typeProperty.ValueKind is not JsonValueKind.String ||
},
["required"] = new JsonArray { (JsonNode)"result" }
};

structuredOutputRequiresWrapping = true;
}

outputSchema = JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
return JsonSerializer.Deserialize(schemaNode, McpJsonUtilities.JsonContext.Default.JsonElement);
}

return outputSchema;
return naturalSchema;
}

private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion)
{
if (ProtocolTool.OutputSchema is null)
{
Expand All @@ -568,10 +653,14 @@ typeProperty.ValueKind is not JsonValueKind.String ||
_ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
};

if (_structuredOutputRequiresWrapping)
// Pre-SEP-2106 clients expect the {"result": <value>} envelope for non-object
// schemas. SEP-2106 clients see the natural shape. The classification is decided
// fresh per request from the stored natural schema.
if (!McpSessionHandler.SupportsNaturalOutputSchemas(negotiatedProtocolVersion) &&
ShouldWrapValueForLegacyWire(ProtocolTool.OutputSchema.Value))
{
JsonNode? resultNode = elementResult is { } je
? JsonSerializer.SerializeToNode(je, McpJsonUtilities.JsonContext.Default.JsonElement)
JsonNode? resultNode = elementResult is { } v
? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement)
: null;
return JsonSerializer.SerializeToElement(new JsonObject
{
Expand Down
15 changes: 14 additions & 1 deletion src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -690,9 +690,22 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)

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

Expand Down
Loading
Loading