Skip to content

Fulfill input_required results on the legacy wire per SEP-2322 - #501

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:mrtr_legacy_shim
Open

Fulfill input_required results on the legacy wire per SEP-2322#501
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:mrtr_legacy_shim

Conversation

@koic

@koickoic commented Aug 10, 2026

Copy link
Copy Markdown
Member

Motivation and Context

SEP-2322 handlers return InputRequiredResult instead of issuing in-flight server-to-client requests, but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown resultType as a final result, so the server rejected input_required on legacy requests with an internal error. That forced authors to keep two handler styles, one per era.

This adds the dual-era authoring shim both reference implementations converged on: the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381) and the Python SDK's version-negotiating resolver (python-sdk#2986). When a tools/call, prompts/get, or resources/read handler returns an InputRequiredResult on the legacy wire, the server fulfills it in place of the client's driver:

  • Every inputRequests entry is sent verbatim as the equivalent real server-to-client request through a new ServerSession#fulfill_input_request (per the spec, clients treat each embedded entry exactly like the standalone request), stamped with the originating request id so the association requirement of SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream.
  • The answers are collected under the same keys and the handler re-runs with inputResponses and the raw requestState merged into the original params - the same deterministic replay contract the modern client driver follows, read through the existing server_context.input_responses/request_state accessors. Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through requestState.
  • requestState round-trips in-process as the value the handler wrote. RequestStateSecurity sealing is wire hardening against a tampering client and does not apply to the in-process replay.
  • The embedded-capability gate still runs each round against the session's declared client capabilities; a violation fails before any client contact. The -32021 code is 2026-only, so the legacy wire gets an internal error naming the missing capabilities.
  • Rounds are capped at 8 (LEGACY_INPUT_REQUIRED_MAX_ROUNDS), matching the TypeScript shim's maxRounds default; exhaustion fails with an explanatory internal error, which also bounds the requestState-only load-shedding form that cannot make progress in-process.

The shim is on by default, matching the TypeScript SDK; Server.new(input_required_legacy_shim: false) restores the strict rejection. Requests without a session (no notification path back to a client) and all modern requests are untouched: the modern wire keeps returning the input_required result for the client driver.

Resolves#382.

How Has This Been Tested?

bundle exec rake passes: 1601 test runs / 4094 assertions with zero failures and RuboCop clean. The frozen 2025-11-25 conformance server leg passes 78/78 (the legacy wire is where the shim lives), and the modern input-required-result-* scenarios stay green, confirming the modern path is untouched.

New test/mcp/server_input_required_legacy_shim_test.rb covering: completion through a real elicitation with the SEP-2260 association asserted on the transport, replay exposing the collected responses and the raw requestState, sealing bypass under RequestStateSecurity, prompts/get and resources/read shimming, multi-round collection, the round cap, the capability gate failing before client contact, the opt-out, session-less rejection, and modern requests staying untouched.

The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.

Breaking Changes

Only for handlers that returned InputRequiredResult to legacy clients THROUGH A SESSION and relied on the internal-error rejection: those now drive a real fulfilment round trip by default. No such handler could have worked end to end before (the request always failed), so this is the intended upgrade; input_required_legacy_shim: false restores the previous behavior. Everything else is unchanged.

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

## Motivation and Context
SEP-2322 handlers return `InputRequiredResult` instead of issuing in-flight server-to-client requests,
but that result type exists only on the 2026-07-28 wire: pre-2026 clients treat an unknown `resultType`
as a final result, so the server rejected `input_required` on legacy requests with an internal error.
That forced authors to keep two handler styles, one per era.
This adds the dual-era authoring shim both reference implementations converged on:
the TypeScript SDK's default-on legacy fulfilment shim (typescript-sdk#2381)
and the Python SDK's version-negotiating resolver (python-sdk#2986).
When a `tools/call`, `prompts/get`, or `resources/read` handler returns an `InputRequiredResult` on
the legacy wire, the server fulfills it in place of the client's driver:
- Every `inputRequests` entry is sent verbatim as the equivalent real server-to-client request through
a new `ServerSession#fulfill_input_request` (per the spec, clients treat each embedded entry exactly like
the standalone request), stamped with the originating request id so the association requirement of
SEP-2260 holds and Streamable HTTP delivery rides the originating POST stream.
- The answers are collected under the same keys and the handler re-runs with `inputResponses`
and the raw `requestState` merged into the original params - the same deterministic replay contract
the modern client driver follows, read through the existing `server_context.input_responses`/`request_state` accessors.
Only the current round's responses are sent, matching the client driver; handlers carry earlier answers through `requestState`.
- `requestState` round-trips in-process as the value the handler wrote. `RequestStateSecurity` sealing is
wire hardening against a tampering client and does not apply to the in-process replay.
- The embedded-capability gate still runs each round against the session's declared client capabilities;
a violation fails before any client contact. The `-32021` code is 2026-only, so the legacy wire gets
an internal error naming the missing capabilities.
- Rounds are capped at 8 (`LEGACY_INPUT_REQUIRED_MAX_ROUNDS`), matching the TypeScript shim's `maxRounds` default;
exhaustion fails with an explanatory internal error, which also bounds the `requestState`-only load-shedding form
that cannot make progress in-process.
The shim is on by default, matching the TypeScript SDK; `Server.new(input_required_legacy_shim: false)` restores
the strict rejection. Requests without a session (no notification path back to a client) and all modern requests
are untouched: the modern wire keeps returning the `input_required` result for the client driver.
Resolvesmodelcontextprotocol#382.
## How Has This Been Tested?
`bundle exec rake` passes: 1601 test runs / 4094 assertions with zero failures and RuboCop clean.
The frozen 2025-11-25 conformance server leg passes 78/78 (the legacy wire is where the shim lives),
and the modern `input-required-result-*` scenarios stay green, confirming the modern path is untouched.
New `test/mcp/server_input_required_legacy_shim_test.rb` covering: completion through
a real elicitation with the SEP-2260 association asserted on the transport, replay exposing
the collected responses and the raw `requestState`, sealing bypass under `RequestStateSecurity`,
`prompts/get` and `resources/read` shimming, multi-round collection, the round cap, the capability gate failing
before client contact, the opt-out, session-less rejection, and modern requests staying untouched.
The pre-existing legacy rejection test (session-less) and all modern MRTR serialization tests pass unchanged.
## Breaking Changes
Only for handlers that returned `InputRequiredResult` to legacy clients THROUGH A SESSION and relied
on the internal-error rejection: those now drive a real fulfilment round trip by default.
No such handler could have worked end to end before (the request always failed), so this is the intended upgrade;
`input_required_legacy_shim: false` restores the previous behavior. Everything else is unchanged.
Sign up for freeto 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.

SEP-2322: Multi Round-Trip Requests

1 participant

@koic