Relax outputSchema to any JSON Schema 2020-12 document per SEP-2106 (#1568)

Co-authored-by: Mike Kistler <mikekistler@microsoft.com>
This commit is contained in:
Jayaraman Venkatesan 2026-06-16 18:02:04 -04:00 • committed by GitHub
commit 0663b7c878
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 839 additions and 260 deletions

View file

@ -84,6 +84,17 @@ public static partial class McpJsonUtilities
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,

View file

@ -72,6 +72,30 @@ internal sealed partial class McpSessionHandler : IAsyncDisposable
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;

View file

@ -81,17 +81,24 @@ public sealed class Tool : IBaseMetadata
} = 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")]
@ -100,9 +107,9 @@ public sealed class Tool : IBaseMetadata
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;

View file

@ -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>
@ -120,7 +119,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
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,
};
@ -156,7 +155,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
options.Meta;
}
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)
@ -218,14 +217,13 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
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;
}
@ -235,6 +233,41 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
/// <inheritdoc />
public override IReadOnlyList<object> Metadata => _metadata;
/// <summary>
/// Returns a <see cref="Tool"/> clone whose <see cref="Tool.OutputSchema"/> is rewritten
/// into the wire shape required by clients on protocol versions older than
/// <c>"2026-06-30"</c>. Those versions require <c>outputSchema.type == "object"</c>;
/// SEP-2106 (negotiated at <c>"2026-06-30"</c> and later) widens that to any JSON
/// Schema 2020-12 document. To stay compatible, non-object schemas are wrapped in
/// <c>{"type":"object","properties":{"result":&lt;schema&gt;}}</c> and the
/// <c>type:["object","null"]</c> array form is normalized to plain <c>"object"</c>
/// before emission. Returns <see cref="ProtocolTool"/> unchanged when there is no
/// output schema. Callers must gate the call on the negotiated version — this method
/// is unconditional; the gate lives at the emission site.
/// </summary>
internal Tool BuildLegacyWireProtocolTool()
{
if (ProtocolTool.OutputSchema is not { } natural)
{
return ProtocolTool;
}
JsonElement legacyOutputSchema = TransformOutputSchemaForLegacyWire(natural);
return new Tool
{
Name = ProtocolTool.Name,
Title = ProtocolTool.Title,
Description = ProtocolTool.Description,
InputSchema = ProtocolTool.InputSchema,
OutputSchema = legacyOutputSchema,
Annotations = ProtocolTool.Annotations,
Execution = ProtocolTool.Execution,
Icons = ProtocolTool.Icons,
Meta = ProtocolTool.Meta,
};
}
/// <inheritdoc />
public override async ValueTask<CallToolResult> InvokeAsync(
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
@ -256,7 +289,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
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()
@ -468,48 +501,102 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
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;
}
else if (function.ReturnJsonSchema is { } returnSchema)
{
outputSchema = returnSchema;
}
else
{
return null;
return explicitSchema;
}
if (outputSchema.ValueKind is not JsonValueKind.Object ||
!outputSchema.TryGetProperty("type", out JsonElement typeProperty) ||
if (function.ReturnJsonSchema is { } returnSchema)
{
return returnSchema;
}
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")
{
// 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(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;
}
}
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")
{
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",
@ -524,14 +611,12 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
// 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>
@ -580,7 +665,7 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
}
}
private JsonElement? CreateStructuredResponse(object? aiFunctionResult)
private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion)
{
if (ProtocolTool.OutputSchema is null)
{
@ -596,10 +681,14 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
_ => 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
{

View file

@ -825,9 +825,22 @@ internal sealed partial class McpServerImpl : McpServer
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);
}
}

View file

@ -147,4 +147,51 @@ public static class ToolTests
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<ArgumentException>(() => 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));
}
}

View file

@ -461,15 +461,22 @@ public partial class McpServerToolTests
[MemberData(nameof(StructuredOutput_ReturnsExpectedSchema_Inputs))]
public async Task StructuredOutput_Enabled_ReturnsExpectedSchema<T>(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": <value>} 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<McpServer>();
var request = new RequestContext<CallToolRequestParams>(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 partial class McpServerToolTests
}
[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":<schema>}} envelope.
JsonElement outputSchema = JsonDocument.Parse("""{"type":"string"}""").RootElement;
McpServerTool tool = McpServerTool.Create(() => "result", new()
{
@ -605,16 +614,15 @@ public partial class McpServerToolTests
});
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 partial class McpServerToolTests
});
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": <value>} 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<CallToolRequestParams> CreateRequestContextWithProtocolVersion(string? protocolVersion)
{
var mockServer = new Mock<McpServer>();
mockServer.SetupGet(s => s.NegotiatedProtocolVersion).Returns(protocolVersion);
return new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest(), new CallToolRequestParams { Name = "tool" });
}
[Fact]
@ -794,186 +972,6 @@ public partial class McpServerToolTests
Assert.True(JsonElement.DeepEquals(expectedSchema, tool.ProtocolTool.InputSchema));
}
[Fact]
public async Task StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers()
{
// When a non-object return type contains the same type at multiple locations,
// System.Text.Json's schema exporter emits $ref pointers for deduplication.
// After wrapping the schema under properties.result, those $ref pointers must
// be rewritten to remain valid. This test verifies that fix.
var data = new List<ContactInfo>
{
new()
{
WorkPhones = [new() { Number = "555-0100", Type = "work" }],
HomePhones = [new() { Number = "555-0200", Type = "home" }],
}
};
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
var mockServer = new Mock<McpServer>();
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }),
TestContext.Current.CancellationToken);
Assert.NotNull(tool.ProtocolTool.OutputSchema);
Assert.Equal("object", tool.ProtocolTool.OutputSchema.Value.GetProperty("type").GetString());
Assert.NotNull(result.StructuredContent);
// Verify $ref pointers in the schema point to valid locations after wrapping.
// Without the fix, $ref values like "#/items/..." would be unresolvable because
// the original schema was moved under "#/properties/result".
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
// Also verify that any $ref in the schema starts with #/properties/result
// (confirming the rewrite happened).
string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText();
var schemaNode = JsonNode.Parse(schemaJson)!;
int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result");
Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found.");
int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode);
Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found.");
}
[Fact]
public async Task StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers()
{
// When a non-object return type contains a recursive type, System.Text.Json's
// schema exporter emits $ref pointers (including potentially bare "#") for the
// recursive reference. After wrapping, these must be rewritten. For List<TreeNode>,
// Children's items emit "$ref": "#/items" which must become "#/properties/result/items".
var data = new List<TreeNode>
{
new()
{
Name = "root",
Children = [new() { Name = "child" }],
}
};
JsonSerializerOptions options = new() { TypeInfoResolver = new DefaultJsonTypeInfoResolver() };
McpServerTool tool = McpServerTool.Create(() => data, new() { Name = "tool", UseStructuredContent = true, SerializerOptions = options });
var mockServer = new Mock<McpServer>();
var result = await tool.InvokeAsync(
new RequestContext<CallToolRequestParams>(mockServer.Object, CreateTestJsonRpcRequest(), new() { Name = "tool" }),
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);
string schemaJson = tool.ProtocolTool.OutputSchema.Value.GetRawText();
var schemaNode = JsonNode.Parse(schemaJson)!;
int refCount = AssertAllRefsStartWith(schemaNode, "#/properties/result");
Assert.True(refCount > 0, "Expected at least one $ref in the schema to validate the rewrite, but none were found.");
int resolvableCount = AssertAllRefsResolvable(schemaNode, schemaNode);
Assert.True(resolvableCount > 0, "Expected at least one resolvable $ref in the schema, but none were found.");
}
private static int AssertAllRefsStartWith(JsonNode? node, string expectedPrefix)
{
int count = 0;
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue)
{
Assert.StartsWith(expectedPrefix, refValue);
count++;
}
foreach (var property in obj)
{
count += AssertAllRefsStartWith(property.Value, expectedPrefix);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
count += AssertAllRefsStartWith(item, expectedPrefix);
}
}
return count;
}
/// <summary>
/// Walks the JSON tree and verifies that every <c>$ref</c> pointer resolves to a valid node.
/// </summary>
private static int AssertAllRefsResolvable(JsonNode root, JsonNode? node)
{
int count = 0;
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue &&
refValue.StartsWith("#", StringComparison.Ordinal))
{
var resolved = ResolveJsonPointer(root, refValue);
Assert.True(resolved is not null, $"$ref \"{refValue}\" does not resolve to a valid node in the schema.");
count++;
}
foreach (var property in obj)
{
count += AssertAllRefsResolvable(root, property.Value);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
count += AssertAllRefsResolvable(root, item);
}
}
return count;
}
/// <summary>
/// Resolves a JSON Pointer (e.g., <c>#/properties/result/items</c>) against a root node.
/// Returns <c>null</c> if the pointer cannot be resolved.
/// </summary>
private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer)
{
if (pointer == "#")
{
return root;
}
if (!pointer.StartsWith("#/", StringComparison.Ordinal))
{
return null;
}
JsonNode? current = root;
string[] segments = pointer.Substring(2).Split('/');
foreach (string segment in segments)
{
if (current is JsonObject obj)
{
if (!obj.TryGetPropertyValue(segment, out current))
{
return null;
}
}
else if (current is JsonArray arr && int.TryParse(segment, out int index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
public static IEnumerable<object[]> StructuredOutput_ReturnsExpectedSchema_Inputs()
{
yield return new object[] { "string" };
@ -1106,30 +1104,6 @@ public partial class McpServerToolTests
return options;
}
// Types used by StructuredOutput_WithDuplicateTypeRefs_RewritesRefPointers.
// ContactInfo has two properties of the same type (PhoneNumber) which causes
// System.Text.Json's schema exporter to emit $ref pointers for deduplication.
private sealed class PhoneNumber
{
public string? Number { get; set; }
public string? Type { get; set; }
}
private sealed class ContactInfo
{
public List<PhoneNumber>? WorkPhones { get; set; }
public List<PhoneNumber>? HomePhones { get; set; }
}
// Recursive type used by StructuredOutput_WithRecursiveTypeRefs_RewritesRefPointers.
// When List<TreeNode> is the return type, Children's items emit "$ref": "#/items"
// pointing back to the first TreeNode definition, which must be rewritten after wrapping.
private sealed class TreeNode
{
public string? Name { get; set; }
public List<TreeNode>? Children { get; set; }
}
[Fact]
public void SupportsIconsInCreateOptions()
{
@ -1208,15 +1182,15 @@ public partial class McpServerToolTests
[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": <schema>} 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());
}

View file

@ -0,0 +1,414 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;
namespace ModelContextProtocol.Tests.Server;
/// <summary>
/// SEP-2106 backward-compat at the tools/list emission boundary. Clients negotiating a
/// pre-2026-06-30 protocol version must still receive the legacy
/// <c>{"type":"object","properties":{"result":&lt;schema&gt;},"required":["result"]}</c>
/// envelope for non-object output schemas. In-memory storage stays natural; only the
/// wire emission flips on the negotiated version.
/// </summary>
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<string?> members = [];
foreach (JsonElement entry in typeProperty.EnumerateArray())
{
members.Add(entry.GetString());
}
Assert.Contains("object", members);
Assert.Contains("null", members);
}
}
[Theory]
[InlineData(LegacyProtocolVersion, true)]
[InlineData(DraftSep2106ProtocolVersion, false)]
[InlineData(Sep2106ProtocolVersion, false)]
public async Task ListTools_DuplicateTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped)
{
// List<ContactInfo> has the same type (PhoneNumber) at two locations, so the schema
// exporter emits $ref pointers for deduplication. For legacy clients the array schema
// is wrapped under properties.result, which must rewrite those $refs to stay resolvable.
ConfigureServerWithTools(serverProtocolVersion);
await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
JsonElement schema = await GetOutputSchemaAsync(client, "return_duplicate_refs");
AssertRefsValidForWire(schema, expectWrapped);
}
[Theory]
[InlineData(LegacyProtocolVersion, true)]
[InlineData(DraftSep2106ProtocolVersion, false)]
[InlineData(Sep2106ProtocolVersion, false)]
public async Task ListTools_RecursiveTypeRefsTool_RewritesRefsWhenWrapped(string serverProtocolVersion, bool expectWrapped)
{
// List<TreeNode> is recursive: Children's items emit a $ref back to the TreeNode
// definition (e.g. "#/items"). When wrapped for legacy clients that becomes
// "#/properties/result/items" and must still resolve.
ConfigureServerWithTools(serverProtocolVersion);
await using var client = await CreateMcpClientForServer(new() { ProtocolVersion = serverProtocolVersion });
JsonElement schema = await GetOutputSchemaAsync(client, "return_recursive_refs");
AssertRefsValidForWire(schema, expectWrapped);
}
/// <summary>
/// Asserts the emitted schema's <c>$ref</c> pointers are consistent with the wire shape:
/// when wrapped (legacy clients) the array schema is enveloped under
/// <c>properties.result</c> and every <c>$ref</c> is rewritten under that prefix; otherwise
/// (SEP-2106 clients) the natural array schema is emitted with its original refs. Either
/// way, every <c>$ref</c> must resolve against the emitted schema root.
/// </summary>
private static void AssertRefsValidForWire(JsonElement schema, bool expectWrapped)
{
JsonNode schemaNode = JsonNode.Parse(schema.GetRawText())!;
if (expectWrapped)
{
Assert.Equal("object", schema.GetProperty("type").GetString());
int rewritten = AssertAllRefsStartWith(schemaNode, "#/properties/result");
Assert.True(rewritten > 0, "Expected at least one $ref rewritten under #/properties/result.");
}
else
{
Assert.Equal("array", schema.GetProperty("type").GetString());
Assert.Equal(0, CountRefsStartingWith(schemaNode, "#/properties/result"));
}
int resolvable = AssertAllRefsResolvable(schemaNode, schemaNode);
Assert.True(resolvable > 0, "Expected at least one resolvable $ref in the schema.");
}
private static int AssertAllRefsStartWith(JsonNode? node, string expectedPrefix)
{
int count = 0;
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue)
{
Assert.StartsWith(expectedPrefix, refValue);
count++;
}
foreach (var property in obj)
{
count += AssertAllRefsStartWith(property.Value, expectedPrefix);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
count += AssertAllRefsStartWith(item, expectedPrefix);
}
}
return count;
}
private static int CountRefsStartingWith(JsonNode? node, string prefix)
{
int count = 0;
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue &&
refValue.StartsWith(prefix, StringComparison.Ordinal))
{
count++;
}
foreach (var property in obj)
{
count += CountRefsStartingWith(property.Value, prefix);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
count += CountRefsStartingWith(item, prefix);
}
}
return count;
}
private static int AssertAllRefsResolvable(JsonNode root, JsonNode? node)
{
int count = 0;
if (node is JsonObject obj)
{
if (obj.TryGetPropertyValue("$ref", out JsonNode? refNode) &&
refNode?.GetValue<string>() is string refValue &&
refValue.StartsWith("#", StringComparison.Ordinal))
{
var resolved = ResolveJsonPointer(root, refValue);
Assert.True(resolved is not null, $"$ref \"{refValue}\" does not resolve to a valid node in the schema.");
count++;
}
foreach (var property in obj)
{
count += AssertAllRefsResolvable(root, property.Value);
}
}
else if (node is JsonArray arr)
{
foreach (var item in arr)
{
count += AssertAllRefsResolvable(root, item);
}
}
return count;
}
private static JsonNode? ResolveJsonPointer(JsonNode root, string pointer)
{
if (pointer == "#")
{
return root;
}
if (!pointer.StartsWith("#/", StringComparison.Ordinal))
{
return null;
}
JsonNode? current = root;
string[] segments = pointer.Substring(2).Split('/');
foreach (string segment in segments)
{
if (current is JsonObject obj)
{
if (!obj.TryGetPropertyValue(segment, out current))
{
return null;
}
}
else if (current is JsonArray arr && int.TryParse(segment, out int index) && index >= 0 && index < arr.Count)
{
current = arr[index];
}
else
{
return null;
}
}
return current;
}
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<McpServerOptions>(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 }),
McpServerTool.Create(() => new List<ContactInfo>
{
new()
{
WorkPhones = [new() { Number = "555-0100", Type = "work" }],
HomePhones = [new() { Number = "555-0200", Type = "home" }],
}
}, new() { Name = "return_duplicate_refs", UseStructuredContent = true, SerializerOptions = serializerOptions }),
McpServerTool.Create(() => new List<TreeNode>
{
new() { Name = "root", Children = [new() { Name = "child" }] }
}, new() { Name = "return_recursive_refs", UseStructuredContent = true, SerializerOptions = serializerOptions }),
]);
StartServer();
}
private static async Task<JsonElement> 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);
// ContactInfo has two properties of the same type (PhoneNumber), which makes the schema
// exporter emit $ref pointers for deduplication.
private sealed class PhoneNumber
{
public string? Number { get; set; }
public string? Type { get; set; }
}
private sealed class ContactInfo
{
public List<PhoneNumber>? WorkPhones { get; set; }
public List<PhoneNumber>? HomePhones { get; set; }
}
// Recursive type: Children's items emit a $ref back to the TreeNode definition.
private sealed class TreeNode
{
public string? Name { get; set; }
public List<TreeNode>? Children { get; set; }
}
}