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`).
 

Reply via email to