fix(bridge): reject resource subscriptions on rmcp's stateless HTTP path - #490
Merged
Merged
Conversation
This was referenced Sep 21, 2026
bug-ops
enabled auto-merge (squash)
September 21, 2026 12:52
rmcp's HTTP router and its request handler classify a request's lifecycle differently: the router treats a request whose `_meta` carries both discover-lifecycle keys as stateless regardless of the declared protocol version, while the handler treats the same request as legacy whenever that declared version predates 2026-07-28. For that mismatch case, `resources/subscribe`/`unsubscribe` reached a per-request `McplsServer` instance whose subscription state is dropped the moment the request completes, making both calls a silent no-op. Reject such requests with an explicit error instead. Detection is gated on the request actually being HTTP-served (never stdio) and fires when the request's `_meta` matches rmcp's own discover-lifecycle check or when it echoes no `Mcp-Session-Id` header at all -- a present session header proves nothing on its own, since rmcp never validates it on this path.
bug-ops
force-pushed
the
fix/482-stateless-subscriptions
branch
from
September 21, 2026 12:54
f86dfa4 to
59ea09b
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
resources/subscribe/unsubscribewere silently dropped as no-ops when arequest landed on rmcp's stateless per-request HTTP discover-lifecycle path.
rmcp's router (
tower.rs) and its request handler (handler/server.rs)classify a request's lifecycle using different inputs: the router treats a
request as stateless whenever its
_metacarries both discover-lifecyclekeys, regardless of what protocol version they declare, while the handler
treats the same request as legacy whenever the declared version predates
2026-07-28. For that mismatch case (_metapresent with a pre-2026-07-28version), the router serves the request through an ephemeral, per-request
McplsServerinstance, but the handler dispatches straight toMcplsServer::subscribe/unsubscribeanyway — so the subscription isrecorded into state that's dropped the instant the request completes,
returning
result: {}as if it had succeeded.This closes the gap by rejecting the affected requests with an explicit
error (
-32051) instead of a silent no-op. Detection (reject_if_stateless_httpin
crates/mcpls-core/src/mcp/server.rs) is gated on the request actuallybeing HTTP-served (
Partsextension present), so stdio — mcpls's primarytransport — is never affected regardless of what
_metaa client sendsthere. It fires when the request's
_metamatches rmcp's owndiscover-lifecycle check (via rmcp's public
RequestMetaObject::missing_required_keys)or when the request echoes no
Mcp-Session-Idheader at all — a presentsession header (fabricated, stale, or even from a genuinely live session)
proves nothing on its own, since rmcp never validates it on this branch.
Investigation went through several rounds of adversarial review before
landing on this shape: an initial pass concluded no code change was needed
(rmcp appeared to already reject these requests) but that premise was
falsified by a live repro; a first guard keyed on the session header alone
turned out to be bypassable by attaching any header value; a second version
keyed purely on
_metafalse-positived on stdio. The final version combinesboth signals, gated on the request being HTTP-served.
Changes
crates/mcpls-core/src/mcp/server.rs—reject_if_stateless_httpguard,called first in
subscribe/unsubscribe; newSTATELESS_SUBSCRIPTION_ERROR_CODE(-32051).crates/mcpls-core/src/transport.rs— regression tests covering theoriginal rmcp-level rejection case, the mcpls-level lifecycle-mismatch
case (with and without a fabricated session header), the
unsubscribecase, and non-regression for legitimate legacy sessions.
crates/mcpls-core/src/bridge/resources.rs— updatedSubscriptionRegistrydoc describing the limitation and how it's addressed.
CHANGELOG.md— one-line entry under[Unreleased]/Fixed.Test plan
cargo +nightly fmt --all -- --checkcargo clippy --all-targets --all-features --workspace -- -D warningscargo nextest run --workspace --all-features --lib --bins(973 passed, 1 skipped)RUSTFLAGS="-D warnings" RUSTDOCFLAGS="--deny rustdoc::broken_intra_doc_links" cargo doc --no-deps --all-features --workspacecargo test --doc --all-features --workspacetransport-http) build compiles cleanCloses #482