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);