Skip to content

feat: add request-state key rotation - #1128

Merged
DaleSeo merged 11 commits into
modelcontextprotocol:mainfrom
gocamille:gocamille/request-state-key-rotation
Aug 28, 2026
Merged

feat: add request-state key rotation#1128
DaleSeo merged 11 commits into
modelcontextprotocol:mainfrom
gocamille:gocamille/request-state-key-rotation

Conversation

@gocamille

Copy link
Copy Markdown
Contributor

Adds targeted key selection and rolling-safe signing-key rotation to RequestStateCodec for SEP-2322 requestState values.

Closes #1096.

Motivation and Context

The existing rs1.<body>.<tag> format identifies the wire format but not the key that produced the HMAC. Rotating a deployment-wide request-state key therefore either invalidates every in-flight multi-round exchange or requires applications to trial-open each token against every retained key.

That is particularly difficult for multi-replica deployments, where every replica that may continue an MRTR exchange must share the same signing keys and cannot safely switch them atomically.

How

  • Added an rs2.<base64url(kid)>.<base64url(expiry || payload)>.<base64url(tag)> format. The key id is included in the HMAC input, allowing opening to select one key directly while preventing key-id substitution.

  • Added RequestStateCodec::new_with_keyring(active_kid, keys) for keyed sealing and targeted verification. active_kid selects the signing key, while any configured key can verify an rs2 value naming that key.

  • Key ids are opaque, case-sensitive UTF-8 strings limited to 255 bytes. They are authenticated but visible to clients and are not confidential.

  • Preserved RequestStateCodec::new() and the existing rs1 wire format byte-for-byte.

  • Added with_rs1_signing() and with_rs1_fallback() for a rolling-safe migration:

    1. Deploy both keys while continuing to emit rs1 with the old key.
    2. Promote the new key and emit rs2, retaining the old key as an rs1 fallback.
    3. Wait for existing request states to expire, then remove the old key.
  • Legacy fallback verification evaluates every configured rs1 fallback before returning, so the HMAC count does not reveal which fallback matched. The rustdocs recommend keeping this set small and temporary.

  • Added distinct HMAC domains for rs1 and rs2, with length-prefixed key ids and associated data.

  • Added explicit errors for unknown key ids, invalid wire key ids, and invalid keyring configuration. Configuration messages are documented as diagnostics rather than values applications should match programmatically.

  • The rustdocs require applications to map all token-opening failures to one client-visible error.

  • Added wire-format, key-selection, migration, and key-retirement guidance to the codec rustdocs and README.

How Has This Been Tested?

  • cargo test -p rmcp --features request-state model::request_state::tests --lib — 34 tests passed.
  • cargo test -p rmcp --features request-state --doc — 33 doctests passed, including the staged-rotation example.
  • cargo clippy --all-targets --all-features -- -D warnings
  • Strict all-feature rustdoc generation with warnings denied.
  • Known-answer tests confirm the existing rs1 output is unchanged and independently verify the new rs2 format.
  • Rotation tests cover old-key verification, key promotion, retirement, multiple legacy fallbacks, and bidirectional compatibility during migration.
  • Security tests cover key-id, body, and tag tampering; version-domain separation; associated data; TTL enforcement; key redaction; and authentication before JSON deserialization.
  • Parser tests cover malformed tokens, invalid and oversized key ids, unknown keys, stable error precedence, and arbitrary mutated inputs without panics.

Breaking Changes

None. Existing RequestStateCodec::new() callers continue to emit and accept the same rs1 format. Keyrings and rs2 are opt-in behind the existing request-state feature.

New public API:

  • RequestStateCodec::new_with_keyring
  • RequestStateCodec::with_rs1_signing
  • RequestStateCodec::with_rs1_fallback
  • RequestStateError::{UnknownKeyId, InvalidKeyId, InvalidKeyring}

RequestStateError remains #[non_exhaustive], so adding these variants does not require downstream callers to update exhaustive matches.

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

Note that the codec intentionally does not provide runtime key reload or a default TTL. Applications must retain old verification keys until the maximum request-state lifetime has elapsed before retiring them.

@gocamille
gocamille requested a review from a team as a code owner August 4, 2026 14:36
@github-actions github-actions Bot added T-documentation Documentation improvements T-core Core library changes T-model Model/data structure changes labels Aug 4, 2026
Comment thread crates/rmcp/src/model/request_state.rs Outdated
Comment thread crates/rmcp/CHANGELOG.md
@gocamille
gocamille requested a review from DaleSeo August 18, 2026 23:21
@DaleSeo
DaleSeo merged commit 3ef34e6 into modelcontextprotocol:main Aug 28, 2026
22 checks passed
This was referenced Aug 27, 2026
@ianewsfr-a11y

Copy link
Copy Markdown

Reading through your merged PR on request-state key rotation, because our marketplace has a per-agent-secret + hash-chained mutation shape that would need the same story eventually. Concrete question, not a pitch: given the rs1 fallback flag, how long do you recommend keeping it on in production before removing, and is there a wire-visible signal for "safe to disable" (all in-flight rs1 exchanges drained)? Keyless read at ergonia.works/api/arena
if you want to see the marketplace side. Disclosure: I'm the human behind Ergonia. Feel free to close if wrong venue for a cold reach.

jmagar added a commit to dinglebear-ai/rust-sdk that referenced this pull request Sep 4, 2026
* fix: allow concurrent streamable http requests (modelcontextprotocol#1186)

* fix: allow concurrent streamable http requests

* fix: keep streamable http recovery responsive

Keep cancellation and replies available while old session POSTs finish.
Bound the wait for old POSTs and the replacement initialization handshake.
Do not retry interrupted POSTs because the server may have processed them.

Add regressions for recovery, queued cancellation, control timeouts, and
server replies needed by active requests.

* fix: preserve response and cancellation ordering

* refactor: clarify streamable http control flow

* fix: match stream responses against pending request ids

Match responses against all pending requests before removing a stream
registration. Keep distinct numeric and string ids separate while preserving
the existing fallback for servers that stringify numeric ids.

Add a mixed-id regression and keep a separate registration owner alive in
the abandoned-cancellation test.

* feat: make streamable http control timeouts configurable

* fix(transport): fall back after sessionless HTTP discover rejections (modelcontextprotocol#1211)

* fix(transport): fall back after HTTP discover rejection

* fix(transport): limit legacy fallback to sessionless probes

* style: apply nightly rustfmt import ordering

* ci: pin GitHub Actions to commit SHAs (modelcontextprotocol#1216)

* ci: scope release token permissions to jobs (modelcontextprotocol#1220)

* ci: default workflow tokens to read-only contents (modelcontextprotocol#1218)

* ci: remove coverage job write permission (modelcontextprotocol#1219)

* ci: pin release-plz fork revision (modelcontextprotocol#1221)

* feat: add request-state key rotation (modelcontextprotocol#1128)

* feat: add request-state key rotation

* docs: streamline request-state codec documentation

* fix: harden request-state fallback verification

* refactor: refine request-state keyring API

* docs: make request-state rotation guidance self-contained

* docs: streamline request-state keyring rustdocs

* test: streamline request-state keyring coverage

* docs: restore request-state key rotation doctest

* chore: remove manual changelog entry

* ci: reduce dependabot update noise (modelcontextprotocol#1184)

* docs: use auto lifecycle in HTTP example (modelcontextprotocol#1187)

* chore(deps): bump taiki-e/install-action from 2.85.13 to 2.86.7 (modelcontextprotocol#1230)

Bumps [taiki-e/install-action](https://github.com/taiki-e/install-action) from 2.85.13 to 2.86.7.
- [Release notes](https://github.com/taiki-e/install-action/releases)
- [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md)
- [Commits](taiki-e/install-action@82cd3e7...b6ff580)

---
updated-dependencies:
- dependency-name: taiki-e/install-action
  dependency-version: 2.86.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Add typed custom request responses

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Nick Cooper <142361983+nickcoai@users.noreply.github.com>
Co-authored-by: King Star <mcxin.y@gmail.com>
Co-authored-by: Jack Amadeo <jackamadeo@block.xyz>
Co-authored-by: camillelawrence <gocamille@users.noreply.github.com>
Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-documentation Documentation improvements T-model Model/data structure changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

rs1. request-state format carries no kid, so key rotation cannot be targeted

3 participants