Skip to content

fix(model): preserve explicit null structuredContent in CallToolResult - #1295

Merged
DaleSeo merged 1 commit into
modelcontextprotocol:mainfrom
JMLX42:fix/preserve-explicit-null-structured-content
Sep 24, 2026
Merged

DaleSeo merged 1 commit into
modelcontextprotocol:mainfrom
JMLX42:fix/preserve-explicit-null-structured-content

Conversation

@JMLX42

@JMLX42 JMLX42 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Motivation and Context

The MCP 2026-07-28 specification, Tools > Structured Content says:

Structured content is returned as a JSON value in the structuredContent field of a result. This can be any JSON value (object, array, string, number, boolean, or null) that conforms to the tool's outputSchema if one is defined.

For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.

The 2026-07-28 schema types CallToolResult.structuredContent as unknown ("This can be any JSON value (object, array, string, number, boolean, or null)").

So null is a legal structured result, and the TextContent copy is only a SHOULD. Today CallToolResult's custom Deserialize reads structured_content as a plain Option<Value>, which decodes a present null as None, the same as an absent field:

  1. {"resultType":"complete","content":[],"structuredContent":null} decodes with structured_content == None: the explicit null is lost.
  2. {"resultType":"complete","structuredContent":null} fails to decode as CallToolResult ("expected at least one known CallToolResult field"), so inside ServerResult it falls through to CustomResult.

We hit this while proxying tool results whose structured value is legitimately null.

Fix

  • The deserialization helper decodes a present structuredContent as Some(value) (#[serde(default, deserialize_with = "deserialize_present_value")]). An explicit null becomes Some(Value::Null) and counts as a known field in the existing guard.
  • The public type stays Option<Value>. Serialization is unchanged: skip_serializing_if = "Option::is_none" already omits only None, so Some(Value::Null) serializes as "structuredContent": null.
  • The field's doc comment (and the committed message schema description) now says "any JSON value, including null" instead of "JSON object".

How Has This Been Tested?

New tests, all failing on main except the two guards:

  • test_structured_output.rs: explicit null with content is preserved; explicit null without content decodes; an absent field stays None; Some(Value::Null) round-trips as "structuredContent": null while None omits the field.
  • test_deserialization.rs (untagged_server_result): both payloads above, plus {"structuredContent": null}, deserialize as ServerResult::CallToolResult with Some(Value::Null); an object with only unknown null fields still falls through to CustomResult (the fix: prevent CallToolResult from shadowing CustomResult in untagged enum deserialization #771 invariant).

cargo test --all-features, cargo +nightly fmt --all -- --check, and both clippy invocations from CI pass locally.

Breaking Changes

None. Absent structuredContent still decodes as None, and the public field type is unchanged. The only behavior change is that a present null now decodes as Some(Value::Null) instead of None, and the second payload above now decodes as CallToolResult instead of failing.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Related but distinct: #750, #919, #1046, #1094.


🤖 Written by Claude Code (2.1.280) using model claude-opus-5-5

The MCP 2026-07-28 spec allows `structuredContent` to be any JSON value,
including `null`. Deserializing `CallToolResult` collapsed a present
`"structuredContent": null` into `None`, the same as an absent field, and
rejected `{"resultType":"complete","structuredContent":null}` because no
known non-null field was left.

Decode a present `structuredContent` as `Some(value)`, so an explicit
`null` becomes `Some(Value::Null)` and counts as a known field. An absent
field still decodes as `None`, and serialization still omits only `None`.

Signed-off-by: Jean-Marc Le Roux <jeanmarc@lx.industries>
@JMLX42
JMLX42 requested a review from a team as a code owner September 23, 2026 19:08
@github-actions github-actions Bot added T-test Testing related changes T-config Configuration file changes T-core Core library changes labels Sep 23, 2026

@DaleSeo DaleSeo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the fix, @JMLX42!

@DaleSeo
DaleSeo merged commit 90516bf into modelcontextprotocol:main Sep 24, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-config Configuration file changes T-core Core library changes T-test Testing related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants