Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/json-serialization/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- |
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ namespace CleanModelContextProtocol.Presentation.Serialization;
/// </summary>
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true,
WriteIndented = false,
GenerationMode = JsonSourceGenerationMode.Default)]
Expand Down
2 changes: 1 addition & 1 deletion src/Presentation/Serialization/McpSerializer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
16 changes: 16 additions & 0 deletions tests/Presentation.Tests.Integration/Tools/MovieToolTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, object?> { ["query"] = "no movie has this title" };

// Act
var result = await this.Client.CallToolAsync("movies_search", arguments);

// Assert
result.Content.OfType<TextContentBlock>().First().Text.ShouldContain("\"nextOffset\":null");
result.StructuredContent.ShouldNotBeNull().GetProperty("nextOffset").ValueKind.ShouldBe(JsonValueKind.Null);
}

[Fact]
public async Task SearchMovies_ShouldFind_MovieByTitle()
{
Expand Down
Loading