diff --git a/.agents/skills/json-serialization/SKILL.md b/.agents/skills/json-serialization/SKILL.md index d6e0cdd..9242a90 100644 --- a/.agents/skills/json-serialization/SKILL.md +++ b/.agents/skills/json-serialization/SKILL.md @@ -9,7 +9,7 @@ metadata: ## The rule -Tool arguments, tool results, resources and prompt attachments all use one set of options: `McpSerializer.Options` in `src/Presentation/Serialization/McpSerializer.cs`. It starts from the SDK's `McpJsonUtilities.DefaultOptions`, then sets camelCase names and leaves nulls out, and puts `AppJsonSerializerContext` first in the resolver chain so the listed types use generated code. The options are read-only once built. +Tool arguments, tool results, resources and prompt attachments all use one set of options: `McpSerializer.Options` in `src/Presentation/Serialization/McpSerializer.cs`. It starts from the SDK's `McpJsonUtilities.DefaultOptions`, then sets camelCase names and writes nulls, because tool output schemas list nullable fields such as `nextOffset` as required and strict clients reject a result that leaves them out, and puts `AppJsonSerializerContext` first in the resolver chain so the listed types use generated code. The options are read-only once built. | Used by | How | | --- | --- | @@ -35,7 +35,7 @@ Results are trees. A review inside a movie carries the movie's id and title, not var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = JsonIgnoreCondition.Never }; options.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); options.MakeReadOnly(); diff --git a/src/Presentation/Serialization/AppJsonSerializerContext.cs b/src/Presentation/Serialization/AppJsonSerializerContext.cs index 7f9275e..216582b 100644 --- a/src/Presentation/Serialization/AppJsonSerializerContext.cs +++ b/src/Presentation/Serialization/AppJsonSerializerContext.cs @@ -9,7 +9,6 @@ namespace CleanModelContextProtocol.Presentation.Serialization; /// [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true, WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Default)] diff --git a/src/Presentation/Serialization/McpSerializer.cs b/src/Presentation/Serialization/McpSerializer.cs index 0d9c713..b151f50 100644 --- a/src/Presentation/Serialization/McpSerializer.cs +++ b/src/Presentation/Serialization/McpSerializer.cs @@ -21,7 +21,7 @@ private static JsonSerializerOptions CreateOptions() var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions) { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = JsonIgnoreCondition.Never }; options.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); options.MakeReadOnly(); diff --git a/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs b/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs index 68d89f2..3cd6e93 100644 --- a/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs +++ b/tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs @@ -1,6 +1,8 @@ namespace CleanModelContextProtocol.Presentation.Tests.Integration.Tools; +using System.Text.Json; using ModelContextProtocol; +using ModelContextProtocol.Protocol; using Presentation.Models; using Shouldly; using Xunit; @@ -55,6 +57,20 @@ public async Task SearchMovies_ShouldPage_ThroughEveryMovie() titles.Count.ShouldBe(50); } + [Fact] + public async Task SearchMovies_ShouldWrite_NullNextOffset_OnTheLastPage() + { + // Arrange + var arguments = new Dictionary { ["query"] = "no movie has this title" }; + + // Act + var result = await this.Client.CallToolAsync("movies_search", arguments); + + // Assert + result.Content.OfType().First().Text.ShouldContain("\"nextOffset\":null"); + result.StructuredContent.ShouldNotBeNull().GetProperty("nextOffset").ValueKind.ShouldBe(JsonValueKind.Null); + } + [Fact] public async Task SearchMovies_ShouldFind_MovieByTitle() {