Skip to content
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 @@ -72,6 +72,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 @@ -81,17 +81,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 not required to be <c>"object"</c>.
/// </para>
/// </remarks>
[JsonPropertyName("outputSchema")]
Expand All @@ -100,9 +107,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)
Comment thread
PranavSenthilnathan marked this conversation as resolved.
{
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)
Comment thread
mikekistler marked this conversation as resolved.
{
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 @@ -547,14 +634,12 @@ typeProperty.ValueKind is not JsonValueKind.String ||
// paths (e.g., "#/items/..." or "#") are now invalid because the original schema
// has moved under "#/properties/result". Rewrite them to account for the new location.
RewriteRefPointers(schemaNode["properties"]!["result"]);

structuredOutputRequiresWrapping = true;
}

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

return outputSchema;
return naturalSchema;
}

/// <summary>
Expand Down Expand Up @@ -603,7 +688,7 @@ private static void RewriteRefPointers(JsonNode? node)
}
}

private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion)
{
if (ProtocolTool.OutputSchema is null)
{
Expand All @@ -619,10 +704,14 @@ private static void RewriteRefPointers(JsonNode? node)
_ => 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 @@ -733,9 +733,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