This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/docs by this push:
new 36aad8b252 docs: MCP MRTR replay-cache + argument-hash sealing
(TODO-333)
36aad8b252 is described below
commit 36aad8b252a06c005383f430b4d7bc1b1052ec5c
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 20:34:52 2026 -0700
docs: MCP MRTR replay-cache + argument-hash sealing (TODO-333)
Co-authored-by: Cursor <[email protected]>
---
pages/release-notes/10.0.0.md | 11 +++++++++++
pages/topics/11.04.JuneauRestServerMcp.md | 32 +++++++++++++++++++++++++++++--
2 files changed, 41 insertions(+), 2 deletions(-)
diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index 4afad79718..40f427abfa 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -956,6 +956,17 @@ See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#configuring-the-en
See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#key-management-keyprovider)
and the [MRTR key management
recipes](/docs/topics/JuneauMcpRecipes#mrtr-key-management-v2-only) for the
full topics.
+### MCP `2026-07-28` — MRTR replay protection + argument-hash sealing
+
+`2026-07-28`-only. Adds two optional hardenings on top of the MRTR
pause/resume foundation above: a replay cache to reject reuse of a consumed
`requestState` token, and an always-on hash check binding a resume's
`arguments` back to what the original, paused call authorized.
+
+- **Replay protection, opt-in.** By default a `requestState` token is
unchanged — a multi-use bearer credential, replayable any number of times
within its TTL. A new `ReplayCache` SPI
(`org.apache.juneau.rest.server.mcp.v20260728`) — a single atomic
`checkAndRecord(String jti, long expiresAtMs): boolean` — narrows that default
to single-use once wired via `McpMrtrConfig.setReplayCache(ReplayCache)`; unset
(the default) means no replay check at all. The built-in `InMemoryReplayCache`
is a [...]
+- **Argument-hash sealing, always-on.** The original
`tools/call`/`prompts/get` `arguments` are now canonicalized (RFC 8785 JSON
Canonicalization Scheme) and SHA-256-hashed at PAUSE time, then re-verified
against the RESUME request's own `arguments` on every round — no configuration,
no opt-out. A faithful client that re-sends identical arguments (the
documented, expected case) sees no change in behavior; a resume with different
arguments is now hard-rejected with the new `CODE_REQUEST_S [...]
+- **Bounded-JSON safety now covers `arguments` too.** Because the always-on
hash above canonicalizes the full `arguments` value, Juneau's existing
bounded-JSON safety checks (max depth, max node count) now also apply to
tool/prompt `arguments` on every PAUSE and RESUME — including schemaless
`tools/call` and `prompts/get`, which previously had no input-shape guard at
all — so pathologically large or deep `arguments` are rejected with `-32602`
(invalid params) before the handler ever runs.
+- **`McpRequestState` gains two additive fields** (`jti`, `argumentsHash`)
alongside its existing `continuation`/`method`/`round`/`expiresAtMs`. Both ride
inside the AEAD-sealed plaintext, so neither is forgeable without breaking the
existing integrity check; no change to
`RequestStateCodec`/`AeadRequestStateCodec` or the sealed-token wire format.
+
+See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#replay-protection-replaycache-opt-in)
for the full topic.
+
### MCP `2026-07-28` — OAuth 2.1 authorization: client acquisition + server
resource server (TODO-312f)
`2026-07-28`-only, and **off by default** — an endpoint that doesn't opt in
behaves exactly as before. 10.0.0 lands the OAuth 2.1 / MCP authorization
baseline in three slices: client-side token acquisition (F1), a server-side
resource-server (F2), and dynamic client registration + authorization hardening
(F3). Together they let a Juneau MCP client authenticate to, and a Juneau MCP
endpoint protect, a `2026-07-28` MCP resource end to end, covering the MCP
authorization SEPs at a baseline [...]
diff --git a/pages/topics/11.04.JuneauRestServerMcp.md
b/pages/topics/11.04.JuneauRestServerMcp.md
index d435934971..987735473d 100644
--- a/pages/topics/11.04.JuneauRestServerMcp.md
+++ b/pages/topics/11.04.JuneauRestServerMcp.md
@@ -770,7 +770,7 @@ Only the sealed `continuation` is tamper-evident across
rounds; the per-round `a
### Sealing `requestState`: the codec SPI
-`RequestStateCodec` is the pluggable seal/unseal SPI (mirroring the
client-side `McpResponseCache` "SPI + built-in default" shape). The built-in
default, `AeadRequestStateCodec`, is AES-256-GCM; the key material it
seals/unseals under is itself pluggable via a `KeyProvider` (see below) — the
codec is AEAD-specific, `KeyProvider` is the layer that varies by deployment.
The canonical AAD binding each token to its originating request is `method +
'\u0000' + protocolVersion` (NUL-separated); [...]
+`RequestStateCodec` is the pluggable seal/unseal SPI (mirroring the
client-side `McpResponseCache` "SPI + built-in default" shape). The built-in
default, `AeadRequestStateCodec`, is AES-256-GCM; the key material it
seals/unseals under is itself pluggable via a `KeyProvider` (see below) — the
codec is AEAD-specific, `KeyProvider` is the layer that varies by deployment.
The canonical AAD binding each token to its originating request is `method +
'\u0000' + protocolVersion` (NUL-separated); [...]
The sealed-token wire format is four dot-joined segments: `version .
b64url(keyId) . b64url(nonce) . b64url(ciphertext+tag)`. `keyId` travels in the
clear (it must be read before decryption can select the key) but is folded into
the AEAD's authenticated data, so a swapped `keyId` fails the integrity check
exactly like a tampered ciphertext.
@@ -808,15 +808,43 @@ protected McpOptions createMcpOptions() {
`McpOptions` (and hence `mrtr`) is memoized once per binding and treated as
read-only afterward. With the ephemeral default, each `AeadRequestStateCodec`
holds a distinct random key, so exactly one instance must be published per
binding — the mixin path gets its own per-binding key too, with no sharing
across separate endpoint instances. Supplying a shared `KeyProvider` via
`setKeyProvider(...)` is the supported way to opt back into
cross-binding/cross-instance sharing for a load-balance [...]
+### Replay protection: `ReplayCache` (opt-in)
+
+By default a sealed `requestState` keeps the behavior described above: it's a
multi-use bearer token, replayable any number of times within its TTL, so
handlers must keep resume side effects idempotent — that default is
**unchanged**. To narrow it to single-use, wire a `ReplayCache`:
+
+```java
+public interface ReplayCache {
+ boolean checkAndRecord(String jti, long expiresAtMs);
+}
+```
+
+```java
+new McpOptions().mrtr(m -> m.setReplayCache(new InMemoryReplayCache()));
+```
+
+`checkAndRecord` is a single atomic check-and-record call — not a separate
`seen`/`record` pair — so two near-simultaneous submissions of the same token
can't both observe "not yet seen" and both proceed. `expiresAtMs` (the token's
own sealed expiry) is passed through so an implementation can self-evict
without a separate cache-wide TTL setting.
+
+- **`InMemoryReplayCache`** — the built-in implementation: a per-process,
seen-`jti` set that self-evicts once a record's own `expiresAtMs` passes. Like
`EphemeralKeyProvider`, it is **not shareable across process instances** — two
nodes behind a load balancer each enforce single-use independently, so a token
resumed against one node and then replayed against the other is not caught.
Cross-node single-use requires an operator-supplied `ReplayCache` backed by a
store shared across every n [...]
+- **Fail-open on throw; fail-closed only if you return `false`.** If a wired
`ReplayCache.checkAndRecord` throws, the dispatcher logs it and treats the
token as first-seen — a transient store outage degrades to the default
multi-use-tolerant behavior instead of rejecting all MRTR resume traffic. An
operator who wants fail-closed instead must catch their own store failures
internally and return `false` (a "replay" outcome) rather than let the
exception propagate; the framework applies no [...]
+- A detected replay is rejected with `CODE_REQUEST_STATE_REPLAYED` (`-32025` —
see [Error codes](#error-codes) below), only ever thrown when a `ReplayCache`
is configured.
+
+### Argument-hash sealing (always-on)
+
+Unlike replay protection, this check needs no configuration — it's always on.
The original `tools/call`/`prompts/get` request's `arguments` are canonicalized
(RFC 8785 JSON Canonicalization Scheme) and SHA-256-hashed at PAUSE time; the
same hash is recomputed from the RESUME request's own `arguments` and compared.
A well-behaved client that re-sends the exact same `arguments` it originally
sent — the documented, expected case — sees **no change in behavior**.
+
+A resume that changes the arguments is rejected with
`CODE_REQUEST_STATE_ARGUMENTS_MISMATCH` (`-32026`), closing the
previously-documented gap where a client was merely *expected* to re-send its
original arguments faithfully, with nothing enforcing it. `resources/read`'s
exact-path branch takes no `arguments` at all and is unaffected — it hashes the
canonical empty object at both PAUSE and RESUME, so the two always agree.
+
### Error codes
-Alongside the standard JSON-RPC codes, MRTR dispatch adds three v2-only codes
(constants on `org.apache.juneau.rest.server.mcp.v20260728.McpRevision`):
+Alongside the standard JSON-RPC codes, MRTR dispatch adds five v2-only codes
(constants on `org.apache.juneau.rest.server.mcp.v20260728.McpRevision`):
| Code | Constant | Condition |
|---|---|---|
| `-32021` | `CODE_MISSING_REQUIRED_CLIENT_CAPABILITY` | A handler tried to
pause, but the request's `_meta.clientCapabilities` did not advertise
`elicitation`. Gating reuses the existing `elicitation` client capability —
there is no dedicated `input_required` capability flag. |
| `-32022` | `CODE_REQUEST_STATE_EXPIRED` | The echoed `requestState` unsealed
successfully but its sealed expiry has passed. |
| `-32023` | `CODE_MAX_ROUNDS_EXCEEDED` | The resume chain exceeded the
configured max-rounds cap. |
+| `-32025` | `CODE_REQUEST_STATE_REPLAYED` | A configured `ReplayCache`
reports the echoed `requestState` has already been consumed once before. Only
thrown when a `ReplayCache` is wired — replay rejection is opt-in. |
+| `-32026` | `CODE_REQUEST_STATE_ARGUMENTS_MISMATCH` | The resume request's
`arguments` don't hash to the same value sealed at PAUSE time. Always checked. |
A `requestState` that fails to unseal — tampered, truncated, sealed for a
different method, or otherwise invalid — is the standard `-32602`
invalid-params code (the codec returns empty rather than throwing; the
dispatcher maps that to `-32602`).