From 519e96ce172906db49bd35622afbb4f9f929fd6f Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 13 Oct 2026 23:15:52 +0800 Subject: [PATCH 1/2] Fix nullable structured tool results --- .../Server/AIFunctionMcpServerTool.cs | 13 +++- .../Server/McpServerToolTests.cs | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 82b6ceb9d..e8477854e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -14,6 +15,7 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; + private readonly JsonSerializerOptions _structuredContentSerializerOptions; /// /// Creates an instance for a method, specified via a instance. @@ -225,6 +227,13 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider ProtocolTool = tool; _metadata = metadata; + _structuredContentSerializerOptions = tool.OutputSchema is not null && + function.JsonSerializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull + ? new JsonSerializerOptions(function.JsonSerializerOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.Never, + } + : function.JsonSerializerOptions; } /// @@ -677,7 +686,7 @@ private static void RewriteRefPointers(JsonNode? node) JsonElement jsonElement => jsonElement, JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), + _ => JsonSerializer.SerializeToElement(aiFunctionResult, _structuredContentSerializerOptions.GetTypeInfo(typeof(object))), }; // Pre-SEP-2106 clients expect the {"result": } envelope for non-object @@ -805,4 +814,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 8fd1d9954..2a7a9af55 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -481,6 +481,58 @@ 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_VoidReturningTools_ReturnsExpectedSchema() { @@ -1091,6 +1143,15 @@ 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 static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions); From de107a5375bc8d910fbe78700778ecf0e9def962 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Tue, 20 Oct 2026 07:33:04 +0800 Subject: [PATCH 2/2] Fix structured output for required nullable properties Signed-off-by: mikemikimike <13286568797@163.com> --- .../Server/AIFunctionMcpServerTool.cs | 118 ++++++++++++++++-- .../Server/McpServerToolTests.cs | 64 ++++++++++ 2 files changed, 172 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index e8477854e..4bef486af 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,7 +6,6 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -15,7 +14,6 @@ namespace ModelContextProtocol.Server; internal sealed partial class AIFunctionMcpServerTool : McpServerTool { private readonly IReadOnlyList _metadata; - private readonly JsonSerializerOptions _structuredContentSerializerOptions; /// /// Creates an instance for a method, specified via a instance. @@ -227,13 +225,6 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool, IServiceProvider ProtocolTool = tool; _metadata = metadata; - _structuredContentSerializerOptions = tool.OutputSchema is not null && - function.JsonSerializerOptions.DefaultIgnoreCondition is JsonIgnoreCondition.WhenWritingNull - ? new JsonSerializerOptions(function.JsonSerializerOptions) - { - DefaultIgnoreCondition = JsonIgnoreCondition.Never, - } - : function.JsonSerializerOptions; } /// @@ -686,9 +677,11 @@ private static void RewriteRefPointers(JsonNode? node) JsonElement jsonElement => jsonElement, JsonNode node => JsonSerializer.SerializeToElement(node, McpJsonUtilities.JsonContext.Default.JsonNode), null => null, - _ => JsonSerializer.SerializeToElement(aiFunctionResult, _structuredContentSerializerOptions.GetTypeInfo(typeof(object))), + _ => JsonSerializer.SerializeToElement(aiFunctionResult, AIFunction.JsonSerializerOptions.GetTypeInfo(typeof(object))), }; + elementResult = AddMissingRequiredNullableProperties(elementResult); + // Pre-SEP-2106 clients expect the {"result": } envelope for non-object // schemas. SEP-2106 clients see the natural shape. The classification is decided // fresh per request from the stored natural schema. @@ -707,6 +700,111 @@ private static void RewriteRefPointers(JsonNode? node) return elementResult; } + private JsonElement? AddMissingRequiredNullableProperties(JsonElement? elementResult) + { + 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); + if (resultNode is null || schemaNode is null || !AddMissingRequiredNullableProperties(resultNode, schemaNode)) + { + return elementResult; + } + + return JsonSerializer.SerializeToElement(resultNode, McpJsonUtilities.JsonContext.Default.JsonNode); + } + + private static bool AddMissingRequiredNullableProperties(JsonNode resultNode, JsonNode schemaNode) + { + bool changed = false; + + if (resultNode is JsonObject resultObject && schemaNode is JsonObject schemaObject) + { + JsonArray? required = schemaObject["required"] as JsonArray; + JsonObject? properties = schemaObject["properties"] as JsonObject; + + if (properties is not null) + { + foreach (KeyValuePair property in resultObject) + { + if (property.Value is not null && properties.TryGetPropertyValue(property.Key, out JsonNode? propertySchema)) + { + changed |= AddMissingRequiredNullableProperties(property.Value, propertySchema!); + } + } + } + + if (required is not null && properties is not null) + { + foreach (JsonNode? requiredProperty in required) + { + string? propertyName = requiredProperty?.GetValue(); + if (propertyName is not null && + !resultObject.ContainsKey(propertyName) && + properties.TryGetPropertyValue(propertyName, out JsonNode? propertySchema) && + SchemaAllowsNull(propertySchema)) + { + resultObject[propertyName] = null; + changed = true; + } + } + } + } + else if (resultNode is JsonArray resultArray && + schemaNode is JsonObject { } arraySchema && + arraySchema["items"] is JsonNode itemSchema) + { + foreach (JsonNode? item in resultArray) + { + if (item is not null) + { + changed |= AddMissingRequiredNullableProperties(item, itemSchema); + } + } + } + + return changed; + } + + private static bool SchemaAllowsNull(JsonNode? schemaNode) + { + if (schemaNode is JsonValue schemaValue && schemaValue.TryGetValue(out bool allowsNull)) + { + return allowsNull; + } + + if (schemaNode is not JsonObject schemaObject) + { + return false; + } + + if (schemaObject["type"] is JsonValue typeValue && typeValue.TryGetValue(out string? type)) + { + return type == "null"; + } + + if (schemaObject["type"] is JsonArray types && + types.Any(type => type is JsonValue value && value.TryGetValue(out string? typeName) && typeName == "null")) + { + return true; + } + + foreach (string keyword in new[] { "anyOf", "oneOf" }) + { + if (schemaObject[keyword] is JsonArray alternatives && alternatives.Any(SchemaAllowsNull)) + { + return true; + } + } + + return false; + } + private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumerable contentItems, JsonElement? structuredContent) { List contentList = []; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 2a7a9af55..cc05d1b16 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -533,6 +533,53 @@ public async Task StructuredOutput_Enabled_RequiredNullablePropertiesInClass_Mat 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_VoidReturningTools_ReturnsExpectedSchema() { @@ -1152,6 +1199,23 @@ private sealed class RequiredNullablePropertiesOutput 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 static JsonSerializerOptions CreateSerializerOptionsWithPerson() { JsonSerializerOptions options = new(McpJsonUtilities.DefaultOptions);