Skip to content

Serve both lifecycle eras over stdio with an era lock per SEP-2575 - #478

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

Serve both lifecycle eras over stdio with an era lock per SEP-2575#478
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:stdio_era_lock

Conversation

@koic

@koic koic commented Aug 2, 2026

Copy link
Copy Markdown
Member

Motivation and Context

Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for the 2026-07-28 MCP spec release. StdioTransport now serves the legacy handshake lifecycle and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era for its lifetime.

  • Each stdin frame is parsed once (symbolize_names: true) so era classification can inspect its method and _meta. Frames that are not JSON objects fall back to ServerSession#handle_json, keeping protocol-level error responses byte-identical.
  • A successful initialize locks :legacy (already a side effect of ServerSession#mark_initialized!). A successful server/discover or a successful request carrying the full modern _meta triple locks :modern. Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked, so a client probe can still fall back to the other era.
  • Era violations are rejected in-band by Server#lift_request_envelope, which now also covers the legacy side: a modern envelope arriving on a legacy-locked session is an invalid request (-32600), mirroring the existing modern-side rules (initialize after a modern lock is -32022; a missing envelope after a modern lock is -32600).
  • StdioTransport#send_request raises on a modern-locked session: the modern lifecycle forbids server-initiated JSON-RPC requests, which multi round-trip input_required results (SEP-2322) replace. The inline read loop inside send_request dispatches through the same era-aware path as the main loop.

Refs #389.

How Has This Been Tested?

New tests in test/mcp/server/transports/stdio_transport_test.rb drive full stdin/stdout round trips: legacy lock via initialize then rejection of a modern envelope, modern lock via server/discover then -32022 (with data.supported) for a late initialize, modern lock via an envelope-carrying request, no lock when the probe fails with an unsupported version (a legacy initialize still succeeds afterwards), the envelope requirement after a modern lock, and the send_request prohibition on modern sessions.

bundle exec rake (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.

Breaking Changes

None. Legacy clients send initialize first and take the same code path as before; the era lock only constrains message sequences that mix lifecycles on one connection, which no legacy client produces.

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

## Motivation and Context

Third step of the stateless lifecycle (SEP-2575, modelcontextprotocol/modelcontextprotocol#2575) for
the 2026-07-28 MCP spec release. `StdioTransport` now serves the legacy handshake lifecycle
and the modern per-request-envelope lifecycle on one connection, with the same era-lock semantics as
the TypeScript and Python SDKs: the first era-distinctive message to SUCCEED locks the connection era
for its lifetime.

- Each stdin frame is parsed once (`symbolize_names: true`) so era classification can inspect its method
  and `_meta`. Frames that are not JSON objects fall back to `ServerSession#handle_json`,
  keeping protocol-level error responses byte-identical.
- A successful `initialize` locks `:legacy` (already a side effect of `ServerSession#mark_initialized!`).
  A successful `server/discover` or a successful request carrying the full modern `_meta` triple locks `:modern`.
  Failed era-distinctive messages (for example an unsupported envelope version) leave the connection unlocked,
  so a client probe can still fall back to the other era.
- Era violations are rejected in-band by `Server#lift_request_envelope`, which now also covers the legacy side:
  a modern envelope arriving on a legacy-locked session is an invalid request (`-32600`), mirroring
  the existing modern-side rules (`initialize` after a modern lock is `-32022`; a missing envelope after
  a modern lock is `-32600`).
- `StdioTransport#send_request` raises on a modern-locked session: the modern lifecycle forbids
  server-initiated JSON-RPC requests, which multi round-trip `input_required` results (SEP-2322) replace.
  The inline read loop inside `send_request` dispatches through the same era-aware path as the main loop.

Refs modelcontextprotocol#389.

## How Has This Been Tested?

New tests in `test/mcp/server/transports/stdio_transport_test.rb` drive full stdin/stdout round trips:
legacy lock via `initialize` then rejection of a modern envelope, modern lock via `server/discover`
then `-32022` (with `data.supported`) for a late `initialize`, modern lock via an envelope-carrying request,
no lock when the probe fails with an unsupported version (a legacy `initialize` still succeeds afterwards),
the envelope requirement after a modern lock, and the `send_request` prohibition on modern sessions.

`bundle exec rake` (tests, RuboCop, and conformance baseline, including the stdio conformance scenarios) passes.

## Breaking Changes

None. Legacy clients send `initialize` first and take the same code path as before;
the era lock only constrains message sequences that mix lifecycles on one connection,
which no legacy client produces.
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.

2 participants