Class: MCP::Server::RequestStateSecurity
- Inherits:
-
Object
- Object
- MCP::Server::RequestStateSecurity
- Defined in:
- lib/mcp/server/request_state_security.rb
Overview
Opt-in protection for the SEP-2322 requestState echo. The opaque continuation string leaves the server,
sits in the client's hands, and comes back as client-controlled input, so it must be treated like
any other untrusted data. Sealing encrypts the state with AES-256-GCM (clients cannot read it) and binds
a claims envelope that unsealing verifies fail-closed:
exp: a TTL window (re-sealed each round)m/t: the originating method and target (tool/prompt name or resource URI)a: a digest of the originating arguments, so the state only resumes the same call with the same inputsaud: an optional audience, so tokens cannot cross servers sharing a key
Pass an instance via Server.new(request_state_security:) and the seal/unseal happens transparently;
handlers keep reading plaintext through server_context.request_state. Without it, the state crosses
the wire exactly as the handler wrote it (the author's responsibility, matching the Python SDK's low-level Server).
The key must be shared across workers in multi-process
deployments; a per-process random key makes retries that land on another worker fail with an invalid-state error, forcing clients to restart the flow.
The token format is v1.<base64url(iv || ciphertext || tag)>, with the version prefix bound as GCM associated data,
following the Python SDK's AESGCMRequestStateCodec.
Defined Under Namespace
Classes: InvalidStateError
Constant Summary collapse
- VERSION_PREFIX =
"v1."- KEY_BYTES =
32- IV_BYTES =
12- TAG_BYTES =
16- DEFAULT_TTL =
300
Instance Method Summary collapse
-
#initialize(key:, ttl: DEFAULT_TTL, audience: nil) ⇒ RequestStateSecurity
constructor
A new instance of RequestStateSecurity.
-
#seal(state, method:, target:, arguments_digest:) ⇒ Object
Seals a plaintext state into an opaque token bound to the originating request.
-
#unseal(sealed, method:, target:, arguments_digest:) ⇒ Object
Unseals a client-echoed token and verifies every claim, failing closed with
InvalidStateErroron tampering, expiry, or a claims mismatch.
Constructor Details
#initialize(key:, ttl: DEFAULT_TTL, audience: nil) ⇒ RequestStateSecurity
Returns a new instance of RequestStateSecurity.
37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/mcp/server/request_state_security.rb', line 37 def initialize(key:, ttl: DEFAULT_TTL, audience: nil) unless key.is_a?(String) && key.bytesize == KEY_BYTES raise ArgumentError, "key must be a #{KEY_BYTES}-byte String" end unless ttl.is_a?(Numeric) && ttl.positive? raise ArgumentError, "ttl must be a positive number of seconds" end @key = key.dup.force_encoding(Encoding::BINARY).freeze @ttl = ttl @audience = audience end |
Instance Method Details
#seal(state, method:, target:, arguments_digest:) ⇒ Object
Seals a plaintext state into an opaque token bound to the originating request.
51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/mcp/server/request_state_security.rb', line 51 def seal(state, method:, target:, arguments_digest:) claims = { v: 1, exp: Time.now.to_i + @ttl, m: method, t: target, a: arguments_digest, aud: @audience, s: state, }.compact cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt cipher.key = @key iv = cipher.random_iv cipher.auth_data = VERSION_PREFIX ciphertext = cipher.update(JSON.generate(claims)) + cipher.final VERSION_PREFIX + base64url_encode(iv + ciphertext + cipher.auth_tag) end |
#unseal(sealed, method:, target:, arguments_digest:) ⇒ Object
Unseals a client-echoed token and verifies every claim, failing closed with
InvalidStateError on tampering, expiry, or a claims mismatch.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/mcp/server/request_state_security.rb', line 73 def unseal(sealed, method:, target:, arguments_digest:) unless sealed.is_a?(String) && sealed.start_with?(VERSION_PREFIX) raise InvalidStateError, "malformed token" end blob = base64url_decode(sealed.delete_prefix(VERSION_PREFIX)) raise InvalidStateError, "malformed token" if blob.bytesize < IV_BYTES + TAG_BYTES iv = blob.byteslice(0, IV_BYTES) tag = blob.byteslice(-TAG_BYTES, TAG_BYTES) ciphertext = blob.byteslice(IV_BYTES, blob.bytesize - IV_BYTES - TAG_BYTES) cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt cipher.key = @key cipher.iv = iv cipher.auth_tag = tag cipher.auth_data = VERSION_PREFIX plaintext = begin cipher.update(ciphertext) + cipher.final rescue OpenSSL::Cipher::CipherError raise InvalidStateError, "authentication failed" end claims = begin JSON.parse(plaintext, symbolize_names: true) rescue JSON::ParserError raise InvalidStateError, "malformed claims" end verify!(claims, method: method, target: target, arguments_digest: arguments_digest) claims[:s] end |