diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
index abb6d29df..b4413f47c 100644
--- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs
+++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs
@@ -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,
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 77c18b8be..172c32023 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -65,6 +65,30 @@ internal static bool SupportsPrimingEvent(string? protocolVersion)
return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0;
}
+ ///
+ /// Checks whether the negotiated protocol version permits emitting non-object output
+ /// schemas and their structured content in their natural shape (per SEP-2106).
+ ///
+ /// The negotiated protocol version, or null if
+ /// negotiation has not completed.
+ /// true if the version is "2026-06-30" or later (including the
+ /// in-flight "DRAFT-2026-06-v1" , since 'D' > '2' ordinally); false
+ /// otherwise. A false return signals that the wire emission boundary must apply
+ /// the {"result": <value>} envelope expected by clients on protocol versions
+ /// that pre-date SEP-2106's widening of outputSchema to any JSON Schema 2020-12
+ /// document.
+ 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;
diff --git a/src/ModelContextProtocol.Core/Protocol/Tool.cs b/src/ModelContextProtocol.Core/Protocol/Tool.cs
index 8abbfd88c..2b0365963 100644
--- a/src/ModelContextProtocol.Core/Protocol/Tool.cs
+++ b/src/ModelContextProtocol.Core/Protocol/Tool.cs
@@ -80,17 +80,24 @@ 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 valid JSON Schema 2020-12 document — i.e., not a JSON object or a
+ /// JSON boolean.
+ ///
///
///
- /// 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 checks that the
+ /// supplied value is a structurally valid JSON Schema 2020-12 document (a JSON object, or
+ /// the boolean schemas true /false per §4.3); 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 +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;
diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
index 961344c2c..cc9578984 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,
};
@@ -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)
@@ -241,14 +240,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;
}
@@ -258,6 +256,41 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider
///
public override IReadOnlyList Metadata => _metadata;
+ ///
+ /// Returns a clone whose is rewritten
+ /// into the wire shape required by clients on protocol versions older than
+ /// "2026-06-30" . Those versions require outputSchema.type == "object" ;
+ /// SEP-2106 (negotiated at "2026-06-30" and later) widens that to any JSON
+ /// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in
+ /// {"type":"object","properties":{"result":<schema>}} and the
+ /// type:["object","null"] array form is normalized to plain "object"
+ /// before emission. Returns 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.
+ ///
+ 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,
+ };
+ }
+
///
public override async ValueTask InvokeAsync(
RequestContext request, CancellationToken cancellationToken = default)
@@ -279,7 +312,7 @@ public override async ValueTask 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()
@@ -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)
{
- 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;
+ }
+
+ ///
+ /// Returns iff the structured-content value must be wrapped in
+ /// the {"result": <value>} envelope on the wire — i.e., the output schema
+ /// is neither plain object-typed (type:"object" ) nor the
+ /// type:["object","null"] array form. Used by
+ /// to decide whether to apply the envelope when emitting to a client that negotiated a
+ /// protocol version older than "2026-06-30" (those versions pre-date SEP-2106's
+ /// allowance of non-object output schemas). The inner type:["object","null"]
+ /// check is hoisted into a named bool to keep the surrounding control flow free of
+ /// empty branches.
+ ///
+ 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;
+ }
+
+ ///
+ /// Transforms into the wire shape required by clients
+ /// on protocol versions older than "2026-06-30" : non-object schemas are wrapped
+ /// in {"type":"object","properties":{"result":<schema>},"required":["result"]} ,
+ /// the type:["object","null"] array form is normalized to plain "object" ,
+ /// and plain object-typed schemas pass through unchanged. SEP-2106 clients
+ /// ("2026-06-30" +) see the natural schema and never need this transform.
+ /// Dispatches on so the wrap decision lives
+ /// in one place.
+ ///
+ /// The natural JSON Schema 2020-12 document.
+ 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": } envelope.
schemaNode = new JsonObject
{
["type"] = "object",
@@ -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)
{
@@ -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": } 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
{
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index 04d11e016..f7997af02 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -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":}}). 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);
}
}
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 a283bf18c..7fdcd2285 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
@@ -461,15 +461,22 @@ 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.
+ //
+ // Pinned to a SEP-2106 negotiated version because the assertion compares the natural
+ // in-memory schema against the emitted value. Under a legacy negotiated version the
+ // emitted value would be re-wrapped in {"result": } for backward compatibility
+ // and would no longer validate against the natural schema.
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => value, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
- var mockServer = new Mock();
- var request = new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new CallToolRequestParams { Name = "tool" });
+ var request = CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion);
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 +601,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 +614,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 +631,177 @@ 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 _));
+ }
+
+ // SEP-2106 backward-compat: for clients negotiating a pre-2026-06-30 protocol version,
+ // non-object structured content is wrapped in the legacy {"result": } envelope.
+ // Clients on the SEP-2106 protocol ("2026-06-30" and later, including the draft) see the
+ // natural value shape. In-memory storage stays natural in both modes — only the wire
+ // emission flips.
+ private const string LegacyProtocolVersion = "2025-11-25";
+ private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1";
+ private const string Sep2106ProtocolVersion = "2026-06-30";
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(null, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task StructuredContent_StringReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped)
+ {
+ McpServerTool tool = McpServerTool.Create(() => "hello", new() { Name = "tool", UseStructuredContent = true });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ if (expectWrapped)
+ {
+ Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind);
+ Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner));
+ Assert.Equal("hello", inner.GetString());
+ }
+ else
+ {
+ Assert.Equal(JsonValueKind.String, result.StructuredContent.Value.ValueKind);
+ Assert.Equal("hello", result.StructuredContent.Value.GetString());
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(null, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task StructuredContent_IntegerReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped)
+ {
+ McpServerTool tool = McpServerTool.Create(() => 42, new() { Name = "tool", UseStructuredContent = true });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ if (expectWrapped)
+ {
+ Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind);
+ Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner));
+ Assert.Equal(42, inner.GetInt32());
+ }
+ else
+ {
+ Assert.Equal(JsonValueKind.Number, result.StructuredContent.Value.ValueKind);
+ Assert.Equal(42, result.StructuredContent.Value.GetInt32());
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(null, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task StructuredContent_ArrayReturn_WrapsForLegacyClients(string? protocolVersion, bool expectWrapped)
+ {
+ McpServerTool tool = McpServerTool.Create(() => new[] { "a", "b" }, new() { Name = "tool", UseStructuredContent = true });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ if (expectWrapped)
+ {
+ Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind);
+ Assert.True(result.StructuredContent.Value.TryGetProperty("result", out var inner));
+ Assert.Equal(JsonValueKind.Array, inner.ValueKind);
+ Assert.Equal(2, inner.GetArrayLength());
+ }
+ else
+ {
+ Assert.Equal(JsonValueKind.Array, result.StructuredContent.Value.ValueKind);
+ Assert.Equal(2, result.StructuredContent.Value.GetArrayLength());
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion)]
+ [InlineData(null)]
+ [InlineData(DraftSep2106ProtocolVersion)]
+ [InlineData(Sep2106ProtocolVersion)]
+ public async Task StructuredContent_ObjectReturn_NeverWrapped(string? protocolVersion)
+ {
+ // Object-typed return: the stored schema is type:"object" — already the form
+ // expected by clients on protocol versions older than 2026-06-30, so no envelope
+ // is applied at any protocol version. Wire shape must be identical across versions.
+ McpServerTool tool = McpServerTool.Create(() => new Person("John", 27), new()
+ {
+ Name = "tool",
+ UseStructuredContent = true,
+ SerializerOptions = CreateSerializerOptionsWithPerson(),
+ });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("result", out _));
+ Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString());
+ Assert.Equal(27, result.StructuredContent.Value.GetProperty("age").GetInt32());
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion)]
+ [InlineData(null)]
+ [InlineData(DraftSep2106ProtocolVersion)]
+ [InlineData(Sep2106ProtocolVersion)]
+ public async Task StructuredContent_NullableObjectReturn_NeverWrapped(string? protocolVersion)
+ {
+ // type:["object","null"] — for clients on protocol versions older than 2026-06-30,
+ // the SCHEMA is normalized to plain type:"object" (verified in
+ // Sep2106ListToolsBackCompatTests), but the value side is never envelope-wrapped at
+ // any protocol version. So the emitted structured content stays a plain object
+ // across versions.
+ JsonElement outputSchema = JsonDocument.Parse(
+ """{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement;
+ McpServerTool tool = McpServerTool.Create(() => new Person("John", 27), new()
+ {
+ Name = "tool",
+ UseStructuredContent = true,
+ OutputSchema = outputSchema,
+ SerializerOptions = CreateSerializerOptionsWithPerson(),
+ });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Object, result.StructuredContent.Value.ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("result", out _));
+ Assert.Equal("John", result.StructuredContent.Value.GetProperty("name").GetString());
+ }
+
+ private static RequestContext CreateRequestContextWithProtocolVersion(string? protocolVersion)
+ {
+ var mockServer = new Mock();
+ mockServer.SetupGet(s => s.NegotiatedProtocolVersion).Returns(protocolVersion);
+ return new RequestContext(mockServer.Object, CreateTestJsonRpcRequest(), new CallToolRequestParams { Name = "tool" });
}
[Fact]
@@ -1004,15 +1182,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());
}
diff --git a/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs
new file mode 100644
index 000000000..32dc59cec
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/Sep2106ListToolsBackCompatTests.cs
@@ -0,0 +1,197 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// SEP-2106 backward-compat at the tools/list emission boundary. Clients negotiating a
+/// pre-2026-06-30 protocol version must still receive the legacy
+/// {"type":"object","properties":{"result":<schema>},"required":["result"]}
+/// envelope for non-object output schemas. In-memory storage stays natural; only the
+/// wire emission flips on the negotiated version.
+///
+public class Sep2106ListToolsBackCompatTests : ClientServerTestBase
+{
+ private const string LegacyProtocolVersion = "2025-11-25";
+ private const string DraftSep2106ProtocolVersion = "DRAFT-2026-06-v1";
+ private const string Sep2106ProtocolVersion = "2026-06-30";
+
+ public Sep2106ListToolsBackCompatTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper, startServer: false)
+ {
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task ListTools_StringTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped)
+ {
+ ConfigureServerWithTools(serverProtocolVersion);
+ await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
+
+ JsonElement schema = await GetOutputSchemaAsync(client, "return_string");
+
+ if (expectWrapped)
+ {
+ AssertResultEnvelope(schema, innerType: "string");
+ }
+ else
+ {
+ Assert.Equal("string", schema.GetProperty("type").GetString());
+ Assert.False(schema.TryGetProperty("properties", out _));
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task ListTools_IntegerTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped)
+ {
+ ConfigureServerWithTools(serverProtocolVersion);
+ await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
+
+ JsonElement schema = await GetOutputSchemaAsync(client, "return_int");
+
+ if (expectWrapped)
+ {
+ AssertResultEnvelope(schema, innerType: "integer");
+ }
+ else
+ {
+ Assert.Equal("integer", schema.GetProperty("type").GetString());
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task ListTools_ArrayTool_WrapsOutputSchemaForLegacyClients(string serverProtocolVersion, bool expectWrapped)
+ {
+ ConfigureServerWithTools(serverProtocolVersion);
+ await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
+
+ JsonElement schema = await GetOutputSchemaAsync(client, "return_array");
+
+ if (expectWrapped)
+ {
+ AssertResultEnvelope(schema, innerType: "array");
+ }
+ else
+ {
+ Assert.Equal("array", schema.GetProperty("type").GetString());
+ }
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion)]
+ [InlineData(DraftSep2106ProtocolVersion)]
+ [InlineData(Sep2106ProtocolVersion)]
+ public async Task ListTools_ObjectTool_NeverWrapsOutputSchema(string serverProtocolVersion)
+ {
+ // Object-shaped schemas should be wire-identical across all protocol versions.
+ ConfigureServerWithTools(serverProtocolVersion);
+ await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
+
+ JsonElement schema = await GetOutputSchemaAsync(client, "return_person");
+
+ Assert.Equal("object", schema.GetProperty("type").GetString());
+ Assert.True(schema.GetProperty("properties").TryGetProperty("name", out _));
+ Assert.True(schema.GetProperty("properties").TryGetProperty("age", out _));
+ Assert.False(schema.GetProperty("properties").TryGetProperty("result", out _));
+ }
+
+ [Theory]
+ [InlineData(LegacyProtocolVersion, true)]
+ [InlineData(DraftSep2106ProtocolVersion, false)]
+ [InlineData(Sep2106ProtocolVersion, false)]
+ public async Task ListTools_NullableObjectTool_NormalizesTypeArrayForLegacyClients(string serverProtocolVersion, bool expectNormalized)
+ {
+ // For clients on protocol versions older than 2026-06-30, type:["object","null"]
+ // must be emitted as plain type:"object" (those versions accept object schemas but
+ // not type-arrays — and the value side stays a plain object, no envelope). SEP-2106
+ // clients (2026-06-30+) see the natural type-array intact per the SEP's
+ // any-JSON-Schema-2020-12 allowance.
+ ConfigureServerWithTools(serverProtocolVersion);
+ await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
+
+ JsonElement schema = await GetOutputSchemaAsync(client, "return_nullable_object");
+
+ Assert.False(schema.GetProperty("properties").TryGetProperty("result", out _),
+ "type:['object','null'] schemas must not be re-wrapped in a result envelope.");
+
+ JsonElement typeProperty = schema.GetProperty("type");
+ if (expectNormalized)
+ {
+ // Legacy wire shape: ["object","null"] collapsed to plain "object" string.
+ Assert.Equal(JsonValueKind.String, typeProperty.ValueKind);
+ Assert.Equal("object", typeProperty.GetString());
+ }
+ else
+ {
+ // SEP-2106 wire shape: the natural type array passes through with both
+ // members, in either order.
+ Assert.Equal(JsonValueKind.Array, typeProperty.ValueKind);
+ Assert.Equal(2, typeProperty.GetArrayLength());
+ HashSet members = [];
+ foreach (JsonElement entry in typeProperty.EnumerateArray())
+ {
+ members.Add(entry.GetString());
+ }
+ Assert.Contains("object", members);
+ Assert.Contains("null", members);
+ }
+ }
+
+ private void ConfigureServerWithTools(string protocolVersion)
+ {
+ JsonSerializerOptions serializerOptions = new()
+ {
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ };
+ JsonElement nullableObjectSchema = JsonDocument.Parse(
+ """{"type":["object","null"],"properties":{"name":{"type":"string"}}}""").RootElement;
+
+ ServiceCollection.Configure(o => o.ProtocolVersion = protocolVersion);
+ McpServerBuilder.WithTools(
+ [
+ McpServerTool.Create(() => "hello", new() { Name = "return_string", UseStructuredContent = true, SerializerOptions = serializerOptions }),
+ McpServerTool.Create(() => 42, new() { Name = "return_int", UseStructuredContent = true, SerializerOptions = serializerOptions }),
+ McpServerTool.Create(() => new[] { "a", "b" }, new() { Name = "return_array", UseStructuredContent = true, SerializerOptions = serializerOptions }),
+ McpServerTool.Create(() => new Person("John", 27), new() { Name = "return_person", UseStructuredContent = true, SerializerOptions = serializerOptions }),
+ McpServerTool.Create(() => new Person("John", 27), new() { Name = "return_nullable_object", UseStructuredContent = true, OutputSchema = nullableObjectSchema, SerializerOptions = serializerOptions }),
+ ]);
+ StartServer();
+ }
+
+ private static async Task GetOutputSchemaAsync(McpClient client, string toolName)
+ {
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+ var tool = tools.Single(t => t.Name == toolName);
+ Assert.NotNull(tool.ProtocolTool.OutputSchema);
+ return tool.ProtocolTool.OutputSchema.Value;
+ }
+
+ private static void AssertResultEnvelope(JsonElement schema, string innerType)
+ {
+ Assert.Equal("object", schema.GetProperty("type").GetString());
+
+ JsonElement properties = schema.GetProperty("properties");
+ Assert.True(properties.TryGetProperty("result", out JsonElement inner));
+ Assert.Equal(innerType, inner.GetProperty("type").GetString());
+
+ JsonElement required = schema.GetProperty("required");
+ Assert.Equal(JsonValueKind.Array, required.ValueKind);
+ Assert.Equal(1, required.GetArrayLength());
+ Assert.Equal("result", required[0].GetString());
+ }
+
+ private sealed record Person(string Name, int Age);
+}