Skip to content

fix(bridge): reject resource subscriptions on rmcp's stateless HTTP path - #490

Merged
bug-ops merged 2 commits into
mainfrom
fix/482-stateless-subscriptions
Sep 21, 2026
Merged

bug-ops merged 2 commits into
mainfrom
fix/482-stateless-subscriptions

Conversation

@bug-ops

@bug-ops bug-ops commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Summary

resources/subscribe/unsubscribe were silently dropped as no-ops when a
request 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 _meta carries both discover-lifecycle
keys, 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 (_meta present with a pre-2026-07-28
version), the router serves the request through an ephemeral, per-request
McplsServer instance, but the handler dispatches straight to
McplsServer::subscribe/unsubscribe anyway — so the subscription is
recorded 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_http
in crates/mcpls-core/src/mcp/server.rs) is gated on the request actually
being HTTP-served (Parts extension present), so stdio — mcpls's primary
transport — is never affected regardless of what _meta a client sends
there. It fires when the request's _meta matches rmcp's own
discover-lifecycle check (via rmcp's public RequestMetaObject::missing_required_keys)
or when the request echoes no Mcp-Session-Id header at all — a present
session 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 _meta false-positived on stdio. The final version combines
both signals, gated on the request being HTTP-served.

Changes

  • crates/mcpls-core/src/mcp/server.rs — reject_if_stateless_http guard,
    called first in subscribe/unsubscribe; new STATELESS_SUBSCRIPTION_ERROR_CODE (-32051).
  • crates/mcpls-core/src/transport.rs — regression tests covering the
    original rmcp-level rejection case, the mcpls-level lifecycle-mismatch
    case (with and without a fabricated session header), the unsubscribe
    case, and non-regression for legitimate legacy sessions.
  • crates/mcpls-core/src/bridge/resources.rs — updated SubscriptionRegistry
    doc describing the limitation and how it's addressed.
  • CHANGELOG.md — one-line entry under [Unreleased]/Fixed.

Test plan

  • cargo +nightly fmt --all -- --check
  • cargo clippy --all-targets --all-features --workspace -- -D warnings
  • cargo 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 --workspace
  • cargo test --doc --all-features --workspace
  • Default (no transport-http) build compiles clean
  • New tests independently proven to fail against pre-fix code and pass against the fix

Closes #482

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
bug-ops force-pushed the fix/482-stateless-subscriptions branch from f86dfa4 to 59ea09b Compare September 21, 2026 12:54
@bug-ops
bug-ops merged commit bfe82b0 into main Sep 21, 2026
29 checks passed
@bug-ops
bug-ops deleted the fix/482-stateless-subscriptions branch September 21, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation mcpls-core mcpls-core crate changes rust Rust code changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

resource subscriptions are per-request, not per-session, on rmcp's stateless HTTP path

1 participant