Skip to content

feat: add request-state key rotation - #1128

Open
gocamille wants to merge 7 commits into
modelcontextprotocol:mainfrom
gocamille:gocamille/request-state-key-rotation
Open

feat: add request-state key rotation#1128
gocamille wants to merge 7 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
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

1 participant