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); +}