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..d494d711c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,11 +1,18 @@ 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.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 +22,10 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; private readonly bool? _returnsCallToolResult; + private OutputSchemaCache? _outputSchema; + private static readonly ConditionalWeakTable s_sourceContracts = new(); + [ThreadStatic] + private static SourceRecoveryContext? t_serializingSourceMember; /// /// Creates an instance for a method, specified via a instance. @@ -243,7 +254,6 @@ private AIFunctionMcpServerTool( AIFunction = function; ProtocolTool = tool; - _metadata = metadata; _returnsCallToolResult = returnsCallToolResult; } @@ -770,13 +780,26 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string 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; + } + + elementResult = AddMissingRequiredNullableProperties(elementResult, aiFunctionResult); // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided @@ -796,6 +819,1910 @@ private static void RewriteRefProperty(JsonObject schema, string keyword, string return elementResult; } + private JsonElement? AddMissingRequiredNullableProperties( + JsonElement? elementResult, + object? originalResult) + { + if (elementResult is not { } result || + ProtocolTool.OutputSchema is not { } outputSchema || + result.ValueKind is not (JsonValueKind.Object or JsonValueKind.Array)) + { + return elementResult; + } + + JsonNode? resultNode = JsonSerializer.SerializeToNode(result, McpJsonUtilities.JsonContext.Default.JsonElement); + OutputSchemaCache cachedSchema = GetOutputSchema(outputSchema); + JsonNode? schemaNode = cachedSchema.Node; + JsonSchema? schema = cachedSchema.Schema; + if (resultNode is null || schemaNode is null || schema is null) + { + return elementResult; + } + + if (IsSchemaValid(schema, resultNode) || HasDuplicatePropertyNames(result)) + { + return elementResult; + } + + JsonNode? repairedResult = RepairStructuredResult( + resultNode, + schemaNode, + schema, + originalResult is null ? null : new SourceRecoveryContext( + originalResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), + AIFunction.JsonSerializerOptions); + + return repairedResult is null + ? elementResult + : JsonSerializer.SerializeToElement(repairedResult, McpJsonUtilities.JsonContext.Default.JsonNode); + } + + /// + /// 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) + { + // 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; + references ??= new(schemaDocument); + traversalBudget ??= new(); + // 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 = new(); + + JsonSchema? GetBranchSchema(JsonNode branch, List scope) + { + var schemaKey = (branch, references.GetScopeKey(scope)); + if (!branchSchemas.TryGetValue(schemaKey, out JsonSchema? branchSchema)) + { + 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 }, + }; + 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 || + IsSchemaValid(conditionSchema, ProjectConditionInstance(conditionInstance, condition.Schema)) != condition.Expected) + { + return false; + } + } + + return true; + } + + bool IsRepairValid(JsonNode instance) => + IsSchemaValid(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 = []; + CollectSchemaContexts( + instance, schemaNode, references.GetResourceRoot(schemaNode), string.Empty, contexts, + new SchemaTraversal(traversalBudget), groups, references, dynamicScope); + if (inheritedConditions is { Count: > 0 }) + { + groups = groups.Select(group => group with + { + Conditions = [.. inheritedConditions, .. group.Conditions], + }).ToList(); + } + + List candidates = CreateRepairCandidates( + instance, originalResult, serializerOptions, contexts); + if (candidates.Count == 0) + { + return null; + } + + 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(); + JsonNode combinedResult = instance.DeepClone(); + bool hasCombinedRepairs = false; + foreach (RepairCandidate candidate in ungrouped) + { + ApplyRepairCandidate(combinedResult, candidate); + hasCombinedRepairs = true; + } + + foreach (RepairCandidateGroup group in groups) + { + 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) + { + 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(); + JsonNode branchResult = currentBranch.DeepClone(); + foreach (RepairCandidate candidate in branchCandidates) + { + ApplyRepairCandidate(branchResult, candidate with { ParentPath = candidate.ParentPath.Substring(group.InstancePath.Length) }); + } + + bool branchIsValid = IsSchemaValid(branchSchema, branchResult); + if (!branchIsValid && (branchCandidates.Count > 0 || !includeDescendants) && depth + 1 < remainingDepth) + { + 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()) 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 && + IsSchemaValid(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; + 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; + } + + List branchCandidates = [.. ungrouped]; + foreach (string key in group.Keys) + { + 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++) + { + 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++) + { + fallback.Enqueue(new(instance, [candidates[i], candidates[j]], depth + 1)); + } + } + + return null; + } + + if (EnqueueRepairs(resultNode, 0) is JsonNode composedResult) + { + return composedResult; + } + for (int evaluations = 0; evaluations < maxCandidateSetEvaluations && (pending.Count > 0 || fallback.Count > 0);) + { + RepairAttempt attempt = pending.Count > 0 ? pending.Dequeue() : fallback.Dequeue(); + JsonNode candidateResult = attempt.Result.DeepClone(); + foreach (RepairCandidate candidate in attempt.Candidates) + { + ApplyRepairCandidate(candidateResult, candidate); + } + + if (!visited.Add(candidateResult.ToJsonString())) + { + continue; + } + + evaluations++; + if (IsRepairValid(candidateResult)) + { + return candidateResult; + } + + if (EnqueueRepairs(candidateResult, attempt.Depth) is JsonNode repairedResult) + { + return repairedResult; + } + } + + return null; + } + + 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() }); + 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; + } + + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonSchema? schema = null; + if (schemaNode is not null) + { + TryBuildJsonSchema(schemaNode, out schema); + } + + 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) + { + try + { + JsonElement instanceElement = JsonSerializer.SerializeToElement(instance, McpJsonUtilities.JsonContext.Default.JsonNode); + return schema.Evaluate(instanceElement).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) + { + 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) + { + if (parentObject.ContainsKey(propertyName)) + { + continue; + } + + string candidateKey = contextEntry.Key + "\u0000" + 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) + { + if (TryGetNodeAtPath(resultNode, candidate.ParentPath, out JsonNode? parentNode) && parentNode is JsonObject parentObject) + { + 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 => ParentPath + "\u0000" + 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) + { + if (instance is not JsonObject instanceObject || schema is not JsonObject schemaObject || + schemaObject.Any(static property => property.Key is not ("properties" or "required" or "type"))) + { + 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)) + { + 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; + } + } + } + + return projection; + } + + private sealed record RepairCandidateGroup( + HashSet Keys, HashSet DirectKeys, string InstancePath, JsonNode Schema, + List DynamicScope, List Choices, List Conditions); + + 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 + { + // Shared by document attempts and recursive branch repairs, including cached merges. + private int _remainingWork = 1_000_000; + + internal bool TrySpend(int work) + { + if (work > _remainingWork) + { + _remainingWork = 0; + return false; + } + + _remainingWork -= work; + return true; + } + } + + private sealed class SchemaTraversal(RepairTraversalBudget budget) + { + internal RepairTraversalBudget Budget { get; } = budget; + 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)); + if (!traversal.Budget.TrySpend(1)) + { + return; + } + + if (traversal.Completed.TryGetValue(state, out SchemaTraversalResult? completed)) + { + if (traversal.Budget.TrySpend(completed.Contexts.Count + 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) + { + 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; + } + + CollectSchemaContexts( + propertyInstance, + property.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope); + } + } + + 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) + { + if (patternProperty.Value is not null && Regex.IsMatch(property.Key, patternProperty.Key)) + { + matchedPattern = true; + CollectSchemaContexts( + property.Value, + patternProperty.Value, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope); + } + } + } + + if (!isExplicitProperty && !matchedPattern && additionalProperties is not null) + { + CollectSchemaContexts( + property.Value, + additionalProperties, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + traversal, + groups, references, dynamicScope); + } + + if (schemaObject["unevaluatedProperties"] is JsonNode unevaluatedPropertySchema) + { + CollectSchemaBranch( + property.Value, unevaluatedPropertySchema, schemaRoot, + AppendJsonPointer(instancePath, property.Key), contexts, traversal, groups, references, dynamicScope); + } + } + } + + if (instanceNode is JsonArray arrayInstance) + { + JsonArray? prefixItems = schemaObject["prefixItems"] as JsonArray; + JsonNode? items = schemaObject["items"]; + 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) + { + CollectSchemaContexts( + arrayInstance[i], + itemSchema, + schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, + traversal, + groups, references, dynamicScope); + } + + foreach (string keyword in new[] { "contains", "unevaluatedItems" }) + { + if (schemaObject[keyword] is JsonNode subschema) + { + CollectSchemaBranch( + arrayInstance[i], subschema, schemaRoot, + AppendJsonPointer(instancePath, i.ToString(CultureInfo.InvariantCulture)), + contexts, traversal, groups, references, dynamicScope); + } + } + } + } + + 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) + { + 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; + } + + 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); + } + } + } + } + + 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)); + } + } + + 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); + } + if (traversal.Budget.TrySpend(contexts.Count + 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) + { + Dictionary branchContexts = new(StringComparer.Ordinal); + List branchGroups = []; + CollectSchemaContexts( + instanceNode, branch, schemaRoot, instancePath, branchContexts, + traversal, branchGroups, references, dynamicScope); + List branchScope = SchemaReferenceResolver.EnterResource(dynamicScope, references.GetResourceRoot(branch)); + List choices = exclusiveApplicator is null + ? [] : [new(exclusiveApplicator, branch, instancePath, branchScope)]; + List conditions = condition is null ? [] : [condition]; + 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) + { + group.Add(entry.Key + "\u0000" + property); + } + } + + if (group.Count > 0) + { + 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)); + } + + foreach (RepairCandidateGroup branchGroup in branchGroups) + { + groups.Add(branchGroup with + { + Choices = [.. choices, .. branchGroup.Choices], + Conditions = [.. conditions, .. branchGroup.Conditions], + }); + } + } + + 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.ShouldSerialize is not null || GetIgnoreAttribute(propertyInfo) is not null || propertyInfo.Set is not null) + { + return false; + } + + if (propertyInfo.AttributeProvider is FieldInfo) + { + return serializerOptions.IgnoreReadOnlyFields; + } + + if (propertyInfo.AttributeProvider is not PropertyInfo && serializerOptions.IgnoreReadOnlyFields) + { + return true; + } + + return serializerOptions.IgnoreReadOnlyProperties && + serializerOptions.GetTypeInfo(propertyInfo.PropertyType).Kind is not (JsonTypeInfoKind.Enumerable or JsonTypeInfoKind.Dictionary); + } + + // 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) + { + private readonly Dictionary _children = new(StringComparer.Ordinal); + private List? _items; + private bool _dictionaryRead; + + 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? GetChild(string name, JsonNode? serializedParent) + { + if (_children.TryGetValue(name, out SourceRecoveryContext? child)) + { + return child; + } + + try + { + child = GetChildCore(name, serializedParent); + } + catch (NotSupportedException) + { + // 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); + } + } + + 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!)); + } + } + + 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 && Value is IEnumerable enumerable) + { + object? item; + if (Value is IList list) + { + if (index >= list.Count) + { + return null; + } + + item = list[index]; + } + else + { + if (_items is null) + { + _items = []; + foreach (object? element in enumerable) + { + _items.Add(element); + if (_items.Count >= serializedArray.Count) + { + break; + } + } + } + + if (index >= _items.Count) + { + return null; + } + + item = _items[index]; + } + + return new(item, options.GetTypeInfo(typeInfo.ElementType!)); + } + + 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; + } + + object? memberValue = member.Get(Value); + JsonTypeInfo memberContract = options.GetTypeInfo(member.PropertyType); + return new(memberValue, memberContract, member, Value, typeInfo); + } + + JsonPropertyInfo? extensionData = sourceContract.ExtensionData; + if (extensionData?.Get is not { } getExtensionData || + extensionData.CustomConverter is not null || IsExcludedReadOnlyMember(extensionData, options)) + { + return null; + } + + object? extensionValue = getExtensionData(Value); + if (extensionValue is null || extensionData.ShouldSerialize?.Invoke(Value, extensionValue) == false) + { + 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!)); + } + + if (extensionValue is IDictionary elementData && elementData.TryGetValue(name, out JsonElement elementEntry)) + { + return new(elementEntry, options.GetTypeInfo(typeof(JsonElement))); + } + + return null; + } + + private JsonTypeInfo GetActiveContract() + { + 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) + { + return options.GetTypeInfo(runtimeType); + } + + 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); + // Reuse the owner's object converter, not its frozen metadata. The object-typed + // adapter still serializes the actual owner, preserving cycle detection. + JsonTypeInfo contract = JsonMetadataServices.CreateValueInfo(options, _contract.Converter); + contract.NumberHandling = _contract.NumberHandling; + contract.PolymorphismOptions = null; + contract.OnSerializing = null; + contract.OnSerialized = null; + 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; + } + } + +#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 + OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException) + { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(exception.InnerException!).Throw(); + throw; + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + 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); + JsonElement element; + SourceRecoveryContext? previous = t_serializingSourceMember; + try + { + // Serialization is synchronous; restore the slot for nested invocations. + t_serializingSourceMember = member; + // Value-type serialization must not track the original box as a reference cycle. + object? owner = originalContract.Type.IsValueType ? RuntimeHelpers.GetObjectValue(member.Owner) : member.Owner; + element = JsonSerializer.SerializeToElement(owner!, contract).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 (exception is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or AccessViolationException)) + { + 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 _schemas = []; + + internal SchemaReferenceResolver(JsonNode schema) + { + Register(schema, new Uri("https://mcp.invalid/schema"), isRoot: 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 _schemas) + { + 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; + } + + _schemas.Add(schema); + bool isResource = isRoot; + 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 +2830,4 @@ private static bool IsPrimitiveHeaderType(Type type) type == typeof(uint) || type == typeof(long); } -} \ No newline at end of file +} diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 04f24e51f..6b8c987d2 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(); @@ -20,9 +28,77 @@ entry.Meta["example.com/aot"]!["values"]!.AsArray().Clear(); var services = new ServiceCollection(); +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"]}, + "delegatingCount":{"const":0}, + "unions":{"type":"array","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} + ]}} + } + } + """).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, + }); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() + .WithTools([new ForwardingAotTool(structuredTool), preservedTool, preservedNongenericTool]) .WithTools( [ new ForwardingAotTool(McpServerTool.Create(AotTools.Echo, new() { Name = "ForwardingEcho", UseStructuredContent = true })), @@ -60,6 +136,45 @@ 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("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" }); @@ -117,6 +232,198 @@ internal sealed class AotTools 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(); + + [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; + } +} + +[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; } +} + +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 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)!); +} + +[JsonSerializable(typeof(AotStructuredOutput))] +[JsonSerializable(typeof(AotDerivedOutput))] +[JsonSerializable(typeof(Dictionary))] +[JsonSerializable(typeof(Hashtable))] +internal partial class AotJsonContext : JsonSerializerContext; + internal class ForwardingAotTool(McpServerTool inner) : DelegatingMcpServerTool(inner); internal sealed class OverridingAotTool(McpServerTool inner) : ForwardingAotTool(inner) diff --git a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs index edd2d75df..1b8857e93 100644 --- a/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/DelegatingToolOutputSchemaTests.cs @@ -89,6 +89,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)] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs new file mode 100644 index 000000000..6d157c727 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolConditionalRepairTests.cs @@ -0,0 +1,387 @@ +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}""")] + 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..ffbd4461b --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRecoveryBoundaryTests.cs @@ -0,0 +1,648 @@ +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, 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); + } + + [Fact] + public async Task StructuredOutput_RecoveryBoundary_KeepsConcurrentPlanValuesSeparate() + { + using Barrier barrier = new(2); + JsonSerializerOptions options = CreateStructuredOutputTestOptions(JsonIgnoreCondition.WhenWritingDefault); + 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, results[0].GetProperty("value").GetInt32()); + Assert.Equal(2, results[1].GetProperty("value").GetInt32()); + + McpServerTool CreateTool(int value) => CreateStructuredOutputTestTool( + new RecoveryBoundaryInvocationOutput { Value = new(value) }, """{"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); + } + } + + [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); + } + + [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 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 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 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(RecoveryBoundaryAttributedOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableOutput))] + [JsonSerializable(typeof(RecoveryBoundaryNullableRecord))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicOwner))] + [JsonSerializable(typeof(RecoveryBoundaryCyclicStructOwner))] + [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..29a532486 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolRepairSearchTests.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Json.Schema; +using ModelContextProtocol.Tests.Utils; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public partial class McpServerToolTests +{ + [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 _)); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index c77151884..b6af2963b 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,2172 @@ 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_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 _)); + } + + [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_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.True(source.EnumeratedItems <= count * 4, $"Enumerated {source.EnumeratedItems} source items."); + } + + [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 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 +3271,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 +3294,418 @@ 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 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 +3888,8 @@ public async Task EnablePollingAsync_ThrowsInvalidOperationException_WhenTranspo [JsonSerializable(typeof(DateTimeOffset?))] [JsonSerializable(typeof(Person))] [JsonSerializable(typeof(WeatherRequest))] + [JsonSerializable(typeof(ExplicitlyIncludedReadOnlyOutput))] + [JsonSerializable(typeof(RecoveryConditionalOutput))] partial class JsonContext2 : JsonSerializerContext; // ===== x-mcp-header tests =====