Repository navigation
fix(handler): generate a tool's outputSchema for the serialize contract - #1378
Conversation
schema_for_output used schemars' default deserialize contract, so an always-written Option or #[serde(default)] field was advertised as optional and a type accepting several forms advertised all of them. Tool results are serialised; inputs keep the deserialize contract. Closes modelcontextprotocol#1374
|
@connor4312 is on triage rotation this week and is the reviewer for this one. Labels come from the PR labeler. Please review the change. Automated triage. Labels are a first pass and the assigned maintainer has the final say. |
chrikrah
left a comment
There was a problem hiding this comment.
@atarutin this is mergeable at b1b6189. It fixes the example in #1374, and reverting the one line in schema_for_output fails the new test.
non-blocking: this changes what existing servers advertise. required grows on upgrade for a plain Option field and a #[serde(default)] field. A client that generates types from outputSchema sees those fields flip from optional to required. That is the intended correction, and the PR title carries it into the changelog.
non-blocking: #[tool(output_schema = schema_for_type::<T>())], as in test_json_schema_detection.rs:59, stays on the deserialize contract. That stays untouched, since an explicit override is the author saying what they mean.
$ cargo test -p rmcp --features client,server,macros,elicitation --lib --test test_json_schema_detection --test test_list_tools_result --test test_structured_output --test test_tool_builder_methods # head b1b6189, rustc 1.96.1
241 passed; 7 passed; 1 passed; 21 passed; 6 passed
# schema_for_output calling schema_for_type again, new tests kept
schema_for_output_describes_the_serialized_form ... FAILED
# struct { name: String, note: Option<String>, #[serde(default)] tags: Vec<String> }, schema_for_output::<T>()
# base 50fba5c "required":["name"]
# head b1b6189 "required":["name","note","tags"] # "tags" still carries "default":[]
$ cargo fmt --check # clean
# not run: clippy, the full --all-features suite (no pkg-config for openssl-sys here)
@connor4312, you're the assigned reviewer. The changelog line covers the required change. Does it also need a release note?
|
Thanks for the fix! The change looks right, but I'd like a few more tests before merging:
Optional: in the first test, compare |
SIDDARTHAREDDY8
left a comment
There was a problem hiding this comment.
I read the diff at this head (code-read only, no local run). The direction looks right: tool outputs are serialized by the server, so outputSchema should describe the serialize form, and splitting the serialize cache (CACHE_FOR_SERIALIZED) from the deserialize cache means a type requested both ways cannot get the wrong schema from the other cache. I also confirmed the #[tool] macro path calls schema_for_output (rmcp-macros/src/tool.rs), so generated tools pick this up.
One gap between the docs and the tests: the doc comment on schema_for_serialized promises two more serialize-contract behaviors, a Decimal serializing as a string and a #[serde(default)] field always being written, but the new tests only cover Option with and without skip_serializing_if. Either add cases for those two, or narrow the comment to what the tests cover.
One scoping question: this fixes attribute-driven divergence between the two forms. A type with a hand-written Serialize impl that writes a different shape than its JsonSchema derive describes would still mismatch, since schemars works from the derive. Is that intentionally out of scope here, or worth a note in the function docs?
Call schema_for_type and schema_for_output on one type that derives both Serialize and Deserialize, so their separate caches cannot be mixed up; check that a #[serde(default)] field is required in the output schema and not the input; make the input test call schema_for_input; compare the output test's properties and required together. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the review. Added in e7830c6:
I also took the optional one: the first test compares |
rmcp 3.9.0 (modelcontextprotocol/rust-sdk#1378) generates a tool's outputSchema for what `Serialize` writes rather than what `Deserialize` accepts, so an `Option` or `#[serde(default)]` field without `skip_serializing_if` is now `required`. Eleven `$defs` and the two Secure Kernel trace roots gain required fields: 450 B across sixteen tools' output schemas, nothing model-visible (139,647 B unchanged, wire 416,155 -> 416,605 B against the 420,000 B ceiling). Diffing all 89 tools' output schemas built from this tree against rmcp 3.8.0 and 3.9.0 shows only `required` lists move; no property, type or branch changes. Every newly required field is a derived, always-written key, and structuredContent is `serde_json::to_value` unmodified, so a result validates against the new schema. Re-records tests/golden/tools_list.json and tool_budget.json, moves the standalone M6 wire pin to 15,893 B, and carries the new total into docs/token-budget.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes #1374.
schema_for_outputnow generates withSchemaSettings::draft2020_12().for_serialize(). Tool results are values the server serialises, so their schema describes whatSerializewrites: anOptionwithoutskip_serializing_ifisrequired, and a type that accepts several forms advertises only the one it writes. Inputs (schema_for_type) keep the deserialize contract. There is no public API change, and for types whose two forms are equal the output schema is unchanged.Tests:
Optionisrequiredin an output schema, and a skipped one is not;Optionstays optional, and serde does accept the missing key.Ran:
cargo test -p rmcp --all-features, CI's rmcp clippy command (all features exceptlocal,-D warnings), andcargo +nightly fmt --check.