diff --git a/Makefile b/Makefile
index 993c3dd76..8edda10ee 100644
--- a/Makefile
+++ b/Makefile
@@ -18,6 +18,7 @@ build: restore
test: build
dotnet test \
--no-build \
+ -p:TestTfmsInParallel=false \
--configuration $(CONFIGURATION) \
--filter '(Execution!=Manual)' \
--blame \
diff --git a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
index bb446af55..28ae769e3 100644
--- a/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StdioClientSessionTransport.cs
@@ -1,201 +1,207 @@
-using Microsoft.Extensions.Logging;
-using ModelContextProtocol.Protocol;
-using System.Diagnostics;
-
-namespace ModelContextProtocol.Client;
-
-/// Provides the client side of a stdio-based session transport.
-internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
-{
- private readonly StdioClientTransportOptions _options;
- private readonly Process _process;
- private readonly Queue _stderrRollingLog;
- private readonly DataReceivedEventHandler _errorHandler;
- private int _cleanedUp = 0;
- private readonly int? _processId;
-
- public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
- base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory)
- {
- _options = options;
- _process = process;
- _stderrRollingLog = stderrRollingLog;
- _errorHandler = errorHandler;
- try { _processId = process.Id; } catch { }
- }
-
- ///
- public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
- {
- try
- {
- await base.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
- }
- catch (IOException)
- {
- // We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
- // for the I/O error, we should throw an exception for that instead.
- if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
- {
- throw processExitException;
- }
-
- throw;
- }
- }
-
- ///
- protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
- {
- // Only run the full stdio cleanup once (handler detach, process kill, etc.).
- // If another call is already handling cleanup, cancel the shutdown token
- // to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
- // call SetDisconnected with full StdioClientCompletionDetails.
- if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
- {
- CancelShutdown();
- return;
- }
-
- // We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
- // so create an exception with details about that.
- error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
-
- // Closing the server's stdin is the portable graceful-shutdown signal for stdio transports.
- // Do this before waiting so a well-behaved server can finish its own cleanup and exit.
- try
- {
- _process.StandardInput.Close();
- }
- catch (Exception ex)
- {
- LogStandardInputCloseFailed(Name, ex);
- }
-
- // Ensure all pending ErrorDataReceived events are drained before detaching
- // the handler. GetUnexpectedExitExceptionAsync does this when HasExited is
- // true, but there is a narrow window on Linux where the process has closed
- // stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
- // so HasExited returns false and the drain is skipped. An unconditional
- // wait here covers that gap. When the drain already happened above, the
- // call returns immediately.
- await WaitForProcessExitAsync().ConfigureAwait(false);
-
- // Detach the stderr handler so no further ErrorDataReceived events
- // are dispatched during or after process disposal.
- _process.ErrorDataReceived -= _errorHandler;
-
- // Terminate the server process (or confirm it already exited), then build
- // and publish strongly-typed completion details while the process handle
- // is still valid so we can read the exit code.
- try
- {
- StdioClientTransport.DisposeProcess(
- _process,
- processRunning: true,
- _options.ShutdownTimeout,
- beforeDispose: () => SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error))));
- }
- catch (Exception ex)
- {
- LogTransportShutdownFailed(Name, ex);
- SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error)));
- }
-
- // And handle cleanup in the base type. SetDisconnected has already been
- // called above, so the base call is a no-op for disconnect state but
- // still performs other cleanup (cancelling the read task, etc.).
- await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
- }
-
- private async ValueTask GetUnexpectedExitExceptionAsync()
- {
- if (!StdioClientTransport.HasExited(_process))
- {
- return null;
- }
-
- Debug.Assert(StdioClientTransport.HasExited(_process));
-
- await WaitForProcessExitAsync().ConfigureAwait(false);
-
- string errorMessage = "MCP server process exited unexpectedly";
-
- string? exitCode = null;
- try
- {
- exitCode = $" (exit code: {(uint)_process.ExitCode})";
- }
- catch { }
-
- lock (_stderrRollingLog)
- {
- if (_stderrRollingLog.Count > 0)
- {
- errorMessage =
- $"{errorMessage}{exitCode}{Environment.NewLine}" +
- $"Server's stderr tail:{Environment.NewLine}" +
- $"{string.Join(Environment.NewLine, _stderrRollingLog)}";
- }
- }
-
- return new IOException(errorMessage);
- }
-
- ///
- /// Waits for the process to exit within
- /// and flushes pending events.
- ///
- ///
- /// On .NET, Process.WaitForExitAsync also waits for asynchronous output readers
- /// to complete, ensuring all events have been dispatched. On .NET Framework,
- /// does not guarantee this; the parameterless
- /// overload is needed to flush the event queue.
- /// This method is idempotent—calling it after the process has already been waited on
- /// returns immediately.
- ///
- private async ValueTask WaitForProcessExitAsync()
- {
- try
- {
-#if NET
- using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
- await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
-#else
- if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
- {
- _process.WaitForExit();
- }
-#endif
- }
- catch { }
- }
-
- private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
- {
- StdioClientCompletionDetails details = new()
- {
- Exception = error,
- ProcessId = _processId,
- };
-
- try
- {
- if (StdioClientTransport.HasExited(_process))
- {
- details.ExitCode = _process.ExitCode;
- }
- }
- catch { }
-
- lock (_stderrRollingLog)
- {
- if (_stderrRollingLog.Count > 0)
- {
- details.StandardErrorTail = _stderrRollingLog.ToArray();
- }
- }
-
- return details;
- }
-}
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Protocol;
+using System.Diagnostics;
+
+namespace ModelContextProtocol.Client;
+
+/// Provides the client side of a stdio-based session transport.
+internal sealed class StdioClientSessionTransport : StreamClientSessionTransport
+{
+ private readonly StdioClientTransportOptions _options;
+ private readonly Process _process;
+ private readonly Queue _stderrRollingLog;
+ private readonly DataReceivedEventHandler _errorHandler;
+ private int _cleanedUp = 0;
+ private readonly int? _processId;
+
+ public StdioClientSessionTransport(StdioClientTransportOptions options, Process process, string endpointName, Queue stderrRollingLog, DataReceivedEventHandler errorHandler, ILoggerFactory? loggerFactory) :
+ base(process.StandardInput.BaseStream, process.StandardOutput.BaseStream, encoding: null, endpointName, loggerFactory, startReading: false)
+ {
+ _options = options;
+ _process = process;
+ _stderrRollingLog = stderrRollingLog;
+ _errorHandler = errorHandler;
+ try { _processId = process.Id; } catch { }
+ StartReading();
+ }
+
+ ///
+ public override async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ await base.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (IOException)
+ {
+ // We failed to send due to an I/O error. If the server process has exited, which is then very likely the cause
+ // for the I/O error, we should throw an exception for that instead.
+ if (await GetUnexpectedExitExceptionAsync().ConfigureAwait(false) is Exception processExitException)
+ {
+ throw processExitException;
+ }
+
+ throw;
+ }
+ }
+
+ ///
+ protected override async ValueTask CleanupAsync(Exception? error = null, CancellationToken cancellationToken = default)
+ {
+ // Only run the full stdio cleanup once (handler detach, process kill, etc.).
+ // If another call is already handling cleanup, cancel the shutdown token
+ // to unblock it (e.g. if it's stuck in WaitForExitAsync) and let it
+ // call SetDisconnected with full StdioClientCompletionDetails.
+ if (Interlocked.Exchange(ref _cleanedUp, 1) != 0)
+ {
+ CancelShutdown();
+ return;
+ }
+
+ // We've not yet forcefully terminated the server. If it's already shut down, something went wrong,
+ // so create an exception with details about that.
+ error ??= await GetUnexpectedExitExceptionAsync().ConfigureAwait(false);
+
+ // Closing the server's stdin is the portable graceful-shutdown signal for stdio transports.
+ // Do this before waiting so a well-behaved server can finish its own cleanup and exit.
+ try
+ {
+ _process.StandardInput.Close();
+ }
+ catch (Exception ex)
+ {
+ LogStandardInputCloseFailed(Name, ex);
+ }
+
+ // Stop the read loop before waiting for the process. If the server has
+ // already closed stdout but is still waiting on another inherited handle,
+ // waiting first can leave the transport and its reader blocked together.
+ CancelShutdown();
+
+ // Ensure all pending ErrorDataReceived events are drained before detaching
+ // the handler. GetUnexpectedExitExceptionAsync does this when HasExited is
+ // true, but there is a narrow window on Linux where the process has closed
+ // stdout (causing EOF in ReadMessagesAsync) yet hasn't been fully reaped,
+ // so HasExited returns false and the drain is skipped. An unconditional
+ // wait here covers that gap. When the drain already happened above, the
+ // call returns immediately.
+ await WaitForProcessExitAsync().ConfigureAwait(false);
+
+ // Detach the stderr handler so no further ErrorDataReceived events
+ // are dispatched during or after process disposal.
+ _process.ErrorDataReceived -= _errorHandler;
+
+ // Terminate the server process (or confirm it already exited), then build
+ // and publish strongly-typed completion details while the process handle
+ // is still valid so we can read the exit code.
+ try
+ {
+ StdioClientTransport.DisposeProcess(
+ _process,
+ processRunning: true,
+ _options.ShutdownTimeout,
+ beforeDispose: () => SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error))));
+ }
+ catch (Exception ex)
+ {
+ LogTransportShutdownFailed(Name, ex);
+ SetDisconnected(new ClientTransportClosedException(BuildCompletionDetails(error)));
+ }
+
+ // And handle cleanup in the base type. SetDisconnected has already been
+ // called above, so the base call is a no-op for disconnect state but
+ // still performs other cleanup (cancelling the read task, etc.).
+ await base.CleanupAsync(error, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async ValueTask GetUnexpectedExitExceptionAsync()
+ {
+ if (!StdioClientTransport.HasExited(_process))
+ {
+ return null;
+ }
+
+ Debug.Assert(StdioClientTransport.HasExited(_process));
+
+ await WaitForProcessExitAsync().ConfigureAwait(false);
+
+ string errorMessage = "MCP server process exited unexpectedly";
+
+ string? exitCode = null;
+ try
+ {
+ exitCode = $" (exit code: {(uint)_process.ExitCode})";
+ }
+ catch { }
+
+ lock (_stderrRollingLog)
+ {
+ if (_stderrRollingLog.Count > 0)
+ {
+ errorMessage =
+ $"{errorMessage}{exitCode}{Environment.NewLine}" +
+ $"Server's stderr tail:{Environment.NewLine}" +
+ $"{string.Join(Environment.NewLine, _stderrRollingLog)}";
+ }
+ }
+
+ return new IOException(errorMessage);
+ }
+
+ ///
+ /// Waits for the process to exit within
+ /// and flushes pending events.
+ ///
+ ///
+ /// On .NET, Process.WaitForExitAsync also waits for asynchronous output readers
+ /// to complete, ensuring all events have been dispatched. On .NET Framework,
+ /// does not guarantee this; the parameterless
+ /// overload is needed to flush the event queue.
+ /// This method is idempotent—calling it after the process has already been waited on
+ /// returns immediately.
+ ///
+ private async ValueTask WaitForProcessExitAsync()
+ {
+ try
+ {
+#if NET
+ using var timeoutCts = new CancellationTokenSource(_options.ShutdownTimeout);
+ await _process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
+#else
+ if (_process.WaitForExit((int)_options.ShutdownTimeout.TotalMilliseconds))
+ {
+ _process.WaitForExit();
+ }
+#endif
+ }
+ catch { }
+ }
+
+ private StdioClientCompletionDetails BuildCompletionDetails(Exception? error)
+ {
+ StdioClientCompletionDetails details = new()
+ {
+ Exception = error,
+ ProcessId = _processId,
+ };
+
+ try
+ {
+ if (StdioClientTransport.HasExited(_process))
+ {
+ details.ExitCode = _process.ExitCode;
+ }
+ }
+ catch { }
+
+ lock (_stderrRollingLog)
+ {
+ if (_stderrRollingLog.Count > 0)
+ {
+ details.StandardErrorTail = _stderrRollingLog.ToArray();
+ }
+ }
+
+ return details;
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
index 7a69c1c83..3b1f86a00 100644
--- a/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/StreamClientSessionTransport.cs
@@ -36,11 +36,12 @@ internal class StreamClientSessionTransport : TransportBase
///
/// Optional factory for creating loggers. If null, a NullLogger is used.
///
+ /// Whether to start reading immediately rather than after derived initialization.
///
- /// This constructor starts a background task to read messages from the server output stream.
+ /// By default, this constructor starts a background task to read messages from the server output stream.
/// The transport will be marked as connected once initialized.
///
- public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory)
+ public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Encoding? encoding, string endpointName, ILoggerFactory? loggerFactory, bool startReading = true)
: base(endpointName, loggerFactory)
{
Throw.IfNull(serverInput);
@@ -55,6 +56,15 @@ public StreamClientSessionTransport(Stream serverInput, Stream serverOutput, Enc
SetConnected();
+ if (startReading)
+ {
+ StartReading();
+ }
+ }
+
+ /// Starts reading after state used by overridden cleanup methods has been initialized.
+ protected void StartReading()
+ {
// Start reading messages in the background. We use the rarer pattern of new Task + Start
// in order to ensure that the body of the task will always see _readTask initialized.
// It is then able to reliably null it out on completion.
diff --git a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
index f3eec2350..6c06d2003 100644
--- a/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
+++ b/src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj
@@ -63,6 +63,7 @@
+
diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
index 6d4579f66..f929e9a79 100644
--- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
+++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
@@ -1,11 +1,19 @@
using Microsoft.Extensions.AI;
+using Json.Schema;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
+using System.Collections;
+using System.Collections.Concurrent;
using System.ComponentModel;
+using System.Globalization;
using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
using System.Reflection;
+using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
using System.Text.RegularExpressions;
namespace ModelContextProtocol.Server;
@@ -15,6 +23,14 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool
{
private readonly IReadOnlyList _metadata;
private readonly bool? _returnsCallToolResult;
+ private OutputSchemaCache? _outputSchema;
+ private OutputSchemaCache? _alternateOutputSchema;
+ private static readonly ConditionalWeakTable s_sourceContracts = new();
+ private static readonly ConditionalWeakTable s_schemaEvaluationCosts = new();
+ private static readonly TimeSpan s_recoveryRegexTimeout = TimeSpan.FromMilliseconds(100);
+ private static readonly JsonElement s_propertyNameInstance = JsonSerializer.SerializeToElement(string.Empty, McpJsonUtilities.JsonContext.Default.String);
+ [ThreadStatic]
+ private static SourceMemberSerializationState? t_serializingSourceMember;
///
/// Creates an instance for a method, specified via a instance.
@@ -243,7 +259,6 @@ private AIFunctionMcpServerTool(
AIFunction = function;
ProtocolTool = tool;
-
_metadata = metadata;
_returnsCallToolResult = returnsCallToolResult;
}
@@ -319,8 +334,24 @@ public override async ValueTask InvokeAsync(
return callToolResult;
}
- JsonElement? structuredContent = CreateStructuredResponse(result, request.Server.NegotiatedProtocolVersion);
- return result switch
+ // These results bypass user serialization contracts in CreateStructuredResponse.
+ bool isSerializedJson = result is JsonElement or JsonNode;
+ var recoveryTarget = request.StructuredContentRecoveryTarget;
+ McpServerTool? matchedTool = recoveryTarget?.Tool ?? request.MatchedPrimitive as McpServerTool;
+ JsonElement? matchedSchema = isSerializedJson ? null :
+ recoveryTarget is { } target ? target.Schema : matchedTool?.ProtocolTool.OutputSchema;
+ JsonElement? recoverySchema = isSerializedJson ? null : GetRecoverySchema(matchedTool, matchedSchema);
+ RepairTraversalBudget? sharedBudget = !isSerializedJson && recoveryTarget is not null ? new() : null;
+ string? protocolVersion = request.Server.NegotiatedProtocolVersion;
+ JsonElement? structuredContent = CreateStructuredResponse(
+ result, protocolVersion, recoverySchema, out JsonElement? originalContent,
+ out JsonElement? naturalContent, sharedBudget);
+ (List Identities, Action Callback)? recovery =
+ sharedBudget is not null && originalContent is not null && structuredContent is not null
+ ? CreateStructuredContentRecovery(
+ result, originalContent, naturalContent, structuredContent, recoveryTarget!, protocolVersion, sharedBudget)
+ : null;
+ CallToolResult response = result switch
{
AIContent aiContent => new()
{
@@ -361,6 +392,113 @@ public override async ValueTask InvokeAsync(
StructuredContent = structuredContent,
},
};
+
+ if (recovery is { } plan)
+ {
+ recoveryTarget!.Register(plan.Identities, plan.Callback);
+ }
+
+ return response;
+ }
+
+ private (List Identities, Action Callback)? CreateStructuredContentRecovery(
+ object? originalResult, JsonElement? originalContent, JsonElement? naturalContent,
+ JsonElement? structuredContent,
+ RequestContext.StructuredContentRecoveryState recoveryTarget,
+ string? protocolVersion, RepairTraversalBudget sharedBudget)
+ {
+ const int maxRecoveryTargets = 128;
+ List<(McpServerTool Tool, JsonElement? Schema)> targets = [(recoveryTarget.Tool, recoveryTarget.Schema)];
+ List<(McpServerTool Tool, JsonElement? Content)> projections = [];
+ try
+ {
+ bool hasCustomInvocation = false;
+ McpServerTool current = recoveryTarget.Tool;
+ while (current is DelegatingMcpServerTool delegating)
+ {
+ sharedBudget.Spend(1);
+ if (delegating.HasCustomInvocation)
+ {
+ hasCustomInvocation = true;
+ if (targets.Count == maxRecoveryTargets)
+ {
+ targets.RemoveRange(1, targets.Count - 1);
+ break;
+ }
+ targets.Add((delegating.InnerTool, delegating.InnerTool.ProtocolTool.OutputSchema));
+ }
+ current = delegating.InnerTool;
+ }
+ if (!hasCustomInvocation)
+ {
+ return null;
+ }
+
+ JsonElement? preferredContent = naturalContent;
+ JsonElement? preferredStructuredContent = structuredContent;
+ // Capture repairs while the CLR source still belongs to this invocation.
+ for (int i = targets.Count - 1; i >= 0 && !sharedBudget.IsRecoveryStopped; i--)
+ {
+ var target = targets[i];
+ JsonElement? recovered = AddMissingRequiredNullableProperties(
+ originalContent, originalResult, target.Schema, out bool isValid, sharedBudget, preferredContent);
+ if (isValid)
+ {
+ if (!Nullable.Equals(recovered, preferredContent))
+ {
+ if (recovered is { } content)
+ {
+ sharedBudget.SpendCopy(content);
+ }
+ preferredStructuredContent = ShapeStructuredResponse(recovered, protocolVersion);
+ }
+ projections.Add((target.Tool, preferredStructuredContent));
+ preferredContent = recovered;
+ }
+ }
+ }
+ catch (Exception exception) when (IsNonFatalRecoveryFailure(exception))
+ {
+ sharedBudget.IsRecoveryStopped = true;
+ }
+ if (projections.Count == 0)
+ {
+ return null;
+ }
+
+ List identities = [structuredContent!.Value];
+ foreach (var projection in projections)
+ {
+ if (projection.Content is { } content)
+ {
+ identities.Add(content);
+ }
+ }
+
+ return (identities, (tool, finalResult) =>
+ {
+ if (finalResult.IsError == true)
+ {
+ return;
+ }
+
+ // Accept only this invocation's retained projections, never replaced or reserialized JSON.
+ bool retained = Nullable.Equals(finalResult.StructuredContent, structuredContent);
+ JsonElement? recovered = null;
+ foreach (var projection in projections)
+ {
+ retained |= Nullable.Equals(finalResult.StructuredContent, projection.Content);
+ if (ReferenceEquals(projection.Tool, tool))
+ {
+ recovered = projection.Content;
+ }
+ }
+ if (retained && recovered is not null)
+ {
+ finalResult.StructuredContent = recovered;
+ }
+ }
+ );
}
/// Creates a name to use based on the supplied method and naming policy.
@@ -762,27 +900,50 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string
}
}
- private JsonElement? CreateStructuredResponse(object? aiFunctionResult, string? negotiatedProtocolVersion)
+ private JsonElement? CreateStructuredResponse(
+ object? aiFunctionResult, string? negotiatedProtocolVersion, JsonElement? recoverySchema,
+ out JsonElement? originalContent, out JsonElement? naturalContent, RepairTraversalBudget? sharedBudget)
{
+ originalContent = null;
+ naturalContent = null;
if (ProtocolTool.OutputSchema is null)
{
// Only provide structured responses if the tool has an output schema defined.
return null;
}
- JsonElement? elementResult = aiFunctionResult switch
+ JsonElement? elementResult;
+ switch (aiFunctionResult)
{
- JsonElement jsonElement => jsonElement,
- JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode),
- null => null,
- _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))),
- };
+ case JsonElement jsonElement:
+ elementResult = jsonElement;
+ break;
+ case JsonNode node:
+ elementResult = JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode);
+ break;
+ case null:
+ elementResult = null;
+ break;
+ default:
+ JsonSerializerOptions serializerOptions = AIFunction.JsonSerializerOptions;
+ JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(typeof(object));
+ elementResult = JsonSerializer.SerializeToElement(aiFunctionResult, typeInfo);
+ break;
+ }
+ originalContent = elementResult;
+ naturalContent = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult, recoverySchema, out _, sharedBudget);
+ return ShapeStructuredResponse(naturalContent, negotiatedProtocolVersion);
+ }
+
+ private JsonElement? ShapeStructuredResponse(JsonElement? elementResult, string? negotiatedProtocolVersion)
+ {
// 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))
+ ProtocolTool.OutputSchema is { } outputSchema &&
+ ShouldWrapValueForLegacyWire(outputSchema))
{
JsonNode? resultNode = elementResult is { } v
? JsonSerializer.SerializeToNode(v, McpJsonUtilities.JsonContext.Default.JsonElement)
@@ -796,6 +957,3150 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string
return elementResult;
}
+ private JsonElement? AddMissingRequiredNullableProperties(
+ JsonElement? elementResult,
+ object? originalResult,
+ JsonElement? recoverySchema,
+ out bool isValid,
+ RepairTraversalBudget? sharedBudget = null,
+ JsonElement? preferredContent = null)
+ {
+ isValid = false;
+ JsonElement? fallbackContent = preferredContent ?? elementResult;
+ if (sharedBudget?.IsRecoveryStopped == true ||
+ elementResult is not { } result ||
+ recoverySchema is not { } outputSchema ||
+ result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array))
+ {
+ return fallbackContent;
+ }
+
+ try
+ {
+ OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema);
+ JsonNode? schemaNode = cachedSchema.Node;
+ JsonSchema? schema = cachedSchema.Schema;
+ if (schemaNode is null || schema is null)
+ {
+ return fallbackContent;
+ }
+
+ RepairTraversalBudget budget = sharedBudget ?? new();
+ // A wrapper may mutate the CLR source after serialization. Keep an already-valid projection.
+ if (preferredContent is { } preferred && !preferred.Equals(result) && IsSchemaValid(schema, preferred, budget))
+ {
+ isValid = true;
+ return preferred;
+ }
+ if (IsSchemaValid(schema, result, budget))
+ {
+ isValid = true;
+ return elementResult;
+ }
+ if (HasDuplicatePropertyNames(result))
+ {
+ return fallbackContent;
+ }
+ budget.SpendCopy(result);
+ JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement);
+ if (resultNode is null)
+ {
+ return fallbackContent;
+ }
+
+ JsonNode? repairedResult = RepairStructuredResult(
+ resultNode,
+ schemaNode,
+ schema,
+ originalResult is null ? null : new SourceRecoveryContext(
+ originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object)),
+ fallbackType: GetDeclaredReturnType(originalResult)),
+ AIFunction.JsonSerializerOptions,
+ traversalBudget: budget);
+
+ if (repairedResult is null)
+ {
+ return fallbackContent;
+ }
+
+ budget.SpendCopy(repairedResult);
+ JsonElement repaired = JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode);
+ isValid = true;
+ return repaired;
+ }
+ catch (TargetInvocationException exception) when (
+ exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner))
+ {
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw();
+ throw;
+ }
+ catch (Exception exception) when (IsNonFatalRecoveryFailure(exception))
+ {
+ if (sharedBudget is not null)
+ {
+ sharedBudget.IsRecoveryStopped = true;
+ }
+ return fallbackContent;
+ }
+ }
+
+ private static bool IsNonFatalRecoveryFailure(Exception exception)
+ {
+ int remaining = 128;
+ return Inspect(exception, ref remaining);
+
+ static bool Inspect(Exception exception, ref int remaining)
+ {
+ if (remaining-- <= 0 ||
+ exception is OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)
+ {
+ return false;
+ }
+
+ if (exception is AggregateException aggregate)
+ {
+ foreach (Exception inner in aggregate.InnerExceptions)
+ {
+ if (!Inspect(inner, ref remaining))
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ return exception.InnerException is null || Inspect(exception.InnerException, ref remaining);
+ }
+ }
+
+ private JsonElement? GetRecoverySchema(McpServerTool? matchedTool, JsonElement? matchedSchema)
+ {
+ McpServerTool schemaTool = matchedTool ?? this;
+ McpServerTool current = schemaTool;
+ while (current is DelegatingMcpServerTool delegating)
+ {
+ if (delegating.HasCustomInvocation)
+ {
+ // A transforming wrapper's schema describes its result, not the inner CLR value.
+ schemaTool = delegating.InnerTool;
+ }
+ current = delegating.InnerTool;
+ }
+
+ return ReferenceEquals(current, this)
+ ? ReferenceEquals(schemaTool, matchedTool) ? matchedSchema : schemaTool.ProtocolTool.OutputSchema
+ : ProtocolTool.OutputSchema;
+ }
+
+ private Type? GetDeclaredReturnType(object result)
+ {
+ Type? returnType = AIFunction.UnderlyingMethod?.ReturnType;
+ if (returnType is { IsGenericType: true } &&
+ (returnType.GetGenericTypeDefinition() == typeof(Task<>) || returnType.GetGenericTypeDefinition() == typeof(ValueTask<>)))
+ {
+ returnType = returnType.GetGenericArguments()[0];
+ }
+
+ if (returnType is null || returnType == typeof(object) || !returnType.IsInstanceOfType(result))
+ {
+ return null;
+ }
+
+ return returnType;
+ }
+
+ ///
+ /// Restores omitted required properties only when the complete result validates against the
+ /// published schema. The serializer can omit a value because of either the global ignore
+ /// policy or a member-level . Candidate values are read only
+ /// from members of the active serialization contract, without serializing unrelated members.
+ ///
+ private static JsonNode? RepairStructuredResult(
+ JsonNode resultNode,
+ JsonNode schemaNode,
+ JsonSchema schema,
+ SourceRecoveryContext? originalResult,
+ JsonSerializerOptions serializerOptions,
+ int remainingDepth = 32,
+ JsonNode? schemaDocument = null,
+ SchemaReferenceResolver? references = null,
+ List? dynamicScope = null,
+ RepairTraversalBudget? traversalBudget = null,
+ List? inheritedConditions = null,
+ bool includeValidAlternatives = false)
+ {
+ // An explicit schema may deliberately omit optional nulls. Preserve that valid output
+ // before looking for missing required members.
+ if (remainingDepth <= 0)
+ {
+ return null;
+ }
+
+ const int maxCandidateSetEvaluations = 256;
+ Queue pending = new();
+ Queue fallback = new();
+ HashSet visited = new(StringComparer.Ordinal) { resultNode.ToJsonString() };
+ schemaDocument ??= schemaNode;
+ traversalBudget ??= new();
+ if (references is null)
+ {
+ traversalBudget.SpendCopy(schemaDocument, immutable: true);
+ references = new(schemaDocument, schema.BaseUri);
+ }
+ // Cache predicates independently of their callers. Conditional expectations and
+ // instance paths remain on each group and are checked on every tentative repair.
+ Dictionary<(JsonNode Schema, string Scope), JsonSchema?> branchSchemas = traversalBudget.BranchSchemas;
+ Dictionary conjunctiveSchemas = new(StringComparer.Ordinal);
+ List unevaluatedGroups = [];
+ Dictionary unevaluatedCandidates = new(StringComparer.Ordinal);
+ SchemaReferenceResolver? evaluationReferences = null;
+ bool skippedValidAlternatives = false;
+
+ HashSet<(string Path, string Schema)> GetUnevaluatedApplications(JsonNode instance)
+ {
+ HashSet<(string, string)> applications = [];
+ try
+ {
+ if (evaluationReferences is null)
+ {
+ traversalBudget.SpendCopy(schema.Root.Source);
+ evaluationReferences = new(
+ JsonSerializer.SerializeToNode(schema.Root.Source, McpJsonUtilities.JsonContext.Default.JsonElement)!,
+ schema.BaseUri);
+ }
+ traversalBudget.SpendCopy(instance);
+ JsonElement element = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode);
+ traversalBudget.SpendEvaluation(schema, element, detailed: true);
+ Stack results = new();
+ results.Push(schema.Evaluate(element, new EvaluationOptions { OutputFormat = OutputFormat.Hierarchical }));
+ while (results.Count > 0)
+ {
+ traversalBudget.Spend(1);
+ EvaluationResults evaluation = results.Pop();
+ string path = evaluation.EvaluationPath.ToString();
+ // Evaluation paths retain caller provenance even when SchemaLocation is imprecise across references.
+ if ((path.EndsWith("/unevaluatedProperties", StringComparison.Ordinal) ||
+ path.EndsWith("/unevaluatedItems", StringComparison.Ordinal)) &&
+ evaluationReferences.TryResolveEvaluationPath(path, out Uri? application))
+ {
+ applications.Add((evaluation.InstanceLocation.ToString(), application!.AbsoluteUri));
+ }
+
+ if (evaluation.Details is { } details)
+ {
+ traversalBudget.Spend(details.Count);
+ foreach (EvaluationResults detail in details)
+ {
+ results.Push(detail);
+ }
+ }
+ }
+ }
+ catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException)
+ {
+ applications.Clear();
+ }
+
+ return applications;
+ }
+
+ JsonNode FinalizeRepair(JsonNode instance)
+ {
+ if (unevaluatedGroups.Count == 0)
+ {
+ return instance;
+ }
+
+ HashSet<(string Path, string Schema)> applications = GetUnevaluatedApplications(instance);
+ HashSet checkedKeys = new(StringComparer.Ordinal);
+ foreach (RepairCandidateGroup group in unevaluatedGroups)
+ {
+ if (group.Unevaluated is not { } origin ||
+ applications.Contains((origin.InstancePath, references.GetSchemaUri(origin.Schema).AbsoluteUri)))
+ {
+ continue;
+ }
+
+ foreach (string key in group.Keys)
+ {
+ if (!checkedKeys.Add(key))
+ {
+ continue;
+ }
+ if (checkedKeys.Count > maxCandidateSetEvaluations)
+ {
+ return instance;
+ }
+ traversalBudget.Spend(1);
+
+ if (!unevaluatedCandidates.TryGetValue(key, out RepairCandidate? candidate))
+ {
+ continue;
+ }
+
+ string parentPath = candidate.ParentPath;
+ string name = candidate.PropertyName;
+ if ((TryGetNodeAtPath(resultNode, parentPath, out JsonNode? originalParent) &&
+ originalParent is JsonObject originalObject && originalObject.ContainsKey(name)) ||
+ !TryGetNodeAtPath(instance, parentPath, out JsonNode? parent) ||
+ parent is not JsonObject objectParent || !objectParent.TryGetPropertyValue(name, out JsonNode? value))
+ {
+ continue;
+ }
+
+ objectParent.Remove(name);
+ if (!IsRepairValid(instance))
+ {
+ objectParent[name] = value;
+ }
+ }
+ }
+
+ return instance;
+ }
+
+ JsonSchema? GetBranchSchema(JsonNode branch, List scope)
+ {
+ var schemaKey = (branch, references.GetScopeKey(scope));
+ if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema))
+ {
+ traversalBudget.SpendSchema(schemaDocument);
+ JsonNode document = schemaDocument.DeepClone();
+ references.BindDynamicReferences(schemaDocument, document, scope);
+ if (document is JsonObject documentObject)
+ {
+ documentObject["$id"] = references.GetSchemaUri(schemaDocument).AbsoluteUri;
+ }
+
+ JsonObject branchDocument = new()
+ {
+ ["$ref"] = references.GetSchemaUri(branch).AbsoluteUri,
+ ["$defs"] = new JsonObject { ["document"] = document },
+ };
+ if (references.SupplementalSchemas.Contains(branch))
+ {
+ traversalBudget.SpendSchema(branch);
+ branchDocument["$defs"]!["scope"] = branch.DeepClone();
+ }
+ TryBuildJsonSchema(branchDocument, out branchSchema);
+ branchSchemas.Add(schemaKey, branchSchema);
+ }
+
+ return branchSchema;
+ }
+
+ bool AreConditionsValid(JsonNode instance, List conditions)
+ {
+ foreach (RepairCondition condition in conditions)
+ {
+ JsonSchema? conditionSchema = GetBranchSchema(condition.Schema, condition.DynamicScope);
+ if (conditionSchema is null ||
+ !TryGetNodeAtPath(instance, condition.InstancePath, out JsonNode? conditionInstance) ||
+ conditionInstance is null ||
+ IsCandidateValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema, traversalBudget)) != condition.Expected)
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ bool IsCandidateValid(JsonSchema candidateSchema, JsonNode? instance)
+ {
+ traversalBudget.SpendCopy(instance);
+ return IsSchemaValid(candidateSchema, instance, traversalBudget);
+ }
+
+ bool IsRepairValid(JsonNode instance) =>
+ IsCandidateValid(schema, instance) &&
+ (inheritedConditions is null || AreConditionsValid(instance, inheritedConditions));
+
+ if (IsRepairValid(resultNode))
+ {
+ return null;
+ }
+
+ JsonNode? EnqueueRepairs(JsonNode instance, int depth)
+ {
+ if (depth >= remainingDepth)
+ {
+ return null;
+ }
+
+ Dictionary contexts = new(StringComparer.Ordinal);
+ List groups = [];
+ HashSet<(string Path, string Schema)>? applications = null;
+ SchemaTraversal traversal = new(traversalBudget, (subschema, path) =>
+ (applications ??= GetUnevaluatedApplications(instance)).Contains((path, references.GetSchemaUri(subschema).AbsoluteUri)),
+ (subschema, value, scope) => GetBranchSchema(subschema, scope) is { } compiled && IsCandidateValid(compiled, value),
+ includeValidAlternatives);
+ CollectSchemaContexts(
+ instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, traversal,
+ groups, references, dynamicScope);
+ skippedValidAlternatives |= traversal.SkippedValidAlternatives;
+ traversalBudget.Spend(groups.Count);
+ unevaluatedGroups.AddRange(groups.Where(static group => group.Unevaluated is not null));
+ if (inheritedConditions is { Count: > 0 })
+ {
+ foreach (RepairCandidateGroup group in groups)
+ {
+ traversalBudget.Spend(inheritedConditions.Count + group.Conditions.Count);
+ }
+ groups = groups.Select(group => group with
+ {
+ Conditions = [.. inheritedConditions, .. group.Conditions],
+ }).ToList();
+ }
+
+ // Conjunctive child schemas must qualify together, not as independent alternatives.
+ var envelopes = groups.Where(static group => group.EnclosesInstance)
+ .GroupBy(group => (group.InstancePath, Scope: references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty))
+ .ToDictionary(static bucket => bucket.Key, static bucket => bucket.ToList());
+ HashSet combinedEnvelopes = [];
+ List qualifiedGroups = [];
+ foreach (RepairCandidateGroup group in groups)
+ {
+ if (combinedEnvelopes.Contains(group))
+ {
+ continue;
+ }
+
+ if (!group.EnclosesInstance)
+ {
+ qualifiedGroups.Add(group);
+ continue;
+ }
+
+ var key = (group.InstancePath, references.HasDynamicReferences ? references.GetScopeKey(group.DynamicScope) : string.Empty);
+ List conjuncts = envelopes[key].Where(other =>
+ {
+ traversalBudget.Spend(1 + other.Choices.Count + other.Conditions.Count + (other.Alternatives?.Count ?? 0));
+ return other.Choices.SequenceEqual(group.Choices) && other.Conditions.SequenceEqual(group.Conditions) &&
+ (other.Alternatives ?? []).SequenceEqual(group.Alternatives ?? []);
+ }).ToList();
+ if (conjuncts.Count == 1)
+ {
+ qualifiedGroups.Add(group);
+ continue;
+ }
+
+ string schemaKey = string.Join("\n", conjuncts.Select(item => references.GetSchemaUri(item.Schema).AbsoluteUri)
+ .Distinct(StringComparer.Ordinal).OrderBy(static uri => uri, StringComparer.Ordinal));
+ if (!conjunctiveSchemas.TryGetValue(schemaKey, out JsonNode? conjunction))
+ {
+ JsonArray clauses = [];
+ foreach (string uri in schemaKey.Split('\n'))
+ {
+ traversalBudget.SpendText(uri);
+ clauses.Add((JsonNode)new JsonObject { ["$ref"] = uri });
+ }
+ conjunction = new JsonObject { ["$id"] = "https://mcp.invalid/recovery/" + Guid.NewGuid().ToString("N"), ["allOf"] = clauses };
+ references.AddSupplementalSchema(conjunction);
+ conjunctiveSchemas.Add(schemaKey, conjunction);
+ }
+
+ qualifiedGroups.Add(group with
+ {
+ Schema = conjunction,
+ Keys = CombineKeys(direct: false),
+ DirectKeys = CombineKeys(direct: true),
+ });
+ combinedEnvelopes.UnionWith(conjuncts);
+
+ HashSet CombineKeys(bool direct)
+ {
+ HashSet keys = new(StringComparer.Ordinal);
+ foreach (RepairCandidateGroup conjunct in conjuncts)
+ {
+ HashSet source = direct ? conjunct.DirectKeys : conjunct.Keys;
+ traversalBudget.Spend(source.Count);
+ keys.UnionWith(source);
+ }
+ return keys;
+ }
+ }
+ groups = qualifiedGroups;
+
+ List candidates = CreateRepairCandidates(
+ instance, originalResult, serializerOptions, contexts, traversalBudget);
+ if (unevaluatedGroups.Count > 0)
+ {
+ foreach (RepairCandidate candidate in candidates)
+ {
+ unevaluatedCandidates[candidate.Key] = candidate;
+ }
+ }
+ if (candidates.Count == 0)
+ {
+ return null;
+ }
+
+ foreach (RepairCandidateGroup group in groups)
+ {
+ traversalBudget.Spend(group.Keys.Count);
+ }
+ traversalBudget.Spend(candidates.Count);
+ HashSet grouped = new(groups.SelectMany(static group => group.Keys), StringComparer.Ordinal);
+ Dictionary candidatesByKey = candidates.ToDictionary(static candidate => candidate.Key, StringComparer.Ordinal);
+ List ungrouped = candidates.Where(candidate => !grouped.Contains(candidate.Key)).ToList();
+ Dictionary<(JsonNode Applicator, string Path), JsonNode> exclusiveChoices = new();
+ Dictionary stableConditions = new();
+ RepairMutationPaths? mutations = null;
+ traversalBudget.SpendCopy(instance);
+ JsonNode combinedResult = instance.DeepClone();
+ bool hasCombinedRepairs = false;
+ HashSet completedInstances = new(StringComparer.Ordinal);
+ foreach (RepairCandidate candidate in ungrouped)
+ {
+ ApplyRepairCandidate(combinedResult, candidate, traversalBudget);
+ hasCombinedRepairs = true;
+ }
+
+ foreach (RepairCandidateGroup group in groups)
+ {
+ // A guard untouched by every candidate cannot be rescued by searching subsets.
+ bool rejected = false;
+ foreach (RepairCondition condition in group.Conditions)
+ {
+ if (!stableConditions.TryGetValue(condition, out bool? matches))
+ {
+ matches = IsConditionUnaffected(condition, mutations ??= new(candidates, traversalBudget), traversalBudget)
+ ? AreConditionsValid(instance, [condition])
+ : null;
+ stableConditions.Add(condition, matches);
+ }
+ if (matches == false)
+ {
+ rejected = true;
+ break;
+ }
+ }
+ if (rejected)
+ {
+ continue;
+ }
+
+ string enclosingPath = group.InstancePath;
+ while (enclosingPath.Length > 0 && !completedInstances.Contains(enclosingPath))
+ {
+ enclosingPath = enclosingPath.Substring(0, enclosingPath.LastIndexOf('/'));
+ }
+ if (enclosingPath.Length > 0)
+ {
+ continue;
+ }
+
+ if (group.Choices.Any(choice =>
+ exclusiveChoices.TryGetValue((choice.Applicator, choice.InstancePath), out JsonNode? selected) &&
+ !ReferenceEquals(selected, choice.Branch)))
+ {
+ continue;
+ }
+
+ if (!TryGetNodeAtPath(combinedResult, group.InstancePath, out JsonNode? currentBranch) || currentBranch is null)
+ {
+ continue;
+ }
+
+ JsonSchema? branchSchema = GetBranchSchema(group.Schema, group.DynamicScope);
+ if (branchSchema is null)
+ {
+ continue;
+ }
+
+ // A leaf must not lock an enclosing oneOf branch whose assertions fail.
+ // Once that ancestor has qualified, avoid revalidating its entire subtree.
+ List unqualifiedAncestors = group.Choices.Where(choice =>
+ !(ReferenceEquals(choice.Branch, group.Schema) && choice.InstancePath == group.InstancePath) &&
+ !exclusiveChoices.ContainsKey((choice.Applicator, choice.InstancePath))).ToList();
+
+ // Compose independently valid subtree repairs before searching whole-document subsets.
+ List groupCandidates = [];
+ foreach (string key in group.Keys)
+ {
+ traversalBudget.Spend(1);
+ if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate))
+ {
+ groupCandidates.Add(candidate);
+ }
+ }
+
+ foreach (bool includeDescendants in new[] { false, true })
+ {
+ List branchCandidates = includeDescendants
+ ? groupCandidates
+ : groupCandidates.Where(candidate =>
+ candidate.ParentPath == group.InstancePath && group.DirectKeys.Contains(candidate.Key)).ToList();
+ traversalBudget.SpendCopy(currentBranch);
+ JsonNode branchResult = currentBranch.DeepClone();
+ foreach (RepairCandidate candidate in branchCandidates)
+ {
+ ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }, traversalBudget);
+ }
+
+ bool branchIsValid = IsCandidateValid(branchSchema, branchResult);
+ if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth &&
+ !(group.InstancePath.Length == 0 && ReferenceEquals(group.Schema, schemaNode) &&
+ references.GetScopeKey(group.DynamicScope) ==
+ references.GetScopeKey(dynamicScope ?? [references.GetResourceRoot(schemaNode)])))
+ {
+ SourceRecoveryContext? branchSource = null;
+ if (originalResult is not null)
+ {
+ TryGetSourceObjectAtPath(originalResult, instance, group.InstancePath, out branchSource);
+ }
+
+ if (RepairStructuredResult(
+ branchResult, group.Schema, branchSchema, branchSource,
+ serializerOptions, remainingDepth - depth - 1, schemaDocument, references,
+ group.DynamicScope, traversalBudget,
+ group.Conditions.Where(condition =>
+ condition.InstancePath == group.InstancePath ||
+ condition.InstancePath.StartsWith(group.InstancePath + "/", StringComparison.Ordinal))
+ .Select(condition => condition with
+ {
+ InstancePath = condition.InstancePath.Substring(group.InstancePath.Length),
+ }).ToList(), includeValidAlternatives) is JsonNode repairedBranch)
+ {
+ branchResult = repairedBranch;
+ branchIsValid = true;
+ }
+ }
+
+ if (branchIsValid && (group.Conditions.Count > 0 || unqualifiedAncestors.Count > 0))
+ {
+ // Conditions belong to the caller's instance and dynamic scope, which
+ // may differ from the nested branch being repaired. Test the tentative
+ // replacement, not the original instance or an isolated leaf schema.
+ JsonNode tentativeResult = branchResult;
+ if (group.InstancePath.Length > 0)
+ {
+ ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult);
+ tentativeResult = combinedResult;
+ }
+
+ try
+ {
+ branchIsValid = AreConditionsValid(tentativeResult, group.Conditions);
+ foreach (RepairExclusiveChoice ancestor in unqualifiedAncestors)
+ {
+ if (!branchIsValid)
+ {
+ break;
+ }
+
+ JsonSchema? ancestorSchema = GetBranchSchema(ancestor.Branch, ancestor.DynamicScope);
+ branchIsValid = ancestorSchema is not null &&
+ TryGetNodeAtPath(tentativeResult, ancestor.InstancePath, out JsonNode? ancestorInstance) &&
+ ancestorInstance is not null &&
+ IsCandidateValid(ancestorSchema, ancestorInstance);
+ }
+ }
+ finally
+ {
+ if (group.InstancePath.Length > 0)
+ {
+ ReplaceRepairBranch(branchResult, group.InstancePath, currentBranch);
+ }
+ }
+ }
+
+ if (branchIsValid)
+ {
+ if (group.InstancePath.Length == 0)
+ {
+ combinedResult = branchResult;
+ }
+ else
+ {
+ ReplaceRepairBranch(currentBranch, group.InstancePath, branchResult);
+ }
+
+ hasCombinedRepairs = true;
+ if (group.EnclosesInstance)
+ {
+ completedInstances.Add(group.InstancePath);
+ }
+ foreach (RepairExclusiveChoice choice in group.Choices)
+ {
+ exclusiveChoices[(choice.Applicator, choice.InstancePath)] = choice.Branch;
+ }
+
+ break;
+ }
+ }
+ }
+
+ if (hasCombinedRepairs)
+ {
+ // The common case composes independent repairs. Avoid allocating subset queues
+ // (or repeatedly scanning the full candidate list) when that result already works.
+ if (IsRepairValid(combinedResult))
+ {
+ return combinedResult;
+ }
+
+ pending.Enqueue(new(combinedResult, [], depth + 1));
+ }
+
+ pending.Enqueue(new(instance, candidates, depth + 1));
+ foreach (RepairCandidateGroup group in groups)
+ {
+ if (pending.Count >= maxCandidateSetEvaluations)
+ {
+ break;
+ }
+
+ traversalBudget.Spend(ungrouped.Count);
+ List branchCandidates = [.. ungrouped];
+ foreach (string key in group.Keys)
+ {
+ traversalBudget.Spend(1);
+ if (candidatesByKey.TryGetValue(key, out RepairCandidate? candidate))
+ {
+ branchCandidates.Add(candidate);
+ }
+ }
+
+ if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count &&
+ pending.Count < maxCandidateSetEvaluations)
+ {
+ pending.Enqueue(new(instance, branchCandidates, depth + 1));
+ }
+ }
+
+ for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++)
+ {
+ traversalBudget.Spend(1);
+ fallback.Enqueue(new(instance, [candidates[i]], depth + 1));
+ }
+
+ for (int i = 0; i < candidates.Count && fallback.Count < maxCandidateSetEvaluations; i++)
+ {
+ for (int j = i + 1; j < candidates.Count && fallback.Count < maxCandidateSetEvaluations; j++)
+ {
+ traversalBudget.Spend(2);
+ fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1));
+ }
+ }
+
+ return null;
+ }
+
+ if (EnqueueRepairs(resultNode, 0) is JsonNode composedResult)
+ {
+ return FinalizeRepair(composedResult);
+ }
+ for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);)
+ {
+ RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue();
+ traversalBudget.SpendCopy(attempt.Result);
+ JsonNode candidateResult = attempt.Result.DeepClone();
+ foreach (RepairCandidate candidate in attempt.Candidates)
+ {
+ ApplyRepairCandidate(candidateResult, candidate, traversalBudget);
+ }
+
+ traversalBudget.SpendCopy(candidateResult);
+ if (!visited.Add(candidateResult.ToJsonString()))
+ {
+ continue;
+ }
+
+ evaluations++;
+ if (IsRepairValid(candidateResult))
+ {
+ return FinalizeRepair(candidateResult);
+ }
+
+ if (EnqueueRepairs(candidateResult, attempt.Depth) is JsonNode repairedResult)
+ {
+ return FinalizeRepair(repairedResult);
+ }
+ }
+
+ // A valid composition can still need another branch's evaluated-property annotations.
+ // Prefer ordinary recovery before considering changes to already-valid choices.
+ return !includeValidAlternatives && skippedValidAlternatives &&
+ s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root)).HasUnevaluated
+ ? RepairStructuredResult(
+ resultNode, schemaNode, schema, originalResult, serializerOptions, remainingDepth,
+ schemaDocument, references, dynamicScope, traversalBudget, inheritedConditions,
+ includeValidAlternatives: true)
+ : null;
+ }
+
+ private static bool TrySetSchemaRegexTimeouts(JsonNode source, JsonSchemaNode root)
+ {
+ SchemaReferenceResolver references = new(source, root.BaseUri);
+ HashSet visited = [root];
+ Stack pending = new();
+ pending.Push(root);
+ List nodes = [];
+ while (pending.Count > 0)
+ {
+ JsonSchemaNode node = pending.Pop();
+ nodes.Add(node);
+ foreach (KeywordData keyword in node.Keywords)
+ {
+ // Do not borrow mutable nodes or unbounded regexes from a global registry.
+ if (keyword.Handler.Name is "$ref" or "$dynamicRef" or "$recursiveRef" &&
+ !references.CanResolveReference(node.BaseUri, keyword.RawValue.GetString()!))
+ {
+ return false;
+ }
+ foreach (JsonSchemaNode child in keyword.Subschemas)
+ {
+ if (visited.Add(child))
+ {
+ pending.Push(child);
+ }
+ }
+ }
+ }
+
+ foreach (JsonSchemaNode node in nodes)
+ {
+ foreach (KeywordData keyword in node.Keywords)
+ {
+ if (keyword.Handler.Name == "pattern" && keyword.Value is Regex pattern)
+ {
+ keyword.Value = new Regex(pattern.ToString(), pattern.Options, s_recoveryRegexTimeout);
+ }
+ else if (keyword.Handler.Name == "patternProperties" && keyword.Value is Dictionary patterns)
+ {
+ Dictionary bounded = new(patterns.Count, patterns.Comparer);
+ foreach (KeyValuePair entry in patterns)
+ {
+ bounded.Add(entry.Key, new Regex(entry.Value.ToString(), entry.Value.Options, s_recoveryRegexTimeout));
+ }
+ keyword.Value = bounded;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static bool TryBuildJsonSchema(JsonNode schemaNode, out JsonSchema? schema)
+ {
+ try
+ {
+ JsonElement schemaElement = JsonSerializer.SerializeToElement(schemaNode, McpJsonUtilities.JsonContext.Default.JsonNode);
+ schema = JsonSchema.Build(schemaElement, new BuildOptions { SchemaRegistry = new SchemaRegistry() });
+ if (!TrySetSchemaRegexTimeouts(schemaNode, schema.Root))
+ {
+ schema = null;
+ return false;
+ }
+ return true;
+ }
+ catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException)
+ {
+ schema = null;
+ return false;
+ }
+ }
+
+ private OutputSchemaCache GetOutputSchema(JsonElement outputSchema)
+ {
+ OutputSchemaCache? cachedSchema = Volatile.Read(ref _outputSchema);
+ if (cachedSchema is not null && cachedSchema.Source.Equals(outputSchema))
+ {
+ return cachedSchema;
+ }
+
+ OutputSchemaCache? alternateSchema = Volatile.Read(ref _alternateOutputSchema);
+ if (alternateSchema is not null && alternateSchema.Source.Equals(outputSchema))
+ {
+ return alternateSchema;
+ }
+
+ JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement);
+ JsonSchema? schema = null;
+ if (schemaNode is not null)
+ {
+ TryBuildJsonSchema(schemaNode, out schema);
+ }
+
+ Volatile.Write(ref _alternateOutputSchema, cachedSchema);
+ cachedSchema = new(outputSchema, schemaNode, schema);
+ Volatile.Write(ref _outputSchema, cachedSchema);
+ return cachedSchema;
+ }
+
+ private sealed record OutputSchemaCache(JsonElement Source, JsonNode? Node, JsonSchema? Schema);
+
+ private static bool IsSchemaValid(JsonSchema schema, JsonNode? instance, RepairTraversalBudget budget)
+ {
+ try
+ {
+ JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode);
+ return IsSchemaValid(schema, instanceElement, budget);
+ }
+ catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsSchemaValid(JsonSchema schema, JsonElement instance, RepairTraversalBudget budget)
+ {
+ try
+ {
+ budget.SpendEvaluation(schema, instance);
+ return schema.Evaluate(instance, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid;
+ }
+ catch (Exception ex) when (ex is JsonException or JsonSchemaException or RefResolutionException or InvalidOperationException or ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ private static List CreateRepairCandidates(
+ JsonNode resultNode,
+ SourceRecoveryContext? originalResult,
+ JsonSerializerOptions serializerOptions,
+ Dictionary contexts,
+ RepairTraversalBudget budget)
+ {
+ List candidates = [];
+ HashSet seen = new(StringComparer.Ordinal);
+
+ foreach (KeyValuePair contextEntry in contexts)
+ {
+ if (!TryGetNodeAtPath(resultNode, contextEntry.Key, out JsonNode? parentNode) || parentNode is not JsonObject parentObject)
+ {
+ continue;
+ }
+
+ foreach (string propertyName in contextEntry.Value.Required)
+ {
+ budget.SpendText(contextEntry.Key);
+ budget.SpendText(propertyName);
+ if (parentObject.ContainsKey(propertyName))
+ {
+ continue;
+ }
+
+ string candidateKey = AppendJsonPointer(contextEntry.Key, propertyName);
+ if (!seen.Add(candidateKey))
+ {
+ continue;
+ }
+
+ if (TryGetSourceValue(
+ originalResult,
+ resultNode,
+ contextEntry.Key,
+ propertyName,
+ serializerOptions,
+ out JsonNode? sourceValue))
+ {
+ candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, sourceValue));
+ }
+ }
+ }
+
+ return candidates;
+ }
+
+ private static void ApplyRepairCandidate(JsonNode resultNode, RepairCandidate candidate, RepairTraversalBudget budget)
+ {
+ if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject)
+ {
+ budget.SpendCopy(candidate.Value);
+ parentObject[candidate.PropertyName] = candidate.Value?.DeepClone();
+ }
+ }
+
+ private sealed class SchemaObjectContext
+ {
+ internal HashSet Required { get; } = new(StringComparer.Ordinal);
+ }
+
+ private sealed record RepairCandidate(string ParentPath, string PropertyName, JsonNode? Value)
+ {
+ internal string Key => AppendJsonPointer(ParentPath, PropertyName);
+ }
+
+ private sealed record RepairAttempt(JsonNode Result, List Candidates, int Depth);
+
+ private sealed record RepairExclusiveChoice(
+ JsonNode Applicator, JsonNode Branch, string InstancePath, List DynamicScope);
+
+ private sealed record RepairCondition(
+ JsonNode Schema, string InstancePath, List DynamicScope, bool Expected);
+
+ private static JsonNode ProjectConditionInstance(JsonNode instance, JsonNode schema, RepairTraversalBudget budget)
+ {
+ if (instance is not JsonObject instanceObject || schema is not JsonObject schemaObject ||
+ schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties")))
+ {
+ return instance;
+ }
+
+ // These keywords cannot observe unrelated properties. In particular, do not
+ // copy a large sibling collection for every small descendant repair.
+ JsonObject projection = new();
+ if (schemaObject["properties"] is JsonObject properties)
+ {
+ foreach (var property in properties)
+ {
+ if (instanceObject.TryGetPropertyValue(property.Key, out JsonNode? value))
+ {
+ budget.SpendCopy(value);
+ projection[property.Key] = value?.DeepClone();
+ }
+ }
+ }
+
+ if (schemaObject["required"] is JsonArray required)
+ {
+ foreach (JsonNode? item in required)
+ {
+ if (item is JsonValue name && name.TryGetValue(out string? propertyName) &&
+ propertyName is not null && !projection.ContainsKey(propertyName) &&
+ instanceObject.ContainsKey(propertyName))
+ {
+ projection[propertyName] = null;
+ }
+ }
+ }
+
+ if (schemaObject.ContainsKey("minProperties") || schemaObject.ContainsKey("maxProperties"))
+ {
+ foreach (KeyValuePair property in instanceObject)
+ {
+ budget.SpendText(property.Key);
+ if (!projection.ContainsKey(property.Key))
+ {
+ projection[property.Key] = null;
+ }
+ }
+ }
+
+ return projection;
+ }
+
+ private static bool IsConditionUnaffected(
+ RepairCondition condition, RepairMutationPaths mutations, RepairTraversalBudget budget)
+ {
+ if (condition.Schema is not JsonObject schema ||
+ schema.Any(static property => property.Key is not ("properties" or "required" or "type" or "minProperties" or "maxProperties")))
+ {
+ return false;
+ }
+
+ for (string path = condition.InstancePath; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/')))
+ {
+ budget.SpendText(path);
+ if (mutations.Additions.Contains(path))
+ {
+ return false;
+ }
+ }
+
+ if (mutations.DirectAdditions.TryGetValue(condition.InstancePath, out HashSet? added))
+ {
+ if (schema.ContainsKey("minProperties") || schema.ContainsKey("maxProperties"))
+ {
+ return false;
+ }
+ if (schema["required"] is JsonArray required)
+ {
+ foreach (JsonNode? item in required)
+ {
+ budget.Spend(1);
+ if (item?.GetValue() is string name && added.Contains(name))
+ {
+ return false;
+ }
+ }
+ }
+ }
+
+ if (schema["properties"] is JsonObject properties)
+ {
+ foreach (KeyValuePair property in properties)
+ {
+ budget.SpendText(property.Key);
+ if (mutations.AffectedAncestors.Contains(AppendJsonPointer(condition.InstancePath, property.Key)))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ private sealed class RepairMutationPaths
+ {
+ internal HashSet Additions { get; } = new(StringComparer.Ordinal);
+ internal HashSet AffectedAncestors { get; } = new(StringComparer.Ordinal);
+ internal Dictionary> DirectAdditions { get; } = new(StringComparer.Ordinal);
+
+ internal RepairMutationPaths(List candidates, RepairTraversalBudget budget)
+ {
+ foreach (RepairCandidate candidate in candidates)
+ {
+ budget.SpendText(candidate.ParentPath);
+ budget.SpendText(candidate.PropertyName);
+ string path = candidate.Key;
+ Additions.Add(path);
+ if (!DirectAdditions.TryGetValue(candidate.ParentPath, out HashSet? names))
+ {
+ names = new(StringComparer.Ordinal);
+ DirectAdditions.Add(candidate.ParentPath, names);
+ }
+ names.Add(candidate.PropertyName);
+ for (; path.Length > 0; path = path.Substring(0, path.LastIndexOf('/')))
+ {
+ budget.SpendText(path);
+ AffectedAncestors.Add(path);
+ }
+ }
+ }
+ }
+
+ private sealed record RepairCandidateGroup(
+ HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema,
+ List DynamicScope, List Choices, List Conditions,
+ RepairUnevaluatedApplication? Unevaluated = null, bool EnclosesInstance = false,
+ List? Alternatives = null);
+
+ private sealed record RepairUnevaluatedApplication(JsonNode Schema, string InstancePath);
+
+ private static void ReplaceRepairBranch(JsonNode currentBranch, string instancePath, JsonNode replacement)
+ {
+ string segment = DecodeJsonPointerSegment(instancePath.Substring(instancePath.LastIndexOf('/') + 1));
+ if (currentBranch.Parent is JsonArray parentArray)
+ {
+ parentArray[int.Parse(segment, NumberStyles.None, CultureInfo.InvariantCulture)] = replacement;
+ }
+ else if (currentBranch.Parent is JsonObject parentObject)
+ {
+ parentObject[segment] = replacement;
+ }
+ }
+
+ private static string DecodeJsonPointerSegment(string segment) => segment.Replace("~1", "/").Replace("~0", "~");
+
+ private sealed class RepairTraversalBudget
+ {
+ internal bool IsRecoveryStopped { get; set; }
+
+ internal const int MaxEvaluationWork = 65_536;
+ // Shared by document attempts and recursive branch repairs, including cached merges.
+ // Costs include copied entries, JSON nodes, and 16-character text units.
+ private int _remainingWork = 2_000_000;
+ private int _remainingCopyWork = 1_000_000;
+ // Compiled branch schemas retain copies of the complete source document.
+ private int _remainingSchemaWork = 65_536;
+ private int _remainingEvaluationWork = MaxEvaluationWork;
+ private readonly Dictionary _immutableCosts = new();
+ internal Dictionary<(JsonNode Schema, string Scope), JsonSchema?> BranchSchemas { get; } = new();
+
+ internal void Spend(int work)
+ {
+ if (work < 0 || work > _remainingWork)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+
+ _remainingWork -= work;
+ }
+
+ internal void SpendText(string text) => Spend(1 + text.Length / 16);
+
+ internal void SpendEvaluation(JsonSchema schema, JsonElement instance, bool detailed = false)
+ {
+ SchemaEvaluationCost evaluation = s_schemaEvaluationCosts.GetValue(schema, static compiled => new(compiled.Root));
+ if (!detailed && !evaluation.HasUnevaluated)
+ {
+ return;
+ }
+
+ int instanceCost = 0;
+ Measure(instance, ref instanceCost, 0);
+ int cost = evaluation.GetCost(instance, instanceCost, _remainingEvaluationWork);
+ if (cost > _remainingEvaluationWork)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+ _remainingEvaluationWork -= cost;
+ }
+
+ internal void SpendContexts(Dictionary contexts)
+ {
+ Spend(contexts.Count);
+ foreach (KeyValuePair entry in contexts)
+ {
+ Spend(entry.Value.Required.Count);
+ }
+ }
+
+ internal void SpendSchema(JsonNode schema)
+ {
+ int cost = GetCopyCost(schema, immutable: true);
+ if (cost > _remainingSchemaWork)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+
+ SpendCopyCost(cost);
+ _remainingSchemaWork -= cost;
+ }
+
+ internal void SpendCopy(JsonNode? node, bool immutable = false) => SpendCopyCost(GetCopyCost(node, immutable));
+
+ internal void SpendCopy(JsonElement element)
+ {
+ int cost = 0;
+ Measure(element, ref cost, 0);
+ SpendCopyCost(cost);
+ }
+
+ private void SpendCopyCost(int cost)
+ {
+ if (cost > _remainingCopyWork)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+ _remainingCopyWork -= cost;
+ }
+
+ private int GetCopyCost(JsonNode? node, bool immutable)
+ {
+ if (immutable && node is not null && _immutableCosts.TryGetValue(node, out int cached))
+ {
+ return cached;
+ }
+
+ int cost = 0;
+ Measure(node, ref cost, 0);
+ if (immutable && node is not null)
+ {
+ _immutableCosts[node] = cost;
+ }
+ return cost;
+ }
+
+ private void AddCost(ref int cost, int work, int depth)
+ {
+ if (depth >= 256 || work > _remainingCopyWork - cost)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+ cost += work;
+ }
+
+ private void Measure(JsonNode? node, ref int cost, int depth)
+ {
+ AddCost(ref cost, 1, depth);
+ if (node is JsonObject obj)
+ {
+ foreach (KeyValuePair property in obj)
+ {
+ AddCost(ref cost, 1 + property.Key.Length / 16, depth);
+ Measure(property.Value, ref cost, depth + 1);
+ }
+ }
+ else if (node is JsonArray array)
+ {
+ foreach (JsonNode? item in array)
+ {
+ Measure(item, ref cost, depth + 1);
+ }
+ }
+ else if (node is JsonValue value && value.TryGetValue(out string? text) && text is not null)
+ {
+ AddCost(ref cost, text.Length / 16, depth);
+ }
+ else if (node is JsonValue number && number.GetValueKind() == JsonValueKind.Number)
+ {
+ AddCost(ref cost, number.ToJsonString().Length / 16, depth);
+ }
+ }
+
+ private void Measure(JsonElement element, ref int cost, int depth)
+ {
+ AddCost(ref cost, 1, depth);
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ AddCost(ref cost, 1 + property.Name.Length / 16, depth);
+ Measure(property.Value, ref cost, depth + 1);
+ }
+ break;
+ case JsonValueKind.Array:
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ Measure(item, ref cost, depth + 1);
+ }
+ break;
+ case JsonValueKind.String:
+ AddCost(ref cost, element.GetString()!.Length / 16, depth);
+ break;
+ case JsonValueKind.Number:
+ AddCost(ref cost, element.GetRawText().Length / 16, depth);
+ break;
+ }
+ }
+ }
+
+ private sealed class RecoveryBudgetExceededException : Exception;
+
+ private sealed class SchemaEvaluationCost
+ {
+ internal bool HasUnevaluated { get; }
+ private readonly JsonSchemaNode _root;
+ private readonly int _expandedCost;
+ private readonly Dictionary<(Uri Resource, string Anchor), JsonSchemaNode> _dynamicTargets = new();
+ private bool _needsInstanceCost;
+
+ internal SchemaEvaluationCost(JsonSchemaNode root)
+ {
+ _root = root;
+ HashSet visited = [root];
+ Stack pending = new();
+ pending.Push(root);
+ while (pending.Count > 0)
+ {
+ JsonSchemaNode node = pending.Pop();
+ foreach (KeywordData keyword in node.Keywords)
+ {
+ HasUnevaluated |= keyword.Handler.Name is "unevaluatedProperties" or "unevaluatedItems";
+ if (keyword.Handler.Name == "$dynamicAnchor")
+ {
+ _dynamicTargets[(node.BaseUri, keyword.RawValue.GetString()!)] = node;
+ }
+ foreach (JsonSchemaNode child in keyword.Subschemas)
+ {
+ if (visited.Add(child))
+ {
+ pending.Push(child);
+ }
+ }
+ }
+ }
+
+ _expandedCost = Measure(root, new(), new(), 0);
+ }
+
+ internal int GetCost(JsonElement instance, int instanceCost, int limit)
+ {
+ if (!_needsInstanceCost)
+ {
+ return _expandedCost > limit / instanceCost ? limit + 1 : _expandedCost * instanceCost;
+ }
+
+ int remaining = limit;
+ int cost = MeasureInstance(_root, instance, string.Empty, false, [], new(), new(), 0, ref remaining);
+ return Math.Max(cost, limit - remaining);
+ }
+
+ private int Measure(
+ JsonSchemaNode node, Dictionary completed, HashSet active, int depth)
+ {
+ const int limit = RepairTraversalBudget.MaxEvaluationWork + 1;
+ if (completed.TryGetValue(node, out int cached))
+ {
+ return cached;
+ }
+ if (depth >= 128 || !active.Add(node))
+ {
+ _needsInstanceCost = true;
+ return limit;
+ }
+
+ int cost = 1;
+ foreach (KeywordData keyword in node.Keywords)
+ {
+ if (keyword.Handler.Name is "$defs" or "definitions")
+ {
+ continue;
+ }
+
+ cost++;
+ if (keyword.Handler.Name is "$dynamicRef" or "$recursiveRef" && keyword.Subschemas.Length == 0)
+ {
+ _needsInstanceCost = true;
+ cost = limit;
+ }
+ foreach (JsonSchemaNode child in keyword.Subschemas)
+ {
+ cost = Math.Min(limit, cost + Measure(child, completed, active, depth + 1));
+ if (cost >= limit)
+ {
+ break;
+ }
+ }
+ if (cost >= limit)
+ {
+ break;
+ }
+ }
+
+ active.Remove(node);
+ completed[node] = cost;
+ return cost;
+ }
+
+ private int MeasureInstance(
+ JsonSchemaNode node, JsonElement instance, string path, bool propertyName, List scope,
+ Dictionary<(JsonSchemaNode, string, string, bool), int> completed,
+ HashSet<(JsonSchemaNode, string, string, bool)> active, int depth, ref int remaining)
+ {
+ const int limit = RepairTraversalBudget.MaxEvaluationWork + 1;
+ if (--remaining < 0 || depth >= 128)
+ {
+ return limit;
+ }
+ if (!scope.Contains(node.BaseUri))
+ {
+ scope = [.. scope, node.BaseUri];
+ }
+ foreach (Uri resource in scope)
+ {
+ remaining -= 1 + resource.AbsoluteUri.Length / 16;
+ if (remaining < 0)
+ {
+ return limit;
+ }
+ }
+ var state = (node, path, string.Join("\n", scope), propertyName);
+ if (completed.TryGetValue(state, out int cached))
+ {
+ return cached;
+ }
+ if (!active.Add(state))
+ {
+ return limit;
+ }
+
+ int cost = 1;
+ foreach (KeywordData keyword in node.Keywords)
+ {
+ string name = keyword.Handler.Name;
+ if (name is "$defs" or "definitions")
+ {
+ continue;
+ }
+ cost++;
+ if (name == "$recursiveRef" && keyword.Subschemas.Length == 0)
+ {
+ // Legacy recursive scope can stop at an intervening non-recursive resource.
+ return limit;
+ }
+ if (name == "$dynamicRef" && keyword.Subschemas.Length == 0)
+ {
+ JsonSchemaNode? target = null;
+ if (Uri.TryCreate(node.BaseUri, keyword.RawValue.GetString(), out Uri? reference))
+ {
+ string anchor = Uri.UnescapeDataString(reference.Fragment.TrimStart('#'));
+ foreach (Uri resource in scope)
+ {
+ if (_dynamicTargets.TryGetValue((resource, anchor), out target))
+ {
+ break;
+ }
+ }
+ }
+ cost = target is null ? limit :
+ Math.Min(limit, cost + MeasureInstance(target, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining));
+ }
+ foreach (JsonSchemaNode child in keyword.Subschemas)
+ {
+ if (name == "properties")
+ {
+ string property = child.RelativePath[child.RelativePath.SegmentCount - 1].ToString();
+ if (instance.ValueKind == JsonValueKind.Object && instance.TryGetProperty(property, out JsonElement value))
+ {
+ cost += MeasureInstance(child, value, AppendPath(path, property, ref remaining), false,
+ scope, completed, active, depth + 1, ref remaining);
+ }
+ }
+ else if (name is "patternProperties" or "additionalProperties" or "unevaluatedProperties" or "propertyNames")
+ {
+ if (instance.ValueKind == JsonValueKind.Object)
+ {
+ foreach (JsonProperty property in instance.EnumerateObject())
+ {
+ // Property-name schemas cannot observe the property's value.
+ JsonElement value = name == "propertyNames" ? s_propertyNameInstance : property.Value;
+ cost += MeasureInstance(child, value, AppendPath(path, property.Name, ref remaining), name == "propertyNames",
+ scope, completed, active, depth + 1, ref remaining);
+ if (cost >= limit)
+ {
+ break;
+ }
+ }
+ }
+ }
+ else if (name is "items" or "prefixItems" or "contains" or "unevaluatedItems" or "additionalItems")
+ {
+ if (instance.ValueKind == JsonValueKind.Array)
+ {
+ int index = 0;
+ foreach (JsonElement value in instance.EnumerateArray())
+ {
+ cost += MeasureInstance(child, value, AppendPath(path, (index++).ToString(CultureInfo.InvariantCulture), ref remaining), false,
+ scope, completed, active, depth + 1, ref remaining);
+ if (cost >= limit)
+ {
+ break;
+ }
+ }
+ }
+ }
+ else
+ {
+ cost += MeasureInstance(child, instance, path, propertyName, scope, completed, active, depth + 1, ref remaining);
+ }
+ if (cost >= limit)
+ {
+ break;
+ }
+ }
+ if (cost >= limit)
+ {
+ cost = limit;
+ break;
+ }
+ }
+
+ active.Remove(state);
+ completed[state] = cost;
+ return cost;
+ }
+
+ private static string AppendPath(string path, string segment, ref int remaining)
+ {
+ remaining -= 1 + path.Length / 16 + segment.Length / 16;
+ if (remaining < 0)
+ {
+ throw new RecoveryBudgetExceededException();
+ }
+ return AppendJsonPointer(path, segment);
+ }
+ }
+
+ private sealed class SchemaTraversal(
+ RepairTraversalBudget budget, Func appliesUnevaluated,
+ Func, bool> isBranchValid, bool includeValidAlternatives = false)
+ {
+ internal RepairTraversalBudget Budget { get; } = budget;
+ internal Func AppliesUnevaluated { get; } = appliesUnevaluated;
+ internal Func, bool> IsBranchValid { get; } = isBranchValid;
+ internal bool IncludeValidAlternatives { get; } = includeValidAlternatives;
+ internal bool SkippedValidAlternatives { get; set; }
+ internal Dictionary<(JsonNode Schema, string Path, string Scope), SchemaTraversalResult> Completed { get; } = new();
+ internal HashSet<(JsonNode Schema, string Path, string Scope)> Active { get; } = [];
+ internal int IncompleteVersion { get; set; }
+ }
+
+ private sealed record SchemaTraversalResult(
+ Dictionary Contexts, List Groups);
+
+ private static string GetSchemaPointer(JsonNode schema, JsonNode resource)
+ {
+ string pointer = string.Empty;
+ for (JsonNode node = schema; !ReferenceEquals(node, resource) && node.Parent is JsonNode parent; node = parent)
+ {
+ string segment = parent is JsonArray array
+ ? array.IndexOf(node).ToString(CultureInfo.InvariantCulture)
+ : ((JsonObject)parent).First(property => ReferenceEquals(property.Value, node)).Key;
+ pointer = AppendJsonPointer(string.Empty, segment) + pointer;
+ }
+
+ return pointer;
+ }
+
+ private static void CollectSchemaContexts(
+ JsonNode? instanceNode,
+ JsonNode? schemaNode,
+ JsonNode schemaRoot,
+ string instancePath,
+ Dictionary contexts,
+ SchemaTraversal traversal,
+ List groups,
+ SchemaReferenceResolver references,
+ List? dynamicScope = null)
+ {
+ if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _))
+ {
+ return;
+ }
+
+ if (schemaNode is not JsonObject schemaObject)
+ {
+ return;
+ }
+
+ dynamicScope ??= [schemaRoot];
+ if (!ReferenceEquals(schemaRoot, schemaObject) && schemaObject.ContainsKey("$id"))
+ {
+ schemaRoot = schemaObject;
+ dynamicScope = SchemaReferenceResolver.EnterResource(dynamicScope, schemaRoot);
+ }
+
+ var state = (schemaNode, instancePath, references.GetScopeKey(dynamicScope));
+ traversal.Budget.Spend(1);
+
+ if (traversal.Completed.TryGetValue(state, out SchemaTraversalResult? completed))
+ {
+ traversal.Budget.SpendContexts(completed.Contexts);
+ traversal.Budget.Spend(completed.Groups.Count);
+ MergeSchemaContexts(contexts, completed.Contexts);
+ groups.AddRange(completed.Groups);
+
+ return;
+ }
+
+ if (traversal.Active.Count >= 256 || !traversal.Active.Add(state))
+ {
+ traversal.IncompleteVersion++;
+ return;
+ }
+
+ int incompleteVersion = traversal.IncompleteVersion;
+ Dictionary destinationContexts = contexts;
+ List destinationGroups = groups;
+ contexts = new(StringComparer.Ordinal);
+ groups = [];
+
+ foreach (string referenceKeyword in new[] { "$ref", "$dynamicRef" })
+ {
+ if (schemaObject[referenceKeyword] is JsonValue refValue &&
+ refValue.TryGetValue(out string? refText) &&
+ refText is not null)
+ {
+ if (references.TryResolve(schemaRoot, refText, out JsonNode? referencedSchema, out JsonNode referencedResource))
+ {
+ if (referenceKeyword == "$dynamicRef" && referencedSchema is JsonObject referencedObject &&
+ referencedObject["$dynamicAnchor"]?.GetValue() is string anchor &&
+ refText.IndexOf('#') is int fragmentStart && fragmentStart >= 0 &&
+ string.Equals(Uri.UnescapeDataString(refText[(fragmentStart + 1)..]), anchor, StringComparison.Ordinal))
+ {
+ foreach (JsonNode resource in dynamicScope)
+ {
+ if (TryFindSchemaAnchor(resource, anchor, out JsonNode? dynamicSchema, dynamicOnly: true))
+ {
+ referencedSchema = dynamicSchema;
+ referencedResource = resource;
+ break;
+ }
+ }
+ }
+
+ CollectSchemaContexts(instanceNode, referencedSchema, referencedResource, instancePath, contexts, traversal, groups, references,
+ SchemaReferenceResolver.EnterResource(dynamicScope, referencedResource));
+ }
+ }
+ }
+
+ SchemaObjectContext context = GetOrCreateSchemaContext(contexts, instancePath);
+ if (schemaObject["required"] is JsonArray required)
+ {
+ foreach (JsonNode? requiredProperty in required)
+ {
+ if (requiredProperty?.GetValue() is string propertyName)
+ {
+ traversal.Budget.SpendText(propertyName);
+ context.Required.Add(propertyName);
+ }
+ }
+ }
+
+ if (schemaObject["properties"] is JsonObject properties)
+ {
+ foreach (KeyValuePair property in properties)
+ {
+ if (property.Value is null)
+ {
+ continue;
+ }
+
+ if (instanceNode is not JsonObject instanceObject ||
+ !instanceObject.TryGetPropertyValue(property.Key, out JsonNode? propertyInstance) ||
+ propertyInstance is null)
+ {
+ continue;
+ }
+
+ CollectSchemaBranch(
+ propertyInstance,
+ property.Value,
+ schemaRoot,
+ AppendJsonPointer(instancePath, property.Key),
+ contexts,
+ traversal,
+ groups, references, dynamicScope, enclosesInstance: true);
+ }
+ }
+
+ if (instanceNode is JsonObject objectInstance)
+ {
+ JsonObject? patternProperties = schemaObject["patternProperties"] as JsonObject;
+ JsonNode? additionalProperties = schemaObject["additionalProperties"];
+ foreach (KeyValuePair property in objectInstance)
+ {
+ bool isExplicitProperty = schemaObject["properties"] is JsonObject explicitProperties &&
+ explicitProperties.ContainsKey(property.Key);
+
+ bool matchedPattern = false;
+ if (patternProperties is not null)
+ {
+ foreach (KeyValuePair patternProperty in patternProperties)
+ {
+ traversal.Budget.SpendText(patternProperty.Key);
+ traversal.Budget.SpendText(property.Key);
+ if (patternProperty.Value is not null &&
+ Regex.IsMatch(property.Key, patternProperty.Key, RegexOptions.None, s_recoveryRegexTimeout))
+ {
+ matchedPattern = true;
+ CollectSchemaBranch(
+ property.Value,
+ patternProperty.Value,
+ schemaRoot,
+ AppendJsonPointer(instancePath, property.Key),
+ contexts,
+ traversal,
+ groups, references, dynamicScope, enclosesInstance: true);
+ }
+ }
+ }
+
+ if (!isExplicitProperty && !matchedPattern && additionalProperties is not null)
+ {
+ CollectSchemaBranch(
+ property.Value,
+ additionalProperties,
+ schemaRoot,
+ AppendJsonPointer(instancePath, property.Key),
+ contexts,
+ traversal,
+ groups, references, dynamicScope, enclosesInstance: true);
+ }
+
+ if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema &&
+ traversal.AppliesUnevaluated(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key)))
+ {
+ CollectSchemaBranch(
+ property.Value, unevaluatedPropertySchema, schemaRoot,
+ AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope,
+ unevaluated: new(unevaluatedPropertySchema, AppendJsonPointer(instancePath, property.Key)));
+ }
+ }
+ }
+
+ if (instanceNode is JsonArray arrayInstance)
+ {
+ JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray;
+ JsonNode? items = schemaObject["items"];
+ double minimumMatches = schemaObject["minContains"] is JsonValue minimum &&
+ minimum.TryGetValue(out double count) ? count : 1;
+ bool repairContains = schemaObject["contains"] is JsonNode contains &&
+ arrayInstance.Count(item => traversal.IsBranchValid(contains, item, dynamicScope)) < minimumMatches;
+ for (int i = 0; i < arrayInstance.Count; i++)
+ {
+ JsonNode? itemSchema = prefixItems is not null && i < prefixItems.Count ? prefixItems[i] : items;
+ if (itemSchema is not null)
+ {
+ CollectSchemaBranch(
+ arrayInstance[i],
+ itemSchema,
+ schemaRoot,
+ AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)),
+ contexts,
+ traversal,
+ groups, references, dynamicScope, enclosesInstance: true);
+ }
+
+ foreach (string keyword in new[] { "contains", "unevaluatedItems" })
+ {
+ if (schemaObject[keyword] is JsonNode subschema &&
+ (keyword != "contains" || repairContains) &&
+ (keyword != "unevaluatedItems" ||
+ traversal.AppliesUnevaluated(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)))))
+ {
+ CollectSchemaBranch(
+ arrayInstance[i], subschema, schemaRoot,
+ AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)),
+ contexts, traversal, groups, references, dynamicScope,
+ unevaluated: keyword == "unevaluatedItems"
+ ? new(subschema, AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture))) : null);
+ }
+ }
+ }
+ }
+
+ if (instanceNode is JsonObject dependencyInstance &&
+ schemaObject["dependentRequired"] is JsonObject dependentRequired)
+ {
+ foreach (KeyValuePair dependency in dependentRequired)
+ {
+ if (dependencyInstance.ContainsKey(dependency.Key) && dependency.Value is JsonArray requiredProperties)
+ {
+ foreach (JsonNode? requiredProperty in requiredProperties)
+ {
+ if (requiredProperty?.GetValue() is string propertyName)
+ {
+ traversal.Budget.SpendText(propertyName);
+ context.Required.Add(propertyName);
+ }
+ }
+ }
+ }
+ }
+
+ if (instanceNode is JsonObject dependencyInstanceForSchemas &&
+ schemaObject["dependentSchemas"] is JsonObject dependentSchemas)
+ {
+ foreach (KeyValuePair dependentSchema in dependentSchemas)
+ {
+ if (dependentSchema.Value is not null && dependencyInstanceForSchemas.ContainsKey(dependentSchema.Key))
+ {
+ CollectSchemaContexts(
+ instanceNode,
+ dependentSchema.Value,
+ schemaRoot,
+ instancePath,
+ contexts,
+ traversal,
+ groups, references, dynamicScope);
+ }
+ }
+ }
+
+ foreach (string compositionKeyword in new[] { "allOf", "anyOf", "oneOf" })
+ {
+ if (schemaObject[compositionKeyword] is not JsonArray composition)
+ {
+ continue;
+ }
+
+ if (compositionKeyword != "allOf")
+ {
+ int validBranches = composition.Count(branch => branch is not null &&
+ traversal.IsBranchValid(branch, instanceNode, dynamicScope));
+ if ((compositionKeyword == "anyOf" && validBranches > 0) ||
+ (compositionKeyword == "oneOf" && validBranches == 1))
+ {
+ if (!traversal.IncludeValidAlternatives)
+ {
+ traversal.SkippedValidAlternatives = true;
+ continue;
+ }
+ }
+ }
+
+ foreach (JsonNode? branch in composition)
+ {
+ if (branch is not null)
+ {
+ if (compositionKeyword == "allOf")
+ {
+ CollectSchemaContexts(
+ instanceNode, branch, schemaRoot, instancePath, contexts,
+ traversal, groups, references, dynamicScope);
+ }
+ else
+ {
+ CollectSchemaBranch(instanceNode, branch, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope,
+ compositionKeyword == "oneOf" ? composition : null,
+ alternativeApplicator: compositionKeyword == "anyOf" ? composition : null);
+ }
+ }
+ }
+ }
+
+ if (schemaObject["if"] is JsonNode ifSchema)
+ {
+ if (schemaObject["then"] is JsonNode thenSchema)
+ {
+ CollectSchemaBranch(instanceNode, thenSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope,
+ condition: new(ifSchema, instancePath,
+ SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), true));
+ }
+
+ if (schemaObject["else"] is JsonNode elseSchema)
+ {
+ CollectSchemaBranch(instanceNode, elseSchema, schemaRoot, instancePath, contexts, traversal, groups, references, dynamicScope,
+ condition: new(ifSchema, instancePath,
+ SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(ifSchema)), false));
+ }
+ }
+
+ traversal.Budget.Spend(groups.Count);
+ groups = groups.Distinct().ToList();
+ traversal.Active.Remove(state);
+ // A cycle or depth cutoff produces a partial summary that cannot be reused
+ // when this schema is reached later through a different caller.
+ if (incompleteVersion == traversal.IncompleteVersion)
+ {
+ traversal.Completed[state] = new(contexts, groups);
+ }
+ traversal.Budget.SpendContexts(contexts);
+ traversal.Budget.Spend(groups.Count);
+ MergeSchemaContexts(destinationContexts, contexts);
+ destinationGroups.AddRange(groups);
+ }
+
+ private static void MergeSchemaContexts(
+ Dictionary destination,
+ Dictionary source)
+ {
+ foreach (KeyValuePair entry in source)
+ {
+ GetOrCreateSchemaContext(destination, entry.Key).Required.UnionWith(entry.Value.Required);
+ }
+ }
+
+ private static void CollectSchemaBranch(
+ JsonNode? instanceNode,
+ JsonNode branch,
+ JsonNode schemaRoot,
+ string instancePath,
+ Dictionary contexts,
+ SchemaTraversal traversal,
+ List groups,
+ SchemaReferenceResolver references,
+ List dynamicScope,
+ JsonNode? exclusiveApplicator = null,
+ RepairCondition? condition = null,
+ RepairUnevaluatedApplication? unevaluated = null,
+ bool enclosesInstance = false,
+ JsonNode? alternativeApplicator = null)
+ {
+ Dictionary branchContexts = new(StringComparer.Ordinal);
+ List branchGroups = [];
+ CollectSchemaContexts(
+ instanceNode, branch, schemaRoot, instancePath, branchContexts,
+ traversal, branchGroups, references, dynamicScope);
+ traversal.Budget.SpendContexts(branchContexts);
+ List branchScope = SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch));
+ List choices = exclusiveApplicator is null
+ ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)];
+ List conditions = condition is null ? [] : [condition];
+ List alternatives = alternativeApplicator is null
+ ? [] : [new(alternativeApplicator, branch, instancePath, branchScope)];
+ HashSet group = new(StringComparer.Ordinal);
+ foreach (KeyValuePair entry in branchContexts)
+ {
+ GetOrCreateSchemaContext(contexts, entry.Key).Required.UnionWith(entry.Value.Required);
+ foreach (string property in entry.Value.Required)
+ {
+ traversal.Budget.SpendText(entry.Key);
+ traversal.Budget.SpendText(property);
+ group.Add(AppendJsonPointer(entry.Key, property));
+ }
+ }
+
+ if (group.Count > 0)
+ {
+ traversal.Budget.Spend(group.Count);
+ foreach (RepairCandidateGroup nested in branchGroups)
+ {
+ traversal.Budget.Spend(nested.Keys.Count);
+ }
+ HashSet directKeys = new(group, StringComparer.Ordinal);
+ directKeys.ExceptWith(branchGroups.SelectMany(static nested => nested.Keys));
+ // Restore this branch's own discriminator before composing nested selectors.
+ // Nested alternatives must not be added en masse before re-evaluating them.
+ groups.Add(new(group, directKeys, instancePath, branch, branchScope, choices, conditions, unevaluated, enclosesInstance, alternatives));
+ }
+
+ foreach (RepairCandidateGroup branchGroup in branchGroups)
+ {
+ traversal.Budget.Spend(1 + choices.Count + branchGroup.Choices.Count +
+ conditions.Count + branchGroup.Conditions.Count +
+ alternatives.Count + (branchGroup.Alternatives?.Count ?? 0));
+ groups.Add(branchGroup with
+ {
+ Choices = [.. choices, .. branchGroup.Choices],
+ Conditions = [.. conditions, .. branchGroup.Conditions],
+ Unevaluated = unevaluated ?? branchGroup.Unevaluated,
+ Alternatives = [.. alternatives, .. branchGroup.Alternatives ?? []],
+ });
+ }
+ }
+
+ private static SchemaObjectContext GetOrCreateSchemaContext(Dictionary contexts, string instancePath)
+ {
+ if (!contexts.TryGetValue(instancePath, out SchemaObjectContext? context))
+ {
+ contexts[instancePath] = context = new SchemaObjectContext();
+ }
+
+ return context;
+ }
+
+ private static string AppendJsonPointer(string parentPath, string segment)
+ {
+ string escapedSegment = segment.Replace("~", "~0").Replace("/", "~1");
+ return parentPath.Length == 0 ? "/" + escapedSegment : parentPath + "/" + escapedSegment;
+ }
+
+ private static bool TryGetNodeAtPath(JsonNode? root, string path, out JsonNode? value)
+ {
+ value = root;
+ if (path.Length == 0)
+ {
+ return root is not null;
+ }
+
+ if (!path.StartsWith("/", StringComparison.Ordinal))
+ {
+ value = null;
+ return false;
+ }
+
+ foreach (string rawSegment in path.Substring(1).Split('/'))
+ {
+ string segment = DecodeJsonPointerSegment(rawSegment);
+ if (value is JsonObject objectValue && objectValue.TryGetPropertyValue(segment, out JsonNode? objectChild))
+ {
+ value = objectChild;
+ }
+ else if (value is JsonArray arrayValue && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) &&
+ index >= 0 && index < arrayValue.Count)
+ {
+ value = arrayValue[index];
+ }
+ else
+ {
+ value = null;
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool TryGetSourceValue(
+ SourceRecoveryContext? sourceRoot,
+ JsonNode serializedRoot,
+ string parentPath,
+ string propertyName,
+ JsonSerializerOptions serializerOptions,
+ out JsonNode? value)
+ {
+ value = null;
+ if (sourceRoot is null ||
+ !TryGetSourceObjectAtPath(sourceRoot, serializedRoot, parentPath, out SourceRecoveryContext? parent) ||
+ parent is null ||
+ !TryGetNodeAtPath(serializedRoot, parentPath, out JsonNode? serializedParent) ||
+ parent.GetChild(propertyName, serializedParent) is not { Property: { } property, Owner: { } owner } member)
+ {
+ return false;
+ }
+
+ if (!WasOmittedBySupportedIgnoreCondition(owner, member.Value, property, serializerOptions))
+ {
+ return false;
+ }
+
+ return TrySerializeSourceMember(member, out value);
+ }
+
+ private static bool WasOmittedBySupportedIgnoreCondition(
+ object sourceParent,
+ object? value,
+ JsonPropertyInfo? propertyInfo,
+ JsonSerializerOptions serializerOptions)
+ {
+ JsonIgnoreAttribute? ignoreAttribute = GetIgnoreAttribute(propertyInfo);
+
+ if (propertyInfo is not null && IsExcludedReadOnlyMember(propertyInfo, serializerOptions))
+ {
+ return false;
+ }
+
+ if (value is null && serializerOptions.RespectNullableAnnotations && propertyInfo is { IsGetNullable: false })
+ {
+ return false;
+ }
+
+ if (propertyInfo?.ShouldSerialize is { } shouldSerialize &&
+ (ignoreAttribute is null || !IsJsonIgnoreConditionPredicate(shouldSerialize)) &&
+ !shouldSerialize(sourceParent, value))
+ {
+ return false;
+ }
+
+ JsonIgnoreCondition ignoreCondition = ignoreAttribute?.Condition ?? serializerOptions.DefaultIgnoreCondition;
+ // For an absent value-type member, the serializer already compared against default.
+ // Boxed Equals can disagree with the EqualityComparer used by the serializer.
+ return ignoreCondition switch
+ {
+ JsonIgnoreCondition.WhenWritingNull => value is null,
+ JsonIgnoreCondition.WhenWritingDefault =>
+ propertyInfo is not null &&
+ (value is null || (propertyInfo.PropertyType.IsValueType &&
+ Nullable.GetUnderlyingType(propertyInfo.PropertyType) is null)),
+ _ => false,
+ };
+ }
+
+ private static JsonIgnoreAttribute? GetIgnoreAttribute(JsonPropertyInfo? propertyInfo) =>
+ propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider
+ ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true)
+ .OfType()
+ .FirstOrDefault()
+ : null;
+
+ private static bool IsExcludedReadOnlyMember(JsonPropertyInfo propertyInfo, JsonSerializerOptions serializerOptions)
+ {
+ if (propertyInfo.Set is not null ||
+ (!serializerOptions.IgnoreReadOnlyProperties && !serializerOptions.IgnoreReadOnlyFields))
+ {
+ return false;
+ }
+
+ // AttributeProvider is optional and does not control serialization. Use the configured
+ // capability to retain readonly-member, converter, and explicitly set ShouldSerialize rules.
+ return !SourceMemberCapabilities.CanSerialize(propertyInfo);
+ }
+
+ private static class SourceMemberCapabilities
+ {
+ internal static readonly Func CanSerialize = CreateCanSerialize();
+
+#if !NETSTANDARD2_0
+ [DynamicDependency("get_CanSerialize", typeof(JsonPropertyInfo))]
+#endif
+ private static Func CreateCanSerialize() =>
+ typeof(JsonPropertyInfo).GetProperty("CanSerialize", BindingFlags.Instance | BindingFlags.NonPublic)
+ ?.GetMethod?.CreateDelegate(typeof(Func)) is Func canSerialize
+ ? canSerialize
+ : static _ => false;
+ }
+
+ // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead.
+ private static bool IsJsonIgnoreConditionPredicate(Delegate predicate)
+ {
+ Type? generatedClosureType = predicate.Method.DeclaringType;
+ Type? propertyInfoType = generatedClosureType?.DeclaringType;
+
+ return predicate.Method.Name.StartsWith("b__", StringComparison.Ordinal) &&
+ propertyInfoType?.Assembly == typeof(JsonPropertyInfo).Assembly &&
+ string.Equals(propertyInfoType.Name, "JsonPropertyInfo`1", StringComparison.Ordinal);
+ }
+
+ private static bool TryGetSourceObjectAtPath(
+ SourceRecoveryContext sourceRoot,
+ JsonNode serializedRoot,
+ string path,
+ out SourceRecoveryContext? value)
+ {
+ value = sourceRoot;
+ if (path.Length == 0)
+ {
+ return true;
+ }
+
+ if (!path.StartsWith("/", StringComparison.Ordinal))
+ {
+ value = null;
+ return false;
+ }
+
+ JsonNode? serializedParent = serializedRoot;
+ foreach (string rawSegment in path.Substring(1).Split('/'))
+ {
+ string segment = rawSegment.Replace("~1", "/").Replace("~0", "~");
+ if (value?.GetChild(segment, serializedParent) is not { } child)
+ {
+ value = null;
+ return false;
+ }
+
+ value = child;
+ serializedParent = serializedParent switch
+ {
+ JsonObject jsonObject => jsonObject[segment],
+ JsonArray jsonArray when int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) &&
+ index >= 0 && index < jsonArray.Count => jsonArray[index],
+ _ => null,
+ };
+ }
+
+ return true;
+ }
+
+ private sealed class SourceRecoveryContext(
+ object? value, JsonTypeInfo contract, JsonPropertyInfo? property = null,
+ object? owner = null, JsonTypeInfo? ownerContract = null, SourceRecoveryContext? parent = null,
+ Type? fallbackType = null)
+ {
+ private readonly Dictionary _children = new(StringComparer.Ordinal);
+ private Dictionary, JsonTypeInfo>? _scopeContracts;
+ private JsonTypeInfo? _activeContract;
+ private bool _dictionaryRead;
+ private bool _extensionDataRead;
+ private object? _extensionData;
+
+ internal object? Value { get; } = value;
+ internal JsonTypeInfo Contract { get; } = contract;
+ internal JsonPropertyInfo? Property { get; } = property;
+ internal object? Owner { get; } = owner;
+ internal JsonTypeInfo? OwnerContract { get; } = ownerContract;
+ internal SourceRecoveryContext? Parent { get; } = parent;
+
+ internal SourceRecoveryContext? GetChild(string name, JsonNode? serializedParent)
+ {
+ if (_children.TryGetValue(name, out SourceRecoveryContext? child))
+ {
+ return child;
+ }
+
+ try
+ {
+ child = GetChildCore(name, serializedParent);
+ }
+ catch (NotSupportedException exception) when (IsNonFatalRecoveryFailure(exception))
+ {
+ // Missing metadata and opaque converter contracts cannot authorize recovery.
+ child = null;
+ }
+
+ _children[name] = child;
+ return child;
+ }
+
+ private SourceRecoveryContext? GetChildCore(string name, JsonNode? serializedParent)
+ {
+ if (Value is null || Property?.CustomConverter is not null)
+ {
+ return null;
+ }
+
+ JsonSerializerOptions options = Contract.Options;
+ JsonTypeInfo typeInfo = GetActiveContract();
+ if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) &&
+ serializedParent is JsonObject referenceObject)
+ {
+ if (referenceObject.ContainsKey("$ref"))
+ {
+ return null;
+ }
+
+ if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && name == "$values" &&
+ referenceObject["$values"] is JsonArray)
+ {
+ return new(Value, typeInfo, parent: this);
+ }
+ }
+
+ if (typeInfo.Kind == JsonTypeInfoKind.Dictionary &&
+ serializedParent is JsonObject serializedDictionary && Value is IEnumerable entries)
+ {
+ if (!_dictionaryRead)
+ {
+ _dictionaryRead = true;
+ JsonTypeInfo keyContract = options.GetTypeInfo(typeInfo.KeyType!);
+ foreach (object entry in entries)
+ {
+ if (!TryGetDictionaryEntry(entry, out object? entryKey, out object? entryValue))
+ {
+ continue;
+ }
+
+ // Non-generic dictionaries dispatch non-string keys through the object converter.
+ JsonTypeInfo entryKeyContract = Value is IDictionary && keyContract.Type == typeof(string) && entryKey is not string
+ ? options.GetTypeInfo(typeof(object))
+ : keyContract;
+ if (!TrySerializeDictionaryKey(entryKey, entryKeyContract, out string serializedKey) ||
+ !serializedDictionary.ContainsKey(serializedKey))
+ {
+ continue;
+ }
+
+ _children[serializedKey] = _children.ContainsKey(serializedKey)
+ ? null
+ : new(entryValue, options.GetTypeInfo(typeInfo.ElementType!), parent: this);
+ }
+ }
+
+ return _children.TryGetValue(name, out SourceRecoveryContext? entryContext) ? entryContext : null;
+ }
+
+ if (typeInfo.Kind == JsonTypeInfoKind.Enumerable && serializedParent is JsonArray serializedArray &&
+ int.TryParse(name, NumberStyles.None, CultureInfo.InvariantCulture, out int index) &&
+ index >= 0 && index < serializedArray.Count)
+ {
+ if (Value is IList list)
+ {
+ return index < list.Count ? new(list[index], options.GetTypeInfo(typeInfo.ElementType!), parent: this) : null;
+ }
+
+ if (TryGetGenericListElement(Value, typeInfo.ElementType!, index, out object? element))
+ {
+ return new(element, options.GetTypeInfo(typeInfo.ElementType!), parent: this);
+ }
+ }
+
+ if (typeInfo.Kind != JsonTypeInfoKind.Object)
+ {
+ return null;
+ }
+
+ SourceObjectContract sourceContract = s_sourceContracts.GetValue(typeInfo, static contract => new(contract));
+ if (sourceContract.Properties.TryGetValue(name, out JsonPropertyInfo? member))
+ {
+ if (member.Get is null || IsExcludedReadOnlyMember(member, options))
+ {
+ return null;
+ }
+
+ if (serializedParent is JsonObject serializedObject && serializedObject.ContainsKey(name) &&
+ sourceContract.ExtensionData is { } extension &&
+ GetExtensionData(extension) is { } collisionData &&
+ (extension.CustomConverter is not null || collisionData switch
+ {
+ IDictionary data => data.ContainsKey(name),
+ IDictionary data => data.ContainsKey(name),
+ JsonObject data => data.ContainsKey(name),
+ _ => true,
+ }))
+ {
+ // The JSON path may belong to extension data instead of this CLR member.
+ return null;
+ }
+
+ object? memberValue = member.Get(Value);
+ if (serializedParent is JsonObject parentObject && parentObject.ContainsKey(name) &&
+ member.ShouldSerialize?.Invoke(Value, memberValue) == false)
+ {
+ return null;
+ }
+
+ JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType);
+ return new(memberValue, memberContract, member, Value, typeInfo, this);
+ }
+
+ JsonPropertyInfo? extensionData = sourceContract.ExtensionData;
+ if (extensionData is null || extensionData.CustomConverter is not null)
+ {
+ return null;
+ }
+
+ object? extensionValue = GetExtensionData(extensionData);
+ if (extensionValue is null)
+ {
+ return null;
+ }
+
+ JsonTypeInfo extensionType = options.GetTypeInfo(extensionData.PropertyType);
+ if (extensionType.Kind != JsonTypeInfoKind.Dictionary)
+ {
+ return null;
+ }
+
+ object? extensionEntry;
+ if (extensionValue is IDictionary objectData && objectData.TryGetValue(name, out extensionEntry))
+ {
+ return new(extensionEntry, options.GetTypeInfo(extensionType.ElementType!), parent: this);
+ }
+
+ if (extensionValue is IDictionary elementData && elementData.TryGetValue(name, out JsonElement elementEntry))
+ {
+ return new(elementEntry, options.GetTypeInfo(typeof(JsonElement)), parent: this);
+ }
+
+ return null;
+ }
+
+#if !NETSTANDARD2_0
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IReadOnlyList<>))]
+ [DynamicDependency(DynamicallyAccessedMemberTypes.PublicProperties, typeof(IList<>))]
+#endif
+ [UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The indexed interface properties are preserved by DynamicDependency.")]
+ private static bool TryGetGenericListElement(object value, Type elementType, int index, out object? element)
+ {
+ element = null;
+ foreach (Type contract in value.GetType().GetInterfaces())
+ {
+ if (!contract.IsGenericType || contract.GetGenericArguments()[0] != elementType)
+ {
+ continue;
+ }
+
+ Type definition = contract.GetGenericTypeDefinition();
+ if (definition == typeof(IList<>) || definition == typeof(IReadOnlyList<>))
+ {
+ PropertyInfo? item = contract.GetProperty("Item");
+ if (item is not null)
+ {
+ try
+ {
+ element = item.GetValue(value, [index]);
+ return true;
+ }
+ catch (TargetInvocationException ex) when (ex.InnerException is ArgumentOutOfRangeException or IndexOutOfRangeException)
+ {
+ return false;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private object? GetExtensionData(JsonPropertyInfo property)
+ {
+ if (!_extensionDataRead)
+ {
+ _extensionDataRead = true;
+ if (property.Get is { } getter && !IsExcludedReadOnlyMember(property, Contract.Options))
+ {
+ object? data = getter(Value!);
+ if (property.ShouldSerialize?.Invoke(Value!, data) != false)
+ {
+ _extensionData = data;
+ }
+ }
+ }
+
+ return _extensionData;
+ }
+
+ internal object GetSerializationOwner() =>
+ GetActiveContract().Type.IsValueType ? RuntimeHelpers.GetObjectValue(Value!)! : Value!;
+
+ internal JsonTypeInfo GetScopeContract(JsonTypeInfo childContract)
+ {
+ _scopeContracts ??= new();
+ if (!_scopeContracts.TryGetValue(childContract, out JsonTypeInfo? scope))
+ {
+ scope = CreateSerializationObjectContract(GetActiveContract());
+ JsonPropertyInfo child = JsonMetadataServices.CreatePropertyInfo(scope.Options, new()
+ {
+ DeclaringType = typeof(object),
+ IsProperty = true,
+ IsPublic = true,
+ PropertyName = "value",
+ JsonPropertyName = "value",
+ PropertyTypeInfo = childContract,
+ Getter = static _ => t_serializingSourceMember!.GetNextOwner(),
+ });
+ child.ShouldSerialize = static (_, _) => true;
+ scope.Properties.Add(child);
+ scope.MakeReadOnly();
+ _scopeContracts.Add(childContract, scope);
+ }
+
+ return scope;
+ }
+
+ private JsonTypeInfo GetActiveContract() => _activeContract ??= ResolveActiveContract();
+
+ private JsonTypeInfo ResolveActiveContract()
+ {
+ JsonSerializerOptions options = Contract.Options;
+ Type runtimeType = Value!.GetType();
+ if (runtimeType == Contract.Type)
+ {
+ return Contract;
+ }
+
+ if (Contract.Type == typeof(object) && Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly)
+ {
+ try
+ {
+ return options.GetTypeInfo(runtimeType);
+ }
+ catch (NotSupportedException) when (runtimeType.IsDefined(typeof(CompilerGeneratedAttribute), false) &&
+ typeof(IEnumerable).IsAssignableFrom(runtimeType) && fallbackType is not null)
+ {
+ JsonTypeInfo fallbackContract = options.GetTypeInfo(fallbackType);
+ if (fallbackContract.Kind != JsonTypeInfoKind.Enumerable)
+ {
+ throw;
+ }
+
+ return fallbackContract;
+ }
+ }
+
+ if (Nullable.GetUnderlyingType(Contract.Type) is { } underlyingType &&
+ Contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly)
+ {
+ return options.GetTypeInfo(underlyingType);
+ }
+
+ if (Contract.PolymorphismOptions is not { } polymorphism)
+ {
+ return Contract;
+ }
+
+ foreach (JsonDerivedType derived in polymorphism.DerivedTypes)
+ {
+ if (derived.DerivedType == runtimeType)
+ {
+ return options.GetTypeInfo(runtimeType);
+ }
+ }
+
+ if (polymorphism.UnknownDerivedTypeHandling == JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor)
+ {
+ Type? nearest = null;
+ foreach (JsonDerivedType derived in polymorphism.DerivedTypes)
+ {
+ if (derived.DerivedType.IsAssignableFrom(runtimeType) &&
+ (nearest is null || nearest.IsAssignableFrom(derived.DerivedType)))
+ {
+ nearest = derived.DerivedType;
+ }
+ }
+
+ if (nearest is not null)
+ {
+ return options.GetTypeInfo(nearest);
+ }
+ }
+
+ return Contract;
+ }
+ }
+
+ private sealed class SourceObjectContract
+ {
+ private readonly JsonTypeInfo _contract;
+ private readonly ConcurrentDictionary>> _serializationContracts = new();
+
+ internal Dictionary Properties { get; } = new(StringComparer.Ordinal);
+ internal JsonPropertyInfo? ExtensionData { get; }
+
+ internal SourceObjectContract(JsonTypeInfo contract)
+ {
+ _contract = contract;
+ foreach (JsonPropertyInfo property in contract.Properties)
+ {
+ if (property.IsExtensionData)
+ {
+ ExtensionData = property;
+ }
+ else
+ {
+ Properties.Add(property.Name, property);
+ }
+ }
+ }
+
+ internal JsonTypeInfo GetSerializationContract(JsonPropertyInfo property) =>
+ _serializationContracts.GetOrAdd(property, member => new(() => CreateSerializationContract(member))).Value;
+
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "Trimming", "IL2026",
+ Justification = "The property type metadata is cached in the original options before CreateJsonPropertyInfo reuses its typed property factory.")]
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "Aot", "IL3050",
+ Justification = "CreateJsonPropertyInfo uses the cached property type metadata, so no new generic types are constructed.")]
+ private JsonTypeInfo CreateSerializationContract(JsonPropertyInfo property)
+ {
+ JsonSerializerOptions options = _contract.Options;
+ _ = options.GetTypeInfo(property.PropertyType);
+ JsonTypeInfo contract = CreateSerializationObjectContract(_contract);
+ JsonPropertyInfo candidate = contract.CreateJsonPropertyInfo(property.PropertyType, property.Name);
+ candidate.Get = static _ => t_serializingSourceMember!.Value;
+ candidate.ShouldSerialize = static (_, _) => true;
+ candidate.CustomConverter = GetPropertyConverter(property, property.PropertyType);
+ candidate.NumberHandling = property.NumberHandling;
+ candidate.IsGetNullable = property.IsGetNullable;
+ contract.Properties.Add(candidate);
+ contract.MakeReadOnly();
+ return contract;
+ }
+ }
+
+ private static JsonTypeInfo CreateSerializationObjectContract(JsonTypeInfo original)
+ {
+ JsonConverter converter = original.Kind == JsonTypeInfoKind.Object
+ ? original.Converter
+ : JsonMetadataServices.CreateObjectInfo(original.Options, new()
+ {
+ PropertyMetadataInitializer = static _ => [],
+ }).Converter;
+ // Collections use a single-property object view to seed the same reference stack
+ // without enumerating unrelated entries. These ancestor envelopes are never published.
+ JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(original.Options, converter);
+ contract.NumberHandling = original.NumberHandling;
+ contract.PolymorphismOptions = null;
+ contract.OnSerializing = null;
+ contract.OnSerialized = null;
+ return contract;
+ }
+
+ private sealed class SourceMemberSerializationState(object? value, Stack? owners)
+ {
+ internal object? Value { get; } = value;
+ internal object GetNextOwner() => owners!.Pop().GetSerializationOwner();
+ }
+
+#if !NETSTANDARD2_0
+ [System.Diagnostics.CodeAnalysis.DynamicDependency(
+ System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicProperties, typeof(KeyValuePair<,>))]
+#endif
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "Trimming", "IL2075",
+ Justification = "The reflected properties belong to KeyValuePair<,>, whose public properties are explicitly preserved.")]
+ private static bool TryGetDictionaryEntry(object entry, out object? key, out object? value)
+ {
+ if (entry is DictionaryEntry dictionaryEntry)
+ {
+ key = dictionaryEntry.Key;
+ value = dictionaryEntry.Value;
+ return true;
+ }
+
+ Type type = entry.GetType();
+ if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(KeyValuePair<,>))
+ {
+ key = type.GetProperty(nameof(KeyValuePair.Key))!.GetValue(entry);
+ value = type.GetProperty(nameof(KeyValuePair.Value))!.GetValue(entry);
+ return true;
+ }
+
+ key = value = null;
+ return false;
+ }
+
+#if !NETSTANDARD2_0
+ [System.Diagnostics.CodeAnalysis.DynamicDependency(
+ nameof(JsonConverter.WriteAsPropertyName), typeof(JsonConverter<>))]
+#endif
+ [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage(
+ "Trimming", "IL2075",
+ Justification = "The reflected method belongs to JsonConverter, whose WriteAsPropertyName method is explicitly preserved.")]
+ private static bool TrySerializeDictionaryKey(object? key, JsonTypeInfo contract, out string name)
+ {
+ name = string.Empty;
+ try
+ {
+ if (key is string stringValue && contract.Type == typeof(string) &&
+ contract.Converter.GetType().Assembly == typeof(JsonSerializer).Assembly)
+ {
+ name = contract.Options.DictionaryKeyPolicy is { } policy ? policy.ConvertName(stringValue) : stringValue;
+ if (name is not null)
+ {
+ return true;
+ }
+
+ name = string.Empty;
+ return false;
+ }
+
+ using MemoryStream stream = new();
+ using (Utf8JsonWriter writer = new(stream))
+ {
+ writer.WriteStartObject();
+ if (contract.Converter is JsonConverter stringConverter && key is string stringKey)
+ {
+ stringConverter.WriteAsPropertyName(writer, stringKey, contract.Options);
+ }
+ else if (contract.Converter is JsonConverter objectConverter && key is not null)
+ {
+ objectConverter.WriteAsPropertyName(writer, key, contract.Options);
+ }
+ else
+ {
+ Type? converterType = contract.Converter.GetType();
+ while (converterType is not null &&
+ (!converterType.IsGenericType || converterType.GetGenericTypeDefinition() != typeof(JsonConverter<>)))
+ {
+ converterType = converterType.BaseType;
+ }
+
+ converterType?.GetMethod(nameof(JsonConverter.WriteAsPropertyName))?
+ .Invoke(contract.Converter, [writer, key, contract.Options]);
+ }
+
+ writer.WriteNullValue();
+ writer.WriteEndObject();
+ }
+
+ using JsonDocument document = JsonDocument.Parse(stream.ToArray());
+ name = document.RootElement.EnumerateObject().Single().Name;
+ return true;
+ }
+ catch (TargetInvocationException exception) when (
+ exception.InnerException is { } inner && !IsNonFatalRecoveryFailure(inner))
+ {
+ System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw();
+ throw;
+ }
+ catch (Exception exception) when (IsNonFatalRecoveryFailure(exception))
+ {
+ return false;
+ }
+ }
+
+ private static bool TrySerializeSourceMember(SourceRecoveryContext member, out JsonNode? serializedValue)
+ {
+ serializedValue = null;
+ try
+ {
+ JsonTypeInfo originalContract = member.OwnerContract!;
+ JsonSerializerOptions options = originalContract.Options;
+ JsonPropertyInfo property = member.Property!;
+ JsonTypeInfo contract = s_sourceContracts.GetValue(originalContract, static owner => new(owner))
+ .GetSerializationContract(property);
+ SourceRecoveryContext ownerContext = member.Parent!;
+ Stack? owners = null;
+ int scopeDepth = 0;
+ if (ReferenceEquals(options.ReferenceHandler, ReferenceHandler.IgnoreCycles) && ownerContext.Parent is not null)
+ {
+ owners = new();
+ owners.Push(ownerContext);
+ while (ownerContext.Parent is { } ancestor)
+ {
+ contract = ancestor.GetScopeContract(contract);
+ owners.Push(ancestor);
+ ownerContext = ancestor;
+ scopeDepth++;
+ }
+ owners.Pop();
+ }
+
+ JsonElement element;
+ SourceMemberSerializationState? previous = t_serializingSourceMember;
+ try
+ {
+ // Serialization is synchronous; restore the slot for nested invocations.
+ t_serializingSourceMember = new(member.Value, owners);
+ // Value-type serialization must not track the original box as a reference cycle.
+ element = JsonSerializer.SerializeToElement(ownerContext.GetSerializationOwner(), contract);
+ for (int i = 0; i < scopeDepth; i++)
+ {
+ element = element.GetProperty("value");
+ }
+ element = element.GetProperty(property.Name);
+ }
+ finally
+ {
+ t_serializingSourceMember = previous;
+ }
+ if (options.ReferenceHandler is { } handler && !ReferenceEquals(handler, ReferenceHandler.IgnoreCycles) &&
+ ContainsReferenceMetadata(element))
+ {
+ return false;
+ }
+
+ serializedValue = JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement);
+ return true;
+ }
+ catch (Exception exception) when (IsNonFatalRecoveryFailure(exception))
+ {
+ return false;
+ }
+ }
+
+ private static bool ContainsReferenceMetadata(JsonElement element) =>
+ element.ValueKind switch
+ {
+ JsonValueKind.Object => element.EnumerateObject().Any(static property =>
+ property.Name is "$id" or "$ref" || ContainsReferenceMetadata(property.Value)),
+ JsonValueKind.Array => element.EnumerateArray().Any(static item => ContainsReferenceMetadata(item)),
+ _ => false,
+ };
+
+ private static bool HasDuplicatePropertyNames(JsonElement element)
+ {
+ if (element.ValueKind == JsonValueKind.Object)
+ {
+ HashSet names = new(StringComparer.Ordinal);
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ if (!names.Add(property.Name) || HasDuplicatePropertyNames(property.Value))
+ {
+ return true;
+ }
+ }
+ }
+ else if (element.ValueKind == JsonValueKind.Array)
+ {
+ return element.EnumerateArray().Any(static item => HasDuplicatePropertyNames(item));
+ }
+
+ return false;
+ }
+
+ private static JsonConverter? GetPropertyConverter(JsonPropertyInfo? propertyInfo, Type propertyType)
+ {
+ if (propertyInfo is null)
+ {
+ return null;
+ }
+
+ if (propertyInfo.CustomConverter is JsonConverter customConverter)
+ {
+ return customConverter;
+ }
+
+ if (propertyInfo.AttributeProvider is ICustomAttributeProvider attributeProvider &&
+ attributeProvider.GetCustomAttributes(typeof(JsonConverterAttribute), inherit: true)
+ .OfType()
+ .FirstOrDefault() is { } converterAttribute)
+ {
+ return converterAttribute.CreateConverter(propertyType);
+ }
+
+ return null;
+ }
+
+ private sealed class SchemaReferenceResolver
+ {
+ private readonly Dictionary _resources = new();
+ private readonly Dictionary _resourceIds = new();
+ private readonly List _dynamicSchemas = [];
+ private readonly JsonNode _root;
+ private readonly Uri? _rootUri;
+ internal HashSet SupplementalSchemas { get; } = [];
+ internal bool HasDynamicReferences => _dynamicSchemas.Count != 0;
+
+ internal bool CanResolveReference(Uri resourceUri, string reference) =>
+ _resources.TryGetValue(resourceUri, out JsonNode? resource) &&
+ TryResolve(resource, reference, out _, out _);
+
+ internal SchemaReferenceResolver(JsonNode schema, Uri? rootUri = null)
+ {
+ _root = schema;
+ _rootUri = rootUri;
+ Register(schema, new Uri("https://mcp.invalid/schema"), isRoot: true);
+ }
+
+ internal void AddSupplementalSchema(JsonNode schema)
+ {
+ Register(schema, new Uri("https://mcp.invalid/recovery"));
+ SupplementalSchemas.Add(schema);
+ }
+
+ internal bool TryResolveEvaluationPath(string path, out Uri? schemaUri)
+ {
+ schemaUri = null;
+ JsonNode? node = _root;
+ List scope = [GetResourceRoot(_root)];
+ foreach (string segment in path.Split('/').Skip(1).Select(DecodeJsonPointerSegment))
+ {
+ if (node is JsonObject obj)
+ {
+ if (segment is "$ref" or "$dynamicRef" &&
+ obj[segment] is JsonValue value && value.TryGetValue(out string? reference) &&
+ reference is not null)
+ {
+ if (!TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out JsonNode resource))
+ {
+ return false;
+ }
+
+ if (segment == "$dynamicRef" && target is JsonObject targetObject &&
+ targetObject["$dynamicAnchor"]?.GetValue() is string anchor &&
+ reference.IndexOf('#') is int fragment && fragment >= 0 &&
+ Uri.UnescapeDataString(reference.Substring(fragment + 1)) == anchor)
+ {
+ foreach (JsonNode caller in scope)
+ {
+ if (TryFindSchemaAnchor(caller, anchor, out JsonNode? bound, dynamicOnly: true))
+ {
+ target = bound;
+ resource = caller;
+ break;
+ }
+ }
+ }
+
+ node = target;
+ scope = EnterResource(scope, resource);
+ }
+ else
+ {
+ node = obj[segment];
+ }
+ }
+ else if (node is JsonArray array &&
+ int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) &&
+ index >= 0 && index < array.Count)
+ {
+ node = array[index];
+ }
+ else
+ {
+ return false;
+ }
+
+ if (node is null)
+ {
+ return false;
+ }
+
+ if (_resourceIds.ContainsKey(node))
+ {
+ scope = EnterResource(scope, node);
+ }
+ }
+
+ schemaUri = GetSchemaUri(node!);
+ return true;
+ }
+
+ internal Uri GetSchemaUri(JsonNode schema)
+ {
+ JsonNode resource = GetResourceRoot(schema);
+ string pointer = GetSchemaPointer(schema, resource);
+ return pointer.Length == 0 ? _resourceIds[resource] : new Uri(_resourceIds[resource], "#" + Uri.EscapeDataString(pointer));
+ }
+
+ internal JsonNode GetResourceRoot(JsonNode schema)
+ {
+ JsonNode resource = schema;
+ while (!_resourceIds.ContainsKey(resource) && resource.Parent is JsonNode parent)
+ {
+ resource = parent;
+ }
+
+ return resource;
+ }
+
+ internal static List EnterResource(List scope, JsonNode resource) =>
+ scope.Contains(resource) ? scope : [.. scope, resource];
+
+ internal string GetScopeKey(List scope) =>
+ string.Join("\n", scope.Select(resource => _resourceIds[resource].AbsoluteUri));
+
+ internal void BindDynamicReferences(JsonNode document, JsonNode copy, List scope)
+ {
+ // JsonSchemaNode exposes evaluation metadata, but EvaluationContext cannot be
+ // constructed with a caller scope. Bind only names already fixed by that scope:
+ // entering further resources cannot change their outermost dynamic anchor.
+ foreach (JsonNode node in _dynamicSchemas)
+ {
+ if (node is not JsonObject schemaObject ||
+ schemaObject["$dynamicRef"] is not JsonValue value ||
+ !value.TryGetValue(out string? reference) ||
+ reference is null ||
+ !TryResolve(GetResourceRoot(node), reference, out JsonNode? target, out _) ||
+ target is not JsonObject targetObject ||
+ targetObject["$dynamicAnchor"]?.GetValue() is not string anchor ||
+ reference.IndexOf('#') < 0 ||
+ !string.Equals(Uri.UnescapeDataString(reference[(reference.IndexOf('#') + 1)..]), anchor, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ foreach (JsonNode resource in scope)
+ {
+ if (TryFindSchemaAnchor(resource, anchor, out JsonNode? bound, dynamicOnly: true) &&
+ bound is not null &&
+ TryGetNodeAtPath(copy, GetSchemaPointer(node, document), out JsonNode? copiedNode) &&
+ copiedNode is JsonObject copiedObject)
+ {
+ copiedObject.Remove("$dynamicRef");
+ if (copiedObject.ContainsKey("$ref"))
+ {
+ JsonArray allOf = copiedObject["allOf"] as JsonArray ?? [];
+ if (allOf.Parent is null)
+ {
+ copiedObject["allOf"] = allOf;
+ }
+
+ allOf.Add((JsonNode)new JsonObject { ["$ref"] = GetSchemaUri(bound).AbsoluteUri });
+ }
+ else
+ {
+ copiedObject["$ref"] = GetSchemaUri(bound).AbsoluteUri;
+ }
+ break;
+ }
+ }
+ }
+ }
+
+ private void Register(JsonNode? schema, Uri baseUri, bool isRoot = false)
+ {
+ if (schema is not JsonObject schemaObject)
+ {
+ return;
+ }
+
+ if (schemaObject.ContainsKey("$dynamicRef"))
+ {
+ _dynamicSchemas.Add(schema);
+ }
+ bool isResource = isRoot;
+ if (isRoot && _rootUri is not null)
+ {
+ baseUri = _rootUri;
+ }
+ else if (schemaObject["$id"] is JsonValue idValue && idValue.TryGetValue(out string? id) &&
+ Uri.TryCreate(baseUri, id, out Uri? resourceUri))
+ {
+ baseUri = resourceUri;
+ isResource = true;
+ }
+
+ if (isResource)
+ {
+ _resourceIds[schema] = baseUri;
+ _resources[new UriBuilder(baseUri) { Fragment = string.Empty }.Uri] = schema;
+ }
+
+ foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" })
+ {
+ if (schemaObject[keyword] is JsonObject schemas)
+ {
+ foreach (KeyValuePair child in schemas)
+ {
+ Register(child.Value, baseUri);
+ }
+ }
+ }
+
+ foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" })
+ {
+ if (schemaObject[keyword] is JsonArray schemas)
+ {
+ foreach (JsonNode? child in schemas)
+ {
+ Register(child, baseUri);
+ }
+ }
+ }
+
+ foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not", "propertyNames" })
+ {
+ Register(schemaObject[keyword], baseUri);
+ }
+ }
+
+ internal bool TryResolve(JsonNode currentResource, string reference, out JsonNode? schema, out JsonNode resource)
+ {
+ schema = null;
+ resource = currentResource;
+ if (!_resourceIds.TryGetValue(currentResource, out Uri? baseUri) ||
+ !Uri.TryCreate(baseUri, reference, out Uri? targetUri) ||
+ !_resources.TryGetValue(new UriBuilder(targetUri) { Fragment = string.Empty }.Uri, out JsonNode? targetResource) ||
+ !TryResolveLocalReference(targetResource, targetUri.Fragment.Length == 0 ? "#" : targetUri.Fragment, out schema))
+ {
+ return false;
+ }
+
+ resource = targetResource;
+ for (JsonNode? node = schema; node is not null; node = node.Parent)
+ {
+ if (_resourceIds.ContainsKey(node))
+ {
+ resource = node;
+ break;
+ }
+ }
+
+ return true;
+ }
+ }
+
+ private static bool TryResolveLocalReference(JsonNode schemaRoot, string reference, out JsonNode? resolvedSchema)
+ {
+ resolvedSchema = null;
+ if (!reference.StartsWith("#", StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ string pointer = Uri.UnescapeDataString(reference[1..]);
+ if (pointer.Length == 0)
+ {
+ resolvedSchema = schemaRoot;
+ return true;
+ }
+
+ if (!pointer.StartsWith("/", StringComparison.Ordinal))
+ {
+ return TryFindSchemaAnchor(schemaRoot, pointer, out resolvedSchema);
+ }
+
+ JsonNode? current = schemaRoot;
+ foreach (string rawSegment in pointer[1..].Split('/'))
+ {
+ string segment = rawSegment.Replace("~1", "/").Replace("~0", "~");
+ if (current is JsonObject currentObject && currentObject.TryGetPropertyValue(segment, out JsonNode? child))
+ {
+ current = child;
+ }
+ else if (current is JsonArray currentArray && int.TryParse(segment, out int index) && index >= 0 && index < currentArray.Count)
+ {
+ current = currentArray[index];
+ }
+ else
+ {
+ return false;
+ }
+ }
+
+ resolvedSchema = current;
+ return true;
+ }
+
+ private static bool TryFindSchemaAnchor(JsonNode? node, string anchor, out JsonNode? resolvedSchema, bool dynamicOnly = false, bool isResourceRoot = true)
+ {
+ resolvedSchema = null;
+ if (node is not JsonObject schemaObject || (!isResourceRoot && schemaObject.ContainsKey("$id")))
+ {
+ return false;
+ }
+
+ if ((!dynamicOnly && schemaObject["$anchor"]?.GetValue() == anchor) ||
+ schemaObject["$dynamicAnchor"]?.GetValue() == anchor)
+ {
+ resolvedSchema = node;
+ return true;
+ }
+
+ foreach (string keyword in new[] { "$defs", "definitions", "properties", "patternProperties", "dependentSchemas" })
+ {
+ if (schemaObject[keyword] is JsonObject schemas)
+ {
+ foreach (KeyValuePair schema in schemas)
+ {
+ if (TryFindSchemaAnchor(schema.Value, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false))
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ foreach (string keyword in new[] { "allOf", "anyOf", "oneOf", "prefixItems" })
+ {
+ if (schemaObject[keyword] is JsonArray schemas)
+ {
+ foreach (JsonNode? schema in schemas)
+ {
+ if (TryFindSchemaAnchor(schema, anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false))
+ {
+ return true;
+ }
+ }
+ }
+ }
+
+ foreach (string keyword in new[] { "items", "additionalProperties", "unevaluatedProperties", "unevaluatedItems", "contains", "if", "then", "else", "not", "propertyNames" })
+ {
+ if (TryFindSchemaAnchor(schemaObject[keyword], anchor, out resolvedSchema, dynamicOnly, isResourceRoot: false))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent)
{
List contentList = [];
@@ -903,4 +4208,4 @@ private static bool IsPrimitiveHeaderType(Type type)
type == typeof(uint) ||
type == typeof(long);
}
-}
\ No newline at end of file
+}
diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs
index d2bce270f..f9471746e 100644
--- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs
+++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerTool.cs
@@ -6,6 +6,10 @@ namespace ModelContextProtocol.Server;
///
/// This is recommended as a base type when building tools that can be chained around an underlying .
/// The default implementation simply passes each call to the inner tool instance.
+/// When an invocation override retains the original element,
+/// server dispatch can apply required-member recovery against the registered tool's effective output schema.
+/// Calling the base invocation also finalizes recovery for the inner forwarding segment before an override consumes it.
+/// Overrides that replace or reserialize structured content are responsible for matching their advertised schema.
///
public abstract class DelegatingMcpServerTool : McpServerTool
{
@@ -35,9 +39,24 @@ protected DelegatingMcpServerTool(McpServerTool innerTool)
///
public override ValueTask InvokeAsync(
- RequestContext request,
+ RequestContext request,
CancellationToken cancellationToken = default) =>
- _innerTool.InvokeAsync(request, cancellationToken);
+ HasCustomInvocation && request is { StructuredContentRecoveryTarget: { } recoveryTarget }
+ ? InvokeInnerToolAsync(request, recoveryTarget, cancellationToken)
+ : _innerTool.InvokeAsync(request, cancellationToken);
+
+ private async ValueTask InvokeInnerToolAsync(
+ RequestContext request,
+ RequestContext.StructuredContentRecoveryState recoveryTarget,
+ CancellationToken cancellationToken)
+ {
+ CallToolResult result = await _innerTool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
+ if (ReferenceEquals(request.StructuredContentRecoveryTarget, recoveryTarget))
+ {
+ request.RecoverStructuredContent(_innerTool, result);
+ }
+ return result;
+ }
///
public override string ToString() => _innerTool.ToString();
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index db11a84de..3250c8a55 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -1610,7 +1610,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false)
{
if (request.MatchedPrimitive is McpServerTool tool)
{
- return tool.InvokeAsync(request, cancellationToken);
+ return InvokeToolAsync(tool, request, cancellationToken);
}
return originalHandler(request, cancellationToken);
@@ -1643,7 +1643,37 @@ private static async ValueTask> InvokeToolWith
RequestContext request,
CancellationToken cancellationToken)
{
- return await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
+ return await InvokeToolAsync(tool, request, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static ValueTask InvokeToolAsync(
+ McpServerTool tool,
+ RequestContext request,
+ CancellationToken cancellationToken) =>
+ tool is DelegatingMcpServerTool || request.StructuredContentRecoveryTarget is not null
+ ? InvokeToolWithRecoveryAsync(tool, request, cancellationToken)
+ : tool.InvokeAsync(request, cancellationToken);
+
+ private static async ValueTask InvokeToolWithRecoveryAsync(
+ McpServerTool tool,
+ RequestContext request,
+ CancellationToken cancellationToken)
+ {
+ var previousTarget = request.StructuredContentRecoveryTarget;
+ var recoveryTarget = new RequestContext.StructuredContentRecoveryState(
+ tool, tool.ProtocolTool.OutputSchema);
+ request.StructuredContentRecoveryTarget = recoveryTarget;
+ try
+ {
+ CallToolResult result = await tool.InvokeAsync(request, cancellationToken).ConfigureAwait(false);
+ request.RecoverStructuredContent(tool, result);
+ return result;
+ }
+ finally
+ {
+ recoveryTarget.Clear();
+ request.StructuredContentRecoveryTarget = previousTarget;
+ }
}
private McpRequestHandler> BuildComposedCallToolHandler(
diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
index b0b6b3de7..ae094fda8 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs
@@ -124,8 +124,16 @@ public sealed class McpServerToolCreateOptions
/// The default is .
///
///
+ ///
/// When enabled, the tool will attempt to populate the
/// and provide structured content in the property.
+ ///
+ ///
+ /// Recovery of required members omitted by null or default-value serialization policies is best effort.
+ /// Recovery does not re-enumerate non-indexable collections, such as sets, queues, or linked lists.
+ /// Materialize these collections into arrays or lists when their elements need this recovery.
+ /// If safe recovery cannot produce a schema-valid result, the original serialized content is retained.
+ ///
///
public bool UseStructuredContent { get; set; }
diff --git a/src/ModelContextProtocol.Core/Server/RequestContext.cs b/src/ModelContextProtocol.Core/Server/RequestContext.cs
index 596712eea..c02a53919 100644
--- a/src/ModelContextProtocol.Core/Server/RequestContext.cs
+++ b/src/ModelContextProtocol.Core/Server/RequestContext.cs
@@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol;
+using System.Text.Json;
namespace ModelContextProtocol.Server;
@@ -47,6 +48,71 @@ public RequestContext(McpServer server, JsonRpcRequest jsonRpcRequest)
///
public IMcpServerPrimitive? MatchedPrimitive { get; set; }
+ private readonly AsyncLocal _structuredContentRecoveryTarget = new();
+
+ internal StructuredContentRecoveryState? StructuredContentRecoveryTarget
+ {
+ get => _structuredContentRecoveryTarget.Value is { IsActive: true } target ? target : null;
+ set => _structuredContentRecoveryTarget.Value = value;
+ }
+
+ internal void RecoverStructuredContent(McpServerTool tool, CallToolResult result) =>
+ StructuredContentRecoveryTarget?.Recover(tool, result);
+
+ internal sealed class StructuredContentRecoveryState(McpServerTool tool, JsonElement? schema)
+ {
+ private readonly object _lock = new();
+ private readonly Dictionary> _plans = [];
+ private bool _isActive = true;
+
+ internal McpServerTool Tool { get; } = tool;
+ internal JsonElement? Schema { get; } = schema;
+ internal bool IsActive => Volatile.Read(ref _isActive);
+
+ internal void Register(IEnumerable identities, Action recovery)
+ {
+ lock (_lock)
+ {
+ if (!_isActive)
+ {
+ return;
+ }
+
+ foreach (JsonElement identity in identities)
+ {
+ _plans[identity] = recovery;
+ }
+ }
+ }
+
+ internal void Recover(McpServerTool tool, CallToolResult result)
+ {
+ if (result.IsError == true || result.StructuredContent is not { } identity)
+ {
+ return;
+ }
+
+ Action? recovery;
+ lock (_lock)
+ {
+ if (!_isActive || !_plans.TryGetValue(identity, out recovery))
+ {
+ return;
+ }
+ }
+ recovery(tool, result);
+ }
+
+ internal void Clear()
+ {
+ lock (_lock)
+ {
+ _isActive = false;
+ _plans.Clear();
+ }
+ }
+ }
+
///
/// Gets the JSON-RPC request associated with this context.
///
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
index 260bd3204..eb669e8c7 100644
--- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
@@ -1,12 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.AI;
+using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Apps;
using ModelContextProtocol.Extensions.Skills;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
+using System.Collections;
+using System.Collections.ObjectModel;
+using System.Dynamic;
using System.IO.Pipelines;
+using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
Pipe clientToServerPipe = new(), serverToClientPipe = new();
const string FallbackUri = "skill://aot-fallback/SKILL.md";
@@ -22,13 +30,285 @@
var package = McpServerSkillPackage.Create(
[McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")], createOptions);
var primitive = package.Skill;
+
var services = new ServiceCollection();
var catalogTracker = new AotCatalogTracker();
services.AddSingleton(catalogTracker);
services.AddScoped();
+JsonSerializerOptions structuredOptions = new(McpJsonUtilities.DefaultOptions)
+{
+ IgnoreReadOnlyProperties = true,
+ TypeInfoResolver = new AotCachingResolver(JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver)
+ .WithAddedModifier(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(AotBaseOutput))
+ {
+ typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false;
+ }
+ })),
+};
+McpServerTool structuredTool = McpServerTool.Create(
+ () => new AotStructuredOutput(),
+ new()
+ {
+ Name = "RequiredDefaults",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""
+ {
+ "$id":"https://example.test/aot",
+ "type":"object",
+ "required":["id","note","value","items","genericItems","dynamicItems","unions","numbers","extension","contract","nullable","delegatingCount"],
+ "anyOf":[{"required":["count"]},{"required":["note"]}],
+ "$defs":{"child":{"$id":"child","type":"object","required":["count"],"properties":{"count":{"const":0}}}},
+ "properties":{
+ "id":{"type":"null"},"note":{"type":"null"},"value":{"const":"converted"},
+ "items":{"type":"object","additionalProperties":{"$ref":"child"}},
+ "genericItems":{"type":"object","additionalProperties":{"$ref":"child"}},
+ "dynamicItems":{"type":"object","additionalProperties":{"$ref":"child"}},
+ "numbers":{"required":["count"],"properties":{"count":{"const":"0"}}},
+ "extension":{"properties":{"child/~":{"required":["note"]}}},
+ "contract":{"oneOf":[{"required":["flag"]},{"required":["note"]}]},
+ "nullable":{"required":["note"]},
+ "unevaluated":{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}},
+ "delegatingCount":{"const":0},
+ "unions":{"type":"array","allOf":[{"items":{"oneOf":[
+ {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false},
+ {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false}
+ ]}},{"items":{"not":{"required":["child1"]}}}]}
+ }
+ }
+ """).RootElement,
+ });
+JsonSerializerOptions preservedOptions = new(structuredOptions) { ReferenceHandler = ReferenceHandler.Preserve };
+McpServerTool preservedTool = McpServerTool.Create(
+ new AotPreservedFunction(preservedOptions),
+ new()
+ {
+ UseStructuredContent = true,
+ OutputSchema = JsonDocument.Parse("""
+ {
+ "required":["entry/1~","entry/2~"],
+ "properties":{
+ "entry/1~":{"required":["count"],"properties":{"count":{"type":"integer"}}},
+ "entry/2~":{"required":["count"],"properties":{"count":{"type":"integer"}}}
+ }
+ }
+ """).RootElement,
+ });
+McpServerTool preservedNongenericTool = McpServerTool.Create(
+ new AotPreservedFunction(preservedOptions, nongeneric: true),
+ new()
+ {
+ UseStructuredContent = true,
+ OutputSchema = preservedTool.ProtocolTool.OutputSchema,
+ });
+McpServerTool ancestorTool = McpServerTool.Create(
+ new AotAncestorFunction(new(structuredOptions)
+ {
+ ReferenceHandler = ReferenceHandler.IgnoreCycles,
+ DefaultIgnoreCondition = JsonIgnoreCondition.Never,
+ }),
+ new()
+ {
+ UseStructuredContent = true,
+ OutputSchema = JsonDocument.Parse("""
+ {
+ "type":"object",
+ "$defs":{"node":{"required":["missing"],"properties":{"missing":{
+ "required":["owner"],"properties":{"owner":{"type":"null"}}
+ }}}},
+ "properties":{
+ "child":{"$ref":"#/$defs/node"},
+ "items":{"items":{"$ref":"#/$defs/node"}},
+ "entries":{"additionalProperties":{"$ref":"#/$defs/node"}}
+ }
+ }
+ """).RootElement,
+ });
+McpServerTool iteratorTool = McpServerTool.Create(
+ AotTools.Iterator,
+ new()
+ {
+ Name = "Iterator",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement,
+ });
+McpServerTool genericListTool = McpServerTool.Create(
+ () => new AotGenericList(),
+ new()
+ {
+ Name = "GenericList",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"type":"array","items":{"required":["count"]}}""").RootElement,
+ });
+McpServerTool genericMutableListTool = McpServerTool.Create(
+ () => new AotGenericMutableList(),
+ new()
+ {
+ Name = "GenericMutableList",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = genericListTool.ProtocolTool.OutputSchema,
+ });
+JsonArray recoveryAlternatives = [];
+for (int i = 0; i < 128; i++)
+{
+ recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)) });
+}
+recoveryAlternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") });
+McpServerTool boundedRecoveryTool = McpServerTool.Create(
+ () => new AotNullableChild(),
+ new()
+ {
+ Name = "BoundedRecovery",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse(new JsonObject { ["anyOf"] = recoveryAlternatives }.ToJsonString()).RootElement,
+ });
+McpServerTool annotationRecoveryTool = McpServerTool.Create(
+ () => new AotNullableChild { Real = "kept" },
+ new()
+ {
+ Name = "AnnotationRecovery",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"anyOf":[true,{"required":["note"],"properties":{"real":true,"note":true}}],"unevaluatedProperties":false}""").RootElement,
+ });
+McpServerTool regexRecoveryTool = McpServerTool.Create(
+ () => new AotNullableChild { Real = new string('a', 26) + "!" },
+ new()
+ {
+ Name = "RegexRecovery",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"anyOf":[{"properties":{"real":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""").RootElement,
+ });
+McpServerTool callbackRecoveryTool = McpServerTool.Create(
+ () => new AotCallbackOutput(),
+ new()
+ {
+ Name = "CallbackRecovery",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"required":["note"]}""").RootElement,
+ });
+List readOnlyRecoveryTools = [];
+foreach (bool field in new[] { false, true })
+{
+ JsonSerializerOptions readOnlyOptions = new(structuredOptions)
+ {
+ IgnoreReadOnlyProperties = field,
+ IgnoreReadOnlyFields = !field,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(AotJsonContext.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver)
+ .WithAddedModifier(typeInfo =>
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ }
+ }),
+ };
+ readOnlyRecoveryTools.Add(McpServerTool.Create(() => new AotReadOnlyOutput(), new()
+ {
+ Name = field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery",
+ UseStructuredContent = true,
+ SerializerOptions = readOnlyOptions,
+ OutputSchema = JsonDocument.Parse(field
+ ? """{"required":["fieldNote"],"not":{"required":["note"]}}"""
+ : """{"required":["note"],"not":{"required":["fieldNote"]}}""").RootElement,
+ }));
+}
+List forwardingRecoveryTools = [];
+foreach (bool required in new[] { false, true })
+{
+ foreach (bool rebindMatch in new[] { false, true })
+ {
+ JsonElement requiredSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement;
+ JsonElement optionalSchema = JsonDocument.Parse("""{"type":"object","properties":{"note":{"type":"string"}}}""").RootElement;
+ McpServerTool inner = McpServerTool.Create(() => new AotNullableChild(), new()
+ {
+ Name = (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : ""),
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = required ? optionalSchema : requiredSchema,
+ });
+ forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema, rebindMatch));
+ if (!rebindMatch)
+ {
+ forwardingRecoveryTools.Add(new PayloadAotTool(
+ new ForwardingSchemaAotTool(inner, required ? requiredSchema : optionalSchema)));
+ }
+ }
+}
+var mutableNote = new AotMutableNote();
+McpServerTool mutableNoteTool = McpServerTool.Create(() => mutableNote, new()
+{
+ Name = "ForwardingMutableNote",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement,
+});
+forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(
+ mutableNoteTool, JsonDocument.Parse(mutableNoteTool.ProtocolTool.OutputSchema!.Value.GetRawText()).RootElement,
+ afterInvocation: () => mutableNote.Note = "changed after serialization"));
+var mutableCount = new AotMutableCount();
+McpServerTool mutableCountTool = McpServerTool.Create(() => mutableCount, new()
+{
+ Name = "ForwardingMutableCount",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"type":"object"}""").RootElement,
+});
+forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(
+ mutableCountTool,
+ JsonDocument.Parse("""{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}""").RootElement,
+ afterInvocation: () => mutableCount.Count = 42));
+var firstDispatchEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+var releaseFirstDispatch = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+int dispatchCount = 0;
+McpServerTool overlappingTool = McpServerTool.Create(async (CancellationToken cancellationToken) =>
+{
+ if (Interlocked.Increment(ref dispatchCount) == 1)
+ {
+ firstDispatchEntered.TrySetResult(true);
+ await releaseFirstDispatch.Task.WaitAsync(cancellationToken);
+ }
+ return new AotNullableChild();
+}, new()
+{
+ Name = "OverlappingForwardingNote",
+ UseStructuredContent = true,
+ SerializerOptions = structuredOptions,
+ OutputSchema = JsonDocument.Parse("""{"type":"object"}""").RootElement,
+});
+forwardingRecoveryTools.Add(new ForwardingSchemaAotTool(
+ overlappingTool,
+ JsonDocument.Parse("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement));
+services.Configure(options =>
+{
+ options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) =>
+ {
+ if (request.Params?.Name != "OverlappingForwardingNote")
+ {
+ return await next(request, cancellationToken);
+ }
+ var first = next(request, cancellationToken).AsTask();
+ await firstDispatchEntered.Task.WaitAsync(cancellationToken);
+ try { await next(request, cancellationToken); }
+ finally { releaseFirstDispatch.TrySetResult(true); }
+ return await first;
+ });
+});
services.AddMcpServer()
.WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())
.WithTools()
+ .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool, ancestorTool, iteratorTool, genericListTool, genericMutableListTool, boundedRecoveryTool, annotationRecoveryTool, regexRecoveryTool, callbackRecoveryTool])
+ .WithTools(readOnlyRecoveryTools)
+ .WithTools(forwardingRecoveryTools)
.WithTools(
[
new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })),
@@ -96,6 +376,48 @@
throw new Exception($"Unexpected result: {result}");
}
+var structuredResult = await client.CallToolAsync("RequiredDefaults");
+if (structuredResult.StructuredContent is not { } structured ||
+ structured.GetProperty("id").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("note").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("value").GetString() != "converted" ||
+ structured.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32() != 0 ||
+ structured.GetProperty("genericItems").GetProperty("42").GetProperty("count").GetInt32() != 0 ||
+ structured.GetProperty("dynamicItems").GetProperty("entry").GetProperty("count").GetInt32() != 0 ||
+ structured.GetProperty("numbers").GetProperty("count").GetString() != "0" ||
+ structured.GetProperty("extension").GetProperty("child/~").GetProperty("note").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("contract").GetProperty("note").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("contract").TryGetProperty("flag", out _) ||
+ structured.GetProperty("nullable").GetProperty("note").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("unevaluated").GetProperty("evaluated\0path").GetProperty("real").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("unevaluated").GetProperty("evaluated\0path").TryGetProperty("note", out _) ||
+ structured.GetProperty("unevaluated").GetProperty("other").GetProperty("note").ValueKind != JsonValueKind.Null ||
+ structured.GetProperty("delegatingCount").GetInt32() != 0 ||
+ structured.TryGetProperty("count", out _))
+{
+ throw new Exception($"Unexpected structured output: {structuredResult.StructuredContent}");
+}
+
+foreach (JsonElement item in structured.GetProperty("unions").EnumerateArray())
+{
+ if (item.GetProperty("child0").GetProperty("note").ValueKind != JsonValueKind.Null ||
+ item.TryGetProperty("child1", out _))
+ {
+ throw new Exception($"Unexpected union output: {item}");
+ }
+}
+
+foreach (string name in new[] { "PreservedDictionary", "PreservedNongenericDictionary" })
+{
+ var preservedResult = await client.CallToolAsync(name);
+ if (preservedResult.StructuredContent is not { } preserved ||
+ preserved.GetProperty("entry/1~").GetProperty("count").GetInt32() != 0 ||
+ preserved.GetProperty("entry/2~").GetProperty("count").GetInt32() != 5)
+ {
+ throw new Exception($"Unexpected preserved dictionary output: {preservedResult.StructuredContent}");
+ }
+}
+
foreach (string name in new[] { "ForwardingEcho", "OverridingEcho" })
{
var delegatedResult = await client.CallToolAsync(name, new Dictionary { ["arg"] = "Hello World" });
@@ -105,6 +427,130 @@
}
}
+var iteratorResult = await client.CallToolAsync("Iterator");
+if (iteratorResult.StructuredContent is not { } iterator ||
+ iterator.ValueKind != JsonValueKind.Array ||
+ iterator.GetArrayLength() != 1 ||
+ iterator[0].ValueKind != JsonValueKind.Object ||
+ iterator[0].TryGetProperty("count", out _))
+{
+ throw new Exception($"Unexpected iterator output: {iteratorResult.StructuredContent}");
+}
+
+foreach (string name in new[] { "GenericList", "GenericMutableList" })
+{
+ var genericListResult = await client.CallToolAsync(name);
+ if (genericListResult.StructuredContent is not { } genericList ||
+ genericList[0].GetProperty("count").GetInt32() != 0)
+ {
+ throw new Exception($"Unexpected generic list output: {genericListResult.StructuredContent}");
+ }
+}
+
+var boundedRecoveryResult = await client.CallToolAsync("BoundedRecovery");
+if (boundedRecoveryResult.StructuredContent is not { } boundedRecovery ||
+ boundedRecovery.GetRawText() != "{}")
+{
+ throw new Exception($"Unexpected bounded recovery output: {boundedRecoveryResult.StructuredContent}");
+}
+
+var annotationRecoveryResult = await client.CallToolAsync("AnnotationRecovery");
+if (annotationRecoveryResult.StructuredContent is not { } annotationRecovery ||
+ annotationRecovery.GetProperty("note").ValueKind != JsonValueKind.Null ||
+ annotationRecovery.GetProperty("real").GetString() != "kept")
+{
+ throw new Exception($"Unexpected annotation recovery output: {annotationRecoveryResult.StructuredContent}");
+}
+
+var regexRecoveryResult = await client.CallToolAsync("RegexRecovery");
+if (regexRecoveryResult.StructuredContent is not { } regexRecovery ||
+ regexRecovery.TryGetProperty("note", out _) ||
+ regexRecovery.GetProperty("real").GetString() != new string('a', 26) + "!")
+{
+ throw new Exception($"Unexpected regex recovery output: {regexRecoveryResult.StructuredContent}");
+}
+
+foreach (bool field in new[] { false, true })
+{
+ var readOnlyResult = await client.CallToolAsync(field ? "ReadOnlyFieldRecovery" : "ReadOnlyPropertyRecovery");
+ if (readOnlyResult.StructuredContent is not { } readOnly ||
+ !readOnly.TryGetProperty(field ? "fieldNote" : "note", out JsonElement note) ||
+ note.ValueKind != JsonValueKind.Null ||
+ readOnly.TryGetProperty(field ? "note" : "fieldNote", out _))
+ {
+ throw new Exception($"Unexpected readonly recovery output: {readOnlyResult.StructuredContent}");
+ }
+}
+
+var callbackRecoveryResult = await client.CallToolAsync("CallbackRecovery");
+if (callbackRecoveryResult.IsError == true ||
+ callbackRecoveryResult.StructuredContent?.GetRawText() != """{"kept":"kept"}""")
+{
+ throw new Exception($"Unexpected callback recovery output: {callbackRecoveryResult.StructuredContent}");
+}
+
+foreach (bool required in new[] { false, true })
+{
+ foreach (bool rebindMatch in new[] { false, true })
+ {
+ var forwardingResult = await client.CallToolAsync(
+ (required ? "ForwardingRequiredNote" : "ForwardingOptionalNote") + (rebindMatch ? "Rebound" : ""));
+ if (forwardingResult.IsError == true ||
+ forwardingResult.StructuredContent is not { } forwarded ||
+ forwarded.TryGetProperty("note", out JsonElement note) != required ||
+ (required && note.ValueKind != JsonValueKind.Null))
+ {
+ throw new Exception($"Unexpected forwarding recovery output: {forwardingResult.StructuredContent}");
+ }
+ }
+ var payloadResult = await client.CallToolAsync(required ? "PayloadForwardingRequiredNote" : "PayloadForwardingOptionalNote");
+ if (payloadResult.IsError == true ||
+ payloadResult.StructuredContent is not { } payload ||
+ payload.GetProperty("payload").TryGetProperty("note", out JsonElement payloadNote) != required ||
+ (required && payloadNote.ValueKind != JsonValueKind.Null))
+ {
+ throw new Exception($"Unexpected stacked forwarding recovery output: {payloadResult.StructuredContent}");
+ }
+}
+var mutableNoteResult = await client.CallToolAsync("ForwardingMutableNote");
+if (mutableNoteResult.IsError == true ||
+ mutableNoteResult.StructuredContent is not { } mutableNoteContent ||
+ !mutableNoteContent.TryGetProperty("note", out JsonElement mutableNoteValue) ||
+ mutableNoteValue.ValueKind != JsonValueKind.Null ||
+ mutableNote.Note != "changed after serialization")
+{
+ throw new Exception($"Unexpected mutable forwarding recovery output: {mutableNoteResult.StructuredContent}");
+}
+var mutableCountResult = await client.CallToolAsync("ForwardingMutableCount");
+if (mutableCountResult.IsError == true ||
+ mutableCountResult.StructuredContent is not { } mutableCountContent ||
+ !mutableCountContent.TryGetProperty("count", out JsonElement mutableCountValue) ||
+ mutableCountValue.GetInt32() != 0 ||
+ mutableCount.Count != 42)
+{
+ throw new Exception($"Unexpected captured forwarding recovery output: {mutableCountResult.StructuredContent}");
+}
+
+var overlappingResult = await client.CallToolAsync("OverlappingForwardingNote");
+if (dispatchCount != 4 ||
+ overlappingResult.IsError == true ||
+ overlappingResult.StructuredContent?.GetProperty("note").ValueKind != JsonValueKind.Null)
+{
+ throw new Exception($"Unexpected overlapping dispatch recovery output: {overlappingResult.StructuredContent}");
+}
+
+var ancestorResult = await client.CallToolAsync("AncestorCycles");
+JsonElement ancestor = ancestorResult.StructuredContent!.Value;
+foreach (JsonElement node in ancestor.GetProperty("items").EnumerateArray()
+ .Concat(ancestor.GetProperty("entries").EnumerateObject().Select(property => property.Value))
+ .Prepend(ancestor.GetProperty("child")))
+{
+ if (node.GetProperty("missing").GetProperty("owner").ValueKind != JsonValueKind.Null)
+ {
+ throw new Exception($"Unexpected ancestor cycle output: {ancestor}");
+ }
+}
+
// List, get, and verify a skill.
var skills = await client.ListSkillsAsync();
if (skills.Count != 1 || skills[0].Name != "aot")
@@ -161,14 +607,6 @@
Console.WriteLine("Success!");
-[McpServerToolType]
-internal sealed class AotTools
-{
- [McpServerTool(Name = "Echo", UseStructuredContent = true)]
- [McpAppUi(ResourceUri = "ui://aot/echo")]
- public static string Echo(string arg) => $"Echo: {arg}";
-}
-
internal sealed class AotCatalogTracker
{
public int Created;
@@ -185,15 +623,393 @@ public AotCatalogDependency(AotCatalogTracker tracker)
Interlocked.Increment(ref tracker.Created);
}
- public async ValueTask DisposeAsync()
+ public ValueTask DisposeAsync()
{
- await Task.Yield();
Interlocked.Increment(ref _tracker.Disposed);
+ return ValueTask.CompletedTask;
+ }
+}
+
+[McpServerToolType]
+internal sealed class AotTools
+{
+ internal static IEnumerable Iterator()
+ {
+ yield return default;
+ }
+
+ [McpServerTool(Name = "Echo", UseStructuredContent = true)]
+ [McpAppUi(ResourceUri = "ui://aot/echo")]
+ public static string Echo(string arg) => $"Echo: {arg}";
+}
+
+internal sealed class AotStructuredOutput
+{
+ public int Count => throw new InvalidOperationException("Excluded getter was invoked.");
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RequestId Id { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonConverter(typeof(AotElementConverter))]
+ public JsonElement Value { get; init; }
+
+ public ReadOnlyDictionary Items { get; init; } =
+ new(new Dictionary { [42] = default });
+
+ public AotGenericOnlyDictionary GenericItems { get; init; } =
+ new(new Dictionary { [42] = default });
+
+ public ExpandoObject DynamicItems { get; init; } = CreateDynamicItems();
+
+ public AotUnionOutput[] Unions { get; init; } = Enumerable.Range(0, 5).Select(_ => new AotUnionOutput()).ToArray();
+
+ public AotNumberOutput Numbers { get; init; } = new();
+ public AotExtensionOutput Extension { get; init; } = new();
+ public AotBaseOutput Contract { get; init; } = new AotDerivedOutput();
+ public AotNullableChild? Nullable { get; init; } = new AotNullableChild();
+ public AotUnevaluatedOutput Unevaluated { get; init; } = new();
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonConverter(typeof(AotDelegatingIntConverter))]
+ public int DelegatingCount { get; init; }
+
+ [JsonConverter(typeof(AotRejectNullConverter))]
+ public string? Optional { get; init; }
+
+ private static ExpandoObject CreateDynamicItems()
+ {
+ ExpandoObject items = new();
+ ((IDictionary)items)["entry"] = default(AotStructuredChild);
+ return items;
+ }
+}
+
+internal sealed class AotGenericList : IReadOnlyList
+{
+ public int Count => 1;
+ public AotStructuredChild this[int index] => index == 0 ? default : throw new ArgumentOutOfRangeException(nameof(index));
+ public IEnumerator GetEnumerator() => ((IEnumerable)new AotStructuredChild[1]).GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+}
+
+internal sealed class AotGenericMutableList : IList
+{
+ private readonly List _items = [default];
+ public AotStructuredChild this[int index] { get => _items[index]; set => throw new NotSupportedException(); }
+ public int Count => _items.Count;
+ public bool IsReadOnly => true;
+ public IEnumerator GetEnumerator() => _items.GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ public int IndexOf(AotStructuredChild item) => _items.IndexOf(item);
+ public bool Contains(AotStructuredChild item) => _items.Contains(item);
+ public void CopyTo(AotStructuredChild[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex);
+ public void Add(AotStructuredChild item) => throw new NotSupportedException();
+ public void Clear() => throw new NotSupportedException();
+ public void Insert(int index, AotStructuredChild item) => throw new NotSupportedException();
+ public bool Remove(AotStructuredChild item) => throw new NotSupportedException();
+ public void RemoveAt(int index) => throw new NotSupportedException();
+}
+
+[JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+internal sealed class AotNumberOutput
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+}
+
+internal sealed class AotExtensionOutput
+{
+ [JsonExtensionData]
+ public Dictionary Data { get; set; } = new() { ["child/~"] = new AotNullableChild() };
+}
+
+internal class AotBaseOutput
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public bool Flag { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+}
+
+internal sealed class AotDerivedOutput : AotBaseOutput;
+
+internal sealed class AotRejectNullConverter : JsonConverter
+{
+ public override bool HandleNull => true;
+ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) =>
+ throw new ArgumentNullException(nameof(value));
+}
+
+internal sealed class AotUnionOutput
+{
+ public string Tag { get; init; } = "0";
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public AotNullableChild Child0 { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public AotNullableChild Child1 { get; init; }
+}
+
+internal struct AotNullableChild
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Real { get; init; }
+}
+
+internal sealed class AotMutableNote
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; set; }
+}
+
+internal sealed class AotMutableCount
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; set; }
+}
+
+internal sealed class AotUnevaluatedOutput
+{
+ [JsonPropertyName("evaluated\0path")]
+ public AotNullableChild Evaluated { get; init; }
+ public AotNullableChild Other { get; init; }
+}
+
+internal sealed class AotGenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary
+{
+ public TValue this[TKey key] => entries[key];
+ public IEnumerable Keys => entries.Keys;
+ public IEnumerable Values => entries.Values;
+ public int Count => entries.Count;
+ public bool ContainsKey(TKey key) => entries.ContainsKey(key);
+ public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!);
+ public IEnumerator> GetEnumerator() => entries.GetEnumerator();
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+}
+
+internal struct AotStructuredChild
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+}
+
+internal sealed class AotElementConverter : JsonConverter
+{
+ public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) =>
+ writer.WriteStringValue("converted");
+}
+
+internal sealed class AotDelegatingIntConverter : JsonConverter
+{
+ private bool _writing;
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
+ {
+ if (_writing)
+ {
+ throw new InvalidOperationException("Recursive member converter invocation.");
+ }
+
+ _writing = true;
+ try
+ {
+ JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int)));
+ }
+ finally
+ {
+ _writing = false;
+ }
+ }
+}
+
+[JsonConverter(typeof(AotDictionaryKeyConverter))]
+internal readonly record struct AotDictionaryKey(int Value);
+
+internal sealed class AotDictionaryKeyConverter : JsonConverter
+{
+ public override AotDictionaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) =>
+ writer.WriteNumberValue(value.Value);
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, AotDictionaryKey value, JsonSerializerOptions options) =>
+ writer.WritePropertyName($"entry/{value.Value}~");
+}
+
+internal sealed class AotPreservedFunction(JsonSerializerOptions options, bool nongeneric = false) : AIFunction
+{
+ private readonly object _value = nongeneric
+ ? new Hashtable
+ {
+ [new AotDictionaryKey(1)] = default(AotStructuredChild),
+ [new AotDictionaryKey(2)] = new AotStructuredChild { Count = 5 },
+ }
+ : new Dictionary
+ {
+ [new(1)] = default,
+ [new(2)] = new() { Count = 5 },
+ };
+ public override string Name => nongeneric ? "PreservedNongenericDictionary" : "PreservedDictionary";
+ public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement;
+ public override JsonSerializerOptions JsonSerializerOptions => options;
+ protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
+ new(_value);
+}
+
+internal sealed class AotAncestorRoot
+{
+ public AotAncestorOwner Child { get; set; } = new();
+ public AotAncestorOwner[] Items { get; set; } = [new(), new()];
+ public Dictionary Entries { get; set; } = new()
+ {
+ ["root"] = new(),
+ ["container"] = new(),
+ };
+}
+
+internal sealed class AotAncestorOwner
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public AotAncestorValue Missing { get; set; }
+}
+
+internal struct AotAncestorValue : IEquatable
+{
+ public object? Owner { get; set; }
+ public bool Equals(AotAncestorValue other) => true;
+ public override bool Equals(object? obj) => obj is AotAncestorValue;
+ public override int GetHashCode() => 0;
+}
+
+internal sealed class AotAncestorFunction(JsonSerializerOptions options) : AIFunction
+{
+ private readonly AotAncestorRoot _value = CreateValue();
+ public override string Name => "AncestorCycles";
+ public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement;
+ public override JsonSerializerOptions JsonSerializerOptions => options;
+ protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
+ new(_value);
+
+ private static AotAncestorRoot CreateValue()
+ {
+ AotAncestorRoot root = new();
+ root.Child.Missing = new() { Owner = root };
+ root.Items[0].Missing = new() { Owner = root };
+ root.Items[1].Missing = new() { Owner = root.Items };
+ root.Entries["root"].Missing = new() { Owner = root };
+ root.Entries["container"].Missing = new() { Owner = root.Entries };
+ return root;
}
}
+internal sealed class AotCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver
+{
+ private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new();
+
+ public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) =>
+ _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!);
+}
+
+internal sealed class AotReadOnlyOutput
+{
+ public string? Note => null;
+ [JsonInclude]
+ public readonly string? FieldNote = null;
+}
+
+internal sealed class AotCallbackOutput : IJsonOnSerializing, IJsonOnSerialized
+{
+ private bool _serializing;
+ public string Kept { get; set; } = "kept";
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note => _serializing ? null : throw new InvalidOperationException("Getter requires callback state.");
+ public void OnSerializing() => _serializing = true;
+ public void OnSerialized() => _serializing = false;
+}
+
+[JsonSerializable(typeof(AotStructuredOutput))]
+[JsonSerializable(typeof(AotDerivedOutput))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(Hashtable))]
+[JsonSerializable(typeof(AotAncestorRoot))]
+[JsonSerializable(typeof(IEnumerable))]
+[JsonSerializable(typeof(AotGenericList))]
+[JsonSerializable(typeof(AotGenericMutableList))]
+[JsonSerializable(typeof(AotReadOnlyOutput))]
+[JsonSerializable(typeof(AotCallbackOutput))]
+[JsonSerializable(typeof(AotMutableNote))]
+[JsonSerializable(typeof(AotMutableCount))]
+internal partial class AotJsonContext : JsonSerializerContext;
+
internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner);
+internal sealed class PayloadAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner)
+{
+ public override Tool ProtocolTool { get; } = new()
+ {
+ Name = "Payload" + inner.ProtocolTool.Name,
+ InputSchema = inner.ProtocolTool.InputSchema,
+ OutputSchema = JsonDocument.Parse($$$$"""{"type":"object","required":["payload"],"properties":{"payload":{{{{inner.ProtocolTool.OutputSchema!.Value.GetRawText()}}}}}}""").RootElement,
+ };
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ result.StructuredContent = JsonDocument.Parse($$"""{"payload":{{result.StructuredContent!.Value.GetRawText()}}}""").RootElement;
+ return result;
+ }
+}
+
+internal sealed class ForwardingSchemaAotTool(
+ McpServerTool inner, JsonElement schema, bool rebindMatch = false, Action? afterInvocation = null)
+ : DelegatingMcpServerTool(inner)
+{
+ private readonly McpServerTool _inner = inner;
+
+ public override Tool ProtocolTool { get; } = new()
+ {
+ Name = inner.ProtocolTool.Name,
+ InputSchema = inner.ProtocolTool.InputSchema,
+ OutputSchema = schema,
+ };
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ IMcpServerPrimitive? original = request.MatchedPrimitive;
+ if (rebindMatch)
+ {
+ request.MatchedPrimitive = _inner;
+ }
+ try
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ await base.InvokeAsync(request, cancellationToken);
+ afterInvocation?.Invoke();
+ return result;
+ }
+ finally
+ {
+ request.MatchedPrimitive = original;
+ }
+ }
+}
+
internal sealed class OverridingAotTool(McpServerTool inner) : ForwardingAotTool(inner)
{
public override ValueTask InvokeAsync(
diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs
index edd2d75df..718160065 100644
--- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs
@@ -14,6 +14,804 @@ namespace ModelContextProtocol.Tests.Server;
public partial class DelegatingToolOutputSchemaTests
{
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task CallTool_RepeatedInnerInvocations_VisitOnlyTheRetainedResultPlan(string protocolVersion)
+ {
+ McpServerTool inner = McpServerTool.Create(() => new MutableNote(), new()
+ {
+ Name = "indexed_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ var repeated = new ObservedRecoveryTool(inner);
+ McpServerTool tool = new SchemaOverrideTool(repeated, ParseJson("""{"required":["note"]}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind);
+ Assert.Equal(1, repeated.EarlierPlanVisits);
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task CallTool_OverlappingFilterDispatches_KeepIndependentRecovery(
+ string protocolVersion, bool suspendAfterInner)
+ {
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ foreach (bool secondThrows in new[] { false, true })
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ McpServerTool inner = McpServerTool.Create(() => new MutableNote(), new()
+ {
+ Name = "overlapping_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new GatedInvocationTool(inner, entered, release, suspendAfterInner, secondThrows),
+ ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind);
+ }, alternateHandler, options =>
+ {
+ if (alternateHandler)
+ {
+#pragma warning disable MCPEXP002 // Exercise overlapping dispatch through the alternate pipeline.
+ options.Filters.Request.CallToolWithAlternateFilters.Add(async (request, next, cancellationToken) =>
+ {
+ var first = next(request, cancellationToken).AsTask();
+ await entered.Task.WaitAsync(cancellationToken);
+ try { await next(request, cancellationToken); }
+ catch (InvalidOperationException exception) when (exception.Message == "Second dispatch failed.") { }
+ finally { release.TrySetResult(true); }
+ return await first;
+ });
+#pragma warning restore MCPEXP002
+ }
+ else
+ {
+ options.Filters.Request.CallToolFilters.Add(next => async (request, cancellationToken) =>
+ {
+ var first = next(request, cancellationToken).AsTask();
+ await entered.Task.WaitAsync(cancellationToken);
+ try { await next(request, cancellationToken); }
+ catch (InvalidOperationException exception) when (exception.Message == "Second dispatch failed.") { }
+ finally { release.TrySetResult(true); }
+ return await first;
+ });
+ }
+ });
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_DeferredRecovery_PreservesWrapperOwnedResults(string protocolVersion)
+ {
+ foreach (int mode in new[] { 0, 1, 2 })
+ {
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ McpServerTool inner = McpServerTool.Create(() => new MutableCount(), new()
+ {
+ Name = "owned_count",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new OwnedResultTool(inner, mode), ParseJson("""{"required":["count"]}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal(mode == 0, result.IsError == true);
+ Assert.Equal(mode == 1 ? """{"count":99}""" : "{}", result.StructuredContent!.Value.GetRawText());
+ }, alternateHandler);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_DeferredRecovery_DoesNotReadSourceOrRunConvertersAfterWrapperCompletion(string protocolVersion)
+ {
+ var source = new ExpiringOutput();
+ var converter = new ExpiringIntConverter();
+ JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ };
+ options.Converters.Add(converter);
+ McpServerTool inner = McpServerTool.Create(() => source, new()
+ {
+ Name = "expired_count",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = options,
+ });
+ McpServerTool tool = new SchemaOverrideTool(new RequestStateTool(inner, null, () =>
+ {
+ source.Expired = true;
+ converter.Expired = true;
+ }), ParseJson("""{"required":["count"],"properties":{"count":{"const":"zero"}}}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(source.Expired);
+ Assert.True(converter.Expired);
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal("zero", result.StructuredContent!.Value.GetProperty("count").GetString());
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ForwardingSegment_RecoversBeforeTransformingWrapper(
+ string protocolVersion, bool requiresNull)
+ {
+ JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}""");
+ foreach (int forwardingMode in new[] { 1, 2, 5 })
+ {
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "stacked_note",
+ UseStructuredContent = true,
+ OutputSchema = requiresNull ? optional : required,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new PayloadTool(new SchemaOverrideTool(
+ new ForwardingInvocationTool(inner, forwardingMode), requiresNull ? required : optional));
+ tool = new PayloadTool(new ForwardingInvocationTool(tool, 2));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid);
+ JsonElement content = result.StructuredContent.Value.GetProperty("payload").GetProperty("payload");
+ Assert.Equal(requiresNull, content.TryGetProperty("note", out _));
+ }, alternateHandler);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", "object")]
+ [InlineData("2025-06-18", "array")]
+ [InlineData("2025-06-18", "nested")]
+ [InlineData("2025-11-25", "object")]
+ [InlineData("2025-11-25", "array")]
+ [InlineData("2025-11-25", "nested")]
+ [InlineData("2026-07-28", "object")]
+ [InlineData("2026-07-28", "array")]
+ [InlineData("2026-07-28", "nested")]
+ public async Task ListTools_ForwardingInvocation_CapturesOmittedValuesBeforeSourceMutation(
+ string protocolVersion, string shape)
+ {
+ const string requiredCount = """{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}""";
+ string schema = shape switch
+ {
+ "array" => $$"""{"type":"array","items":{{requiredCount}}}""",
+ "nested" => $$$$"""{"type":"object","required":["child"],"properties":{"child":{{{{requiredCount}}}}}}""",
+ _ => requiredCount,
+ };
+ foreach (int forwardingMode in new[] { 1, 2, 5 })
+ {
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ var source = new MutableCount();
+ object value = shape switch
+ {
+ "array" => new[] { source },
+ "nested" => new { Child = source },
+ _ => source,
+ };
+ McpServerTool inner = McpServerTool.Create(() => value, new()
+ {
+ Name = "captured_count",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson(shape == "array" ? """{"type":"array"}""" : """{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(new RequestStateTool(
+ new ForwardingInvocationTool(inner, forwardingMode), null, () => source.Count = 42), ParseJson(schema));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+ JsonElement content = result.StructuredContent!.Value;
+
+ Assert.Equal(42, source.Count);
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid);
+ if (shape == "array")
+ {
+ if (protocolVersion != "2026-07-28")
+ {
+ content = content.GetProperty("result");
+ }
+ content = content[0];
+ }
+ else if (shape == "nested")
+ {
+ content = content.GetProperty("child");
+ }
+ Assert.Equal(0, content.GetProperty("count").GetInt32());
+ }, alternateHandler);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ConcurrentForwardingInvocation_CapturesEachResultBeforeMutation(
+ string protocolVersion, bool returnSecond)
+ {
+ var source = new MutableCount();
+ int started = 0;
+ var bothStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ McpServerTool inner = McpServerTool.Create(async () =>
+ {
+ if (Interlocked.Increment(ref started) == 2)
+ {
+ bothStarted.TrySetResult(true);
+ }
+ await bothStarted.Task.WaitAsync(TestContext.Current.CancellationToken);
+ return source;
+ }, new()
+ {
+ Name = "concurrent_captured_count",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new ConcurrentInvocationTool(inner, returnSecond, () => source.Count = 42),
+ ParseJson("""{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, started);
+ Assert.Equal(42, source.Count);
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(0, result.StructuredContent!.Value.GetProperty("count").GetInt32());
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_ForwardingInvocation_RecoversRegisteredSchemaWhenSegmentLimitIsExceeded(string protocolVersion)
+ {
+ McpServerTool tool = McpServerTool.Create(() => new MutableCount(), new()
+ {
+ Name = "bounded_forwarding",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object"}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ for (int i = 0; i < 129; i++)
+ {
+ tool = new ForwardingInvocationTool(tool, 1);
+ }
+ tool = new SchemaOverrideTool(tool, ParseJson("""{"required":["count"]}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(0, result.StructuredContent!.Value.GetProperty("count").GetInt32());
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_ForwardingInvocation_CapturesSchemaBeforeUserCodeRuns(string protocolVersion)
+ {
+ JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}""");
+ McpServerTool? tool = null;
+ McpServerTool inner = McpServerTool.Create(() =>
+ {
+ tool!.ProtocolTool.OutputSchema = optional;
+ return new { Note = (string?)null };
+ }, new()
+ {
+ Name = "changed_schema",
+ UseStructuredContent = true,
+ OutputSchema = optional,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required);
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(optional, tool.ProtocolTool.OutputSchema);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_TransformingDelegator_RecoversAgainstInnerSchema(string protocolVersion, bool overrideInnerSchema)
+ {
+ JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "transformed_note",
+ UseStructuredContent = true,
+ OutputSchema = overrideInnerSchema ? ParseJson("""{"type":"object"}""") : required,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ if (overrideInnerSchema)
+ {
+ inner = new SchemaOverrideTool(inner, required);
+ }
+ McpServerTool tool = new DelegatingTool(new PayloadTool(inner));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("payload").GetProperty("note").ValueKind);
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_StackedTransformingDelegator_RecoversBeforeOuterTransformation(string protocolVersion)
+ {
+ JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}""");
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "stacked_transformed_note",
+ UseStructuredContent = true,
+ OutputSchema = optional,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+
+ McpServerTool tool = new PayloadTool(
+ new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1), required));
+
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ JsonElement payload = result.StructuredContent!.Value.GetProperty("payload");
+ Assert.Equal(JsonValueKind.Null, payload.GetProperty("note").ValueKind);
+ Assert.True(ParseSchema(tool.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent.Value).IsValid);
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false, 0)]
+ [InlineData("2025-06-18", true, 0)]
+ [InlineData("2025-11-25", false, 0)]
+ [InlineData("2025-11-25", true, 0)]
+ [InlineData("2026-07-28", false, 0)]
+ [InlineData("2026-07-28", true, 0)]
+ [InlineData("2025-06-18", false, 1)]
+ [InlineData("2025-06-18", true, 1)]
+ [InlineData("2025-11-25", false, 1)]
+ [InlineData("2025-11-25", true, 1)]
+ [InlineData("2026-07-28", false, 1)]
+ [InlineData("2026-07-28", true, 1)]
+ [InlineData("2025-06-18", false, 2)]
+ [InlineData("2025-06-18", true, 2)]
+ [InlineData("2025-11-25", false, 2)]
+ [InlineData("2025-11-25", true, 2)]
+ [InlineData("2026-07-28", false, 2)]
+ [InlineData("2026-07-28", true, 2)]
+ [InlineData("2025-06-18", false, 3)]
+ [InlineData("2025-06-18", true, 3)]
+ [InlineData("2025-11-25", false, 3)]
+ [InlineData("2025-11-25", true, 3)]
+ [InlineData("2026-07-28", false, 3)]
+ [InlineData("2026-07-28", true, 3)]
+ [InlineData("2025-06-18", false, 4)]
+ [InlineData("2025-06-18", true, 4)]
+ [InlineData("2025-11-25", false, 4)]
+ [InlineData("2025-11-25", true, 4)]
+ [InlineData("2026-07-28", false, 4)]
+ [InlineData("2026-07-28", true, 4)]
+ [InlineData("2025-06-18", false, 5)]
+ [InlineData("2025-06-18", true, 5)]
+ [InlineData("2025-11-25", false, 5)]
+ [InlineData("2025-11-25", true, 5)]
+ [InlineData("2026-07-28", false, 5)]
+ [InlineData("2026-07-28", true, 5)]
+ public async Task ListTools_DelegatingTool_RecoversAgainstEffectiveSchema(
+ string protocolVersion, bool outerRequiresNull, int forwardingMode)
+ {
+ JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement optionalSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}""");
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "effective_note",
+ UseStructuredContent = true,
+ OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool forwarded = new DelegatingTool(inner);
+ if (forwardingMode != 0)
+ {
+ forwarded = new ForwardingInvocationTool(forwarded, forwardingMode);
+ }
+ McpServerTool tool = new SchemaOverrideTool(forwarded, outerRequiresNull ? requiredSchema : optionalSchema);
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+ JsonElement content = result.StructuredContent!.Value;
+
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid);
+ Assert.Equal(outerRequiresNull, content.TryGetProperty("note", out _));
+ }, alternateHandler: forwardingMode == 4);
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ForwardingArrayInvocation_PreservesWireShape(string protocolVersion, bool outerRequiresNull)
+ {
+ JsonElement requiredSchema = ParseJson("""{"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}}""");
+ JsonElement optionalSchema = ParseJson("""{"type":"array","items":{"properties":{"note":{"type":"string"}}}}""");
+ McpServerTool inner = McpServerTool.Create(() => new[] { new { Note = (string?)null } }, new()
+ {
+ Name = "forwarded_array",
+ UseStructuredContent = true,
+ OutputSchema = outerRequiresNull ? optionalSchema : requiredSchema,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new ForwardingInvocationTool(inner, 2), outerRequiresNull ? requiredSchema : optionalSchema);
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+ JsonElement content = result.StructuredContent!.Value;
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid);
+ if (protocolVersion != "2026-07-28")
+ {
+ content = content.GetProperty("result");
+ }
+ Assert.Equal(outerRequiresNull, content[0].TryGetProperty("note", out _));
+ });
+ }
+
+ [Fact]
+ public async Task ListTools_ForwardingInvocation_RecoversAfterAsynchronousCompletion()
+ {
+ var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ JsonElement requiredSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ McpServerTool inner = McpServerTool.Create(async () =>
+ {
+ started.TrySetResult(true);
+ await release.Task;
+ return new { Note = (string?)null };
+ }, new()
+ {
+ Name = "async_forwarded_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 2), requiredSchema);
+ await WithClientAsync(tool, "2026-07-28", async client =>
+ {
+ Task pending = client.CallToolAsync(tool.ProtocolTool.Name,
+ cancellationToken: TestContext.Current.CancellationToken).AsTask();
+ try
+ {
+ Assert.Same(started.Task, await Task.WhenAny(started.Task, pending));
+ }
+ finally
+ {
+ release.TrySetResult(true);
+ }
+
+ CallToolResult result = await pending;
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(requiredSchema).Evaluate(result.StructuredContent!.Value).IsValid);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ForwardingInvocation_PreservesRegisteredSchemaWhenMatchChanges(
+ string protocolVersion, bool outerRequiresNull)
+ {
+ JsonElement required = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement optional = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}""");
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "rebound_note",
+ UseStructuredContent = true,
+ OutputSchema = outerRequiresNull ? optional : required,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new RequestStateTool(inner, inner, null), outerRequiresNull ? required : optional);
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid);
+ Assert.Equal(outerRequiresNull, result.StructuredContent.Value.TryGetProperty("note", out _));
+ }, alternateHandler);
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ForwardingInvocation_PreservesValidJsonAfterSourceMutation(
+ string protocolVersion, bool arrayResult)
+ {
+ const string itemSchema = """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""";
+ string schema = arrayResult ? $$"""{"type":"array","items":{{itemSchema}}}""" : itemSchema;
+ foreach (bool alternateHandler in new[] { false, true })
+ {
+ var source = new MutableNote();
+ McpServerTool inner = McpServerTool.Create(() => arrayResult ? (object)new[] { source } : source, new()
+ {
+ Name = "mutated_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson(schema),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(
+ new RequestStateTool(inner, null, () => source.Note = "changed after serialization"), ParseJson(schema));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+ JsonElement content = result.StructuredContent!.Value;
+
+ Assert.Equal("changed after serialization", source.Note);
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid);
+ if (arrayResult)
+ {
+ if (protocolVersion != "2026-07-28")
+ {
+ content = content.GetProperty("result");
+ }
+ content = content[0];
+ }
+ Assert.Equal(JsonValueKind.Null, content.GetProperty("note").ValueKind);
+ }, alternateHandler);
+ }
+ }
+
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_ForwardingInvocation_PreservesInnerProjectionWhenOuterRecoveryFails(
+ string protocolVersion, bool invalidSchema)
+ {
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "unrecoverable_outer_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object","required":["note"]}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1),
+ ParseJson(invalidSchema ? """{"type":42}""" : """{"required":["note","missing"]}"""));
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal("""{"note":null}""", result.StructuredContent!.Value.GetRawText());
+ });
+ }
+
+ [Fact]
+ public async Task ListTools_ForwardingInvocation_DoesNotRestartRecoveryAfterRegexTimeout()
+ {
+ string real = new string('a', 26) + "!";
+ McpServerTool inner = McpServerTool.Create(() => new { Real = real, Note = (string?)null }, new()
+ {
+ Name = "timed_out_forwarded_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object","properties":{"real":{"pattern":"^(a+)+$"}}}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(new ForwardingInvocationTool(inner, 1),
+ ParseJson("""{"type":"object","required":["note"]}"""));
+ await WithClientAsync(tool, "2026-07-28", async client =>
+ {
+ var result = await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal(real, result.StructuredContent!.Value.GetProperty("real").GetString());
+ Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _));
+ });
+ }
+
+ [Theory]
+ [InlineData("2025-06-18")]
+ [InlineData("2025-11-25")]
+ [InlineData("2026-07-28")]
+ public async Task ListTools_TransformingInvocation_ReceivesInnerRecoveryAndRetainsItsOutput(string protocolVersion)
+ {
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "transformed_note",
+ UseStructuredContent = true,
+ OutputSchema = ParseJson("""{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}"""),
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new NoteTransformingTool(inner);
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(result.StructuredContent!.Value).IsValid);
+ Assert.Equal("transformed", result.StructuredContent.Value.GetProperty("note").GetString());
+ });
+ }
+
[Theory]
[InlineData("2025-06-18", 1, false)]
[InlineData("2025-06-18", 2, false)]
@@ -89,6 +887,50 @@ await WithClientAsync(tool, protocolVersion, async client =>
});
}
+ [Theory]
+ [InlineData("2025-06-18", false)]
+ [InlineData("2025-06-18", true)]
+ [InlineData("2025-11-25", false)]
+ [InlineData("2025-11-25", true)]
+ [InlineData("2026-07-28", false)]
+ [InlineData("2026-07-28", true)]
+ public async Task ListTools_DelegatingTool_RecoversRequiredNullableMembers(string protocolVersion, bool innerUsesComposition)
+ {
+ JsonElement objectSchema = ParseJson(
+ """{"type":"object","properties":{"note":{"type":"null"}},"required":["note"]}""");
+ JsonElement composedSchema = ParseJson($$"""{"allOf":[{{objectSchema.GetRawText()}}]}""");
+ JsonElement innerSchema = innerUsesComposition ? composedSchema : objectSchema;
+ JsonElement outerSchema = innerUsesComposition ? objectSchema : composedSchema;
+ McpServerTool inner = McpServerTool.Create(() => new { Note = (string?)null }, new()
+ {
+ Name = "required_note",
+ UseStructuredContent = true,
+ OutputSchema = innerSchema,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ McpServerTool tool = new SchemaOverrideTool(new DelegatingTool(inner), outerSchema);
+ await WithClientAsync(tool, protocolVersion, async client =>
+ {
+ var listed = Assert.Single(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var result = await client.CallToolAsync(listed.Name, cancellationToken: TestContext.Current.CancellationToken);
+ JsonElement content = result.StructuredContent!.Value;
+
+ Assert.True(ParseSchema(listed.ProtocolTool.OutputSchema!.Value).Evaluate(content).IsValid);
+ if (innerUsesComposition && protocolVersion != "2026-07-28")
+ {
+ content = content.GetProperty("result");
+ }
+ Assert.Equal(JsonValueKind.Null, content.GetProperty("note").ValueKind);
+ });
+
+ Assert.Equal(innerSchema.GetRawText(), inner.ProtocolTool.OutputSchema!.Value.GetRawText());
+ Assert.Equal(outerSchema.GetRawText(), tool.ProtocolTool.OutputSchema!.Value.GetRawText());
+ }
+
[Theory]
[InlineData("2025-06-18", false)]
[InlineData("2025-06-18", true)]
@@ -845,15 +1687,26 @@ private static JsonElement ParseJson(string json)
return document.RootElement.Clone();
}
- private static async Task WithClientAsync(McpServerTool tool, string protocolVersion, Func action)
+ private static async Task WithClientAsync(
+ McpServerTool tool, string protocolVersion, Func action, bool alternateHandler = false,
+ Action? configure = null)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(TestConstants.DefaultTimeout);
var clientToServer = new Pipe();
var serverToClient = new Pipe();
+ McpServerOptions options = new() { ToolCollection = new() { tool } };
+ if (alternateHandler)
+ {
+#pragma warning disable MCPEXP002 // Exercise registered tools through the explicit alternate handler pipeline.
+ options.Handlers.CallToolWithAlternateHandler =
+ static (_, _) => throw new InvalidOperationException("Registered tool was not matched.");
+#pragma warning restore MCPEXP002
+ }
+ configure?.Invoke(options);
await using var server = McpServer.Create(
new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()),
- new McpServerOptions { ToolCollection = new() { tool } });
+ options);
Task serverTask = server.RunAsync(cts.Token);
try
{
@@ -873,6 +1726,217 @@ private static async Task WithClientAsync(McpServerTool tool, string protocolVer
private sealed class DelegatingTool(McpServerTool inner) : DelegatingMcpServerTool(inner);
+ private sealed class MutableNote
+ {
+ public string? Note { get; set; }
+ }
+
+ private sealed class MutableCount
+ {
+ public int Count { get; set; }
+ }
+
+ private sealed class ExpiringOutput
+ {
+ [JsonIgnore]
+ public bool Expired { get; set; }
+
+ public int Count => Expired ? throw new InvalidOperationException("Source is no longer owned by the inner invocation.") : 0;
+ }
+
+ private sealed class ExpiringIntConverter : JsonConverter
+ {
+ public bool Expired { get; set; }
+
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
+ {
+ if (Expired)
+ {
+ throw new InvalidOperationException("Converter is no longer owned by the inner invocation.");
+ }
+ writer.WriteStringValue(value == 0 ? "zero" : "changed");
+ }
+ }
+
+ private sealed class OwnedResultTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner)
+ {
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ if (mode == 0)
+ {
+ result.IsError = true;
+ }
+ else
+ {
+ result.StructuredContent = ParseJson(mode == 1 ? """{"count":99}""" : result.StructuredContent!.Value.GetRawText());
+ }
+ return result;
+ }
+ }
+
+ private sealed class RequestStateTool(McpServerTool inner, McpServerTool? temporaryMatch, Action? afterInvocation)
+ : DelegatingMcpServerTool(inner)
+ {
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ IMcpServerPrimitive? original = request.MatchedPrimitive;
+ if (temporaryMatch is not null)
+ {
+ request.MatchedPrimitive = temporaryMatch;
+ }
+ try
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ afterInvocation?.Invoke();
+ return result;
+ }
+ finally
+ {
+ request.MatchedPrimitive = original;
+ }
+ }
+ }
+
+ private sealed class ForwardingInvocationTool(McpServerTool inner, int mode) : DelegatingMcpServerTool(inner)
+ {
+ public override ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default) =>
+ mode == 1 ? base.InvokeAsync(request, cancellationToken) : ForwardAsync(request, cancellationToken);
+
+ private async ValueTask ForwardAsync(
+ RequestContext request, CancellationToken cancellationToken)
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ if (mode == 5)
+ {
+ await base.InvokeAsync(request, cancellationToken);
+ return result;
+ }
+ return mode == 2 ? result : new()
+ {
+ Content = result.Content,
+ StructuredContent = result.StructuredContent,
+ IsError = result.IsError,
+ Meta = result.Meta,
+ };
+ }
+ }
+
+ private sealed class ConcurrentInvocationTool(McpServerTool inner, bool returnSecond, Action afterInvocation)
+ : DelegatingMcpServerTool(inner)
+ {
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ Task first = base.InvokeAsync(request, cancellationToken).AsTask();
+ Task second = base.InvokeAsync(request, cancellationToken).AsTask();
+ CallToolResult[] results = await Task.WhenAll(first, second);
+ afterInvocation();
+ return results[returnSecond ? 1 : 0];
+ }
+ }
+
+ private sealed class GatedInvocationTool(
+ McpServerTool inner, TaskCompletionSource entered, TaskCompletionSource release,
+ bool suspendAfterInner, bool secondThrows) : DelegatingMcpServerTool(inner)
+ {
+ private int _calls;
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ bool first = Interlocked.Increment(ref _calls) == 1;
+ if (!first && secondThrows)
+ {
+ throw new InvalidOperationException("Second dispatch failed.");
+ }
+ if (first && !suspendAfterInner)
+ {
+ entered.TrySetResult(true);
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ if (first && suspendAfterInner)
+ {
+ entered.TrySetResult(true);
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ return result;
+ }
+ }
+
+ private sealed class ObservedRecoveryTool(McpServerTool inner) : DelegatingMcpServerTool(inner)
+ {
+ public int EarlierPlanVisits { get; private set; }
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ CallToolResult first = await base.InvokeAsync(request, cancellationToken);
+ object target = typeof(RequestContext)
+ .GetProperty("StructuredContentRecoveryTarget", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(request)!;
+ var plans = (Dictionary>)target.GetType()
+ .GetField("_plans", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(target)!;
+ var original = plans[first.StructuredContent!.Value];
+ JsonElement[] identities = plans.Where(entry => ReferenceEquals(entry.Value, original)).Select(entry => entry.Key).ToArray();
+ Action observed = (tool, result) =>
+ {
+ EarlierPlanVisits++;
+ original(tool, result);
+ };
+ target.GetType().GetMethod("Register", BindingFlags.Instance | BindingFlags.NonPublic)!
+ .Invoke(target, [identities, observed]);
+ for (int i = 1; i < 32; i++)
+ {
+ await base.InvokeAsync(request, cancellationToken);
+ }
+ return first;
+ }
+ }
+
+ private sealed class NoteTransformingTool(McpServerTool inner) : DelegatingMcpServerTool(inner)
+ {
+ public override Tool ProtocolTool { get; } = new()
+ {
+ Name = inner.ProtocolTool.Name,
+ InputSchema = inner.ProtocolTool.InputSchema,
+ OutputSchema = ParseJson("""{"type":"object","properties":{"note":{"type":"string"}}}"""),
+ };
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent!.Value.GetProperty("note").ValueKind);
+ result.StructuredContent = ParseJson("""{"note":"transformed"}""");
+ return result;
+ }
+ }
+
+ private sealed class PayloadTool(McpServerTool inner) : DelegatingMcpServerTool(inner)
+ {
+ public override Tool ProtocolTool { get; } = new()
+ {
+ Name = inner.ProtocolTool.Name,
+ InputSchema = inner.ProtocolTool.InputSchema,
+ OutputSchema = ParseJson($$$$"""{"type":"object","required":["payload"],"properties":{"payload":{{{{inner.ProtocolTool.OutputSchema!.Value.GetRawText()}}}}}}"""),
+ };
+
+ public override async ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default)
+ {
+ CallToolResult result = await base.InvokeAsync(request, cancellationToken);
+ result.StructuredContent = ParseJson($$"""{"payload":{{result.StructuredContent!.Value.GetRawText()}}}""");
+ return result;
+ }
+ }
+
private sealed class AlternateInvocationTool(McpServerTool inner, CallToolResult response) : DelegatingMcpServerTool(inner)
{
public override ValueTask InvokeAsync(
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs
new file mode 100644
index 000000000..f195eed69
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs
@@ -0,0 +1,388 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using ModelContextProtocol.Server;
+
+namespace ModelContextProtocol.Tests.Server;
+
+public partial class McpServerToolTests
+{
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_LeavesExactContainsSearchExhaustionUnchanged()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(),
+ """
+ {
+ "contains":{
+ "if":{"properties":{"tag":{"const":0}}},
+ "then":{"required":["a"],"not":{"required":["b"]}},
+ "else":{"required":["b"],"not":{"required":["a"]}}
+ },
+ "minContains":64,
+ "maxContains":64
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ // Exact-count subset search remains bounded; never emit an invalid partial repair.
+ Assert.Equal(129, result.GetArrayLength());
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.Equal(1, item.GetProperty("tag").GetInt32());
+ Assert.False(item.TryGetProperty("a", out _));
+ Assert.False(item.TryGetProperty("b", out _));
+ });
+ }
+
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_ComposesSiblingChoicesBeforeQualifyingAncestor()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new
+ {
+ Tag = 1,
+ Left = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null },
+ Right = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null },
+ }).ToArray(),
+ """
+ {
+ "items":{"oneOf":[
+ {"properties":{"tag":{"const":0},"left":{"$ref":"#/$defs/zero"},"right":{"$ref":"#/$defs/zero"}},"required":["tag"]},
+ {"properties":{"tag":{"const":1},"left":{"$ref":"#/$defs/one"},"right":{"$ref":"#/$defs/one"}},"required":["tag"]}
+ ]},
+ "$defs":{
+ "zero":{"oneOf":[{"required":["a0"]},{"required":["b0"]}]},
+ "one":{"oneOf":[{"required":["a1"]},{"required":["b1"]}]}
+ }
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ foreach (string name in new[] { "left", "right" })
+ {
+ JsonElement child = item.GetProperty(name);
+ Assert.False(child.TryGetProperty("a0", out _));
+ Assert.False(child.TryGetProperty("b0", out _));
+ Assert.NotEqual(child.TryGetProperty("a1", out _), child.TryGetProperty("b1", out _));
+ }
+ });
+ }
+
+ [Theory]
+ [InlineData("""{"properties":{"tag":{"const":1}}}""")]
+ [InlineData("""{"type":"object","properties":{"tag":{"const":1}},"required":["marker","items"]}""")]
+ [InlineData("""{"properties":{"tag":{"const":1}},"minProperties":3}""")]
+ [InlineData("""{"properties":{"tag":{"const":1}},"maxProperties":3}""")]
+ public async Task StructuredOutput_ConditionalRepair_ValidatesAncestorGuardWithoutLosingRequiredProperties(string guard)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new
+ {
+ Tag = 1,
+ Marker = "present",
+ Items = Enumerable.Range(0, 1024).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray(),
+ },
+ """
+ {
+ "if":GUARD,
+ "then":{"properties":{"items":{"items":{"oneOf":[
+ {"required":["a"],"not":{"required":["b"]}},
+ {"required":["absent"]}
+ ]}}}},
+ "else":{"properties":{"items":{"items":{"required":["b"],"not":{"required":["a"]}}}}}
+ }
+ """.Replace("GUARD", guard));
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.GetProperty("items").EnumerateArray(), item =>
+ {
+ Assert.True(item.TryGetProperty("a", out _));
+ Assert.False(item.TryGetProperty("b", out _));
+ });
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_ConditionalRepair_QualifiesAncestorChoices(bool omitTag, bool nestedChild)
+ {
+ int tag = omitTag ? 0 : 1;
+ var items = Enumerable.Range(0, 129).Select(_ => new
+ {
+ Tag = tag,
+ A0 = (string?)null,
+ B0 = (string?)null,
+ A1 = (string?)null,
+ B1 = (string?)null,
+ Child = new { A0 = (string?)null, B0 = (string?)null, A1 = (string?)null, B1 = (string?)null },
+ }).ToArray();
+ JsonObject CreateBranch(int branchTag, string first, string second)
+ {
+ JsonArray alternatives = [
+ new JsonObject { ["required"] = new JsonArray(first) },
+ new JsonObject { ["required"] = new JsonArray(second) },
+ ];
+ JsonObject properties = new() { ["tag"] = new JsonObject { ["const"] = branchTag } };
+ JsonObject branch = new() { ["properties"] = properties, ["required"] = new JsonArray("tag") };
+ if (nestedChild)
+ {
+ properties["child"] = new JsonObject { ["oneOf"] = alternatives };
+ }
+ else
+ {
+ branch["oneOf"] = alternatives;
+ }
+
+ return branch;
+ }
+
+ JsonObject schema = new()
+ {
+ ["items"] = new JsonObject
+ {
+ ["oneOf"] = new JsonArray(CreateBranch(1 - tag, "a0", "b0"), CreateBranch(tag, "a1", "b1")),
+ },
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ items, schema.ToJsonString(), CreateStructuredOutputTestOptions(
+ omitTag ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull));
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.Equal(tag, item.GetProperty("tag").GetInt32());
+ JsonElement value = nestedChild ? item.GetProperty("child") : item;
+ Assert.False(value.TryGetProperty("a0", out _));
+ Assert.False(value.TryGetProperty("b0", out _));
+ Assert.NotEqual(value.TryGetProperty("a1", out _), value.TryGetProperty("b1", out _));
+ });
+ }
+
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_ReevaluatesInsideOneOfAfterRequiredRepair()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(),
+ """
+ {
+ "items":{"oneOf":[
+ {
+ "required":["tag"],
+ "if":{"properties":{"tag":{"const":1}}},
+ "then":{"required":["a"],"not":{"required":["b"]}},
+ "else":{"required":["b"],"not":{"required":["a"]}}
+ },
+ {"required":["absent"]}
+ ]}
+ }
+ """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_PreservesGuardsDuringRecursiveRepair()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Child = default(ConditionalRepairChild) }).ToArray(),
+ """
+ {
+ "items":{
+ "if":{"properties":{"child":{"required":["b"]}}},
+ "then":{
+ "required":["child"],
+ "properties":{"child":{"oneOf":[
+ {"required":["a"],"not":{"required":["b"]}},
+ {"required":["b"],"not":{"required":["a"]}}
+ ]}}
+ },
+ "else":false
+ }
+ }
+ """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.True(item.GetProperty("child").TryGetProperty("b", out _));
+ Assert.False(item.GetProperty("child").TryGetProperty("a", out _));
+ });
+ }
+
+ [Theory]
+ [InlineData(129, false, false)]
+ [InlineData(129, false, true)]
+ [InlineData(1024, false, true)]
+ [InlineData(1024, true, true)]
+ public async Task StructuredOutput_ConditionalRepair_ComposesOnlyMatchingBranches(int count, bool dictionary, bool mixed)
+ {
+ var items = Enumerable.Range(0, count)
+ .Select(index => new { Tag = mixed ? index % 2 : 1, A = (string?)null, B = (string?)null }).ToArray();
+ object output = dictionary
+ ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/~1/{pair.index}~", pair => pair.item)
+ : items;
+ string container = dictionary ? "\"additionalProperties\"" : "\"items\"";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, $$$"""
+ {
+ {{{container}}}:{
+ "if":{"properties":{"tag":{"const":0} } },
+ "then":{"required":["a"],"not":{"required":["b"]}},
+ "else":{"required":["b"],"not":{"required":["a"]}}
+ }
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ IEnumerable repaired = dictionary
+ ? result.EnumerateObject().Select(property => property.Value)
+ : result.EnumerateArray();
+ Assert.Equal(count, repaired.Count());
+ foreach (JsonElement item in repaired)
+ {
+ bool zero = item.GetProperty("tag").GetInt32() == 0;
+ Assert.Equal(JsonValueKind.Null, item.GetProperty(zero ? "a" : "b").ValueKind);
+ Assert.False(item.TryGetProperty(zero ? "b" : "a", out _));
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_ReevaluatesAfterRequiredRepair()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 0, A = (string?)null, B = (string?)null }).ToArray(),
+ """
+ {
+ "items":{
+ "required":["tag"],
+ "if":{"properties":{"tag":{"const":1}}},
+ "then":{"required":["a"],"not":{"required":["b"]}},
+ "else":{"required":["b"],"not":{"required":["a"]}}
+ }
+ }
+ """, CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _)));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_ConditionalRepair_PreservesAncestorGuardsThroughOneOf(bool conditionalInsideOneOf)
+ {
+ const string conditional = """
+ {
+ "if":{"properties":{"tag":{"const":0}}},
+ "then":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/a"},{"required":["absent"]}]}}},
+ "else":{"properties":{"child":{"oneOf":[{"$ref":"#/$defs/b"},{"required":["absent"]}]}}}
+ }
+ """;
+ string entrySchema = conditionalInsideOneOf
+ ? $$"""{"oneOf":[{"allOf":[{{conditional}}],"required":["tag"]},{"required":["absent"]}]}"""
+ : conditional;
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 1, Child = new { A = (string?)null, B = (string?)null } }).ToArray(),
+ $$$"""
+ {
+ "items":{{{entrySchema}}},
+ "$defs":{
+ "a":{"required":["a"],"not":{"required":["b"]}},
+ "b":{"required":["b"],"not":{"required":["a"]}}
+ }
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.True(item.GetProperty("child").TryGetProperty("b", out _));
+ Assert.False(item.GetProperty("child").TryGetProperty("a", out _));
+ });
+ }
+
+ [Fact]
+ public async Task StructuredOutput_ConditionalRepair_KeepsIndependentConditionsAtSamePath()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null, C = (string?)null }).ToArray(),
+ """
+ {
+ "items":{"allOf":[
+ {"if":{"properties":{"tag":{"const":0}}},"then":{"$ref":"#/$defs/a"},"else":{"$ref":"#/$defs/b"}},
+ {"if":{"properties":{"tag":{"const":1}}},"then":{"required":["c"]},"else":{"$ref":"#/$defs/a"}}
+ ]},
+ "$defs":{"a":{"required":["a"],"not":{"required":["b"]}},"b":{"required":["b"],"not":{"required":["a"]}}}
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.True(item.TryGetProperty("b", out _));
+ Assert.True(item.TryGetProperty("c", out _));
+ Assert.False(item.TryGetProperty("a", out _));
+ });
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_ConditionalRepair_UsesCallerReferenceScope(bool dynamicReference)
+ {
+ string condition = dynamicReference ? """{"$dynamicRef":"#guard"}""" : """{"$ref":"#/$defs/guard"}""";
+ string anchor = dynamicReference ? "\"$dynamicAnchor\":\"guard\"," : "";
+ int localTag = dynamicReference ? 1 : 0;
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 129).Select(_ => new { Tag = 1, A = (string?)null, B = (string?)null }).ToArray(),
+ $$$$"""
+ {
+ "$id":"https://example.test/conditional/root",
+ "$defs":{
+ "guard":{ {{{{anchor}}}} "properties":{"tag":{"const":0} } },
+ "entry":{
+ "$id":"entry",
+ "$defs":{"guard":{ {{{{anchor}}}} "properties":{"tag":{"const":{{{{localTag}}}} } } } },
+ "if":{{{{condition}}}},
+ "then":{"required":["a"],"not":{"required":["b"]}},
+ "else":{"required":["b"],"not":{"required":["a"]}}
+ }
+ },
+ "items":{"$ref":"entry"}
+ }
+ """);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item => Assert.True(item.TryGetProperty("b", out _)));
+ }
+
+ private struct ConditionalRepairChild
+ {
+ public string? A { get; set; }
+
+ public string? B { get; set; }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs
new file mode 100644
index 000000000..d0f1d83eb
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs
@@ -0,0 +1,1074 @@
+using System.Collections;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using Microsoft.Extensions.AI;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+
+namespace ModelContextProtocol.Tests.Server;
+
+public partial class McpServerToolTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_PrunesUnevaluatedRepairsAtControlCharacterPaths(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ if (sourceGenerated)
+ {
+ options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default;
+ }
+ McpServerTool tool = CreateRecoveryBoundaryTool(
+ new Dictionary
+ {
+ ["evaluated\0path"] = new(),
+ ["other"] = new(),
+ }, """{"allOf":[{"properties":{"evaluated\u0000path":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("evaluated\0path").GetProperty("real").ValueKind);
+ Assert.False(result.GetProperty("evaluated\0path").TryGetProperty("note", out _));
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("other").GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotTraverseHiddenExtensionDataCollisions(bool sourceGenerated, bool defaultIgnored)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(info =>
+ {
+ if (info.Type == typeof(RecoveryBoundaryFilteredParent) && !defaultIgnored)
+ {
+ info.Properties.Single(property => property.Name == "child").ShouldSerialize = static (_, _) => false;
+ }
+ });
+ object output = defaultIgnored
+ ? new RecoveryBoundaryDefaultParent { Child = new() { Count = 42 } }
+ : new RecoveryBoundaryFilteredParent();
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, """
+ {"properties":{"child":{"required":["count"],"properties":{"count":{"type":"integer"}}}}}
+ """, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Empty(result.GetProperty("child").EnumerateObject());
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_SupportsCachedReadonlyMetadata(bool sourceGenerated, bool record)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ RecoveryBoundaryCachingResolver resolver = new(
+ sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver());
+ options.TypeInfoResolver = resolver;
+ object output = record ? new RecoveryBoundaryNullableRecord() : new RecoveryBoundaryNullableOutput();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options);
+ JsonTypeInfo original = options.GetTypeInfo(output.GetType());
+ Func? originalGetter = original.Properties.Single().Get;
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.True(original.IsReadOnly);
+ Assert.Same(original, resolver.GetTypeInfo(original.Type, options));
+ Assert.Same(originalGetter, original.Properties.Single().Get);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotRebuildWideContracts(bool cacheMetadata)
+ {
+ const int width = 32;
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ int ownerResolutions = 0;
+ IJsonTypeInfoResolver resolver = new DefaultJsonTypeInfoResolver().WithAddedModifier(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(RecoveryBoundaryValue))
+ {
+ Interlocked.Increment(ref ownerResolutions);
+ typeInfo.Properties.Clear();
+ for (int i = 0; i < width; i++)
+ {
+ JsonPropertyInfo property = typeInfo.CreateJsonPropertyInfo(typeof(int), $"count{i}");
+ property.Get = static value => ((RecoveryBoundaryValue)value).Count;
+ typeInfo.Properties.Add(property);
+ }
+ }
+ });
+ options.TypeInfoResolver = cacheMetadata ? new RecoveryBoundaryCachingResolver(resolver) : resolver;
+ string required = string.Join(",", Enumerable.Range(0, width).Select(i => $"\"count{i}\""));
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryValue[8], $$$$"""{"items":{"required":[{{{{required}}}}]}}""", options);
+ options.MakeReadOnly();
+ JsonSerializer.SerializeToElement(new RecoveryBoundaryValue[8], options.GetTypeInfo(typeof(RecoveryBoundaryValue[])));
+ int resolutionsBeforeRecovery = ownerResolutions;
+
+ for (int invocation = 0; invocation < 2; invocation++)
+ {
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.All(result.EnumerateArray(), item =>
+ {
+ Assert.Equal(width, item.EnumerateObject().Count());
+ Assert.All(item.EnumerateObject(), property => Assert.Equal(0, property.Value.GetInt32()));
+ });
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ Assert.Equal(resolutionsBeforeRecovery, ownerResolutions);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate(bool nested)
+ {
+ using Barrier barrier = new(2);
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.ReferenceHandler = nested ? ReferenceHandler.IgnoreCycles : null;
+ options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(new DefaultJsonTypeInfoResolver());
+ options.Converters.Add(new RecoveryBoundaryInvocationConverter(barrier, TestContext.Current.CancellationToken));
+ McpServerTool[] tools = [CreateTool(1), CreateTool(2)];
+
+ JsonElement[] results = await Task.WhenAll(tools.Select(tool =>
+ Task.Run(() => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken)));
+
+ Assert.Equal(1, (nested ? results[0].GetProperty("child") : results[0]).GetProperty("value").GetInt32());
+ Assert.Equal(2, (nested ? results[1].GetProperty("child") : results[1]).GetProperty("value").GetInt32());
+
+ McpServerTool CreateTool(int value)
+ {
+ RecoveryBoundaryInvocationOutput output = new() { Value = new(value) };
+ return nested
+ ? CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = output },
+ """{"properties":{"child":{"required":["value"]}}}""", options)
+ : CreateStructuredOutputTestTool(output, """{"required":["value"]}""", options);
+ }
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_PreservesOwnerCycleScope(bool valueTypeOwner, bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never);
+ options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(
+ sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver());
+ object output = valueTypeOwner ? new RecoveryBoundaryCyclicStructOwner() : new RecoveryBoundaryCyclicOwner();
+ RecoveryBoundaryCyclicValue value = new() { Owner = output };
+ if (output is RecoveryBoundaryCyclicOwner referenceOwner)
+ {
+ referenceOwner.Missing = value;
+ }
+ else
+ {
+ typeof(RecoveryBoundaryCyclicStructOwner).GetProperty(nameof(RecoveryBoundaryCyclicStructOwner.Missing))!.SetValue(output, value);
+ }
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, """{"required":["missing"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ JsonElement owner = result.GetProperty("missing").GetProperty("owner");
+ if (valueTypeOwner)
+ {
+ Assert.Equal(JsonValueKind.Object, owner.ValueKind);
+ Assert.Empty(owner.EnumerateObject());
+ }
+ else
+ {
+ Assert.Equal(JsonValueKind.Null, owner.ValueKind);
+ }
+ }
+
+ public static IEnumerable AncestorCycleCases()
+ {
+ foreach (string shape in new[] { "object", "nestedObject", "struct", "array", "arrayContainer", "dictionary", "dictionaryContainer", "rootArray", "rootDictionary", "extension" })
+ {
+ foreach (bool strict in new[] { false, true })
+ {
+ foreach (bool sourceGenerated in new[] { false, true })
+ {
+ yield return [shape, strict, sourceGenerated];
+ }
+ }
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(AncestorCycleCases))]
+ public async Task StructuredOutput_RecoveryBoundary_PreservesAncestorCycleScope(string shape, bool strict, bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never);
+ options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(
+ sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver());
+ RecoveryBoundaryAncestorRoot root = new();
+ RecoveryBoundaryCyclicOwner first = new(), second = new();
+ RecoveryBoundaryCyclicOwner[] array = [first, second];
+ Dictionary dictionary = new()
+ {
+ ["first/~"] = first,
+ ["second"] = second,
+ };
+ object output = root;
+ object cycleTarget = root;
+ root.Child = shape switch
+ {
+ "nestedObject" => new RecoveryBoundaryAncestorRoot { Child = first },
+ "struct" => new RecoveryBoundaryStructAncestor { Child = first },
+ "array" or "arrayContainer" => array,
+ "dictionary" or "dictionaryContainer" => dictionary,
+ "extension" => new RecoveryBoundaryAncestorExtension { Data = new() { ["entry/~"] = first } },
+ _ => first,
+ };
+ if (shape is "arrayContainer" or "dictionaryContainer")
+ {
+ cycleTarget = root.Child!;
+ }
+ else if (shape is "rootArray" or "rootDictionary")
+ {
+ output = cycleTarget = shape == "rootArray" ? array : dictionary;
+ }
+ first.Missing = second.Missing = new() { Owner = cycleTarget };
+ string ownerSchema = strict ? """{"type":"null"}""" : "{}";
+ string itemSchema = $$$$"""
+ {"type":"object","required":["missing"],"properties":{"missing":{
+ "type":"object","required":["owner"],"properties":{"owner":{{{{ownerSchema}}}}}
+ }}}
+ """;
+ string schema = (shape switch
+ {
+ "nestedObject" or "struct" => """{"properties":{"child":{"properties":{"child":ITEM}}}}""",
+ "array" or "arrayContainer" => """{"properties":{"child":{"items":ITEM}}}""",
+ "dictionary" or "dictionaryContainer" => """{"properties":{"child":{"additionalProperties":ITEM}}}""",
+ "rootArray" => """{"items":ITEM}""",
+ "rootDictionary" => """{"additionalProperties":ITEM}""",
+ "extension" => """{"properties":{"child":{"properties":{"entry/~":ITEM}}}}""",
+ _ => """{"properties":{"child":ITEM}}""",
+ }).Replace("ITEM", itemSchema);
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ JsonElement child = shape switch
+ {
+ "nestedObject" or "struct" => result.GetProperty("child").GetProperty("child"),
+ "rootArray" or "rootDictionary" => result,
+ "extension" => result.GetProperty("child").GetProperty("entry/~"),
+ _ => result.GetProperty("child"),
+ };
+ if (shape is "array" or "arrayContainer" or "rootArray")
+ {
+ Assert.All(child.EnumerateArray(), AssertNullOwner);
+ }
+ else if (shape is "dictionary" or "dictionaryContainer" or "rootDictionary")
+ {
+ Assert.All(child.EnumerateObject(), property => AssertNullOwner(property.Value));
+ }
+ else
+ {
+ AssertNullOwner(child);
+ }
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ if (ReferenceEquals(root, output))
+ {
+ Assert.Equal(2, root.SerializingCalls);
+ Assert.Equal(2, root.GetterCalls);
+ }
+
+ static void AssertNullOwner(JsonElement item) =>
+ Assert.Equal(JsonValueKind.Null, item.GetProperty("missing").GetProperty("owner").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotTreatNonAncestorAsCycle(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never);
+ options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(
+ sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver());
+ RecoveryBoundaryCyclicOwner child = new()
+ {
+ Missing = new() { Owner = new RecoveryBoundaryNullableOutput { Note = "unrelated" } },
+ };
+ McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = child }, """
+ {"properties":{"child":{"required":["missing"],"properties":{"missing":{
+ "required":["owner"],"properties":{"owner":{
+ "type":"object","required":["note"],"properties":{"note":{"const":"unrelated"}}
+ }}
+ }}}}}
+ """, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("unrelated", result.GetProperty("child").GetProperty("missing").GetProperty("owner").GetProperty("note").GetString());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotTrackValueTypeAncestorBoxes(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never);
+ options.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.TypeInfoResolver = new RecoveryBoundaryCachingResolver(
+ sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver());
+ RecoveryBoundaryCyclicOwner child = new();
+ object boxedAncestor = new RecoveryBoundaryStructAncestor { Child = child };
+ child.Missing = new() { Owner = boxedAncestor };
+ McpServerTool tool = CreateRecoveryBoundaryTool(new RecoveryBoundaryAncestorRoot { Child = boxedAncestor }, """
+ {"properties":{"child":{"properties":{"child":{"required":["missing"]}}}}}
+ """, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ JsonElement owner = result.GetProperty("child").GetProperty("child").GetProperty("missing").GetProperty("owner");
+ Assert.Equal(JsonValueKind.Object, owner.ValueKind);
+ Assert.Equal(JsonValueKind.Null, owner.GetProperty("child").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(true, false, false)]
+ [InlineData(true, true, false)]
+ [InlineData(false, false, true)]
+ [InlineData(false, true, true)]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_PreservesMemberConverterScope(bool factory, bool sourceGenerated, bool globalConverter)
+ {
+ RecoveryBoundaryDelegatingConverter converter = new();
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ if (globalConverter)
+ {
+ options.Converters.Insert(0, new RecoveryBoundaryGlobalIntConverter());
+ }
+
+ IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(RecoveryBoundaryConvertedOutput))
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.CustomConverter = factory ? new RecoveryBoundaryConverterFactory(converter) : converter;
+ }
+ }
+ });
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryConvertedOutput(),
+ globalConverter
+ ? """{"required":["count"],"properties":{"count":{"const":"global-0"},"existing":{"const":"global-5"}}}"""
+ : """{"required":["count"],"properties":{"count":{"const":0},"existing":{"const":5}}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.False(converter.RecursionDetected);
+ Assert.False(converter.OptionsChanged);
+ if (globalConverter)
+ {
+ Assert.Equal("global-0", result.GetProperty("count").GetString());
+ Assert.Equal("global-5", result.GetProperty("existing").GetString());
+ }
+ else
+ {
+ Assert.Equal(0, result.GetProperty("count").GetInt32());
+ Assert.Equal(5, result.GetProperty("existing").GetInt32());
+ }
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_UsesSerializedDictionaryKeys(bool preserveReferences, bool reorder)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
+ if (preserveReferences)
+ {
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ }
+
+ RecoveryBoundaryDictionary output = new(reorder);
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, """
+ {
+ "required":["first/~","second"],
+ "properties":{
+ "first/~":{"required":["count"],"properties":{"count":{"type":"integer"}}},
+ "second":{"required":["count"],"properties":{"count":{"type":"integer"}}}
+ }
+ }
+ """, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(0, result.GetProperty("first/~").GetProperty("count").GetInt32());
+ Assert.Equal(5, result.GetProperty("second").GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_UsesDictionaryKeyConverter()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ options.Converters.Insert(0, new RecoveryBoundaryKeyConverter());
+ McpServerTool tool = CreateRecoveryBoundaryTool(
+ new Dictionary
+ {
+ [new(1)] = default,
+ [new(2)] = new() { Count = 5 },
+ },
+ """{"properties":{"key-1":{"required":["count"]},"key-2":{"required":["count"]}}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(0, result.GetProperty("key-1").GetProperty("count").GetInt32());
+ Assert.Equal(5, result.GetProperty("key-2").GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_UsesNongenericDictionaryKeyDispatch(bool customKey, bool preserveReferences)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.Converters.Insert(0, new RecoveryBoundaryKeyConverter());
+ if (preserveReferences)
+ {
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ }
+
+ Hashtable output = new()
+ {
+ [customKey ? new RecoveryBoundaryKey(1) : (object)1] = default(RecoveryBoundaryValue),
+ [customKey ? new RecoveryBoundaryKey(2) : (object)2] = new RecoveryBoundaryValue { Count = 5 },
+ };
+ string first = customKey ? "key-1" : "1";
+ string second = customKey ? "key-2" : "2";
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, $$$$"""
+ {"properties":{"{{{{first}}}}":{"required":["count"]},"{{{{second}}}}":{"required":["count"]}}}
+ """, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(0, result.GetProperty(first).GetProperty("count").GetInt32());
+ Assert.Equal(5, result.GetProperty(second).GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData("root")]
+ [InlineData("object")]
+ [InlineData("array")]
+ public async Task StructuredOutput_RecoveryBoundary_PreservesOutputWithCollidingDictionaryKeys(string shape)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
+ Dictionary dictionary = new()
+ {
+ ["First"] = default,
+ ["first"] = new() { Count = 5 },
+ };
+ object output = shape switch
+ {
+ "object" => new { Child = dictionary },
+ "array" => new[] { dictionary },
+ _ => dictionary,
+ };
+ string schema = shape switch
+ {
+ "object" => """{"properties":{"child":{"required":["unavailable"]}}}""",
+ "array" => """{"items":{"required":["unavailable"]}}""",
+ _ => """{"required":["unavailable"]}""",
+ };
+ McpServerTool tool = CreateRecoveryBoundaryTool(output, schema, options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ result = shape switch
+ {
+ "object" => result.GetProperty("child"),
+ "array" => result[0],
+ _ => result,
+ };
+
+ Assert.Equal(2, result.EnumerateObject().Count());
+ Assert.All(result.EnumerateObject(), property => Assert.Equal("first", property.Name));
+ }
+
+ [Theory]
+ [InlineData("original", false)]
+ [InlineData("original", true)]
+ [InlineData("missing", false)]
+ [InlineData("missing", true)]
+ [InlineData("wrapped", false)]
+ [InlineData("wrapped", true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotRecoverExcludedReadonlyField(string provider, bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.IncludeFields = true;
+ options.IgnoreReadOnlyFields = true;
+ IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ if (provider != "original")
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = provider == "missing" ? null : new RecoveryBoundaryAttributeProvider();
+ }
+ }
+ });
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryReadonlyOutput(), """{"required":["count"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.False(result.TryGetProperty("count", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotAddMembersToReferenceObjects()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ RecoveryBoundaryNullableOutput child = new();
+ McpServerTool tool = CreateRecoveryBoundaryTool(
+ new { First = child, Second = child },
+ """{"properties":{"first":{"required":["note"]},"second":{"required":["note"]}}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ JsonProperty reference = Assert.Single(result.GetProperty("second").EnumerateObject());
+ Assert.Equal("$ref", reference.Name);
+ Assert.False(result.GetProperty("first").TryGetProperty("note", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_TraversesPreservedCollectionValues()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ McpServerTool tool = CreateRecoveryBoundaryTool(
+ new List { new() },
+ """{"properties":{"$values":{"items":{"required":["note"]}}}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("$values")[0].GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotIntroduceIndependentReferenceIds()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.ReferenceHandler = ReferenceHandler.Preserve;
+ McpServerTool tool = CreateRecoveryBoundaryTool(
+ new { Existing = new RecoveryBoundaryNullableOutput(), Missing = default(RecoveryBoundaryReferenceValue) },
+ """{"required":["missing"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.False(result.TryGetProperty("missing", out _));
+ Assert.True(result.GetProperty("existing").TryGetProperty("$id", out _));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotRepeatOwnerCallbacksOrUnrelatedGetters(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ if (sourceGenerated)
+ {
+ options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default;
+ }
+
+ RecoveryBoundaryCallbackOutput output = new();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["count"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(0, result.GetProperty("count").GetInt32());
+ Assert.Equal(2, output.SerializingCalls);
+ Assert.Equal(2, output.SerializedCalls);
+ Assert.Equal(2, output.GetterCalls);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenCallbackStateGetterFails(
+ bool sourceGenerated, bool predicate)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ IJsonTypeInfoResolver resolver = sourceGenerated ? RecoveryBoundaryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ if (predicate && typeInfo.Type == typeof(RecoveryBoundaryStatefulOutput))
+ {
+ JsonPropertyInfo note = typeInfo.Properties.Single(property => property.Name == "note");
+ note.Get = static _ => null;
+ note.ShouldSerialize = static (owner, _) =>
+ owner is RecoveryBoundaryStatefulOutput output && output.SerializingCalls > output.SerializedCalls
+ ? false
+ : throw new InvalidOperationException("Predicate requires serialization callback state.");
+ }
+ });
+ var output = new RecoveryBoundaryStatefulOutput();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """{"required":["note"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("""{"kept":"kept"}""", result.GetRawText());
+ Assert.Equal(2, output.SerializingCalls);
+ Assert.Equal(2, output.SerializedCalls);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalWhenReflectedIndexerFails()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryThrowingList(new InvalidOperationException("Indexer failed.")),
+ """{"items":{"required":["note"]}}""");
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("[{}]", result.GetRawText());
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_RetainsOriginalForNestedAggregateFailure()
+ {
+ Exception failure = new InvalidOperationException("Recovery failed.");
+ for (int i = 0; i < 32; i++)
+ {
+ failure = new AggregateException(failure);
+ }
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}""");
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("""{"kept":"kept"}""", result.GetRawText());
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_PropagatesUnboundedExceptionGraphs()
+ {
+ Exception failure = new InvalidOperationException("Recovery failed.");
+ for (int i = 0; i < 20; i++)
+ {
+ failure = new AggregateException(failure, failure);
+ }
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryStatefulOutput { Failure = failure }, """{"required":["note"]}""");
+
+ Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool)));
+ }
+
+ [Theory]
+ [InlineData("cancel", false)]
+ [InlineData("cancel", true)]
+ [InlineData("memory", false)]
+ [InlineData("memory", true)]
+ [InlineData("stack", false)]
+ [InlineData("stack", true)]
+ [InlineData("access", false)]
+ [InlineData("access", true)]
+ [InlineData("aggregate", false)]
+ [InlineData("aggregate", true)]
+ [InlineData("wrapped-memory", false)]
+ [InlineData("wrapped-memory", true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressCancellationOrFatalGetterFailures(string kind, bool indexed)
+ {
+ Exception failure = kind switch
+ {
+ "cancel" => new OperationCanceledException(),
+ "memory" => new OutOfMemoryException(),
+ "stack" => new StackOverflowException(),
+ "access" => new AccessViolationException(),
+ "wrapped-memory" => new NotSupportedException("Wrapped fatal failure.", new OutOfMemoryException()),
+ _ => new AggregateException(new InvalidOperationException(), new OperationCanceledException()),
+ };
+ object output = indexed
+ ? new RecoveryBoundaryThrowingList(failure)
+ : new RecoveryBoundaryStatefulOutput { Failure = failure };
+ McpServerTool tool = CreateStructuredOutputTestTool(output,
+ indexed ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}""");
+
+ Exception? actual = await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool));
+
+ Assert.Same(failure, actual);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_RecoveryBoundary_DoesNotSuppressOriginalSerializationFailure(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ if (sourceGenerated)
+ {
+ options.TypeInfoResolver = RecoveryBoundaryJsonContext.Default;
+ }
+ var failure = new InvalidOperationException("Initial serialization failed.");
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryStatefulOutput { FailDuringSerialization = true, Failure = failure },
+ """{"required":["note"]}""", options);
+
+ Assert.Same(failure, await Record.ExceptionAsync(() => InvokeStructuredOutputTestTool(tool)));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_RecoveryBoundary_PreservesCompatibleSourceGeneratedContracts()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryBoundaryAttributedOutput(),
+ """{"required":["count"],"properties":{"count":{"const":0}}}""",
+ RecoveryBoundaryJsonContext.Default.Options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(0, result.GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ private sealed class RecoveryBoundaryConvertedOutput
+ {
+ public int Existing { get; set; } = 5;
+ public int Count { get; set; }
+ }
+
+ private sealed class RecoveryBoundaryControlNameOutput
+ {
+ public string? Real { get; set; }
+ public string? Note { get; set; }
+ }
+
+ private sealed class RecoveryBoundaryFilteredParent
+ {
+ public RecoveryBoundaryConvertedOutput Child { get; set; } = new() { Count = 42 };
+ [JsonExtensionData]
+ public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() };
+ }
+
+ private sealed class RecoveryBoundaryDefaultParent
+ {
+ public RecoveryBoundaryHiddenValue Child { get; set; }
+ [JsonExtensionData]
+ public Dictionary Data { get; set; } = new() { ["child"] = new Dictionary() };
+ }
+
+ private struct RecoveryBoundaryHiddenValue : IEquatable
+ {
+ public int Count { get; set; }
+ public bool Equals(RecoveryBoundaryHiddenValue other) => true;
+ public override bool Equals(object? obj) => obj is RecoveryBoundaryHiddenValue;
+ public override int GetHashCode() => 0;
+ }
+
+ private sealed class RecoveryBoundaryDelegatingConverter : JsonConverter
+ {
+ private bool _writing;
+ private JsonSerializerOptions? _originalOptions;
+
+ public bool RecursionDetected { get; private set; }
+ public bool OptionsChanged { get; private set; }
+
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
+ {
+ if (_writing)
+ {
+ RecursionDetected = true;
+ throw new InvalidOperationException("Recursive member converter invocation.");
+ }
+
+ _originalOptions ??= options;
+ OptionsChanged |= !ReferenceEquals(_originalOptions, options);
+ _writing = true;
+ try
+ {
+ JsonSerializer.Serialize(writer, value, options.GetTypeInfo(typeof(int)));
+ }
+ finally
+ {
+ _writing = false;
+ }
+ }
+ }
+
+ private sealed class RecoveryBoundaryConverterFactory(RecoveryBoundaryDelegatingConverter converter) : JsonConverterFactory
+ {
+ public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(int);
+ public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) => converter;
+ }
+
+ private sealed class RecoveryBoundaryGlobalIntConverter : JsonConverter
+ {
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) =>
+ writer.WriteStringValue($"global-{value}");
+ }
+
+ private struct RecoveryBoundaryValue
+ {
+ public int Count { get; set; }
+ }
+
+ private sealed class RecoveryBoundaryDictionary(bool reorder) : IReadOnlyDictionary
+ {
+ private readonly Dictionary _entries = new()
+ {
+ ["First/~"] = default,
+ ["Second"] = new() { Count = 5 },
+ };
+ private int _enumerations;
+
+ public RecoveryBoundaryValue this[string key] => _entries[key];
+ public IEnumerable Keys => _entries.Keys;
+ public IEnumerable Values => _entries.Values;
+ public int Count => _entries.Count;
+ public bool ContainsKey(string key) => _entries.ContainsKey(key);
+ public bool TryGetValue(string key, out RecoveryBoundaryValue value) => _entries.TryGetValue(key, out value);
+ public IEnumerator> GetEnumerator() =>
+ (reorder && ++_enumerations % 2 == 0 ? _entries.Reverse() : _entries).GetEnumerator();
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ private readonly record struct RecoveryBoundaryKey(int Value);
+
+ private sealed class RecoveryBoundaryKeyConverter : JsonConverter
+ {
+ public override RecoveryBoundaryKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) =>
+ writer.WriteStringValue($"value-{value.Value}");
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, RecoveryBoundaryKey value, JsonSerializerOptions options) =>
+ writer.WritePropertyName($"key-{value.Value}");
+ }
+
+ private sealed class RecoveryBoundaryReadonlyOutput
+ {
+ public readonly int Count = 0;
+ }
+
+ private sealed class RecoveryBoundaryAttributeProvider : ICustomAttributeProvider
+ {
+ public object[] GetCustomAttributes(bool inherit) => [];
+ public object[] GetCustomAttributes(Type attributeType, bool inherit) => [];
+ public bool IsDefined(Type attributeType, bool inherit) => false;
+ }
+
+ private sealed class RecoveryBoundaryAttributedOutput
+ {
+ [JsonConverter(typeof(RecoveryBoundaryDelegatingConverter))]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; set; }
+ }
+
+ private sealed class RecoveryBoundaryNullableOutput
+ {
+ public string? Note { get; set; }
+ }
+
+ private sealed record RecoveryBoundaryNullableRecord(string? Note = null);
+
+ private sealed class RecoveryBoundaryInvocationOutput
+ {
+ public RecoveryBoundaryInvocationValue Value { get; set; }
+ }
+
+ private readonly struct RecoveryBoundaryInvocationValue(int value) : IEquatable
+ {
+ public int Value { get; } = value;
+ public bool Equals(RecoveryBoundaryInvocationValue other) => true;
+ public override bool Equals(object? obj) => obj is RecoveryBoundaryInvocationValue;
+ public override int GetHashCode() => 0;
+ }
+
+ private sealed class RecoveryBoundaryInvocationConverter(Barrier barrier, CancellationToken cancellationToken) : JsonConverter
+ {
+ public override RecoveryBoundaryInvocationValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, RecoveryBoundaryInvocationValue value, JsonSerializerOptions options)
+ {
+ if (!barrier.SignalAndWait(TestConstants.DefaultTimeout, cancellationToken))
+ {
+ throw new TimeoutException("Concurrent recovery did not enter both converters.");
+ }
+ writer.WriteNumberValue(value.Value);
+ }
+ }
+
+ private sealed class RecoveryBoundaryCyclicOwner
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RecoveryBoundaryCyclicValue Missing { get; set; }
+ }
+
+ private sealed class RecoveryBoundaryAncestorRoot : IJsonOnSerializing
+ {
+ public object? Child { get; set; }
+ public int Existing { get { GetterCalls++; return 5; } }
+ [JsonIgnore]
+ public int GetterCalls { get; private set; }
+ [JsonIgnore]
+ public int SerializingCalls { get; private set; }
+ public void OnSerializing() => SerializingCalls++;
+ }
+
+ private sealed class RecoveryBoundaryAncestorExtension
+ {
+ [JsonExtensionData]
+ public Dictionary Data { get; set; } = new();
+ }
+
+ private struct RecoveryBoundaryStructAncestor
+ {
+ public RecoveryBoundaryCyclicOwner Child { get; set; }
+ }
+
+ private struct RecoveryBoundaryCyclicStructOwner
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RecoveryBoundaryCyclicValue Missing { get; set; }
+ }
+
+ private struct RecoveryBoundaryCyclicValue : IEquatable
+ {
+ public object? Owner { get; set; }
+ public bool Equals(RecoveryBoundaryCyclicValue other) => true;
+ public override bool Equals(object? obj) => obj is RecoveryBoundaryCyclicValue;
+ public override int GetHashCode() => 0;
+ }
+
+ private sealed class RecoveryBoundaryCachingResolver(IJsonTypeInfoResolver inner) : IJsonTypeInfoResolver
+ {
+ private readonly System.Collections.Concurrent.ConcurrentDictionary<(Type, JsonSerializerOptions), JsonTypeInfo> _contracts = new();
+
+ public JsonTypeInfo? GetTypeInfo(Type type, JsonSerializerOptions options) =>
+ _contracts.GetOrAdd((type, options), key => inner.GetTypeInfo(key.Item1, key.Item2)!);
+ }
+
+ private struct RecoveryBoundaryReferenceValue
+ {
+ public RecoveryBoundaryNullableOutput Child => new();
+ }
+
+ private sealed class RecoveryBoundaryCallbackOutput : IJsonOnSerializing, IJsonOnSerialized
+ {
+ public int Count { get; set; }
+ public int Existing { get { GetterCalls++; return 5; } }
+ [JsonIgnore]
+ public int SerializingCalls { get; private set; }
+ [JsonIgnore]
+ public int SerializedCalls { get; private set; }
+ [JsonIgnore]
+ public int GetterCalls { get; private set; }
+ public void OnSerializing() => SerializingCalls++;
+ public void OnSerialized() => SerializedCalls++;
+ }
+
+ private sealed class RecoveryBoundaryStatefulOutput : IJsonOnSerializing, IJsonOnSerialized
+ {
+ private bool _serializing;
+ public string Kept { get; set; } = "kept";
+ public string? Note => _serializing && !FailDuringSerialization ? null : throw Failure;
+ [JsonIgnore]
+ public Exception Failure { get; set; } = new InvalidOperationException("Getter requires serialization callback state.");
+ [JsonIgnore]
+ public bool FailDuringSerialization { get; set; }
+ [JsonIgnore]
+ public int SerializingCalls { get; private set; }
+ [JsonIgnore]
+ public int SerializedCalls { get; private set; }
+ public void OnSerializing() { _serializing = true; SerializingCalls++; }
+ public void OnSerialized() { _serializing = false; SerializedCalls++; }
+ }
+
+ private sealed class RecoveryBoundaryThrowingList(Exception failure) : IReadOnlyList
+ {
+ public int Count => 1;
+ public RecoveryBoundaryNullableOutput this[int index] => throw failure;
+ public IEnumerator GetEnumerator()
+ {
+ yield return new();
+ }
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ private static McpServerTool CreateRecoveryBoundaryTool(object value, string schema, JsonSerializerOptions options) =>
+ McpServerTool.Create(new RecoveryBoundaryFunction(value, options), new()
+ {
+ UseStructuredContent = true,
+ OutputSchema = JsonDocument.Parse(schema).RootElement,
+ });
+
+ private sealed class RecoveryBoundaryFunction(object value, JsonSerializerOptions options) : AIFunction
+ {
+ public override string Name => "tool";
+ public override JsonElement JsonSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement;
+ public override JsonSerializerOptions JsonSerializerOptions => options;
+ protected override ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) =>
+ new(value);
+ }
+
+ [JsonSourceGenerationOptions(IncludeFields = true, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+ [JsonSerializable(typeof(RecoveryBoundaryConvertedOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryReadonlyOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryCallbackOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryStatefulOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryAttributedOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))]
+ [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))]
+ [JsonSerializable(typeof(Dictionary))]
+ [JsonSerializable(typeof(RecoveryBoundaryFilteredParent))]
+ [JsonSerializable(typeof(RecoveryBoundaryDefaultParent))]
+ [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))]
+ [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))]
+ [JsonSerializable(typeof(RecoveryBoundaryAncestorRoot))]
+ [JsonSerializable(typeof(RecoveryBoundaryAncestorExtension))]
+ [JsonSerializable(typeof(RecoveryBoundaryStructAncestor))]
+ [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner[]))]
+ [JsonSerializable(typeof(Dictionary))]
+ [JsonSerializable(typeof(object))]
+ private partial class RecoveryBoundaryJsonContext : JsonSerializerContext;
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs
new file mode 100644
index 000000000..f2e6573e1
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs
@@ -0,0 +1,686 @@
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using Json.Schema;
+using ModelContextProtocol.Tests.Utils;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Protocol;
+using Moq;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using System.Text.RegularExpressions;
+using System.IO.Pipelines;
+using ModelContextProtocol.Client;
+
+namespace ModelContextProtocol.Tests.Server;
+
+public partial class McpServerToolTests
+{
+ [Theory]
+ [InlineData("element")]
+ [InlineData("node")]
+ [InlineData("document")]
+ public async Task StructuredOutput_Search_RecoversClrParentWithSerializedJsonChild(string kind)
+ {
+ const string json = """{"value":42,"explicit":null}""";
+ using JsonDocument document = JsonDocument.Parse(json);
+ object child = kind switch
+ {
+ "element" => document.RootElement,
+ "node" => JsonNode.Parse(json)!,
+ _ => document,
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Note = (string?)null, Child = child }, """{"required":["note"]}""");
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.Equal(json, result.GetProperty("child").GetRawText());
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Search_PreservesJsonDocumentObjectContractRecovery()
+ {
+ using JsonDocument document = JsonDocument.Parse("""{"value":42}""");
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.TypeInfoResolver = new JsonDocumentObjectContractResolver();
+ McpServerTool tool = CreateStructuredOutputTestTool(document, """{"required":["note"]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("""{"note":null}""", result.GetRawText());
+ }
+
+ private sealed class JsonDocumentObjectContractResolver : DefaultJsonTypeInfoResolver
+ {
+ public override JsonTypeInfo GetTypeInfo(Type type, JsonSerializerOptions options)
+ {
+ if (type != typeof(JsonDocument))
+ {
+ return base.GetTypeInfo(type, options);
+ }
+
+ JsonTypeInfo info = JsonMetadataServices.CreateObjectInfo(options, new()
+ {
+ PropertyMetadataInitializer = static _ => [],
+ });
+ JsonPropertyInfo note = info.CreateJsonPropertyInfo(typeof(string), "note");
+ note.Get = static _ => null;
+ info.Properties.Add(note);
+ return info;
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Search_BoundsFiniteRecursiveAnnotationEvaluation(bool dynamicReference)
+ {
+ string reference = dynamicReference ? "\"$dynamicRef\":\"#node\"" : "\"$ref\":\"#\"";
+ string schema = $$$$"""{"$schema":"https://json-schema.org/draft/2020-12/schema","$dynamicAnchor":"node","required":["note"],"properties":{"child":{ {{{{reference}}}} }},"unevaluatedProperties":true}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Note = (string?)null, Child = new { Note = (string?)null, Child = new { Note = (string?)null } } }, schema);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("child").GetProperty("note").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_Search_RetainsAnnotationChangingAlternatives(bool array, bool exclusive)
+ {
+ string first = exclusive
+ ? """{"not":{"properties":{"child":{"required":["note"]}}}}"""
+ : "true";
+ string choice = $$$$"""{"{{{{(exclusive ? "oneOf" : "anyOf")}}}}":[{{{{first}}}},{"properties":{"child":{"required":["note"]}}}],"unevaluatedProperties":false}""";
+ string schema = array ? $$"""{"items":{{choice}}}""" : choice;
+ object output = array
+ ? new[] { new { Child = new { Note = (string?)null } } }
+ : new { Child = new { Note = (string?)null } };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, (array ? result[0] : result).GetProperty("child").GetProperty("note").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Search_StopsAfterRegexTimeout(bool propertyPattern)
+ {
+ string input = new string('a', 26) + "!";
+ object output = propertyPattern
+ ? new { Note = (string?)null, Items = new Dictionary { [input] = new { Value = 1 } } }
+ : new { Note = (string?)null, Value = input };
+ string schema = propertyPattern
+ ? """{"required":["note"],"properties":{"items":{"patternProperties":{"^(a+)+$":false}}}}"""
+ : """{"anyOf":[{"properties":{"value":{"pattern":"^(a+)+$"}}},{"required":["note"]}]}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+
+ JsonElement result = await Task.Run(
+ () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken)
+ .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ Assert.False(result.TryGetProperty("note", out _));
+ if (propertyPattern)
+ {
+ Assert.Equal(1, result.GetProperty("items").GetProperty(input).GetProperty("value").GetInt32());
+ }
+ else
+ {
+ Assert.Equal(input, result.GetProperty("value").GetString());
+ }
+ }
+
+#if NET
+ [Fact]
+ public async Task StructuredOutput_Search_BoundsLegacyRecursiveDiagnosticExpansion()
+ {
+ object output = new Dictionary { ["kept"] = 1 };
+ for (int i = 0; i < 18; i++)
+ {
+ output = new Dictionary { ["child"] = output };
+ }
+ JsonElement expected = JsonSerializer.SerializeToElement(output, CreateStructuredOutputTestOptions());
+ const string schema = """
+ {
+ "$schema":"https://json-schema.org/draft/2019-09/schema",
+ "$id":"https://example.test/legacy/root","$recursiveAnchor":true,
+ "properties":{"child":{"$ref":"b"}},"unevaluatedProperties":true,
+ "$defs":{
+ "b":{"$id":"b","properties":{"child":{"$ref":"c"}}},
+ "c":{"$id":"c","$recursiveAnchor":true,"allOf":[
+ {"properties":{"child":{"$recursiveRef":"#"}}},
+ {"properties":{"child":{"$recursiveRef":"#"}}}
+ ]}
+ }
+ }
+ """;
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+
+ await Task.Run(() =>
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult();
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+
+ Assert.Equal(expected.GetRawText(), result.GetRawText());
+ Assert.True(allocated < 32 * 1024 * 1024, $"Legacy recursive diagnostics allocated {allocated:N0} bytes.");
+ }, TestContext.Current.CancellationToken);
+ }
+
+ [Theory]
+ [InlineData(4, true)]
+ [InlineData(14, false)]
+ public async Task StructuredOutput_Search_BoundsUnevaluatedDagDiagnostics(int depth, bool recoverable)
+ {
+ JsonObject definitions = new() { ["s0"] = JsonNode.Parse("""{"properties":{"kept":true}}""") };
+ for (int i = 1; i <= depth; i++)
+ {
+ definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}""");
+ }
+ McpServerTool tool = CreateStructuredOutputTestTool(new { Kept = 1, Child = new { Note = (string?)null } },
+ new JsonObject
+ {
+ ["$ref"] = $"#/$defs/s{depth}",
+ ["$defs"] = definitions,
+ ["unevaluatedProperties"] = JsonNode.Parse("""{"required":["note"]}"""),
+ }.ToJsonString());
+
+ await Task.Run(() =>
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ JsonElement result = InvokeStructuredOutputTestTool(tool).GetAwaiter().GetResult();
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for diagnostic DAG depth {depth}.");
+
+ Assert.Equal(1, result.GetProperty("kept").GetInt32());
+ Assert.Equal(recoverable, result.GetProperty("child").TryGetProperty("note", out _));
+ Assert.True(allocated < 32 * 1024 * 1024, $"Diagnostic evaluation allocated {allocated:N0} bytes.");
+ }, TestContext.Current.CancellationToken);
+ }
+
+ [Theory]
+ [InlineData(8, true)]
+ [InlineData(64, false)]
+ [InlineData(128, false)]
+ [InlineData(128, true)]
+ public async Task StructuredOutput_Search_BoundsWideSchemaAllocations(int width, bool recoverable)
+ {
+ JsonArray alternatives = [];
+ for (int i = 0; i < width; i++)
+ {
+ alternatives.Add((JsonNode)new JsonObject
+ {
+ ["required"] = new JsonArray($"missing{i}_" + new string('x', 512)),
+ });
+ }
+ if (recoverable)
+ {
+ alternatives.Add((JsonNode)new JsonObject { ["required"] = new JsonArray("note") });
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Value = "kept", Note = (string?)null }, new JsonObject { ["anyOf"] = alternatives }.ToJsonString());
+
+ await Task.Run(() =>
+ {
+ long before = GC.GetAllocatedBytesForCurrentThread();
+ Task invocation = InvokeStructuredOutputTestTool(tool);
+ Assert.True(invocation.IsCompleted);
+ JsonElement result = invocation.GetAwaiter().GetResult();
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
+ TestContext.Current.TestOutputHelper?.WriteLine($"Allocated {allocated:N0} bytes for {width} branches.");
+
+ Assert.Equal("kept", result.GetProperty("value").GetString());
+ Assert.Equal(recoverable && width == 8, result.TryGetProperty("note", out _));
+ if (width != 8)
+ {
+ Assert.Equal("""{"value":"kept"}""", result.GetRawText());
+ }
+ Assert.True(allocated < 32 * 1024 * 1024, $"Recovery allocated {allocated:N0} bytes for {width} branches.");
+ }, TestContext.Current.CancellationToken);
+ }
+#endif
+
+ [Theory]
+ [InlineData("anyOf")]
+ [InlineData("oneOf")]
+ [InlineData("allOf")]
+ [InlineData("reference")]
+ [InlineData("crossParents")]
+ [InlineData("crossReferences")]
+ public async Task StructuredOutput_Search_QualifiesEnclosingItemAssertions(string shape)
+ {
+ string item = shape switch
+ {
+ "oneOf" => """{"properties":{"a":true},"additionalProperties":false,"oneOf":[{"required":["b"]},{"required":["a"]}]}""",
+ "allOf" => """{"allOf":[{"anyOf":[{"required":["a"]},{"required":["b"]}]},{"not":{"required":["b"]}}]}""",
+ "reference" => """{"$ref":"#/$defs/choice","properties":{"a":true},"additionalProperties":false}""",
+ _ => """{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}""",
+ };
+ var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null, C = (string?)null }).ToArray();
+ string schema = shape switch
+ {
+ "crossParents" => """{"allOf":[{"items":{"anyOf":[{"required":["b"]},{"required":["a"]}]}},{"items":{"required":["c"],"not":{"required":["b"]}}}]}""",
+ "crossReferences" => """{"allOf":[{"items":{"$ref":"#/$defs/choice"}},{"items":{"$ref":"#/$defs/guard"}}],"$defs":{"choice":{"$id":"choice","anyOf":[{"required":["b"]},{"required":["a"]}]},"guard":{"$id":"guard","required":["c"],"not":{"required":["b"]}}}}""",
+ _ => $$$$"""{"items":{{{{item}}}},"$defs":{"choice":{"anyOf":[{"required":["a"]},{"required":["b"]}]}}}""",
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), child =>
+ {
+ Assert.Equal(JsonValueKind.Null, child.GetProperty("a").ValueKind);
+ Assert.False(child.TryGetProperty("b", out _));
+ Assert.Equal(shape is "crossParents" or "crossReferences", child.TryGetProperty("c", out _));
+ });
+ }
+
+ [Theory]
+ [InlineData("object")]
+ [InlineData("array")]
+ [InlineData("reference")]
+ [InlineData("allOf")]
+ [InlineData("anyOf")]
+ [InlineData("contains")]
+ [InlineData("becomesEvaluated")]
+ [InlineData("validAlternative")]
+ public async Task StructuredOutput_Search_DoesNotRepairEvaluatedOptionalMembers(string shape)
+ {
+ var first = new { Note = (string?)null, Real = (string?)null };
+ var second = new { Note = (string?)null, Real = (string?)null };
+ bool array = shape is "array" or "contains";
+ object output = array ? new[] { first, second } : new { Evaluated = first, Other = second };
+ string schema = shape switch
+ {
+ "array" => """{"prefixItems":[{"type":"object"}],"unevaluatedItems":{"required":["note"]}}""",
+ "contains" => """{"prefixItems":[{"properties":{"real":true}}],"contains":{"required":["real"]},"unevaluatedItems":{"required":["note"]}}""",
+ "reference" => """{"$id":"https://example.test/unevaluated","$defs":{"rule":{"properties":{"evaluated":true},"unevaluatedProperties":{"required":["note"]}}},"allOf":[{"$ref":"#/$defs/rule"}]}""",
+ "allOf" => """{"allOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""",
+ "anyOf" => """{"anyOf":[{"properties":{"evaluated":true}}],"unevaluatedProperties":{"required":["note"]}}""",
+ "becomesEvaluated" => """{"properties":{"evaluated":{"required":["real"]}},"unevaluatedProperties":{"required":["note"]}}""",
+ "validAlternative" => """{"anyOf":[{"properties":{"evaluated":true}},{"properties":{"other":{"required":["real"]}}}],"unevaluatedProperties":{"required":["note"]}}""",
+ _ => """{"properties":{"evaluated":{"type":"object"}},"unevaluatedProperties":{"required":["note"]}}""",
+ };
+ if (shape == "contains")
+ {
+ output = new[] { new { Note = (string?)null, Real = (string?)"present" }, first };
+ }
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ JsonElement evaluated = array ? result[0] : result.GetProperty("evaluated");
+ JsonElement other = array ? result[1] : result.GetProperty("other");
+ Assert.False(evaluated.TryGetProperty("note", out _));
+ Assert.Equal(JsonValueKind.Null, other.GetProperty("note").ValueKind);
+ Assert.False(other.TryGetProperty("real", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Search_ComposesIndependentExclusiveChoicesAtSamePath()
+ {
+ var output = new
+ {
+ A0 = (string?)null,
+ B0 = (string?)null,
+ A1 = (string?)null,
+ B1 = (string?)null,
+ A2 = (string?)null,
+ B2 = (string?)null,
+ A3 = (string?)null,
+ B3 = (string?)null,
+ A4 = (string?)null,
+ B4 = (string?)null,
+ };
+ JsonArray allOf = [];
+ for (int i = 0; i < 5; i++)
+ {
+ allOf.Add(JsonNode.Parse($$"""{"oneOf":[{"required":["a{{i}}"]},{"required":["b{{i}}"]}]}"""));
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(output, new JsonObject { ["allOf"] = allOf }.ToJsonString());
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ for (int i = 0; i < 5; i++)
+ {
+ Assert.NotEqual(result.TryGetProperty($"a{i}", out _), result.TryGetProperty($"b{i}", out _));
+ }
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_Search_PreservesCallerScopeForBranchValidation(bool dynamicReference, bool siblingReference)
+ {
+ string reference = dynamicReference ? "\"$dynamicRef\":\"#entry\"" : "\"$ref\":\"https://example.test/root#entry\"";
+ if (siblingReference)
+ {
+ reference += ",\"$ref\":\"#/$defs/childRule\"";
+ }
+
+ string schema = $$$$"""
+ {
+ "$schema":"https://json-schema.org/draft/2020-12/schema",
+ "$id":"https://example.test/root", "$dynamicAnchor":"entry",
+ "required":["note"],
+ "properties":{"items":{"items":{"$ref":"base"}}},
+ "$defs":{"base":{
+ "$id":"base","$dynamicAnchor":"entry",
+ "$defs":{"childRule":{"required":["marker"]}},
+ "oneOf":[
+ {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{ {{{{reference}}}} }},"additionalProperties":false},
+ {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{ {{{{reference}}}} }},"additionalProperties":false}
+ ]
+ }}
+ }
+ """;
+ var output = new
+ {
+ Note = (string?)null,
+ Items = Enumerable.Range(0, 5).Select(_ => new
+ {
+ Tag = "0",
+ Good = (string?)null,
+ Bad = (string?)null,
+ Child = new { Note = (string?)null, Marker = (string?)null },
+ }).ToArray(),
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ foreach (JsonElement item in result.GetProperty("items").EnumerateArray())
+ {
+ Assert.Equal(JsonValueKind.Null, item.GetProperty("good").ValueKind);
+ Assert.False(item.TryGetProperty("bad", out _));
+ Assert.Equal(JsonValueKind.Null, item.GetProperty("child").GetProperty("note").ValueKind);
+ Assert.Equal(siblingReference, item.GetProperty("child").TryGetProperty("marker", out _));
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Search_RepairsLargeMixedTagArray()
+ {
+ const string schema = """
+ {"type":"array","items":{"oneOf":[
+ {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true},"additionalProperties":false},
+ {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true},"additionalProperties":false}
+ ]}}
+ """;
+ var output = Enumerable.Range(0, 10_000).Select(i => new
+ {
+ Tag = (i % 2).ToString(System.Globalization.CultureInfo.InvariantCulture),
+ Good = (string?)null,
+ Bad = (string?)null,
+ }).ToArray();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(output.Length, result.GetArrayLength());
+ for (int i = 0; i < output.Length; i++)
+ {
+ Assert.Equal(JsonValueKind.Null, result[i].GetProperty(i % 2 == 0 ? "good" : "bad").ValueKind);
+ Assert.False(result[i].TryGetProperty(i % 2 == 0 ? "bad" : "good", out _));
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Search_DistinguishesDynamicCallersForSharedBranches()
+ {
+ const string schema = """
+ {
+ "$schema":"https://json-schema.org/draft/2020-12/schema",
+ "$id":"https://example.test/root",
+ "properties":{"left":{"$ref":"left"},"right":{"$ref":"right"}},
+ "$defs":{
+ "left":{"$id":"left","$dynamicAnchor":"entry","required":["leftNote"],
+ "properties":{"items":{"items":{"$ref":"base"}}}},
+ "right":{"$id":"right","$dynamicAnchor":"entry","required":["rightNote"],
+ "properties":{"items":{"items":{"$ref":"base"}}}},
+ "base":{"$id":"base","$dynamicAnchor":"entry","oneOf":[
+ {"required":["tag","good"],"properties":{"tag":{"const":"0"},"good":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false},
+ {"required":["tag","bad"],"properties":{"tag":{"const":"1"},"bad":true,"child":{"$dynamicRef":"#entry"}},"additionalProperties":false}
+ ]}
+ }
+ }
+ """;
+ var items = Enumerable.Range(0, 5).Select(_ => new
+ {
+ Tag = "0",
+ Good = (string?)null,
+ Bad = (string?)null,
+ Child = new { LeftNote = (string?)null, RightNote = (string?)null },
+ }).ToArray();
+ McpServerTool tool = CreateStructuredOutputTestTool(new
+ {
+ Left = new { LeftNote = (string?)null, Items = items },
+ Right = new { RightNote = (string?)null, Items = items },
+ }, schema);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ foreach (string side in new[] { "left", "right" })
+ {
+ foreach (JsonElement item in result.GetProperty(side).GetProperty("items").EnumerateArray())
+ {
+ JsonElement child = item.GetProperty("child");
+ Assert.Equal(JsonValueKind.Null, child.GetProperty(side + "Note").ValueKind);
+ Assert.False(child.TryGetProperty(side == "left" ? "rightNote" : "leftNote", out _));
+ Assert.False(item.TryGetProperty("bad", out _));
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(14, true)]
+ [InlineData(25, false)]
+ public async Task StructuredOutput_Search_TraversesSharedReferenceDagOnce(int depth, bool sourceHasValue)
+ {
+ string property = sourceHasValue ? "note" : "absent";
+ JsonObject definitions = new() { ["s0"] = JsonNode.Parse($$"""{"required":["{{property}}"]}""") };
+ for (int i = 1; i <= depth; i++)
+ {
+ definitions[$"s{i}"] = JsonNode.Parse($$"""{"allOf":[{"$ref":"#/$defs/s{{i - 1}}"},{"$ref":"#/$defs/s{{i - 1}}"}]}""");
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Note = (string?)null },
+ new JsonObject { ["$ref"] = $"#/$defs/s{depth}", ["$defs"] = definitions }.ToJsonString());
+ JsonElement result = await Task.Run(
+ () => InvokeStructuredOutputTestTool(tool), TestContext.Current.CancellationToken)
+ .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ if (sourceHasValue)
+ {
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ // Detailed diagnostics duplicate results for every successful DAG branch.
+ JsonSchema schema = JsonSchema.Build(
+ tool.ProtocolTool.OutputSchema!.Value, new BuildOptions { SchemaRegistry = new SchemaRegistry() });
+ Assert.True(schema.Evaluate(result, new EvaluationOptions { OutputFormat = OutputFormat.Flag }).IsValid);
+ }
+ else
+ {
+ Assert.False(result.TryGetProperty("absent", out _));
+ }
+ }
+
+ [Collection(nameof(DisableParallelization))]
+ public class RecoverySchemaRegistryTests
+ {
+ [Theory]
+ [InlineData("element", false)]
+ [InlineData("element", true)]
+ [InlineData("node", false)]
+ [InlineData("node", true)]
+ [InlineData("document", false)]
+ [InlineData("document", true)]
+ [InlineData("clr", false)]
+ [InlineData("clr", true)]
+ public async Task StructuredOutput_Search_DoesNotLoadSchemasForSerializedJson(string kind, bool forwarding)
+ {
+ foreach (string protocolVersion in new[] { "2025-06-18", "2025-11-25", "2026-07-28" })
+ {
+ foreach (bool array in new[] { false, true })
+ {
+ Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}");
+ JsonSchema external = JsonSchema.FromText(
+ array ? """{"items":{"required":["note"]}}""" : """{"required":["note"]}""",
+ new BuildOptions { SchemaRegistry = new() }, uri);
+ int fetches = 0;
+ var fetch = SchemaRegistry.Global.Fetch;
+ try
+ {
+ SchemaRegistry.Global.Fetch = (requested, registry) =>
+ {
+ if (requested != uri)
+ {
+ return fetch(requested, registry);
+ }
+ Interlocked.Increment(ref fetches);
+ return external;
+ };
+ string json = array ? """[{"value":42,"explicit":null}]""" : """{"value":42,"explicit":null}""";
+ using JsonDocument document = JsonDocument.Parse(json);
+ var clr = new { Note = "present", Child = JsonNode.Parse(json) };
+ object source = kind switch
+ {
+ "element" => document.RootElement,
+ "node" => JsonNode.Parse(json)!,
+ "document" => document,
+ _ => array ? new[] { clr } : (object)clr,
+ };
+ McpServerTool inner = McpServerTool.Create(() => source, new()
+ {
+ Name = "serialized_json",
+ UseStructuredContent = true,
+ SerializerOptions = CreateStructuredOutputTestOptions(),
+ OutputSchema = JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","$ref":"{{uri}}"}""").RootElement,
+ });
+ McpServerTool tool = forwarding
+ ? new ForwardingSchemaTool(inner, JsonDocument.Parse($$"""{"type":"{{(array ? "array" : "object")}}","allOf":[{"$ref":"{{uri}}"}]}""").RootElement)
+ : inner;
+
+ CallToolResult result = await InvokeRegisteredToolAsync(tool, protocolVersion);
+
+ Assert.NotEqual(true, result.IsError);
+ JsonElement content = result.StructuredContent!.Value;
+ if (array && protocolVersion != "2026-07-28")
+ {
+ content = content.GetProperty("result");
+ }
+ Assert.Equal(kind is "clr" or "document", fetches > 0);
+ if (kind == "clr")
+ {
+ Assert.Equal("present", (array ? content[0] : content).GetProperty("note").GetString());
+ }
+ else
+ {
+ Assert.Equal(json, content.GetRawText());
+ }
+ }
+ finally
+ {
+ SchemaRegistry.Global.Fetch = fetch;
+ }
+ }
+ }
+ }
+
+ private static async Task InvokeRegisteredToolAsync(McpServerTool tool, string protocolVersion)
+ {
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TestConstants.DefaultTimeout);
+ var clientToServer = new Pipe();
+ var serverToClient = new Pipe();
+ await using var server = McpServer.Create(
+ new StreamServerTransport(clientToServer.Reader.AsStream(), serverToClient.Writer.AsStream()),
+ new McpServerOptions { ToolCollection = new() { tool } });
+ Task serverTask = server.RunAsync(cts.Token);
+ try
+ {
+ var options = TestMcpClient.CreateOptions();
+ options.ProtocolVersion = protocolVersion;
+ await using var client = await TestMcpClient.CreateAsync(
+ new StreamClientTransport(clientToServer.Writer.AsStream(), serverToClient.Reader.AsStream()),
+ options, cancellationToken: cts.Token);
+ return await client.CallToolAsync(tool.ProtocolTool.Name, cancellationToken: cts.Token);
+ }
+ finally
+ {
+ await cts.CancelAsync();
+ await serverTask;
+ }
+ }
+
+ private sealed class ForwardingSchemaTool(McpServerTool inner, JsonElement schema) : DelegatingMcpServerTool(inner)
+ {
+ public override Tool ProtocolTool { get; } = new()
+ {
+ Name = inner.ProtocolTool.Name,
+ InputSchema = inner.ProtocolTool.InputSchema,
+ OutputSchema = schema,
+ };
+
+ public override ValueTask InvokeAsync(
+ RequestContext request, CancellationToken cancellationToken = default) =>
+ base.InvokeAsync(request, cancellationToken);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Search_DoesNotMutateBorrowedSchemaRegexes()
+ {
+ Uri uri = new($"https://example.test/recovery/{Guid.NewGuid():N}");
+ JsonSchema external = JsonSchema.FromText(
+ """{"required":["note"],"properties":{"value":{"pattern":"^safe$"}}}""",
+ new BuildOptions { SchemaRegistry = new() }, uri);
+ KeywordData pattern = external.Root.Keywords.Single(keyword => keyword.Handler.Name == "properties")
+ .Subschemas.Single().Keywords.Single(keyword => keyword.Handler.Name == "pattern");
+ Regex original = Assert.IsType(pattern.Value);
+ var fetch = SchemaRegistry.Global.Fetch;
+ try
+ {
+ SchemaRegistry.Global.Fetch = (requested, registry) => requested == uri ? external : fetch(requested, registry);
+ McpServerTool tool = McpServerTool.Create(() => new { Note = (string?)null, Value = "safe" }, new()
+ {
+ Name = "tool",
+ UseStructuredContent = true,
+ OutputSchema = JsonDocument.Parse($$"""{"$ref":"{{uri}}"}""").RootElement,
+ SerializerOptions = new(JsonSerializerDefaults.Web)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
+ },
+ });
+ Mock server = new();
+ server.SetupGet(instance => instance.NegotiatedProtocolVersion).Returns("2026-07-28");
+ RequestContext request = new(server.Object,
+ new JsonRpcRequest { Id = new RequestId("test"), Method = "tools/call" },
+ new CallToolRequestParams { Name = "tool" });
+
+ CallToolResult result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotEqual(true, result.IsError);
+ Assert.Equal("""{"value":"safe"}""", result.StructuredContent!.Value.GetRawText());
+ Assert.Same(original, pattern.Value);
+ }
+ finally
+ {
+ SchemaRegistry.Global.Fetch = fetch;
+ }
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
index c77151884..f4774df39 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs
@@ -4,7 +4,9 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
+using System.Collections.ObjectModel;
using System.ComponentModel;
+using System.Dynamic;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
@@ -516,6 +518,2500 @@ public async Task StructuredOutput_Enabled_ReturnsExpectedSchema(T value)
AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
}
+ [Theory]
+ [InlineData(null, Sep2106ProtocolVersion)]
+ [InlineData("note", Sep2106ProtocolVersion)]
+ [InlineData(null, "2025-11-25")]
+ [InlineData("note", "2025-11-25")]
+ public async Task StructuredOutput_Enabled_RequiredNullableProperties_MatchesOutputSchema(string? note, string protocolVersion)
+ {
+ JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(
+ McpJsonUtilities.DefaultOptions.TypeInfoResolver,
+ new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new RequiredNullableOutput("42", note),
+ new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tool.ProtocolTool.OutputSchema);
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
+ }
+
+ [Theory]
+ [InlineData(null, Sep2106ProtocolVersion)]
+ [InlineData("note", Sep2106ProtocolVersion)]
+ [InlineData(null, "2025-11-25")]
+ [InlineData("note", "2025-11-25")]
+ public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_MatchesOutputSchema(string? note, string protocolVersion)
+ {
+ JsonSerializerOptions serializerOptions = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(
+ McpJsonUtilities.DefaultOptions.TypeInfoResolver,
+ new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new RequiredNullablePropertiesOutput { Id = "42", Note = note },
+ new() { Name = "tool", UseStructuredContent = true, SerializerOptions = serializerOptions });
+ var request = CreateRequestContextWithProtocolVersion(protocolVersion);
+
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(tool.ProtocolTool.OutputSchema);
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_OmitsOptionalNullProperty()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"note":{"type":"string"}},"required":["id"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("note", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_EmitsRequiredNullableValueWithIgnorePolicies(string? note)
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"defaultedNote":{"type":["string","null"]},"ignoredNote":{"type":["string","null"]}},"required":["id","defaultedNote","ignoredNote"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithMemberIgnore { Id = "42", DefaultedNote = note, IgnoredNote = note },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ foreach (string propertyName in new[] { "defaultedNote", "ignoredNote" })
+ {
+ JsonElement noteElement = result.StructuredContent.Value.GetProperty(propertyName);
+ Assert.Equal(note is null ? JsonValueKind.Null : JsonValueKind.String, noteElement.ValueKind);
+ Assert.Equal(note, noteElement.GetString());
+ }
+
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesRequiredDefaultValue()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithDefaultValue { Id = "42", Count = 0 },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesMemberConverterForRequiredDefault()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":"string"}},"required":["id","count"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithMemberConverter { Id = "42", Count = 0 },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversRequiredNullWhenCompleteSnapshotFails()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"required":["note"]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithUndefinedEnumDefault { Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("status", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RejectingEnumDoesNotPreventUnionRepair()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"note":{"type":["string","null"]},"status":{"type":"string"}},"anyOf":[{"required":["status"]},{"required":["note"]}]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithUndefinedEnumDefault(),
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversStructDictionaryValueWithEnumConverter()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"items":{"type":"object","additionalProperties":{"type":"object","properties":{"count":{"type":"integer"},"status":{"type":"string"}},"required":["count","status"]}}},"required":["items"]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ options.Converters.Add(new JsonStringEnumConverter(allowIntegerValues: false));
+ McpServerTool tool = McpServerTool.Create(
+ () => new StructDictionaryOutput { Items = new() { [42] = new() { Status = ExplicitSchemaStatus.Ready } } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesDictionaryValuesAndReferences()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "$defs": {
+ "entry": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "note": { "type": ["string", "null"] }
+ },
+ "required": ["id", "note"]
+ }
+ },
+ "type": "object",
+ "additionalProperties": { "$ref": "#/$defs/entry" }
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new Dictionary { ["entry"] = new() { Id = "42", Note = null } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("entry").GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesRecursiveReferences()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "$defs": {
+ "node": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "note": { "type": ["string", "null"] },
+ "next": { "$ref": "#/$defs/node" }
+ },
+ "required": ["id", "note"]
+ }
+ },
+ "$ref": "#/$defs/node"
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new RecursiveStructuredOutput { Id = "root", Note = null, Next = new() { Id = "child", Note = null } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("next").GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesAllOfRequirements()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": { "id": { "type": "string" } },
+ "allOf": [
+ {
+ "properties": { "note": { "type": ["string", "null"] } },
+ "required": ["note"]
+ }
+ ],
+ "required": ["id"]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPrefixItemInsteadOfTrailingItemsSchema()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "array",
+ "prefixItems": [
+ {
+ "type": "object",
+ "properties": { "id": { "type": "string" } },
+ "required": ["id"],
+ "additionalProperties": false
+ }
+ ],
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": { "type": "string" },
+ "note": { "type": ["string", "null"] }
+ },
+ "required": ["id", "note"]
+ }
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new[] { new ExplicitSchemaOutput { Id = "42", Note = null } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.False(result.StructuredContent.Value[0].TryGetProperty("note", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Theory]
+ [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{}},\"required\":[\"id\",\"note\"]}")]
+ [InlineData("{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"string\"},\"note\":{\"const\":null}},\"required\":[\"id\",\"note\"]}")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecognizesPermissiveNullSchemas(string schema)
+ {
+ JsonElement outputSchema = JsonDocument.Parse(schema).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesSplitRequiredAndPropertySchemas()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } },
+ "allOf": [{ "required": ["note"] }],
+ "required": ["id"]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesReferenceRequirementsWithSiblingProperties()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "$defs": { "requiresNote": { "type": "object", "required": ["note"] } },
+ "$ref": "#/$defs/requiresNote",
+ "type": "object",
+ "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } },
+ "required": ["id"]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMemberLevelDefaultIgnore()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"},"count":{"type":["integer","null"]}},"required":["id","count"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithMemberDefault { Id = "42", Count = 0 },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesAlreadyValidAnyOfBranch()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "anyOf": [
+ {
+ "properties": {
+ "count": { "type": "integer", "minimum": 1 },
+ "child": { "type": "object", "properties": { "id": { "type": "string" }, "note": { "type": ["string", "null"] } }, "required": ["id", "note"] }
+ },
+ "required": ["count", "child"]
+ },
+ {
+ "properties": { "count": { "const": 0 }, "child": { "type": "object", "properties": { "id": { "type": "string" } }, "required": ["id"], "additionalProperties": false } },
+ "required": ["count", "child"]
+ }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new AnyOfStructuredOutput { Count = 0, Child = new() { Id = "42", Note = null } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(0, result.StructuredContent.Value.GetProperty("count").GetInt32());
+ Assert.False(result.StructuredContent.Value.GetProperty("child").TryGetProperty("note", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsLargeIntegerBranch()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "anyOf": [
+ { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] },
+ { "type": "null" }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new LargeIntegerStructuredOutput { Value = ulong.MaxValue, Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(ulong.MaxValue, result.StructuredContent.Value.GetProperty("value").GetUInt64());
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMathematicallyIntegralDecimal()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "anyOf": [
+ { "properties": { "value": { "type": "integer" }, "note": { "type": ["string", "null"] } }, "required": ["value", "note"] },
+ { "type": "null" }
+ ]
+ }
+ """).RootElement;
+ using JsonDocument integerDocument = JsonDocument.Parse("1.0");
+ JsonElement integerValue = integerDocument.RootElement.Clone();
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new MathematicalIntegerStructuredOutput { Value = integerValue, Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(1, result.StructuredContent.Value.GetProperty("value").GetDouble());
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralConstEquality()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "anyOf": [
+ { "properties": { "tag": { "const": { "b": 2, "a": 1 } }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] },
+ { "properties": { "tag": { "const": { "different": true } } }, "required": ["tag"] }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesStructuralEnumEquality()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "anyOf": [
+ { "properties": { "tag": { "enum": [{ "b": 2, "a": 1 }] }, "note": { "type": ["string", "null"] } }, "required": ["tag", "note"] },
+ { "properties": { "tag": { "enum": [{ "different": true }] } }, "required": ["tag"] }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new StructuralConstOutput { Tag = new JsonObject { ["a"] = 1, ["b"] = 2 }, Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesJsonNumericEquality()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "anyOf": [
+ { "properties": { "count": { "const": 1.0 }, "note": { "type": ["string", "null"] } }, "required": ["count", "note"] },
+ { "properties": { "count": { "const": 2 } }, "required": ["count"] }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new NumericConstOutput { Count = 1, Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_HandlesNotConditionalForNull()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "properties": { "id": { "type": "string" }, "note": { "if": { "not": { "type": "null" } }, "then": { "type": "string" }, "else": { "type": "null" } } },
+ "required": ["id", "note"]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverThroughFilteredExtensionCollision()
+ {
+ DefaultJsonTypeInfoResolver resolver = new();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(FilteredExtensionCollision))
+ {
+ typeInfo.Properties.Single(property => property.Name == "child").ShouldSerialize = (_, _) => false;
+ }
+ });
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = resolver,
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(new FilteredExtensionCollision(), """
+ {"properties":{"child":{"required":["count"]}}}
+ """, options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.GetProperty("child").TryGetProperty("count", out _));
+ }
+
+ private sealed class FilteredExtensionCollision
+ {
+ public FilteredExtensionChild Child { get; } = new();
+
+ [JsonExtensionData]
+ public Dictionary Extension { get; } = new()
+ {
+ ["child"] = JsonDocument.Parse("{}").RootElement,
+ };
+ }
+
+ private sealed class FilteredExtensionChild
+ {
+ public int Count { get; } = 42;
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRestoreCallerFilteredProperty()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"secret":{"type":"string"}},"required":["secret"]}""").RootElement;
+ DefaultJsonTypeInfoResolver resolver = new();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(ExplicitSchemaFilteredOutput))
+ {
+ typeInfo.Properties.Single(property => property.Name == "secret").ShouldSerialize = (_, _) => false;
+ }
+ });
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaFilteredOutput { Secret = "do-not-publish" },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("secret", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsCallerFilterForMemberDefault()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"integer"},"nullableCount":{"type":"integer"}},"required":["count","nullableCount"]}""").RootElement;
+ DefaultJsonTypeInfoResolver resolver = new();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(ExplicitSchemaFilteredDefaultOutput))
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.ShouldSerialize = (_, _) => false;
+ }
+ }
+ });
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(resolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaFilteredDefaultOutput(),
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("count", out _));
+ Assert.False(result.StructuredContent.Value.TryGetProperty("nullableCount", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeAlreadyValidOutputAgain()
+ {
+ int getterCalls = 0;
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new GetterCountedStructuredOutput(() => getterCalls++),
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal("42", result.StructuredContent.Value.GetProperty("id").GetString());
+ // The result is serialized once for structured content and once for text content.
+ Assert.Equal(2, getterCalls);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotLookUpValuesPresentInCompleteSnapshot()
+ {
+ const int itemCount = 100;
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"items":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id","note"]}}},"required":["items"]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ GetterCountedCollectionStructuredOutput output = new(
+ Enumerable.Range(0, itemCount).Select(index => new ExplicitSchemaCollectionItem { Id = index.ToString() }).ToList());
+ McpServerTool tool = McpServerTool.Create(
+ () => output,
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(itemCount, result.StructuredContent.Value.GetProperty("items").GetArrayLength());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ // Structured output, text output, and the complete-value snapshot each access the collection once.
+ Assert.Equal(3, output.ItemsGetterCalls);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedDefaultValueInPasses()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"child":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}},"required":["child"]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new NestedDefaultedStructuredOutput { Child = default },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentSchema()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"dependentSchemas":{"id":{"required":["note"]}}}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsRequiredPropertyFromDependentRequired()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"id":{"type":"string"},"note":{"type":["string","null"]}},"required":["id"],"dependentRequired":{"id":["note"]}}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_TraversesMatchingPatternForExplicitProperty()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"child":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}},"patternProperties":{"^child$":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}}}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42", Note = null } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(6)]
+ [InlineData(7)]
+ [InlineData(8)]
+ [InlineData(9)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsAValidBranchFromBroadUnion(int tag)
+ {
+ string branchSchemas = string.Join(",", Enumerable.Range(0, 10).Select(index =>
+ $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index}}}":{"type":["string","null"]}},"required":["tag","note{{{index}}}"],"additionalProperties":false}"""));
+ using JsonDocument schemaDocument = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branchSchemas}}]}""");
+ JsonElement outputSchema = schemaDocument.RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag}").ValueKind);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_SelectsBranchWithTwoRequiredMembers(int tag)
+ {
+ string branches = string.Join(",", Enumerable.Range(0, 3).Select(index =>
+ $$$"""{"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 2}}}":{"type":["string","null"]},"note{{{index * 2 + 1}}}":{"type":["string","null"]}},"required":["tag","note{{{index * 2}}}","note{{{index * 2 + 1}}}"],"additionalProperties":false}"""));
+ using JsonDocument document = JsonDocument.Parse($$"""{"type":"object","oneOf":[{{branches}}]}""");
+ JsonElement outputSchema = document.RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new TenBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2}").ValueKind);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty($"note{tag * 2 + 1}").ValueKind);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInventOneOfProperty()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","oneOf":[{"properties":{"note":{"type":["string","null"]}},"required":["note"]},{"properties":{"other":{"type":["string","null"]}},"required":["other"]}]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOneOfSourceOnlyOutput { Id = "42", Note = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("note").ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsNestedOneOfWithoutMixingBranches()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "oneOf": [
+ {"type":"object","properties":{"first":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["first"],"additionalProperties":false},
+ {"type":"object","properties":{"second":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"],"additionalProperties":false}},"required":["second"],"additionalProperties":false}
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new NestedOneOfRecoveryOutput(),
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("first").GetProperty("note").ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("second", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsMatchingOneOfBranchWithoutOtherCandidates()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {
+ "type": "object",
+ "oneOf": [
+ {
+ "properties": {
+ "tag": { "const": "0" },
+ "child": {
+ "type": "object",
+ "properties": { "note": { "type": ["string", "null"] } },
+ "required": ["note"]
+ }
+ },
+ "required": ["tag", "child"],
+ "additionalProperties": false
+ },
+ {
+ "properties": {
+ "tag": { "const": "1" },
+ "other": { "type": ["string", "null"] }
+ },
+ "required": ["tag", "other"],
+ "additionalProperties": false
+ }
+ ]
+ }
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new BranchSpecificOneOfRecoveryOutput { Tag = "0", Child = default, Other = null },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal("0", result.StructuredContent.Value.GetProperty("tag").GetString());
+ Assert.Equal(JsonValueKind.Null, result.StructuredContent.Value.GetProperty("child").GetProperty("note").ValueKind);
+ Assert.False(result.StructuredContent.Value.TryGetProperty("other", out _));
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversIntegerDictionaryKey()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""
+ {"type":"object","properties":{"items":{"type":"object","properties":{"42":{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}},"required":["42"]}},"required":["items"]}
+ """).RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new IntegerKeyDictionaryOutput { Items = new() { [42] = new() { Count = 0 } } },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal(0, result.StructuredContent.Value.GetProperty("items").GetProperty("42").GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesPropertyNumberHandlingOnRecovery()
+ {
+ JsonElement outputSchema = JsonDocument.Parse("""{"type":"object","properties":{"count":{"type":"string"}},"required":["count"]}""").RootElement;
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+ McpServerTool tool = McpServerTool.Create(
+ () => new ExplicitSchemaOutputWithNumberHandling { Count = 0 },
+ new() { Name = "tool", UseStructuredContent = true, OutputSchema = outputSchema, SerializerOptions = options });
+
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+
+ Assert.NotNull(result.StructuredContent);
+ Assert.Equal("0", result.StructuredContent.Value.GetProperty("count").GetString());
+ AssertMatchesJsonSchema(outputSchema, result.StructuredContent);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RefreshesChangedSchema()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object"}""");
+ await InvokeStructuredOutputTestTool(tool);
+
+ tool.ProtocolTool.OutputSchema = JsonDocument.Parse(
+ """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""").RootElement;
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result);
+
+ tool.ProtocolTool.OutputSchema = JsonDocument.Parse(
+ """{"$id":"https://example.test/mutable","$anchor":"entry","type":"object","properties":{"note":false}}""").RootElement;
+ result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("note", out _));
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema.Value, result);
+ }
+
+ [Theory]
+ [InlineData(1, 1)]
+ [InlineData(2, 2)]
+ [InlineData(1, 3)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsContainsMembers(int minimum, int maximum)
+ {
+ string schema = $$$"""
+ {"type":"array","contains":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } },"minContains":{{{minimum}}},"maxContains":{{{maximum}}}}
+ """;
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 3).Select(index => new ExplicitSchemaOutput { Id = index.ToString() }).ToArray(),
+ schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.InRange(result.EnumerateArray().Count(item => item.TryGetProperty("note", out _)), minimum, maximum);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterProducedNull()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DefaultRequestIdOutput(),
+ """{"type":"object","required":["id"],"properties":{"id":{"type":"null"}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversReadOnlyDictionaryValue(bool customKeys)
+ {
+ var output = new ReadOnlyDictionary(
+ new Dictionary
+ {
+ [1] = new() { Count = 5, Status = ExplicitSchemaStatus.Ready },
+ [42] = new() { Count = 0, Status = ExplicitSchemaStatus.Ready },
+ });
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.Converters.Insert(0, new JsonStringEnumConverter(allowIntegerValues: false));
+ if (customKeys)
+ {
+ options.Converters.Add(new FormattedIntegerKeyConverter());
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ output,
+ """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""",
+ options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(5, result.GetProperty(customKeys ? "key-1" : "1").GetProperty("count").GetInt32());
+ Assert.Equal(0, result.GetProperty(customKeys ? "key-42" : "42").GetProperty("count").GetInt32());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesJsonElementMemberConverter()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DefaultJsonElementOutput(),
+ """{"type":"object","required":["value"],"properties":{"value":{"const":"converted"}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal("converted", result.GetProperty("value").GetString());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesNestedRepairs()
+ {
+ string properties = string.Join(",", Enumerable.Range(0, 24).Select(index =>
+ $$$""" "child{{{index}}}":{"type":"object","required":["note"],"properties":{"note":{"type":"null"} } }"""));
+ string required = string.Join(",", Enumerable.Range(0, 24).Select(index => $"\"child{index}\""));
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ManyNestedDefaultOutputs(),
+ $$"""{"type":"object","properties":{ {{properties}} },"required":[{{required}}]}""",
+ CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ for (int i = 0; i < 24; i++)
+ {
+ Assert.Equal(JsonValueKind.Null, result.GetProperty($"child{i}").GetProperty("note").ValueKind);
+ }
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(1)]
+ [InlineData(2)]
+ [InlineData(3)]
+ [InlineData(4)]
+ [InlineData(5)]
+ [InlineData(6)]
+ [InlineData(7)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PrioritizesBranchCandidateGroups(int tag)
+ {
+ string branches = string.Join(",", Enumerable.Range(0, 8).Select(index =>
+ $$$"""
+ {"type":"object","properties":{"tag":{"const":"{{{index}}}"},"note{{{index * 3}}}":{"type":"null"},"note{{{index * 3 + 1}}}":{"type":"null"},"note{{{index * 3 + 2}}}":{"type":"null"}},"required":["tag","note{{{index * 3}}}","note{{{index * 3 + 1}}}","note{{{index * 3 + 2}}}"],"additionalProperties":false}
+ """));
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ManyBranchStructuredOutput { Tag = tag.ToString(System.Globalization.CultureInfo.InvariantCulture) },
+ $$"""{"type":"object","oneOf":[{{branches}}]}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(4, result.EnumerateObject().Count());
+ for (int i = 0; i < 3; i++)
+ {
+ Assert.Equal(JsonValueKind.Null, result.GetProperty($"note{tag * 3 + i}").ValueKind);
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsUnevaluatedMembers(bool isArray)
+ {
+ object output = isArray
+ ? new[] { new ExplicitSchemaOutput { Id = "42" }, new ExplicitSchemaOutput { Id = "43" } }
+ : new Dictionary { ["evaluated"] = new() { Id = "42" }, ["other"] = new() { Id = "43" } };
+ string schema = isArray
+ ? """{"type":"array","prefixItems":[{"type":"object","properties":{"note":false}}],"unevaluatedItems":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}"""
+ : """{"type":"object","properties":{"evaluated":{"type":"object","properties":{"note":false}}},"unevaluatedProperties":{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ JsonElement evaluated = isArray ? result[0] : result.GetProperty("evaluated");
+ JsonElement unevaluated = isArray ? result[1] : result.GetProperty("other");
+ Assert.False(evaluated.TryGetProperty("note", out _));
+ Assert.Equal(JsonValueKind.Null, unevaluated.GetProperty("note").ValueKind);
+ }
+
+ [Theory]
+ [InlineData("$ref", "$anchor")]
+ [InlineData("$ref", "$dynamicAnchor")]
+ [InlineData("$dynamicRef", "$dynamicAnchor")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesNamedAnchor(string referenceKeyword, string anchorKeyword)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ExplicitSchemaOutput { Id = "42" },
+ $$$"""{"type":"object","$defs":{"entry":{"{{{anchorKeyword}}}":"entry","type":"object","required":["note"]}},"{{{referenceKeyword}}}":"#entry"}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsAnchorResourceBoundaries(bool nested)
+ {
+ string schema = nested
+ ? """{"$id":"https://example.test/root","type":"object","properties":{"child":{"$id":"child","$defs":{"entry":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}},"$defs":{"entry":{"$anchor":"entry","required":["other"]}}}"""
+ : """{"$id":"https://example.test/root","type":"object","$defs":{"foreign":{"$id":"foreign","$anchor":"entry","required":["other"]},"local":{"$anchor":"entry","required":["note"]}},"$ref":"#entry"}""";
+ object output = nested
+ ? new ExplicitSchemaPatternChildOutput { Child = new() { Id = "42" } }
+ : new ExplicitSchemaOutput { Id = "42" };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, (nested ? result.GetProperty("child") : result).GetProperty("note").ValueKind);
+ }
+
+ [Theory]
+ [InlineData("#entry")]
+ [InlineData("base#entry")]
+ [InlineData("https://example.test/base#entry")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesDynamicAnchorInCallingScope(string reference)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DynamicReferenceOutput { Child = new() { Id = "42" } },
+ $$$"""
+ {
+ "$id":"https://example.test/root",
+ "$dynamicAnchor":"entry",
+ "$schema":"https://json-schema.org/draft/2020-12/schema",
+ "$ref":"#/$defs/base",
+ "required":["note"],
+ "$defs":{
+ "base":{
+ "$id":"base",
+ "$dynamicAnchor":"entry",
+ "type":"object",
+ "properties":{"child":{"$dynamicRef":"{{{reference}}}"}}
+ }
+ }
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("note").ValueKind);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverFailedNullConversion()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RejectedNullOutput(),
+ """{"type":"object","required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("note", out _));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyExclusions(bool isField)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.IgnoreReadOnlyProperties = !isField;
+ options.IgnoreReadOnlyFields = isField;
+ options.IncludeFields = isField;
+ object output = isField ? new ReadOnlyFieldOutput() : new ReadOnlyPropertyOutput();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","required":["count"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("count", out _));
+ }
+
+ public static IEnumerable ReadOnlyRecoveryInputs =>
+ Enumerable.Range(0, 32).Select(bits => new object[]
+ {
+ (bits & 1) != 0, (bits & 2) != 0, (bits & 4) != 0,
+ (bits & 8) != 0, (bits & 16) != 0,
+ });
+
+ [Theory]
+ [MemberData(nameof(ReadOnlyRecoveryInputs))]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesReadOnlyMemberKindWithoutAttributes(
+ bool sourceGenerated, bool isField, bool ignoreProperties, bool ignoreFields, bool ignoreDefault)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(
+ ignoreDefault ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull);
+ options.IncludeFields = true;
+ options.IgnoreReadOnlyProperties = ignoreProperties;
+ options.IgnoreReadOnlyFields = ignoreFields;
+ IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ bool excluded = isField ? ignoreFields : ignoreProperties;
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ property.Name = "renamed_" + property.Name;
+ if (excluded && property.Get is not null)
+ {
+ property.Get = static _ => throw new InvalidOperationException("Excluded getter was invoked.");
+ }
+ }
+ });
+
+ object output = isField ? new ReadOnlyRecoveryField() : new ReadOnlyRecoveryProperty();
+ string schema = ignoreDefault
+ ? """{"required":["renamed_note","renamed_count"]}"""
+ : """{"required":["renamed_note"]}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ if (excluded)
+ {
+ Assert.Empty(result.EnumerateObject());
+ }
+ else
+ {
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("renamed_note").ValueKind);
+ Assert.Equal(0, result.GetProperty("renamed_count").GetInt32());
+ }
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadExcludedConvertedCollection(
+ bool sourceGenerated, bool removeAttributes)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.IgnoreReadOnlyProperties = true;
+ IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ if (removeAttributes)
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ }
+ }
+ });
+ McpServerTool tool = CreateStructuredOutputTestTool(new ReadOnlyConvertedCollectionOutput(),
+ """{"anyOf":[{"required":["values"]},{"required":["note"]}]}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.False(result.TryGetProperty("values", out _));
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ [InlineData(true, true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinIncludedReadOnlyCollections(
+ bool sourceGenerated, bool isField)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.IncludeFields = true;
+ options.IgnoreReadOnlyProperties = true;
+ options.IgnoreReadOnlyFields = true;
+ IJsonTypeInfoResolver resolver = sourceGenerated ? ReadOnlyRecoveryJsonContext.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ }
+ });
+ object output = isField ? new ReadOnlyRecoveryCollectionField() : new ReadOnlyRecoveryCollectionProperty();
+ McpServerTool tool = CreateStructuredOutputTestTool(output,
+ """{"required":["children"],"properties":{"children":{"items":{"required":["id","note"]}}}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal("42", result.GetProperty("children")[0].GetProperty("id").GetString());
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("children")[0].GetProperty("note").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(5, false, false)]
+ [InlineData(24, false, true)]
+ [InlineData(129, false, true)]
+ [InlineData(5, true, true)]
+ [InlineData(24, true, false)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_CombinesIndependentBranchRepairs(int count, bool dictionary, bool mixed)
+ {
+ IndependentUnionOutput[] items = Enumerable.Range(0, count)
+ .Select(index => new IndependentUnionOutput { Tag = mixed ? (index % 2).ToString() : "0" }).ToArray();
+ object output = dictionary
+ ? items.Select((item, index) => (item, index)).ToDictionary(pair => $"item/{pair.index}~", pair => pair.item)
+ : items;
+ string container = dictionary ? "\"type\":\"object\",\"additionalProperties\"" : "\"type\":\"array\",\"items\"";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, $$$"""
+ {
+ {{{container}}}:{"$ref":"#/$defs/entry"},
+ "$defs":{"entry":{"oneOf":[
+ {"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},
+ {"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}
+ ]}}
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ IEnumerable repaired = dictionary
+ ? result.EnumerateObject().Select(property => property.Value)
+ : result.EnumerateArray();
+ foreach (JsonElement item in repaired)
+ {
+ string tag = item.GetProperty("tag").GetString()!;
+ Assert.Equal(JsonValueKind.Null, item.GetProperty("note" + tag).ValueKind);
+ Assert.False(item.TryGetProperty(tag == "0" ? "note1" : "note0", out _));
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesUnevaluatedSchemaFragments()
+ {
+ var output = new { Child = new { X = (string?)null, Y = (string?)null } };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """
+ {"allOf":[
+ {"properties":{"child":true},"unevaluatedProperties":{"required":["x"]}},
+ {"unevaluatedProperties":{"required":["y"]}}
+ ]}
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ JsonElement child = result.GetProperty("child");
+ Assert.Equal(JsonValueKind.Null, child.GetProperty("y").ValueKind);
+ Assert.False(child.TryGetProperty("x", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverSourceGeneratedIterator()
+ {
+ static IEnumerable Items()
+ {
+ yield return new ExplicitSchemaOutput { Id = "42" };
+ }
+
+ JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(JsonContext2.Default, McpJsonUtilities.DefaultOptions.TypeInfoResolver),
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(Items(), """
+ {"type":"array","items":{"required":["note"],"properties":{"note":{"type":"null"}}}}
+ """, options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result[0].TryGetProperty("note", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RespectsEnclosingAssertionsForRepeatedBranches()
+ {
+ var output = Enumerable.Range(0, 129).Select(_ => new { A = (string?)null, B = (string?)null }).ToArray();
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """
+ {"type":"array","items":{"properties":{"a":true},"additionalProperties":false,"anyOf":[{"required":["a"]},{"required":["b"]}]}}
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ foreach (JsonElement item in result.EnumerateArray())
+ {
+ Assert.Equal(JsonValueKind.Null, item.GetProperty("a").ValueKind);
+ Assert.False(item.TryGetProperty("b", out _));
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DistinguishesRequiredPathsContainingNull()
+ {
+ Dictionary output = new()
+ {
+ ["a"] = new { },
+ ["a\0b"] = new { c = (string?)null },
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """
+ {"anyOf":[{"properties":{"a":{"required":["b\u0000c"]}}},{"properties":{"a\u0000b":{"required":["c"]}}}]}
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("a\0b").GetProperty("c").ValueKind);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesNestedBranchRepairs()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Tag = "outer", Note = (string?)null, Items = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray() },
+ """
+ {
+ "oneOf":[
+ {"required":["tag","note","items"],"properties":{
+ "tag":{"const":"outer"},"note":{"type":"null"},
+ "items":{"type":"array","items":{"oneOf":[
+ {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},
+ {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}
+ ]}}
+ },"additionalProperties":false},
+ {"properties":{"tag":{"const":"other"}},"required":["tag","other"]}
+ ]
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithLocalReferences()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(),
+ """
+ {
+ "items":{"oneOf":[
+ {"$ref":"#/$defs/zero","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"additionalProperties":false},
+ {"$ref":"#/$defs/one","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"additionalProperties":false}
+ ]},
+ "$defs":{"zero":{"required":["tag","note0"]},"one":{"required":["tag","note1"]}}
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData("https://example.test/root")]
+ [InlineData("urn:example:root")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesAtEscapedSchemaPaths(string id)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new Dictionary
+ {
+ ["entry/%2F#~"] = Enumerable.Range(0, 5).Select(_ => new IndependentUnionOutput()).ToArray(),
+ },
+ $$$"""
+ {
+ "$id":"{{{id}}}",
+ "properties":{"entry/%2F#~":{"items":{"oneOf":[
+ {"properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},
+ {"properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}
+ ]} } }
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData(129)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ComposesBranchesWithOmittedDescendants(int count)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ Enumerable.Range(0, count).Select(_ => new NestedIndependentUnionOutput()).ToArray(),
+ """
+ {
+ "items":{"oneOf":[
+ {"properties":{"tag":{"const":"0"},"child0":{"required":["note"]}},"required":["tag","child0"],"additionalProperties":false},
+ {"properties":{"tag":{"const":"1"},"child1":{"required":["note"]}},"required":["tag","child1"],"additionalProperties":false}
+ ]}
+ }
+ """,
+ CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData("expando")]
+ [InlineData("generic-string")]
+ [InlineData("generic-integer")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericOnlyDictionaryValue(string kind)
+ {
+ object output;
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ if (kind == "expando")
+ {
+ ExpandoObject entries = new();
+ ((IDictionary)entries)["first"] = new StructDictionaryChild { Count = 5 };
+ ((IDictionary)entries)["entry/~"] = new StructDictionaryChild();
+ output = entries;
+ }
+ else if (kind == "generic-string")
+ {
+ output = new GenericOnlyDictionary(
+ new Dictionary { ["first"] = new() { Count = 5 }, ["entry/~"] = default });
+ options.DictionaryKeyPolicy = JsonNamingPolicy.SnakeCaseUpper;
+ }
+ else
+ {
+ output = new GenericOnlyDictionary(
+ new Dictionary { [1] = new() { Count = 5 }, [42] = default });
+ options.Converters.Add(new FormattedIntegerKeyConverter());
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ output, """{"type":"object","additionalProperties":{"type":"object","required":["count"],"properties":{"count":{"type":"integer"}}}}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal([0, 5], result.EnumerateObject().Select(property => property.Value.GetProperty("count").GetInt32()).OrderBy(value => value));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInvokeExcludedGetter()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.IgnoreReadOnlyProperties = true;
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ExcludedThrowingGetterOutput(),
+ """{"type":"object","anyOf":[{"required":["count"]},{"required":["note"]}]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.False(result.TryGetProperty("count", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RepairsIndependentArrayUnions()
+ {
+ const string schema = """{"type":"array","items":{"oneOf":[{"type":"object","properties":{"tag":{"const":"0"},"note0":{"type":"null"}},"required":["tag","note0"],"additionalProperties":false},{"type":"object","properties":{"tag":{"const":"1"},"note1":{"type":"null"}},"required":["tag","note1"],"additionalProperties":false}]}}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(Enumerable.Range(0, 5).Select(_ => new ManyBranchStructuredOutput { Tag = "0" }).ToArray(), schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note0").ValueKind));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExpandoMember()
+ {
+ var output = new System.Dynamic.ExpandoObject();
+ ((IDictionary)output)["child"] = new ExplicitSchemaOutputWithMemberIgnore { Id = "42" };
+ McpServerTool tool = CreateStructuredOutputTestTool(output, """{"type":"object","properties":{"child":{"type":"object","required":["ignoredNote"],"properties":{"ignoredNote":{"type":"null"}}}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("child").GetProperty("ignoredNote").ValueKind);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversExplicitlyIncludedReadOnlyMember(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.IgnoreReadOnlyProperties = true;
+ if (sourceGenerated)
+ {
+ options.TypeInfoResolver = JsonContext2.Default;
+ }
+
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new ExplicitlyIncludedReadOnlyOutput(), """{"required":["count"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(0, result.GetProperty("count").GetInt32());
+ }
+
+ [Theory]
+ [InlineData("entry")]
+ [InlineData("https://example.test/entry")]
+ [InlineData("entry#value")]
+ [InlineData("entry#/$defs/a~1b~0c")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResourceReferences(string reference)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DefaultRequestIdOutput(),
+ $$$"""
+ {
+ "$id":"https://example.test/root",
+ "$defs":{"entry":{
+ "$id":"entry","$anchor":"value","required":["id"],
+ "$defs":{"a/b~c":{"required":["id"]}}
+ }},
+ "$ref":"{{{reference}}}"
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind);
+ }
+
+ [Theory]
+ [InlineData("entry")]
+ [InlineData("entry#value")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesResourceInPropertyNames(string reference)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DefaultRequestIdOutput(),
+ $$$"""
+ {
+ "$id":"https://example.test/root",
+ "propertyNames":{"$id":"entry","$anchor":"value","required":["id"]},
+ "$ref":"{{{reference}}}"
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind);
+ }
+
+ [Theory]
+ [InlineData("nested/entry")]
+ [InlineData("#/$defs/entry/$defs/shape")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesReferencesInEmbeddedBaseUri(string reference)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new DefaultRequestIdOutput(),
+ $$$"""
+ {
+ "$id":"https://example.test/base/root",
+ "$defs":{
+ "entry":{"$id":"nested/entry","$defs":{"shape":{"$ref":"../shared"}},"$ref":"#/$defs/shape"},
+ "shared":{"$id":"shared","required":["id"]}
+ },
+ "$ref":"{{{reference}}}"
+ }
+ """);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("id").ValueKind);
+ }
+
+ [Theory]
+ [InlineData("""{"required":"note"}""")]
+ [InlineData("""{"$ref":"#/$defs/missing"}""")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenSchemaCannotBeEvaluated(string schema)
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(new DefaultRequestIdOutput(), schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("id", out _));
+ }
+
+ private sealed class IndependentUnionOutput
+ {
+ public string Tag { get; init; } = "0";
+ public string? Note0 { get; init; }
+ public string? Note1 { get; init; }
+ }
+
+ private sealed class NestedIndependentUnionOutput
+ {
+ public string Tag { get; init; } = "0";
+ public NestedDefaultedStructuredChild Child0 { get; init; }
+ public NestedDefaultedStructuredChild Child1 { get; init; }
+ }
+
+ private sealed class ExcludedThrowingGetterOutput
+ {
+ public int Count => throw new InvalidOperationException("Excluded getter was invoked.");
+ public string? Note { get; init; }
+ }
+
+ private sealed class ExplicitlyIncludedReadOnlyOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count => 0;
+ }
+
+ private sealed class GenericOnlyDictionary(IReadOnlyDictionary entries) : IReadOnlyDictionary
+ {
+ public TValue this[TKey key] => entries[key];
+ public IEnumerable Keys => entries.Keys;
+ public IEnumerable Values => entries.Values;
+ public int Count => entries.Count;
+ public bool ContainsKey(TKey key) => entries.ContainsKey(key);
+ public bool TryGetValue(TKey key, out TValue value) => entries.TryGetValue(key, out value!);
+ public IEnumerator> GetEnumerator() => entries.GetEnumerator();
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotReadIgnoredGetter(bool noAttributeProvider)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.IgnoreReadOnlyProperties = true;
+ if (noAttributeProvider)
+ {
+ var resolver = new DefaultJsonTypeInfoResolver();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(ThrowingGetterOutput))
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ }
+ }
+ });
+ options.TypeInfoResolver = resolver;
+ }
+ McpServerTool tool = CreateStructuredOutputTestTool(new ThrowingGetterOutput(), """{"anyOf":[{"required":["count"]},{"required":["note"],"properties":{"note":{"type":"null"}}}]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_ResolvesEmbeddedResource()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(new ExplicitSchemaOutput { Id = "42" }, """{"$id":"https://example.test/root","default":{"$id":"entry"},"$defs":{"entry":{"$id":"entry","type":"object","required":["note"],"properties":{"note":{"type":"null"}}}},"$ref":"entry"}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ }
+
+ private sealed class ThrowingGetterOutput
+ {
+ public int Count => throw new InvalidOperationException("Excluded getter was invoked");
+ public string? Note { get; init; }
+ }
+
+ [Theory]
+ [InlineData("property", false)]
+ [InlineData("list", false)]
+ [InlineData("dictionary", false)]
+ [InlineData("property", true)]
+ [InlineData("list", true)]
+ [InlineData("dictionary", true)]
+ [InlineData("object", false)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesActiveSerializationContract(string shape, bool polymorphic)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ DefaultJsonTypeInfoResolver resolver = new();
+ resolver.Modifiers.Add(typeInfo =>
+ {
+ if (typeInfo.Type == typeof(RecoveryBase))
+ {
+ typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false;
+ if (polymorphic)
+ {
+ typeInfo.PolymorphismOptions = new()
+ {
+ DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") },
+ };
+ }
+ }
+ });
+ options.TypeInfoResolver = resolver;
+ RecoveryDerived child = new();
+ object output = shape switch
+ {
+ "list" => new List { child },
+ "dictionary" => new Dictionary { ["child"] = child },
+ "object" => new { Child = (object)child },
+ _ => new RecoveryContainer { Child = child },
+ };
+ string schema = shape == "list"
+ ? """{"items":{"required":["flag"]}}"""
+ : """{"properties":{"child":{"required":["flag"]}}}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema, options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ JsonElement entry = shape == "list" ? result[0] : result.GetProperty("child");
+ Assert.Equal(polymorphic || shape == "object", entry.TryGetProperty("flag", out _));
+ if (polymorphic || shape == "object")
+ {
+ Assert.False(entry.GetProperty("flag").GetBoolean());
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotSerializeUnrelatedOmittedMembers()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new OptionalThrowingConverterOutput(),
+ """{"required":["note"],"properties":{"note":{"type":"null"}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.False(result.TryGetProperty("optional", out _));
+ }
+
+ [Theory]
+ [InlineData("argument")]
+ [InlineData("format")]
+ [InlineData("application")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesOutputWhenRequiredNullConverterThrows(string failure)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = failure });
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("note", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesConverterCancellation()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.Converters.Insert(0, new RecoveryRejectNullConverter { Failure = "cancel" });
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new OptionalThrowingConverterOutput(), """{"required":["note"]}""", options);
+ await Assert.ThrowsAnyAsync(() => InvokeStructuredOutputTestTool(tool));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesNullableAnnotationPolicy()
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.RespectNullableAnnotations = true;
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryNonNullableOutput(), """{"required":["note"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("note", out _));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_PreservesContainingNumberHandling(bool propertyOverride)
+ {
+ object output = propertyOverride ? new RecoveryStrictNumberOutput() : new RecoveryStringNumberOutput();
+ string schema = propertyOverride
+ ? """{"required":["count"],"properties":{"count":{"const":0}}}"""
+ : """{"required":["count"],"properties":{"count":{"const":"0"}}}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotInheritNumberHandlingIntoNestedObjects()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryNestedNumberOutput(),
+ """{"required":["child"],"properties":{"child":{"properties":{"count":{"const":0}}}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotOverrideCustomConverterNumberHandling(bool propertyConverter)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ options.NumberHandling = JsonNumberHandling.Strict;
+ if (!propertyConverter)
+ {
+ options.Converters.Insert(0, new RecoveryNumberPolicyConverter());
+ }
+
+ object output = propertyConverter ? new RecoveryCustomNumberOutput() : new RecoveryStringNumberOutput();
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ output, """{"required":["count"],"properties":{"count":{"const":true}}}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversFlattenedExtensionData()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryExtensionOutput { Data = new() { ["child/~"] = new NestedDefaultedStructuredChild() } },
+ """{"required":["child/~"],"properties":{"child/~":{"required":["note"]}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("child/~").GetProperty("note").ValueKind);
+ Assert.False(result.TryGetProperty("data", out _));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRepeatedlyEnumerateSource(bool indexed)
+ {
+ const int count = 256;
+ RecoveryCountingCollection source = indexed ? new RecoveryCountingList(count) : new(count);
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ source, """{"items":{"required":["note"]}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(count, result.GetArrayLength());
+ Assert.All(result.EnumerateArray(), item => Assert.Equal(JsonValueKind.Null, item.GetProperty("note").ValueKind));
+ Assert.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items.");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversGenericListElements(bool mutable)
+ {
+ IEnumerable source = mutable ? new GenericMutableRecoveryList() : new GenericRecoveryList();
+ McpServerTool tool = CreateStructuredOutputTestTool(source,
+ """{"items":{"required":["count"]}}""", CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(0, result[0].GetProperty("count").GetInt32());
+ Assert.Equal(5, result[1].GetProperty("count").GetInt32());
+ }
+
+ [Theory]
+ [InlineData("linked", false)]
+ [InlineData("linked", true)]
+ [InlineData("queue", false)]
+ [InlineData("queue", true)]
+ [InlineData("set", false)]
+ [InlineData("set", true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_NonIndexedCollectionsRequireMaterialization(
+ string shape, bool materialize)
+ {
+ ExplicitSchemaOutput[] items = [new() { Id = "42" }];
+ IEnumerable collection = shape switch
+ {
+ "linked" => new LinkedList(items),
+ "queue" => new Queue(items),
+ _ => new HashSet(items),
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ materialize ? collection.ToArray() : collection,
+ """{"type":"array","items":{"required":["id","note"]}}""");
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("42", result[0].GetProperty("id").GetString());
+ Assert.Equal(materialize, result[0].TryGetProperty("note", out JsonElement note));
+ if (materialize)
+ {
+ Assert.Equal(JsonValueKind.Null, note.ValueKind);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+ }
+
+ private sealed class GenericMutableRecoveryList : IList
+ {
+ private readonly List _items =
+ [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }];
+ ExplicitSchemaOutputWithDefaultValue IList.this[int index] { get => _items[index]; set => throw new NotSupportedException(); }
+ public int Count => _items.Count;
+ public bool IsReadOnly => true;
+ public IEnumerator GetEnumerator() => _items.GetEnumerator();
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ public int IndexOf(ExplicitSchemaOutputWithDefaultValue item) => _items.IndexOf(item);
+ public bool Contains(ExplicitSchemaOutputWithDefaultValue item) => _items.Contains(item);
+ public void CopyTo(ExplicitSchemaOutputWithDefaultValue[] array, int arrayIndex) => _items.CopyTo(array, arrayIndex);
+ public void Add(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException();
+ public void Clear() => throw new NotSupportedException();
+ public void Insert(int index, ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException();
+ public bool Remove(ExplicitSchemaOutputWithDefaultValue item) => throw new NotSupportedException();
+ public void RemoveAt(int index) => throw new NotSupportedException();
+ }
+
+ private sealed class GenericRecoveryList : IReadOnlyList
+ {
+ private readonly ExplicitSchemaOutputWithDefaultValue[] _items =
+ [new() { Id = "a", Count = 0 }, new() { Id = "b", Count = 5 }];
+ public int Count => _items.Length;
+ public ExplicitSchemaOutputWithDefaultValue this[int index] => _items[index];
+ public IEnumerator GetEnumerator() => ((IEnumerable)_items).GetEnumerator();
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverReorderedEnumerable()
+ {
+ ReorderedRecoveryEnumerable source = new();
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ McpServerTool tool = CreateStructuredOutputTestTool(source,
+ """{"items":{"required":["count"]}}""", options);
+
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+
+ Assert.Equal("a", result[0].GetProperty("id").GetString());
+ Assert.False(result[0].TryGetProperty("count", out _));
+ Assert.Equal("b", result[1].GetProperty("id").GetString());
+ Assert.Equal(5, result[1].GetProperty("count").GetInt32());
+ Assert.Equal(2, source.EnumerationCount);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversConditionalMetadata(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.Never);
+ options.IgnoreReadOnlyProperties = true;
+ options.TypeInfoResolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver();
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryConditionalOutput(), """{"required":["note","count","readOnlyCount"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ Assert.Equal(JsonValueKind.Null, result.GetProperty("note").ValueKind);
+ Assert.Equal(0, result.GetProperty("count").GetInt32());
+ Assert.Equal(0, result.GetProperty("readOnlyCount").GetInt32());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverWriteExcludedMetadata(bool sourceGenerated)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions();
+ IJsonTypeInfoResolver resolver = sourceGenerated ? JsonContext2.Default : new DefaultJsonTypeInfoResolver();
+ options.TypeInfoResolver = resolver.WithAddedModifier(typeInfo =>
+ {
+ foreach (JsonPropertyInfo property in typeInfo.Properties)
+ {
+ property.AttributeProvider = null;
+ }
+ });
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryConditionalOutput(), """{"required":["excluded"]}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.TryGetProperty("excluded", out _));
+ }
+
+ [Fact]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotRecoverOutsideDeclaredInterface()
+ {
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new { Child = (IRecoveryContract)new RecoveryDerived() },
+ """{"properties":{"child":{"required":["flag"]}}}""",
+ CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault));
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.GetProperty("child").TryGetProperty("flag", out _));
+ }
+
+ [Theory]
+ [InlineData("property")]
+ [InlineData("list")]
+ [InlineData("dictionary")]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_RecoversWithinNullableStructContract(string shape)
+ {
+ NestedDefaultedStructuredChild? child = new NestedDefaultedStructuredChild();
+ object output = shape switch
+ {
+ "list" => new List { child },
+ "dictionary" => new Dictionary { ["child"] = child },
+ _ => new { Child = child },
+ };
+ string schema = shape == "list"
+ ? """{"items":{"required":["note"]}}"""
+ : """{"properties":{"child":{"required":["note"]}}}""";
+ McpServerTool tool = CreateStructuredOutputTestTool(output, schema);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesSerializerDefaultComparison(bool globalCondition)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(
+ globalCondition ? JsonIgnoreCondition.WhenWritingDefault : JsonIgnoreCondition.WhenWritingNull);
+ object output = globalCondition ? new RecoveryGlobalEquatableOutput() : new RecoveryEquatableOutput();
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ output, """{"required":["child"],"properties":{"child":{"required":["note"]}}}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+
+ [Theory]
+ [InlineData(JsonUnknownDerivedTypeHandling.FallBackToBaseType, false)]
+ [InlineData(JsonUnknownDerivedTypeHandling.FallBackToNearestAncestor, true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_UsesPolymorphicFallbackContract(
+ JsonUnknownDerivedTypeHandling fallback, bool expectedFlag)
+ {
+ JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault);
+ options.TypeInfoResolver = new DefaultJsonTypeInfoResolver
+ {
+ Modifiers =
+ {
+ typeInfo =>
+ {
+ if (typeInfo.Type == typeof(RecoveryBase))
+ {
+ typeInfo.Properties.Single(property => property.Name == "flag").ShouldSerialize = static (_, _) => false;
+ typeInfo.PolymorphismOptions = new()
+ {
+ UnknownDerivedTypeHandling = fallback,
+ DerivedTypes = { new JsonDerivedType(typeof(RecoveryDerived), "derived") },
+ };
+ }
+ },
+ },
+ };
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ new RecoveryContainer { Child = new RecoveryGrandchild() },
+ """{"properties":{"child":{"required":["flag"]}}}""", options);
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.Equal(expectedFlag, result.GetProperty("child").TryGetProperty("flag", out _));
+ if (expectedFlag)
+ {
+ AssertMatchesJsonSchema(tool.ProtocolTool.OutputSchema!.Value, result);
+ }
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StructuredOutput_Enabled_ExplicitSchema_DoesNotTraverseOpaqueConverter(bool propertyConverter)
+ {
+ object output = propertyConverter
+ ? new RecoveryOpaquePropertyOutput()
+ : new { Child = new RecoveryOpaqueTypeOutput() };
+ McpServerTool tool = CreateStructuredOutputTestTool(
+ output, """{"properties":{"child":{"required":["flag"]}}}""");
+ JsonElement result = await InvokeStructuredOutputTestTool(tool);
+ Assert.False(result.GetProperty("child").TryGetProperty("flag", out _));
+ Assert.Equal("visible", result.GetProperty("child").GetProperty("id").GetString());
+ }
+
+ private interface IRecoveryContract;
+
+ private class RecoveryBase
+ {
+ public bool Flag { get; init; }
+ }
+
+ private class RecoveryDerived : RecoveryBase, IRecoveryContract;
+
+ private sealed class RecoveryGrandchild : RecoveryDerived;
+
+ private sealed class RecoveryContainer
+ {
+ public RecoveryBase Child { get; init; } = new RecoveryDerived();
+ }
+
+ private sealed class OptionalThrowingConverterOutput
+ {
+ public string? Note { get; init; }
+
+ [JsonConverter(typeof(RecoveryRejectNullConverter))]
+ public string? Optional { get; init; }
+ }
+
+ private sealed class RecoveryNonNullableOutput
+ {
+ public string Note { get; init; } = null!;
+ }
+
+ private sealed class RecoveryRejectNullConverter : JsonConverter
+ {
+ public string Failure { get; init; } = "argument";
+ public override bool HandleNull => true;
+ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options)
+ {
+ if (value is not null)
+ {
+ writer.WriteStringValue(value);
+ return;
+ }
+
+ throw Failure switch
+ {
+ "format" => new FormatException("Null is not supported."),
+ "application" => new ApplicationException("Null is not supported."),
+ "cancel" => new OperationCanceledException(),
+ _ => new ArgumentNullException(nameof(value)),
+ };
+ }
+ }
+
+ [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+ private sealed class RecoveryStringNumberOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+ }
+
+ [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+ private sealed class RecoveryStrictNumberOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonNumberHandling(JsonNumberHandling.Strict)]
+ public int Count { get; init; }
+ }
+
+ [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+ private sealed class RecoveryNestedNumberOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RecoveryNumberChild Child { get; init; }
+ }
+
+ private struct RecoveryNumberChild
+ {
+ public int Count { get; init; }
+ }
+
+ [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+ private sealed class RecoveryCustomNumberOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonConverter(typeof(RecoveryNumberPolicyConverter))]
+ public int Count { get; init; }
+ }
+
+ private sealed class RecoveryNumberPolicyConverter : JsonConverter
+ {
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) =>
+ writer.WriteBooleanValue(options.NumberHandling == JsonNumberHandling.Strict);
+ }
+
+ private sealed class RecoveryExtensionOutput
+ {
+ [JsonExtensionData]
+ public Dictionary Data { get; init; } = [];
+ }
+
+ private sealed class RecoveryConditionalOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int ReadOnlyCount => 0;
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWriting)]
+ public string? Excluded { get; init; }
+ }
+
+ private sealed class RecoveryEquatableOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RecoveryEquatableChild Child { get; init; } = new(1);
+ }
+
+ private sealed class RecoveryGlobalEquatableOutput
+ {
+ public RecoveryEquatableChild Child { get; init; } = new(1);
+ }
+
+ private readonly struct RecoveryEquatableChild(int ignored) : IEquatable
+ {
+ [JsonIgnore]
+ public int Ignored { get; } = ignored;
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+
+ public bool Equals(RecoveryEquatableChild other) => Note == other.Note;
+ }
+
+ private class RecoveryOpaqueChild
+ {
+ public bool Flag => throw new InvalidOperationException("The converter hides this getter.");
+ }
+
+ private sealed class RecoveryOpaquePropertyOutput
+ {
+ [JsonConverter(typeof(RecoveryOpaqueConverter))]
+ public RecoveryOpaqueChild Child { get; init; } = new();
+ }
+
+ [JsonConverter(typeof(RecoveryOpaqueConverter))]
+ private sealed class RecoveryOpaqueTypeOutput : RecoveryOpaqueChild;
+
+ private sealed class RecoveryOpaqueConverter : JsonConverter
+ {
+ public override bool CanConvert(Type typeToConvert) => typeof(RecoveryOpaqueChild).IsAssignableFrom(typeToConvert);
+ public override RecoveryOpaqueChild Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+ public override void Write(Utf8JsonWriter writer, RecoveryOpaqueChild value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("id", "visible");
+ writer.WriteEndObject();
+ }
+ }
+
+ private sealed class ReorderedRecoveryEnumerable : IEnumerable
+ {
+ public int EnumerationCount { get; private set; }
+
+ public IEnumerator GetEnumerator()
+ {
+ EnumerationCount++;
+ var first = new ExplicitSchemaOutputWithDefaultValue { Id = "a", Count = 0 };
+ var second = new ExplicitSchemaOutputWithDefaultValue { Id = "b", Count = 5 };
+ return (EnumerationCount == 1 ? new[] { first, second } : new[] { second, first }).AsEnumerable().GetEnumerator();
+ }
+
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ private class RecoveryCountingCollection(int count) : IReadOnlyList
+ {
+ public int EnumeratedItems { get; private set; }
+ public int Count => count;
+ public NestedDefaultedStructuredChild this[int index] => default;
+ public IEnumerator GetEnumerator()
+ {
+ for (int i = 0; i < count; i++)
+ {
+ EnumeratedItems++;
+ yield return default;
+ }
+ }
+ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+
+ private sealed class RecoveryCountingList(int count) : RecoveryCountingCollection(count), System.Collections.IList
+ {
+ object? System.Collections.IList.this[int index] { get => this[index]; set => throw new NotSupportedException(); }
+ public bool IsReadOnly => true;
+ public bool IsFixedSize => true;
+ public bool IsSynchronized => false;
+ public object SyncRoot => this;
+ public int Add(object? value) => throw new NotSupportedException();
+ public void Clear() => throw new NotSupportedException();
+ public bool Contains(object? value) => throw new NotSupportedException();
+ public int IndexOf(object? value) => throw new NotSupportedException();
+ public void Insert(int index, object? value) => throw new NotSupportedException();
+ public void Remove(object? value) => throw new NotSupportedException();
+ public void RemoveAt(int index) => throw new NotSupportedException();
+ public void CopyTo(Array array, int index) => throw new NotSupportedException();
+ }
+
+ private static JsonSerializerOptions CreateStructuredOutputTestOptions(JsonIgnoreCondition condition = JsonIgnoreCondition.WhenWritingNull) =>
+ new(McpJsonUtilities.DefaultOptions)
+ {
+ DefaultIgnoreCondition = condition,
+ TypeInfoResolver = JsonTypeInfoResolver.Combine(McpJsonUtilities.DefaultOptions.TypeInfoResolver, new DefaultJsonTypeInfoResolver()),
+ };
+
+ private static McpServerTool CreateStructuredOutputTestTool(T value, string schema, JsonSerializerOptions? options = null) =>
+ McpServerTool.Create(() => value, new()
+ {
+ Name = "tool",
+ UseStructuredContent = true,
+ OutputSchema = JsonDocument.Parse(schema).RootElement,
+ SerializerOptions = options ?? CreateStructuredOutputTestOptions(),
+ });
+
+ private static async Task InvokeStructuredOutputTestTool(McpServerTool tool)
+ {
+ var result = await tool.InvokeAsync(CreateRequestContextWithProtocolVersion(Sep2106ProtocolVersion), TestContext.Current.CancellationToken);
+ Assert.NotEqual(true, result.IsError);
+ Assert.NotNull(result.StructuredContent);
+ return result.StructuredContent.Value;
+ }
+
[Fact]
public async Task StructuredOutput_Enabled_VoidReturningTools_ReturnsExpectedSchema()
{
@@ -1103,7 +3599,7 @@ public object InstanceMethod()
private static void AssertMatchesJsonSchema(JsonElement schemaDoc, JsonElement? value)
{
- JsonSchema schema = JsonSerializer.Deserialize(schemaDoc, JsonContext2.Default.JsonSchema)!;
+ JsonSchema schema = JsonSchema.Build(schemaDoc, new BuildOptions { SchemaRegistry = new SchemaRegistry() });
EvaluationOptions options = new() { OutputFormat = OutputFormat.List };
EvaluationResults results = schema.Evaluate(value!.Value, options);
if (!results.IsValid)
@@ -1126,6 +3622,464 @@ Instance JSON document does not match the specified schema.
record Person(string Name, int Age);
+ private sealed record RequiredNullableOutput(string Id, string? Note);
+
+ private sealed class RequiredNullablePropertiesOutput
+ {
+ public required string Id { get; init; }
+
+ public required string? Note { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutput
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithMemberIgnore
+ {
+ public required string Id { get; init; }
+
+ public string? DefaultedNote { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? IgnoredNote { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithDefaultValue
+ {
+ public required string Id { get; init; }
+
+ public int Count { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithMemberDefault
+ {
+ public required string Id { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+ }
+
+ private sealed class DefaultRequestIdOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public RequestId Id { get; init; }
+ }
+
+ private sealed class DynamicReferenceOutput
+ {
+ public ExplicitSchemaOutput? Child { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class FormattedIntegerKeyConverter : JsonConverter
+ {
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ reader.GetInt32();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) =>
+ writer.WriteNumberValue(value);
+
+ public override int ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ int.Parse(reader.GetString()!.Substring(4), System.Globalization.CultureInfo.InvariantCulture);
+
+ public override void WriteAsPropertyName(Utf8JsonWriter writer, int value, JsonSerializerOptions options) =>
+ writer.WritePropertyName("key-" + value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ private sealed class DefaultJsonElementOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonConverter(typeof(DefaultJsonElementConverter))]
+ public JsonElement Value { get; init; }
+ }
+
+ private sealed class DefaultJsonElementConverter : JsonConverter
+ {
+ public override JsonElement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, JsonElement value, JsonSerializerOptions options) =>
+ writer.WriteStringValue("converted");
+ }
+
+ private sealed class RejectedNullOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ [JsonConverter(typeof(RejectedNullConverter))]
+ public string? Note { get; init; }
+ }
+
+ private sealed class RejectedNullConverter : JsonConverter
+ {
+ public override bool HandleNull => true;
+
+ public override string Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) =>
+ throw new JsonException("Null cannot be serialized.");
+ }
+
+ private sealed class ReadOnlyPropertyOutput
+ {
+ public int Count => 0;
+ }
+
+ private sealed class ReadOnlyFieldOutput
+ {
+ public readonly int Count = 0;
+ }
+
+ private sealed class ReadOnlyRecoveryProperty
+ {
+ public string? Note => null;
+ public int Count => 0;
+ }
+
+ private sealed class ReadOnlyRecoveryField
+ {
+ public readonly string? Note = null;
+ public readonly int Count = 0;
+ }
+
+ private sealed class ReadOnlyConvertedCollectionOutput
+ {
+ [JsonConverter(typeof(ReadOnlyRecoveryListConverter))]
+ public List Values => throw new InvalidOperationException("Excluded getter was invoked.");
+ public string? Note { get; set; }
+ }
+
+ private sealed class ReadOnlyRecoveryCollectionProperty
+ {
+ public List Children { get; } = [new() { Id = "42" }];
+ }
+
+ private sealed class ReadOnlyRecoveryCollectionField
+ {
+ public readonly List Children = [new() { Id = "42" }];
+ }
+
+ private sealed class ReadOnlyRecoveryListConverter : JsonConverter>
+ {
+ public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) =>
+ throw new InvalidOperationException("Excluded converter was invoked.");
+ }
+
+ [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, IncludeFields = true)]
+ [JsonSerializable(typeof(ReadOnlyRecoveryProperty))]
+ [JsonSerializable(typeof(ReadOnlyRecoveryField))]
+ [JsonSerializable(typeof(ReadOnlyConvertedCollectionOutput))]
+ [JsonSerializable(typeof(ReadOnlyRecoveryCollectionProperty))]
+ [JsonSerializable(typeof(ReadOnlyRecoveryCollectionField))]
+ private partial class ReadOnlyRecoveryJsonContext : JsonSerializerContext;
+
+ private sealed class ManyNestedDefaultOutputs
+ {
+ public NestedDefaultedStructuredChild Child0 { get; init; }
+ public NestedDefaultedStructuredChild Child1 { get; init; }
+ public NestedDefaultedStructuredChild Child2 { get; init; }
+ public NestedDefaultedStructuredChild Child3 { get; init; }
+ public NestedDefaultedStructuredChild Child4 { get; init; }
+ public NestedDefaultedStructuredChild Child5 { get; init; }
+ public NestedDefaultedStructuredChild Child6 { get; init; }
+ public NestedDefaultedStructuredChild Child7 { get; init; }
+ public NestedDefaultedStructuredChild Child8 { get; init; }
+ public NestedDefaultedStructuredChild Child9 { get; init; }
+ public NestedDefaultedStructuredChild Child10 { get; init; }
+ public NestedDefaultedStructuredChild Child11 { get; init; }
+ public NestedDefaultedStructuredChild Child12 { get; init; }
+ public NestedDefaultedStructuredChild Child13 { get; init; }
+ public NestedDefaultedStructuredChild Child14 { get; init; }
+ public NestedDefaultedStructuredChild Child15 { get; init; }
+ public NestedDefaultedStructuredChild Child16 { get; init; }
+ public NestedDefaultedStructuredChild Child17 { get; init; }
+ public NestedDefaultedStructuredChild Child18 { get; init; }
+ public NestedDefaultedStructuredChild Child19 { get; init; }
+ public NestedDefaultedStructuredChild Child20 { get; init; }
+ public NestedDefaultedStructuredChild Child21 { get; init; }
+ public NestedDefaultedStructuredChild Child22 { get; init; }
+ public NestedDefaultedStructuredChild Child23 { get; init; }
+ }
+
+ private sealed class ManyBranchStructuredOutput
+ {
+ public required string Tag { get; init; }
+ public string? Note0 { get; init; }
+ public string? Note1 { get; init; }
+ public string? Note2 { get; init; }
+ public string? Note3 { get; init; }
+ public string? Note4 { get; init; }
+ public string? Note5 { get; init; }
+ public string? Note6 { get; init; }
+ public string? Note7 { get; init; }
+ public string? Note8 { get; init; }
+ public string? Note9 { get; init; }
+ public string? Note10 { get; init; }
+ public string? Note11 { get; init; }
+ public string? Note12 { get; init; }
+ public string? Note13 { get; init; }
+ public string? Note14 { get; init; }
+ public string? Note15 { get; init; }
+ public string? Note16 { get; init; }
+ public string? Note17 { get; init; }
+ public string? Note18 { get; init; }
+ public string? Note19 { get; init; }
+ public string? Note20 { get; init; }
+ public string? Note21 { get; init; }
+ public string? Note22 { get; init; }
+ public string? Note23 { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithMemberConverter
+ {
+ public required string Id { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonConverter(typeof(IntAsStringJsonConverter))]
+ public int Count { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithUndefinedEnumDefault
+ {
+ public string? Note { get; init; }
+
+ public ExplicitSchemaStatus Status { get; init; }
+ }
+
+ private sealed class StructDictionaryOutput
+ {
+ public required Dictionary Items { get; init; }
+ }
+
+ private struct StructDictionaryChild
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; set; }
+
+ public ExplicitSchemaStatus Status { get; set; }
+ }
+
+ private enum ExplicitSchemaStatus
+ {
+ Ready = 1,
+ }
+
+ private sealed class IntAsStringJsonConverter : JsonConverter
+ {
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.GetInt32();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) =>
+ writer.WriteStringValue(value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ }
+
+ private sealed class AnyOfStructuredOutput
+ {
+ public int Count { get; init; }
+
+ public required AnyOfStructuredChild Child { get; init; }
+ }
+
+ private sealed class AnyOfStructuredChild
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class LargeIntegerStructuredOutput
+ {
+ public ulong Value { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class MathematicalIntegerStructuredOutput
+ {
+ public JsonElement Value { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class StructuralConstOutput
+ {
+ public required JsonObject Tag { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class NumericConstOutput
+ {
+ public int Count { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class ExplicitSchemaFilteredOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public required string Secret { get; init; }
+ }
+
+ private sealed class ExplicitSchemaFilteredDefaultOutput
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int? NullableCount { get; init; } = 0;
+ }
+
+ private sealed class GetterCountedStructuredOutput(Action onGet)
+ {
+ public string Id
+ {
+ get
+ {
+ onGet();
+ return "42";
+ }
+ }
+ }
+
+ private sealed class GetterCountedCollectionStructuredOutput(List items)
+ {
+ public int ItemsGetterCalls { get; private set; }
+
+ public List Items
+ {
+ get
+ {
+ ItemsGetterCalls++;
+ return items;
+ }
+ }
+ }
+
+ private sealed class ExplicitSchemaCollectionItem
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private struct NestedDefaultedStructuredChild
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+ }
+
+ private struct NestedOneOfRecoveryChild
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+ }
+
+ private sealed class NestedOneOfRecoveryOutput
+ {
+ public NestedOneOfRecoveryChild First { get; init; }
+
+ public NestedOneOfRecoveryChild Second { get; init; }
+ }
+
+ private sealed class BranchSpecificOneOfRecoveryOutput
+ {
+ public required string Tag { get; init; }
+
+ public BranchSpecificOneOfRecoveryChild Child { get; init; }
+
+ public string? Other { get; init; }
+ }
+
+ private struct BranchSpecificOneOfRecoveryChild
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public string? Note { get; init; }
+ }
+
+ private sealed class NestedDefaultedStructuredOutput
+ {
+ public NestedDefaultedStructuredChild Child { get; init; }
+ }
+
+ private sealed class ExplicitSchemaPatternChildOutput
+ {
+ public required ExplicitSchemaPatternChild Child { get; init; }
+ }
+
+ private sealed class ExplicitSchemaPatternChild
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class TenBranchStructuredOutput
+ {
+ public required string Tag { get; init; }
+
+ public string? Note0 { get; init; }
+
+ public string? Note1 { get; init; }
+
+ public string? Note2 { get; init; }
+
+ public string? Note3 { get; init; }
+
+ public string? Note4 { get; init; }
+
+ public string? Note5 { get; init; }
+
+ public string? Note6 { get; init; }
+
+ public string? Note7 { get; init; }
+
+ public string? Note8 { get; init; }
+
+ public string? Note9 { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOneOfSourceOnlyOutput
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+ }
+
+ private sealed class IntegerKeyDictionaryOutput
+ {
+ public required Dictionary Items { get; init; }
+ }
+
+ private sealed class IntegerKeyDictionaryChild
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public int Count { get; init; }
+ }
+
+ private sealed class ExplicitSchemaOutputWithNumberHandling
+ {
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ [JsonNumberHandling(JsonNumberHandling.WriteAsString)]
+ public int Count { get; init; }
+ }
+
+ private sealed class RecursiveStructuredOutput
+ {
+ public required string Id { get; init; }
+
+ public string? Note { get; init; }
+
+ public RecursiveStructuredOutput? Next { get; init; }
+ }
+
private static JsonSerializerOptions CreateSerializerOptionsWithPerson()
{
JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);
@@ -1308,6 +4262,9 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo
[JsonSerializable(typeof(DateTimeOffset?))]
[JsonSerializable(typeof(Person))]
[JsonSerializable(typeof(WeatherRequest))]
+ [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))]
+ [JsonSerializable(typeof(RecoveryConditionalOutput))]
+ [JsonSerializable(typeof(IEnumerable))]
partial class JsonContext2 : JsonSerializerContext;
// ===== x-mcp-header tests =====
diff --git a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
index 5a3705c4e..88170c02e 100644
--- a/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
+++ b/tests/ModelContextProtocol.Tests/Transport/StdioClientTransportTests.cs
@@ -13,6 +13,22 @@ public class StdioClientTransportTests(ITestOutputHelper testOutputHelper) : Log
{
public static bool IsStdErrCallbackSupported => !PlatformDetection.IsMonoRuntime;
+ [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
+ public async Task ConnectAsync_ImmediatelyExitedProcesses_CompleteTheirReaders()
+ {
+ await Task.WhenAll(Enumerable.Range(0, 16).Select(async _ =>
+ {
+ StdioClientTransport transport = PlatformDetection.IsWindows ?
+ new(new() { Command = "cmd", Arguments = ["/c", "exit /b 0"] }, LoggerFactory) :
+ new(new() { Command = "sh", Arguments = ["-c", "exit 0"] }, LoggerFactory);
+
+ await using ITransport session = await transport.ConnectAsync(TestContext.Current.CancellationToken);
+ var exception = await Assert.ThrowsAsync(
+ () => session.MessageReader.Completion.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
+ Assert.IsType(exception.Details);
+ }));
+ }
+
[Fact]
public async Task DisposeAsync_ClosesServerStandardInputForGracefulExit()
{
@@ -118,39 +134,23 @@ public async Task CreateAsync_ValidProcessInvalidServer_Throws()
await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
}
- [Fact(Skip= "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
+ [Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]
public async Task CreateAsync_ValidProcessInvalidServer_StdErrCallbackInvoked()
{
string id = Guid.NewGuid().ToString("N");
- int count = 0;
- StringBuilder sb = new();
- Action stdErrCallback = line =>
- {
- Assert.NotNull(line);
- lock (sb)
- {
- sb.AppendLine(line);
- count++;
- }
- };
+ var stderr = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Action stdErrCallback = line => stderr.TrySetResult(line);
StdioClientTransport transport = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
new(new() { Command = "cmd", Arguments = ["/c", $"echo {id} >&2 & exit /b 1"], StandardErrorLines = stdErrCallback }, LoggerFactory) :
new(new() { Command = "sh", Arguments = ["-c", $"echo {id} >&2; exit 1"], StandardErrorLines = stdErrCallback }, LoggerFactory);
- await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken));
-
- // The stderr reading thread may not have delivered the callback yet
- // after the IOException is thrown. Poll briefly for it to arrive.
- var deadline = DateTime.UtcNow + TestConstants.DefaultTimeout;
- while (Volatile.Read(ref count) == 0 && DateTime.UtcNow < deadline)
- {
- await Task.Delay(50, TestContext.Current.CancellationToken);
- }
+ await Assert.ThrowsAnyAsync(() => McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken))
+ .WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
- Assert.InRange(count, 1, int.MaxValue);
- Assert.Contains(id, sb.ToString());
+ Assert.Contains(id, await stderr.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
}
[Fact(Skip = "Platform not supported by this test.", SkipUnless = nameof(IsStdErrCallbackSupported))]