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 82b6ceb9d..1a8933a92 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,11 +1,16 @@ using Microsoft.Extensions.AI; +using Json.Schema; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using System.Collections; using System.ComponentModel; +using System.Globalization; using System.Diagnostics; using System.Reflection; 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; @@ -14,6 +19,8 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; + private readonly Lazy _outputSchema; + private readonly Lazy _serializerTypeInfoIncludingDefaults; /// /// Creates an instance for a method, specified via a instance. @@ -223,6 +230,15 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider AIFunction = function; ProtocolTool = tool; + _outputSchema = new Lazy(BuildOutputSchema, LazyThreadSafetyMode.ExecutionAndPublication); + _serializerTypeInfoIncludingDefaults = new Lazy(() => + { + JsonSerializerOptions options = new(AIFunction.JsonSerializerOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + }; + return options.GetTypeInfo(typeof(object)); + }, LazyThreadSafetyMode.ExecutionAndPublication); _metadata = metadata; } @@ -672,13 +688,26 @@ private static void RewriteRefPointers(JsonNode? node) 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 @@ -698,6 +727,1056 @@ private static void RewriteRefPointers(JsonNode? node) 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); + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + JsonSchema? schema = _outputSchema.Value; + if (resultNode is null || schemaNode is null || schema is null) + { + return elementResult; + } + + if (IsSchemaValid(schema, resultNode)) + { + return elementResult; + } + + JsonNode? resultNodeIncludingDefaults = resultNode; + if (originalResult is not null and not JsonElement and not JsonNode && + AIFunction.JsonSerializerOptions.DefaultIgnoreCondition is not JsonIgnoreCondition.Never) + { + try + { + JsonElement completeResult = JsonSerializer.SerializeToElement( + originalResult, _serializerTypeInfoIncludingDefaults.Value); + resultNodeIncludingDefaults = JsonSerializer.SerializeToNode( + completeResult, McpJsonUtilities.JsonContext.Default.JsonElement); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) + { + // A value omitted from the actual result may fail when the ignore policy is + // relaxed. Required members can still be recovered individually from metadata. + resultNodeIncludingDefaults = null; + } + } + + JsonNode? repairedResult = RepairStructuredResult( + resultNode, + resultNodeIncludingDefaults, + schemaNode, + schema, + originalResult, + 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 therefore + /// read from the source object when the second serialization snapshot does not contain them. + /// + private static JsonNode? RepairStructuredResult( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + JsonNode schemaNode, + JsonSchema schema, + object? originalResult, + JsonSerializerOptions serializerOptions) + { + // An explicit schema may deliberately omit optional nulls. Preserve that valid output + // before looking for missing required members. + if (IsSchemaValid(schema, resultNode)) + { + return null; + } + + return TryRepairStructuredResult( + resultNode, + resultNodeIncludingDefaults, + schemaNode, + schema, + originalResult, + serializerOptions, + depth: 0, + new RepairSearchState()); + } + + private static JsonNode? TryRepairStructuredResult( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + JsonNode schemaNode, + JsonSchema schema, + object? originalResult, + JsonSerializerOptions serializerOptions, + int depth, + RepairSearchState searchState) + { + if (IsSchemaValid(schema, resultNode)) + { + return resultNode.DeepClone(); + } + + const int maxRepairDepth = 32; + const int maxCandidateSetEvaluations = 256; + if (depth >= maxRepairDepth || searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations || + !searchState.VisitedStates.Add(resultNode.ToJsonString())) + { + return null; + } + + Dictionary contexts = new(StringComparer.Ordinal); + CollectSchemaContexts( + resultNode, + schemaNode, + schemaNode, + string.Empty, + contexts, + new HashSet(StringComparer.Ordinal)); + + List candidates = CreateRepairCandidates( + resultNode, + resultNodeIncludingDefaults, + originalResult, + serializerOptions, + contexts); + if (candidates.Count == 0) + { + return null; + } + + bool[] included = Enumerable.Repeat(true, candidates.Count).ToArray(); + string originalJson = resultNode.ToJsonString(); + JsonNode? repairedResult = null; + List pendingResults = []; + + bool TryCurrentSet() + { + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + { + return false; + } + + searchState.CandidateSetEvaluations++; + JsonNode candidateResult = resultNode.DeepClone(); + bool hasIncludedCandidate = false; + for (int i = 0; i < candidates.Count; i++) + { + if (included[i]) + { + ApplyRepairCandidate(candidateResult, candidates[i]); + hasIncludedCandidate = true; + } + } + + if (!hasIncludedCandidate || string.Equals(candidateResult.ToJsonString(), originalJson, StringComparison.Ordinal)) + { + return false; + } + + if (IsSchemaValid(schema, candidateResult)) + { + repairedResult = candidateResult; + return true; + } + + pendingResults.Add(candidateResult); + return false; + } + + bool SearchInclusions(int nextIndex, int remaining) + { + if (remaining == 0) + { + return TryCurrentSet(); + } + + for (int i = nextIndex; i <= candidates.Count - remaining; i++) + { + included[i] = true; + if (SearchInclusions(i + 1, remaining - 1)) + { + return true; + } + + included[i] = false; + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + { + return false; + } + } + + return false; + } + + if (TryCurrentSet()) + { + return repairedResult; + } + JsonNode? allCandidatesResult = pendingResults.Count == 0 ? null : pendingResults[0]; + pendingResults.Clear(); + + for (int i = 0; i < included.Length; i++) + { + included[i] = false; + } + + for (int includedCount = 1; includedCount <= Math.Min(2, candidates.Count - 1); includedCount++) + { + if (SearchInclusions(0, includedCount)) + { + return repairedResult; + } + + if (searchState.CandidateSetEvaluations >= maxCandidateSetEvaluations) + { + break; + } + } + + if (allCandidatesResult is not null) + { + pendingResults.Add(allCandidatesResult); + } + + foreach (JsonNode pendingResult in pendingResults) + { + searchState.Pending.Enqueue((pendingResult, depth + 1)); + } + + if (depth == 0) + { + while (searchState.Pending.Count > 0 && searchState.CandidateSetEvaluations < maxCandidateSetEvaluations) + { + (JsonNode nextResult, int nextDepth) = searchState.Pending.Dequeue(); + repairedResult = TryRepairStructuredResult( + nextResult, resultNodeIncludingDefaults, schemaNode, schema, + originalResult, serializerOptions, nextDepth, searchState); + if (repairedResult is not null) + { + 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); + return true; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or ArgumentException) + { + schema = null; + return false; + } + } + + private JsonSchema? BuildOutputSchema() + { + if (ProtocolTool.OutputSchema is not { } outputSchema) + { + return null; + } + + JsonNode? schemaNode = JsonSerializer.SerializeToNode(outputSchema, McpJsonUtilities.JsonContext.Default.JsonElement); + return schemaNode is not null && TryBuildJsonSchema(schemaNode, out JsonSchema? schema) + ? schema + : null; + } + + 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 InvalidOperationException or ArgumentException) + { + return false; + } + } + + private static List CreateRepairCandidates( + JsonNode resultNode, + JsonNode? resultNodeIncludingDefaults, + object? 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; + } + + bool hasCompleteValue = TryGetNodeAtPath( + resultNodeIncludingDefaults, + AppendJsonPointer(contextEntry.Key, propertyName), + out JsonNode? completeValue); + + if (hasCompleteValue) + { + candidates.Add(new RepairCandidate(contextEntry.Key, propertyName, completeValue?.DeepClone())); + } + else if (TryGetSourceValue( + originalResult, + 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); + + private sealed class RepairSearchState + { + internal int CandidateSetEvaluations { get; set; } + + internal HashSet VisitedStates { get; } = new(StringComparer.Ordinal); + + internal Queue<(JsonNode Result, int Depth)> Pending { get; } = new(); + } + + private static void CollectSchemaContexts( + JsonNode? instanceNode, + JsonNode? schemaNode, + JsonNode schemaRoot, + string instancePath, + Dictionary contexts, + HashSet activeReferences) + { + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out _)) + { + return; + } + + if (schemaNode is not JsonObject schemaObject) + { + return; + } + + if (schemaObject["$ref"] is JsonValue refValue && + refValue.TryGetValue(out string? refText) && + refText is not null && + activeReferences.Add(refText + "|" + instancePath)) + { + if (TryResolveLocalReference(schemaRoot, refText, out JsonNode? referencedSchema)) + { + CollectSchemaContexts(instanceNode, referencedSchema, schemaRoot, instancePath, contexts, activeReferences); + } + + activeReferences.Remove(refText + "|" + instancePath); + } + + 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, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + + 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, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + if (!isExplicitProperty && !matchedPattern && additionalProperties is not null) + { + CollectSchemaContexts( + property.Value, + additionalProperties, + schemaRoot, + AppendJsonPointer(instancePath, property.Key), + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + 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, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + 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, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + 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) + { + CollectSchemaContexts( + instanceNode, + branch, + schemaRoot, + instancePath, + contexts, + new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + if (schemaObject["if"] is JsonNode ifSchema) + { + if (schemaObject["then"] is JsonNode thenSchema) + { + CollectSchemaContexts(instanceNode, thenSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + } + + if (schemaObject["else"] is JsonNode elseSchema) + { + CollectSchemaContexts(instanceNode, elseSchema, schemaRoot, instancePath, contexts, new HashSet(activeReferences, StringComparer.Ordinal)); + } + } + } + + 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 = rawSegment.Replace("~1", "/").Replace("~0", "~"); + 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( + object? sourceRoot, + string parentPath, + string propertyName, + JsonSerializerOptions serializerOptions, + out JsonNode? value) + { + value = null; + if (sourceRoot is null || !TryGetSourceObjectAtPath(sourceRoot, parentPath, serializerOptions, out object? sourceParent) || sourceParent is null) + { + return false; + } + + if (!TryGetSourceMember( + sourceParent, + propertyName, + serializerOptions, + out object? sourceValue, + out Type? declaredType, + out JsonPropertyInfo? propertyInfo)) + { + return false; + } + + if (!WasOmittedBySupportedIgnoreCondition( + sourceParent, sourceValue, propertyInfo, serializerOptions)) + { + return false; + } + + value = SerializeSourceValue(sourceValue, declaredType, serializerOptions, propertyInfo); + return sourceValue is null || value is not null; + } + + private static bool WasOmittedBySupportedIgnoreCondition( + object sourceParent, + object? value, + JsonPropertyInfo? propertyInfo, + JsonSerializerOptions serializerOptions) + { + JsonIgnoreAttribute? ignoreAttribute = propertyInfo?.AttributeProvider is ICustomAttributeProvider attributeProvider + ? attributeProvider.GetCustomAttributes(typeof(JsonIgnoreAttribute), inherit: true) + .OfType() + .FirstOrDefault() + : null; + + if (propertyInfo?.ShouldSerialize is { } shouldSerialize && + !IsJsonIgnoreConditionPredicate(shouldSerialize) && + !shouldSerialize(sourceParent, value)) + { + return false; + } + + JsonIgnoreCondition ignoreCondition = ignoreAttribute?.Condition ?? serializerOptions.DefaultIgnoreCondition; + return ignoreCondition switch + { + JsonIgnoreCondition.WhenWritingNull => value is null, + JsonIgnoreCondition.WhenWritingDefault => + propertyInfo is not null && + IsDefaultValue(value, propertyInfo.PropertyType), + _ => false, + }; + } + + // System.Text.Json uses this generated delegate for JsonIgnore conditions; evaluate those from metadata instead. + private static bool IsJsonIgnoreConditionPredicate(Delegate predicate) + { + Type? generatedClosureType = predicate.Method.DeclaringType; + Type? propertyInfoType = generatedClosureType?.DeclaringType; + + return predicate.Method.Name.StartsWith("b__", StringComparison.Ordinal) && + propertyInfoType?.Assembly == typeof(JsonPropertyInfo).Assembly && + string.Equals(propertyInfoType.Name, "JsonPropertyInfo`1", StringComparison.Ordinal); + } + + private static bool IsDefaultValue(object? value, Type valueType) + { + if (value is null) + { + return true; + } + + return Nullable.GetUnderlyingType(valueType) is null && + valueType.IsValueType && + value.Equals(CreateDefaultValue(valueType)); + } + + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2067:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", + Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2072:Type argument does not satisfy DynamicallyAccessedMemberTypes requirements", + Justification = "This helper is called only for value types; zero-initializing them does not invoke constructors.")] + private static object CreateDefaultValue(Type valueType) + { +#if NETSTANDARD2_0 + return System.Runtime.Serialization.FormatterServices.GetUninitializedObject(valueType); +#else + return System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(valueType); +#endif + } + + private static bool TryGetSourceObjectAtPath( + object sourceRoot, + string path, + JsonSerializerOptions serializerOptions, + out object? value) + { + value = sourceRoot; + if (path.Length == 0) + { + return true; + } + + if (!path.StartsWith("/", StringComparison.Ordinal)) + { + value = null; + return false; + } + + foreach (string rawSegment in path.Substring(1).Split('/')) + { + string segment = rawSegment.Replace("~1", "/").Replace("~0", "~"); + if (value is null) + { + return false; + } + + if (value is JsonNode jsonNode) + { + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(segment, out JsonNode? childNode)) + { + value = childNode; + continue; + } + + if (jsonNode is JsonArray jsonArray && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int jsonIndex) && + jsonIndex >= 0 && jsonIndex < jsonArray.Count) + { + value = jsonArray[jsonIndex]; + continue; + } + + value = null; + return false; + } + + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(segment, out JsonElement childElement)) + { + value = childElement; + continue; + } + + if (jsonElement.ValueKind == JsonValueKind.Array && int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int elementIndex) && + elementIndex >= 0 && elementIndex < jsonElement.GetArrayLength()) + { + value = jsonElement[elementIndex]; + continue; + } + + value = null; + return false; + } + + if (value is IDictionary dictionary && TryGetDictionaryValue(dictionary, segment, serializerOptions, out object? dictionaryValue)) + { + value = dictionaryValue; + continue; + } + + if (int.TryParse(segment, NumberStyles.None, CultureInfo.InvariantCulture, out int index) && value is IEnumerable enumerable) + { + int currentIndex = 0; + bool found = false; + foreach (object? item in enumerable) + { + if (currentIndex++ == index) + { + value = item; + found = true; + break; + } + } + + if (found) + { + continue; + } + } + + if (!TryGetSourceMember(value, segment, serializerOptions, out object? memberValue, out _, out _)) + { + value = null; + return false; + } + + value = memberValue; + } + + return true; + } + + private static bool TryGetDictionaryValue( + IDictionary dictionary, + string propertyName, + JsonSerializerOptions serializerOptions, + out object? value) + { + value = null; + JsonTypeInfo dictionaryTypeInfo; + try + { + dictionaryTypeInfo = serializerOptions.GetTypeInfo(dictionary.GetType()); + } + catch (NotSupportedException) + { + return false; + } + + if (dictionaryTypeInfo.Kind != JsonTypeInfoKind.Dictionary || + dictionaryTypeInfo.KeyType is null || + dictionaryTypeInfo.ElementType is not { } valueType || + dictionaryTypeInfo.CreateObject?.Invoke() is not IDictionary singleEntry) + { + return false; + } + + foreach (DictionaryEntry entry in dictionary) + { + if (entry.Key is null) + { + continue; + } + + try + { + singleEntry.Clear(); + singleEntry.Add(entry.Key, entry.Value); + JsonElement serialized = JsonSerializer.SerializeToElement(singleEntry, dictionaryTypeInfo); + if (serialized.ValueKind == JsonValueKind.Object && serialized.TryGetProperty(propertyName, out _)) + { + value = entry.Value; + return true; + } + } + catch (Exception exception) when (exception is ArgumentException or JsonException or NotSupportedException or InvalidOperationException) + { + return false; + } + } + + return false; + } + + private static bool TryGetSourceMember( + object source, + string propertyName, + JsonSerializerOptions serializerOptions, + out object? value, + out Type? declaredType, + out JsonPropertyInfo? propertyInfo) + { + value = null; + declaredType = null; + propertyInfo = null; + + if (source is JsonNode jsonNode) + { + if (jsonNode is JsonObject jsonObject && jsonObject.TryGetPropertyValue(propertyName, out JsonNode? childNode)) + { + value = childNode; + declaredType = typeof(JsonNode); + return true; + } + + return false; + } + + if (source is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object && jsonElement.TryGetProperty(propertyName, out JsonElement childElement)) + { + value = childElement; + declaredType = typeof(JsonElement); + return true; + } + + if (source is IDictionary dictionary && dictionary.Contains(propertyName)) + { + value = dictionary[propertyName]; + declaredType = value?.GetType(); + return true; + } + + try + { + JsonTypeInfo typeInfo = serializerOptions.GetTypeInfo(source.GetType()); + foreach (JsonPropertyInfo property in typeInfo.Properties) + { + if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null) + { + value = property.Get(source); + declaredType = property.PropertyType; + propertyInfo = property; + return true; + } + } + } + catch (NotSupportedException) + { + // Without serializer metadata, keep the serialized snapshot as the source of truth. + } + + return false; + } + + private static JsonNode? SerializeSourceValue( + object? value, + Type? declaredType, + JsonSerializerOptions serializerOptions, + JsonPropertyInfo? propertyInfo) + { + if (value is JsonNode jsonNode) + { + return jsonNode.DeepClone(); + } + + if (value is JsonElement jsonElement) + { + return JsonSerializer.SerializeToNode(jsonElement, McpJsonUtilities.JsonContext.Default.JsonElement); + } + + Type valueType = declaredType ?? value?.GetType() ?? typeof(object); + try + { + JsonSerializerOptions effectiveOptions = serializerOptions; + JsonConverter? propertyConverter = GetPropertyConverter(propertyInfo, valueType); + if (propertyConverter is not null || propertyInfo?.NumberHandling is not null) + { + effectiveOptions = new JsonSerializerOptions(serializerOptions); + if (propertyConverter is not null) + { + effectiveOptions.Converters.Insert(0, propertyConverter); + } + + if (propertyInfo?.NumberHandling is { } numberHandling) + { + effectiveOptions.NumberHandling = numberHandling; + } + } + + JsonTypeInfo typeInfo = effectiveOptions.GetTypeInfo(valueType); + JsonElement element = JsonSerializer.SerializeToElement(value, typeInfo); + return JsonSerializer.SerializeToNode(element, McpJsonUtilities.JsonContext.Default.JsonElement); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException or InvalidOperationException) + { + return null; + } + } + + private static JsonConverter? GetPropertyConverter(JsonPropertyInfo? propertyInfo, Type propertyType) + { + if (propertyInfo is null) + { + return null; + } + + PropertyInfo? customConverterProperty = typeof(JsonPropertyInfo).GetProperty("CustomConverter", BindingFlags.Instance | BindingFlags.Public); + if (customConverterProperty?.GetValue(propertyInfo) 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 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 false; + } + + 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 CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) { List contentList = []; @@ -805,4 +1884,4 @@ private static bool IsPrimitiveHeaderType(Type type) type == typeof(uint) || type == typeof(long); } -} \ No newline at end of file +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index c77151884..2c3210d56 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -516,6 +516,1046 @@ 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_VoidReturningTools_ReturnsExpectedSchema() { @@ -1126,6 +2166,289 @@ 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 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);