Skip to content

Align modern envelope validation with the finalized spec - #491

Merged
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:align_modern_envelope_validation_with_final_spec
Aug 8, 2026
Merged

Align modern envelope validation with the finalized spec#491
koic merged 1 commit into
modelcontextprotocol:mainfrom
koic:align_modern_envelope_validation_with_final_spec

Conversation

@koic

@koic koic commented Aug 7, 2026

Copy link
Copy Markdown
Member

Motivation and Context

Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation, both introduced against the frozen SEP text and overtaken by post-final spec changes:

  1. clientInfo was treated as required. Spec PR feat(schema): add optional serverInfo response metadata and make clientInfo optional modelcontextprotocol#3002 made it optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only the protocolVersion + clientCapabilities pair. A conformant client configured not to identify itself was rejected.

  2. A missing or mistyped envelope answered -32600 Invalid Request. The spec maps missing required envelope fields to -32602 Invalid params, and both reference SDKs answer with -32602 naming the offending keys.

Fixing these also corrects the era classification to match the reference SDKs: a request claims the modern lifecycle when _meta carries io.modelcontextprotocol/protocolVersion (the TypeScript envelope claim and the Python _has_modern_envelope are both single-key checks), and a claimed-but-incomplete envelope is now validated and rejected with -32602 naming the missing keys instead of silently flowing through the legacy path. Legacy _meta usage without the claim key (progressToken, trace context) is classified exactly as before.

Unchanged on purpose: the era-lock violations keep their codes (-32600 for a modern envelope on a legacy-locked session, -32022 for initialize on a modern-locked one), matching the Python SDK.

How Has This Been Tested?

test/mcp/request_envelope_test.rb now covers: classification by the claim key alone, a claimed-but-incomplete envelope staying modern, parsing without the optional clientInfo (reader returns nil), -32602 with the offending key names for a missing clientCapabilities and for mistyped required and optional fields.

test/mcp/server_test.rb covers the dispatch-level behavior: a claimed but incomplete envelope answers -32602 naming the missing key, an envelope without clientInfo is served with server_context.client_info reading nil, and a claim-less request on a modern-locked session answers -32602 naming the required keys. The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.

Breaking Changes

None for conforming clients. Requests that were already rejected change error code (-32600 to -32602, HTTP status 400 unchanged), and requests carrying the protocolVersion claim key with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests. Envelopes without clientInfo, previously rejected, now succeed.

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

atesgoral
atesgoral previously approved these changes Aug 7, 2026
@seunghan91

Copy link
Copy Markdown

Adopting this SDK for a production Rails MCP server, so I read #489 and this PR side by side. They overlap in lib/mcp/request_envelope.rb in a way that looks unintentional — flagging it in case it is useful.

Both PRs make the same three changes independently

change #489 #491
drop CLIENT_INFO_META_KEY from REQUIRED_META_KEYS
modern?protocolVersion alone
parse! accepts client_info == nil

So whichever lands first, the other conflicts on those hunks.

The two differ in the wire message, not just the code

#489 sets error_type: :invalid_params without error_code; this PR also sets error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS. In lib/json_rpc_handler.rb#handle_request_error those take different branches:

if error.respond_to?(:error_code) && error.error_code
  code = error.error_code
  message = error.message          # detailed message reaches the wire
else
  code, message = case error_type
  when :invalid_params then [ErrorCode::INVALID_PARAMS, "Invalid params"]   # message replaced

Both yield -32602, but without error_code the detailed text is demoted to data and message becomes the literal "Invalid params".

That matters for one of this PR's own assertions — test/mcp/server_test.rb:

assert_includes response.dig(:error, :message), "io.modelcontextprotocol/clientCapabilities"

This passes with #491's error_code but not with #489's error_type-only form, since message would be the fixed string. Worth deciding which shape is canonical before either merges. #491's "name only the offending keys" (invalid_keys) also seems more useful to clients than #489's "list all required keys", and its refute_includes on protocolVersion encodes that.

initialize on a modern-locked session points opposite ways

  • Enforce the modern lifecycle admission rules per SEP-2575 #489 renames test "#handle rejects initialize on a modern-locked session with -32022" to cover MODERN_REMOVED_METHODS, answering -32601 / HTTP 404.
  • This PR leaves that test untouched, and its description says -32022 is unchanged on purpose to match the Python SDK.

Both are defensible readings of SEP-2575, but the merge order currently decides which one ships. If -32601 is the intent, the "unchanged on purpose" note here may need revisiting; if -32022 is, #489's rename does.

For context on why the wire shape matters downstream: our server has to stay dual-era for a while (Claude Code and other clients still speak the handshake revisions), and the documented HTTP detection is "attempt a modern request, then inspect the body of a 400 before falling back." A legacy-only server that emits a recognizable modern error gets misclassified as modern, so we deliberately avoid -32022 on our legacy endpoint. Which code a modern-locked server returns for initialize feeds directly into that logic.

Happy to test whichever lands against a real dual-era deployment and report back.

## Motivation and Context

Two deviations from the finalized 2026-07-28 specification in the SEP-2575 envelope validation,
both introduced against the frozen SEP text and overtaken by post-final spec changes:

1. `clientInfo` was treated as required. Spec PR modelcontextprotocol/modelcontextprotocol#3002 made it
optional (clients SHOULD include it unless configured not to), and the TypeScript and Python SDKs validate only
the `protocolVersion` + `clientCapabilities` pair.
A conformant client configured not to identify itself was rejected.

2. A missing or mistyped envelope answered `-32600` Invalid Request.
The spec maps missing required envelope fields to `-32602` Invalid params, and both reference SDKs answer
with `-32602` naming the offending keys.

Fixing these also corrects the era classification to match the reference SDKs: a request claims
the modern lifecycle when `_meta` carries `io.modelcontextprotocol/protocolVersion`
(the TypeScript envelope claim and the Python `_has_modern_envelope` are both single-key checks),
and a claimed-but-incomplete envelope is now validated and rejected with `-32602` naming
the missing keys instead of silently flowing through the legacy path. Legacy `_meta` usage without
the claim key (`progressToken`, trace context) is classified exactly as before.

Unchanged on purpose: the era-lock violations keep their codes (`-32600` for a modern envelope on
a legacy-locked session, `-32022` for `initialize` on a modern-locked one), matching the Python SDK.

## How Has This Been Tested?

`test/mcp/request_envelope_test.rb` now covers: classification by the claim key alone,
a claimed-but-incomplete envelope staying modern, parsing without the optional `clientInfo`
(reader returns `nil`), `-32602` with the offending key names for a missing `clientCapabilities`
and for mistyped required and optional fields.

`test/mcp/server_test.rb` covers the dispatch-level behavior: a claimed
but incomplete envelope answers `-32602` naming the missing key, an envelope without `clientInfo` is
served with `server_context.client_info` reading `nil`, and a claim-less request on
a modern-locked session answers `-32602` naming the required keys.
The stdio and Streamable HTTP transport tests assert the new code on their envelope-requirement paths.

## Breaking Changes

None for conforming clients. Requests that were already rejected change error code
(`-32600` to `-32602`, HTTP status 400 unchanged), and requests carrying the `protocolVersion` claim key
with an incomplete envelope are now rejected as the spec mandates instead of being served as legacy requests.
Envelopes without `clientInfo`, previously rejected, now succeed.
@koic
koic force-pushed the align_modern_envelope_validation_with_final_spec branch from 4723a2e to f39b1bf Compare August 8, 2026 07:02
@koic

koic commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

@seunghan91 Thanks for the careful review and for comparing #489 and #491 side by side. The observations about the overlap, the wire behavior, and the dual-era implications were all very helpful.

Resolution: #491 will merge first, and its validation behavior will become the canonical one (-32602 with error_code set, naming only the offending keys). #489 will then be rebased on top of it, dropping the duplicated lib/mcp/request_envelope.rb changes and adopting the same error_code form for the claim-less rejection in lift_request_envelope, so the detailed message continues to reach the wire there as well.

For initialize on a modern-locked session, -32601 with HTTP 404 is the intended direction rather than -32022. The frozen 2026-07-28 conformance requirements mark sep-2575-http-server-method-not-found-404-initialize and the other removed-method checks as MUST requirements, so #489's behavior is required for conformance. The -32022 assertion left unchanged in #491 will therefore be updated to -32601 as part of the #489 rebase.

For the dual-era detection described above, that means a modern probe receiving HTTP 404 with -32601 indicates a removed method on a modern-capable server, while -32022 remains reserved for version negotiation.

Thanks as well for the offer to test against a real dual-era deployment. Feedback on the rebased #489 would be greatly appreciated.

@koic
koic merged commit 05c3585 into modelcontextprotocol:main Aug 8, 2026
11 checks passed
@koic
koic deleted the align_modern_envelope_validation_with_final_spec branch August 8, 2026 07:04
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.

3 participants