Skip to content

Preserve nulls required by structured output schemas - #1929

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

mikemikimike wants to merge 8 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.

_ => false,
};

if (propertyInfo?.ShouldSerialize is not null && !omittedByMemberIgnore)

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.

Could we keep caller-provided ShouldSerialize filters authoritative even when the member-level ignore condition also matches? The earlier filter fix still permits an excluded non-null value to be published.

I reproduced this with [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)] public int Secret { get; init; }, Secret = 0, and a resolver that sets this property's ShouldSerialize to (_, _) => false. An explicit output schema requires secret as integer or null. Both serialization snapshots omit the property, but omittedByMemberIgnore is true, so this guard allows source recovery and the response becomes {"secret":0} despite the caller's exclusion predicate.

The same repro also fails with int? Secret = 0. In that case, IsDefaultValue additionally treats nullable zero as default by unwrapping the nullable type, although default(int?) is null and the member ignore condition alone would not omit zero.

Please preserve caller-defined exclusion predicates without confusing them with the serializer's own ignore-condition predicates, and correct the nullable-default check. Regression coverage should include both the non-nullable and nullable zero cases; fixing only nullable-default detection would leave the first bypass intact.

repairedResult = originalResult.DeepClone();
foreach (RepairCandidate candidate in candidates)
{
ApplyRepairCandidate(repairedResult, candidate);

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.

Could intermediate repairs remain isolated to an applicable composition branch rather than applying every collected candidate? The nested-parent and broad-union regressions now pass individually, but their combination still fails.

I reproduced this under global WhenWritingDefault with an output containing Tag = "0", a default-valued child struct whose nullable Note has member-level WhenWritingNull, and Other = null. The schema is:

{
  "type": "object",
  "oneOf": [
    {
      "properties": {
        "tag": { "const": "0" },
        "child": {
          "type": "object",
          "properties": { "note": { "type": ["string", "null"] } },
          "required": ["note"]
        }
      },
      "required": ["tag", "child"],
      "additionalProperties": false
    },
    {
      "properties": {
        "tag": { "const": "1" },
        "other": { "type": ["string", "null"] }
      },
      "required": ["tag", "other"],
      "additionalProperties": false
    }
  ]
}

The first pass discovers child:{} and other:null. No candidate set validates yet because child.note has not been discovered. This fallback then inserts both candidates. The next pass can add child.note, but other is now an extra property forbidden by the matching tag-0 branch. Later passes only add missing properties and cannot remove it, so repair gives up and returns the original invalid {"tag":"0"}.

Please isolate or backtrack intermediate branch repairs, and add a regression asserting the result contains child.note:null without other.

}

if (instanceNode is JsonObject dependencyInstance &&
schemaObject["dependentSchemas"] is JsonObject dependentSchemas)

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.

Could we also collect names from applicable dependentRequired entries? The new dependentSchemas traversal fixes the previously reported case, but the equivalent dependent-required form is still skipped:

{
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "note": { "type": ["string", "null"] }
  },
  "dependentRequired": { "id": ["note"] }
}

With a tool returning Id = "42", Note = null under the default null-omitting options, I reproduced structured content remaining {"id":"42"}. Validation reports the missing dependency, but candidate collection never adds note, even though the complete snapshot contains its actual null value.

Please merge each active dependentRequired list into the instance's required-name context when its trigger property is present, and add a regression asserting note:null is restored.

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 on lines +758 to +759
JsonElement completeResult = JsonSerializer.SerializeToElement(
originalResult, _serializerTypeInfoIncludingDefaults.Value);
Comment on lines +1032 to +1035
repairedResult = originalResult.DeepClone();
foreach (RepairCandidate candidate in candidates)
{
ApplyRepairCandidate(repairedResult, candidate);
Comment on lines +1186 to +1187
if (instanceNode is JsonObject dependencyInstance &&
schemaObject["dependentSchemas"] is JsonObject dependentSchemas)

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