Skip to content

Preserve nulls required by structured output schemas - #1929

Open
mikemikimike wants to merge 15 commits into
modelcontextprotocol:mainfrom
mikemikimike:fix/structured-content-null-schema
Open

mikemikimike wants to merge 15 commits into
modelcontextprotocol:mainfrom
mikemikimike:fix/structured-content-null-schema

Conversation

@mikemikimike

Copy link
Copy Markdown

Fixes #1915

Issue: #1915

When structured tool output has an output schema and serializer options ignore null values, required nullable properties are omitted from structuredContent. Preserve explicit nulls for this serialization path so the result matches its schema while leaving ordinary text serialization unchanged.

Add regression coverage for record and class outputs with null and non-null values across the current protocol and 2025-11-25.

Validation passed: dotnet test in Debug and Release across the repository test suites; dotnet format whitespace --verify-no-changes; Windows x64 NativeAOT publish and execution; Release NuGet pack; and DocFX with warnings treated as errors. The Core suites passed 2,398 tests per net8.0/net9.0/net10.0 target (4 skipped) and 2,081 tests on net472 (294 skipped); ASP.NET Core passed 635 tests per modern target (30 skipped); Analyzer passed 57 tests.

@PederHP

PederHP commented Oct 14, 2026

Copy link
Copy Markdown
Member

Adding a data point on why this matters beyond the TypeScript 1.x client in #1915: it breaks interop with the other official SDKs' current clients, too. I hit it in a companion sample for my book. A C# server returns a positional record with nullable members (OperationStatus(string OperationId, string Kind, string Status, string? Result, string? Guidance), source), and both other SDKs' clients reject every call where one of those members is null. The repro calls the tool over stdio with an unknown ID, so Result is null:

Client Server on ModelContextProtocol 2.2.0 Same server built against this PR (519e96c)
Python mcp 2.0.0 (negotiates 2026-07-28) Invalid structured content returned by tool check_operation_status: 'result' is a required property OK, "result": null
TypeScript @modelcontextprotocol/client 2.2.0 (versionNegotiation: { mode: 'auto' }) Structured content does not match the tool's output schema: data must have required property 'result' OK, "result": null

Both clients validate structuredContent against outputSchema by default (the spec's SHOULD), while the C# client doesn't, so a C# server passes its own end-to-end tests and only fails when a Python or TypeScript client calls it. The equivalent Python (Pydantic str | None) and TypeScript (zod .nullable()) servers already put an explicit null on the wire, so with this change the three SDKs agree.

Until this ships, the per-member workaround that worked for me is [property: JsonIgnore(Condition = JsonIgnoreCondition.Never)] on the nullable record parameters.

@tarekgh tarekgh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes a real interoperability issue: required nullable properties must be present in structured output, and Python and TypeScript clients reject results when those properties are omitted.

I found one narrow compatibility regression with explicit output schemas, noted inline. Two related ignore-policy gaps also remain, but those predate this PR.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Globally including nulls can invalidate previously valid explicit-schema responses whose optional properties prohibit null.

1 open finding
What changed in this PR

Addresses #1915 by preserving nulls in structured tool results to match required nullable output properties.

Changes:

  • Adds separate serializer options for schema-backed structured content, leaving text serialization unchanged.
  • Adds record and class regression tests across two protocol versions.
File Description
tests/​ModelContextProtocol.Tests/​Server/​McpServerToolTests.cs Tests nullable output properties against their schemas.
src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs Preserves nulls during schema-backed structured serialization.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The previously reported regression remains unresolved for explicit schemas whose optional properties prohibit null values.

1 open finding

🧠 Review effort: Balanced

Signed-off-by: mikemikimike <13286568797@163.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Schema traversal remains incomplete, and restoration can replace actual values with null or invalidate previously valid output.

5 open issues
1 resolved since last review

Review effort: Balanced

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
@tarekgh

tarekgh commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

@mikemikimike thanks for last updates. Could you please look at the copilot comments and address them?

Signed-off-by: mikemikimike <13286568797@163.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

@tarekgh tarekgh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two additional gaps reproduced against f1a39f8: source recovery can fabricate values, and dependentSchemas requirements are not collected.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

@tarekgh tarekgh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three remaining issues reproduced against 0170ae6: caller-filter bypass for default-ignored values, incompatible intermediate union repairs, and missing dependentRequired handling.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Repair search can miss valid union responses, and recovery serialization can throw exceptions that abort tool calls.

3 open issues
3 resolved since last review

Review effort: Balanced

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated
@tarekgh

tarekgh commented Oct 26, 2026

Copy link
Copy Markdown
Contributor

@mikemikimike we got some more copilot comments I hope you can address them to move forward with the merge. Thanks!

@mikemikimike

Copy link
Copy Markdown
Author

The ListResourceTemplatesAsync_WithRequestParams_ReturnsRawResult(clientId: "everything") failure is reproducible on the clean baseline and is not introduced by this PR.

  • Baseline commit: 3338e88e15c42cfc27465143c140d7d10f1a2707
  • Command: dotnet test tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj --framework net10.0 --filter FullyQualifiedName~ListResourceTemplatesAsync_WithRequestParams_ReturnsRawResult
  • Baseline result: test_server passed; everything failed during initialization with System.TimeoutException: Initialization timed out while launching @modelcontextprotocol/server-everything@2026.1.26 through npx.
  • The same everything initialization timeout occurred on the PR head.

The focused structured-output tests pass independently; this integration failure is external to the structured-content changes in this PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved repair-search, schema-caching, and value-recovery defects can leave structured output invalid or cause tool calls to fail.

1 open issue
5 previously missed issues

In code that hasn't changed since last review

Medium severity Refresh cached output schema when OutputSchema changes

src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs:743

ProtocolTool.OutputSchema is publicly mutable, but this cached schema never changes after the first invocation. For example, invoke once with a schema that permits an omitted note, then replace OutputSchema with one requiring nullable note. Subsequent calls validate against the old permissive schema and return before restoring the null, even though tools/list publishes the new requirement. Cache the compiled schema together with its source JsonElement and refresh it when that value changes, so traversal and validation always use the same schema.

[!NOTE]
AI-generated by GitHub Copilot.

Medium severity Traverse array contains schemas for recovery candidate discovery

src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs:1223

Array traversal ignores contains. For example, an explicit schema {"type":"array","contains":{"type":"object","properties":{"note":{"type":["string","null"]}},"required":["note"]}} rejects an initially serialized [{"id":"42"}]. Even when the complete snapshot contains note:null, no candidate is collected because there is no items or prefixItems schema, so the required null remains missing. Traverse contains for candidate discovery too, retaining final schema validation to enforce its cardinality constraints, and add a regression for this case.

[!NOTE]
AI-generated by GitHub Copilot.

Medium severity Preserve converter-produced JSON nulls during required value recovery

src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs:1382

A null JsonNode can represent successful serialization to JSON null, not just failure. For a required RequestId property with member-level WhenWritingDefault, default(RequestId) is omitted from both snapshots, but its converter correctly writes JSON null (Protocol/RequestId.cs:89-95). Recovery then rejects that value here because the boxed struct is non-null and the serialized node is null, leaving the required property absent. Return serialization success separately from the nullable node so converter-produced nulls can be restored without treating serialization failures as success.

[!NOTE]
AI-generated by GitHub Copilot.

Medium severity Resolve existing integer-key dictionary entries during recovery

src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs:1577

Source recovery cannot reach a child under key 42 in a ReadOnlyDictionary<int, Child>: this guard requires a construction delegate, and the later dictionary fallback looks up the string "42", not the integer key. If Child.Count is zero and suppressed by member-level WhenWritingDefault, a schema-required count is absent from both snapshots and cannot be restored. Resolve existing entries without constructing the runtime dictionary type, preserve serialized-key naming and converter semantics, and add this regression.

[!NOTE]
AI-generated by GitHub Copilot.

Medium severity Use member converters when recovering default JsonElement values

src/​ModelContextProtocol.Core/​Server/​AIFunctionMcpServerTool.cs:1680

This shortcut bypasses member converters and runs outside the serialization exception handler. A default JsonElement property decorated with member-level WhenWritingDefault and a converter that writes a valid value for Undefined is omitted from both snapshots. During recovery this branch uses the built-in converter instead, which throws for Undefined, turning the tool call into an error even though the member converter could satisfy the schema. Let values with property metadata use the converter-aware serialization path below.

[!NOTE]
AI-generated by GitHub Copilot.

3 resolved since last review

Review effort: Balanced

Comment thread src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Remaining recovery failures can throw during repairable calls or leave structured output inconsistent with its schema.

4 open issues
1 resolved since last review

Review effort: Balanced

Comment on lines +836 to +840
List<RepairCandidate> branchCandidates = candidates.Where(candidate =>
group.Contains(candidate.Key) || !grouped.Contains(candidate.Key)).ToList();
if (branchCandidates.Count > 0 && branchCandidates.Count < candidates.Count &&
pending.Count < maxCandidateSetEvaluations)
{
Comment on lines +1528 to +1529
if (value is IDictionary dictionary && serializedParent is JsonObject serializedDictionary &&
TryGetDictionaryValue(dictionary, serializedDictionary, segment, serializerOptions, out object? dictionaryValue))
{
if (string.Equals(property.Name, propertyName, StringComparison.Ordinal) && property.Get is not null)
{
value = property.Get(source);
Comment on lines +1733 to +1735
if (!reference.StartsWith("#", StringComparison.Ordinal))
{
return false;

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UseStructuredContent: with the default serializer options, structuredContent omits a null property that outputSchema requires

5 participants