Skip to content

fix(server): omit non-object tool output for legacy peers - #1340

Open
Cassian433 wants to merge 1 commit into
modelcontextprotocol:mainfrom
Cassian433:legacy-peer-output-schema
Open

Cassian433 wants to merge 1 commit into
modelcontextprotocol:mainfrom
Cassian433:legacy-peer-output-schema

Conversation

@Cassian433

Copy link
Copy Markdown

Motivation and Context

Since SEP-2106 landed (#895, #933), a Json<Vec<T>> tool advertises an array outputSchema and returns an array structuredContent to every client, including ones that negotiated 2025-06-18 or 2025-11-25. Both of those schema versions pin outputSchema.type to "object" and type structuredContent as an object, so a client that validates tools/list against its negotiated version rejects the tool. handle_request in handler/server.rs already strips resultType for these peers. The output relaxation was never gated the same way.

With the repro from the issue on main (c1a8eea), initialize at 2025-06-18 and then tools/list + tools/call:

{"tools":[{"name":"names","description":"Returns a list of names","inputSchema":{"properties":{},"type":"object"},"outputSchema":{"$schema":"https://json-schema.org/draft/2020-12/schema","items":{"type":"string"},"type":"array"}}]}
{"content":[{"type":"text","text":"[\"a\",\"b\"]"}],"structuredContent":["a","b"],"isError":false}

On this branch:

{"tools":[{"name":"names","description":"Returns a list of names","inputSchema":{"properties":{},"type":"object"}}]}
{"content":[{"type":"text","text":"[\"a\",\"b\"]"}],"isError":false}

I added ServerResult::strip_non_object_output_for_legacy_peer next to strip_result_type_for_legacy_peer and call it from the same branch for peers below 2026-07-28. It drops a tool's outputSchema when the root type isn't the string "object", which also covers the oneOf and anyOf + $ref roots schemars emits for tagged enums and Option<S>. It drops structuredContent when it isn't an object. Object schemas and object results pass through as before. The text block that Json<T> always emits stays. If a hand-built result has no content at all, the dropped value goes into a text block so nothing is lost. The Json<T> docs now spell out the version split.

Fixes #1337

How Has This Been Tested?

The new tests/test_structured_output_version.rs serves a Json<Vec<String>> tool and a Json<struct> tool over serve_directly, the same way test_result_type_version.rs does. At 2025-06-18 and 2025-11-25 the array tool comes back with no outputSchema and no structuredContent, and into_typed::<Vec<String>>() still decodes it from the text block. The object tool keeps both. At 2026-07-28 the array schema and ["a","b"] come through unchanged. On main the two legacy tests fail, with the assertion printing the "type": "array" schema. The 2026-07-28 test passes either way.

Three unit tests in model.rs pin the rules: array, oneOf and anyOf + $ref schemas are dropped while an object root is kept, an empty content gets the value as text, and an existing text block isn't duplicated. Against the strip without the empty-content fallback, the second one fails with content coming back as [].

I also ran the issue's stdio repro against main and this branch. Asking for 2026-07-28 in initialize still negotiates 2025-11-25, and that session now gets the stripped list too. A 2026-07-28 session using inline _meta produces byte-identical output before and after (479 bytes both ways).

cargo test -p rmcp with every feature except local passes 1321 tests, cargo test --all-features passes 909, and cargo test -p rmcp --lib --no-default-features passes 142. Both CI clippy invocations, cargo +nightly fmt --all -- --check, the nightly rustdoc build and cargo +1.88.0 check are clean.

Breaking Changes

Only for peers below 2026-07-28. Code that reads structured_content directly for a list-returning tool on such a session now gets None and has to use the text block; CallToolResult::into_typed already falls back to it. 2026-07-28 sessions see no change.

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

I went with omitting instead of wrapping. The TypeScript SDK (modelcontextprotocol/typescript-sdk#2337) and the C# SDK wrap for 2025-era peers: the schema becomes {"type":"object","properties":{"result":...}} and the value becomes {"result": value}. That only works if the value-side wrap matches the schema-side wrap, and handle_request only sees the CallToolResult, not the tool it came from. Matching them would mean threading the tool through ToolRouter, and hand-written call_tool impls would still go unmatched. Dropping both sides is stateless and always valid against the 2025 schemas. An opt-in wrapper (the issue's second suggestion) can come later.

Router<S> answers tools/list and routed tools/call itself and never reaches this branch, the same way it already skips the resultType strip and the cache hints. I left that for a follow-up.

@Cassian433
Cassian433 requested a review from a team as a code owner October 11, 2026 12:37
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-handler Handler implementation changes labels Oct 11, 2026

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

T-core Core library changes T-handler Handler implementation changes T-test Testing related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-object outputSchema and structuredContent are sent to clients that negotiated 2025-06-18 / 2025-11-25, where an object is required

1 participant