This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new a24312be95 MCP 2026-07-28 MRTR requestState replay-cache + 
argument-hash sealing (TODO-333)
a24312be95 is described below

commit a24312be95f35af6e0cd94362aead6ec17fa337f
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 20:34:41 2026 -0700

    MCP 2026-07-28 MRTR requestState replay-cache + argument-hash sealing 
(TODO-333)
    
    Adds two opt-in/always-on MRTR hardenings on the v2 (2026-07-28) adapter, 
both
    dispatcher-level with no codec-format change:
    
    - Replay protection (opt-in): new ReplayCache SPI + InMemoryReplayCache 
default,
      wired via McpMrtrConfig.setReplayCache(...). Default unset preserves the
      documented multi-use-within-TTL contract; when configured, a per-token jti
      (128-bit CSPRNG, sealed inside the AEAD) is checked once (atomic 
checkAndRecord).
      Fail-open on store throw, fail-closed on a false return. Rejects replays 
with
      a new -32025 code.
    - Argument-hash sealing (always-on): the original tool/prompt arguments are
      canonicalized (RFC 8785 JCS) + SHA-256'd and, together with the operation
      target (tool name / prompt name / resource uri, folded into the AEAD AAD),
      bound into the sealed requestState. A resume that mutates the arguments or
      retargets a different operation is rejected (-32026 / tamper). 
Bounded-JSON
      safety now also covers schemaless tools and prompts/get.
    
    Also folds in a test-only Connection: close interceptor on the MRTR client
    integration fixture to eliminate a keep-alive pooled-connection flake.
    
    Co-authored-by: Cursor <[email protected]>
---
 .../mcp/v20260728/McpMrtrIntegration_Test.java     |  25 +-
 .../mcp/v20260728/Characterization_Test.java       |  43 ++-
 .../mcp/v20260728/AeadRequestStateCodec.java       |  16 +-
 .../server/mcp/v20260728/InMemoryReplayCache.java  | 111 ++++++
 .../rest/server/mcp/v20260728/McpMrtrConfig.java   |  49 ++-
 .../rest/server/mcp/v20260728/McpRequestState.java |  14 +-
 .../rest/server/mcp/v20260728/McpRevision.java     | 322 +++++++++++++++-
 .../rest/server/mcp/v20260728/ReplayCache.java     |  89 +++++
 .../server/mcp/v20260728/RequestStateCodec.java    |  46 ++-
 .../mcp/v20260728/AeadRequestStateCodec_Test.java  |  32 +-
 .../mcp/v20260728/Characterization_Test.java       |  27 +-
 .../mcp/v20260728/InMemoryReplayCache_Test.java    | 113 ++++++
 .../server/mcp/v20260728/McpBindings_Test.java     |   4 +-
 .../server/mcp/v20260728/McpMrtrConfig_Test.java   |  20 +-
 .../server/mcp/v20260728/McpMrtrDispatch_Test.java | 404 +++++++++++++++++++--
 .../mcp/v20260728/RequestStateCodec_Test.java      |   8 +-
 16 files changed, 1194 insertions(+), 129 deletions(-)

diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
index e39019b652..c820452879 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
@@ -28,6 +28,7 @@ import org.apache.juneau.commons.inject.*;
 import org.apache.juneau.marshall.json.*;
 import org.apache.juneau.marshall.marshaller.Json;
 import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.client.RestCallInterceptor;
 import org.apache.juneau.rest.server.*;
 import org.apache.juneau.rest.server.mcp.*;
 import org.apache.juneau.rest.server.mcp.v20260728.*;
@@ -79,8 +80,10 @@ class McpMrtrIntegration_Test extends TestBase {
                return bytes;
        }
 
-       private static String aad(String method) {
-               return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+       // Mirror of McpRevision#aad(method, target): the sealed AAD binds the 
operation target (here the tool name)
+       // in addition to method+version, so an unseal must supply the same 
target the server sealed with.
+       private static String aad(String method, String target) {
+               return method + '\u0000' + McpProtocol.VERSION_2026_07_28 + 
'\u0000' + (target == null ? "" : target);
        }
 
        private static McpToolHandler ask() {
@@ -180,13 +183,24 @@ class McpMrtrIntegration_Test extends TestBase {
        static MicroserviceTestFixture fixture = 
MicroserviceTestFixture.create()
                .configurations(FixtureConfig.class);
 
+       // Forces a fresh TCP connection per request instead of reusing a 
pooled keep-alive one: a client that
+       // issues two or more requests (every test below does) can otherwise 
race the embedded Jetty fixture
+       // tearing down/resetting an idle pooled connection between calls, 
surfacing as a spurious
+       // NoHttpResponseException instead of the response the test actually 
expects.
+       private static final RestCallInterceptor CLOSE_CONNECTION_PER_REQUEST = 
new RestCallInterceptor() {
+               @Override public void 
onInit(org.apache.juneau.rest.client.RestRequest req) {
+                       req.header("Connection", "close");
+               }
+       };
+
        private static McpClient.Builder clientBuilder(boolean withElicitation) 
{
                var caps = new ClientCapabilities();
                if (withElicitation)
                        caps.setElicitation(new ElicitationCapability());
                return McpClient.builder()
                        .endpoint(fixture.getRootUrl() + "/")
-                       .clientCapabilities(caps);
+                       .clientCapabilities(caps)
+                       .interceptor(CLOSE_CONNECTION_PER_REQUEST);
        }
 
        private static McpClient.Builder clientBuilderAt(String path, boolean 
withElicitation) {
@@ -195,7 +209,8 @@ class McpMrtrIntegration_Test extends TestBase {
                        caps.setElicitation(new ElicitationCapability());
                return McpClient.builder()
                        .endpoint(fixture.getRootUrl() + path)
-                       .clientCapabilities(caps);
+                       .clientCapabilities(caps)
+                       .interceptor(CLOSE_CONNECTION_PER_REQUEST);
        }
 
        // 
=================================================================================================================
@@ -233,7 +248,7 @@ class McpMrtrIntegration_Test extends TestBase {
                        var token2 = (String) paused2.get("requestState");
 
                        // Unseal via the same codec instance the server used 
(round counter lives inside the sealed token).
-                       var state = CODEC.unseal(token2, 
aad(McpMethods.TOOLS_CALL)).orElseThrow();
+                       var state = CODEC.unseal(token2, 
aad(McpMethods.TOOLS_CALL, "askTwice")).orElseThrow();
                        assertEquals(2, state.round());
                        assertEquals("cont-2", state.continuation());
                }
diff --git 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index f0e39e6537..2814f29be3 100644
--- 
a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ 
b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -485,10 +485,27 @@ class Characterization_Test {
        private static final long FAR_FUTURE_MS = 32503680000000L; // ~ year 
3000
        private static final long PAST_MS = 1000L;
 
-       private static String aad(String method) {
-               return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+       // Mirror of McpRevision#aad(method, target): the sealed AAD binds the 
operation target (tool name / resource
+       // uri) in addition to method+version, so every fixture token below 
must seal under the same target its
+       // resume request names or the GCM tag check fails on unseal.
+       private static String aad(String method, String target) {
+               return method + '\u0000' + McpProtocol.VERSION_2026_07_28 + 
'\u0000' + (target == null ? "" : target);
        }
 
+       /**
+        * The canonical {@code argumentsHash} for every RESUME-family fixture 
below. Every {@code ask}/{@code confirm}
+        * resume request in this harness (see the {@code *.request.json} 
fixtures under
+        * {@code MRTR-resume-*}/{@code MRTR-expired-*}/{@code 
MRTR-max-rounds-*}/{@code MRTR-tampered-*}/
+        * {@code ELICIT-resume-*}) carries no {@code arguments} member at all 
&mdash; and the original PAUSE-triggering
+        * call in {@code MRTR-input-required-response.request.json} sends an 
explicit empty {@code "arguments":{}}
+        * &mdash; so every sealed fixture token here must embed the same 
canonical-empty-object hash that
+        * {@code McpRevision#resolveMrtrContext} recomputes from the RESUME 
request's (absent) {@code arguments} on
+        * every replay. Delegates to the real package-private {@link 
McpRevision#argumentsHash(Map)} (this test class
+        * shares its package) rather than re-deriving the 
RFC-8785/SHA-256/base64url pipeline by hand, so the fixture
+        * hash can never drift from what the dispatcher actually computes.
+        */
+       private static final String EMPTY_ARGS_HASH = 
McpRevision.argumentsHash(Map.of());
+
        /**
         * The {@code requestState} the harness seals (with {@link 
FixedKeyGcmCodec}, matching {@link F_Mrtr}'s codec)
         * for each RESUME-family fixture. Substituted into the committed 
request in place of {@link #TOKEN_PLACEHOLDER}
@@ -505,17 +522,25 @@ class Characterization_Test {
         * {@code MRTR-tampered-request-state} was consciously superseded: 
because {@link FixedKeyGcmCodec} makes
         * {@code seal(...)} fully deterministic (fixed key, fixed nonce), 
regenerating the tampered token on every run
         * via {@code tamper(codec.seal(...))} is equally reproducible and 
avoids hand-maintaining opaque ciphertext.
+        *
+        * <p>
+        * Every branch below passes {@link #EMPTY_ARGS_HASH} as {@code 
argumentsHash} (see its javadoc: every resume
+        * request in this fixture set carries no {@code arguments}, so this is 
always the value
+        * {@code McpRevision#resolveMrtrContext}'s always-on argument-binding 
check recomputes and compares against).
+        * {@code jti} is a stable, per-branch literal purely for readability: 
none of these fixtures wire a
+        * {@link ReplayCache} into {@link F_Mrtr}/{@link F_Elicit}'s {@code 
McpMrtrConfig}, so the dispatcher's
+        * opt-in replay check never runs and the exact {@code jti} value is 
otherwise inert.
         */
        private static String mrtrToken(String fixture) {
                var codec = new FixedKeyGcmCodec();
                return switch (fixture) {
-                       case "MRTR-resume-complete" -> codec.seal(new 
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS), 
aad("tools/call"));
-                       case "MRTR-resume-input-required-again" -> 
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS), 
aad("tools/call"));
-                       case "MRTR-expired-request-state" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
-                       case "MRTR-max-rounds-exceeded" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, 
FAR_FUTURE_MS), aad("tools/call"));
-                       case "MRTR-tampered-request-state" -> 
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, 
FAR_FUTURE_MS), aad("tools/call")));
+                       case "MRTR-resume-complete" -> codec.seal(new 
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS, 
"jti-resume-complete", EMPTY_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-resume-input-required-again" -> 
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS, 
"jti-resume-pause-again", EMPTY_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-expired-request-state" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", 1, PAST_MS, "jti-expired", 
EMPTY_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-max-rounds-exceeded" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, 
FAR_FUTURE_MS, "jti-max-rounds", EMPTY_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-tampered-request-state" -> 
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS, 
"jti-tampered", EMPTY_ARGS_HASH), aad("tools/call", "ask")));
                        case "ELICIT-resume-accept-complete", 
"ELICIT-resume-decline", "ELICIT-resume-cancel" ->
-                               codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, FAR_FUTURE_MS), aad("tools/call"));
+                               codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, FAR_FUTURE_MS, "jti-elicit-resume", EMPTY_ARGS_HASH), 
aad("tools/call", "confirm"));
                        default -> throw new IllegalArgumentException("No MRTR 
token mapping for fixture: " + fixture);
                };
        }
@@ -575,7 +600,7 @@ class Characterization_Test {
                var envelope = Json.to(raw, JsonMap.class);
                var result = (Map<?,?>) envelope.get("result");
                var token = (String) result.get("requestState");
-               var state = new FixedKeyGcmCodec().unseal(token, 
aad("tools/call")).orElseThrow();
+               var state = new FixedKeyGcmCodec().unseal(token, 
aad("tools/call", "ask")).orElseThrow();
                assertEquals(2, state.round());
                assertEquals("cont-2", state.continuation());
        }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
index 19a4962043..b0002ebe26 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec.java
@@ -53,13 +53,15 @@ import org.apache.juneau.rest.server.auth.ClaimsPrincipal;
  *
  * <p>
  * The AAD passed to {@link #seal}/{@link #unseal} is authenticated but never 
encrypted (standard AES-GCM AAD
- * semantics). The dispatcher passes the canonical {@code method + '\u0000' + 
protocolVersion} (NUL-separated)
- * form as the caller-supplied AAD (see {@code McpRevision#aad}); this codec 
composes the caller-supplied
- * {@code aad}, the {@link KeyProvider}'s {@code keyId} (per its implicit 
{@code keyId}-authentication contract),
- * and {@code principalIdentity(principal)} (the TODO-325 principal binding, 
below) into the cipher's actual AAD
- * using the same self-delimiting, length-prefixed framing described on {@link 
#principalIdentity(Principal)}
- * &mdash; so the three-field outer composition is unambiguous exactly as the 
two-field inner one is, even if a
- * field happened to contain a NUL.
+ * semantics). The dispatcher passes the canonical {@code method + '\u0000' + 
protocolVersion() + '\u0000' +
+ * target} (NUL-separated) form as the caller-supplied AAD (see {@code 
McpRevision#aad}) &mdash; the trailing
+ * {@code target} field (the tool/prompt {@code name} or resource {@code uri}) 
binds the token to the specific
+ * operation it paused against, not merely the method, so it can't be resumed 
against a different
+ * tool/prompt/resource. This codec composes that caller-supplied {@code aad}, 
the {@link KeyProvider}'s
+ * {@code keyId} (per its implicit {@code keyId}-authentication contract), and 
{@code principalIdentity(principal)}
+ * (the principal binding described below) into the cipher's actual AAD using 
the same self-delimiting,
+ * length-prefixed framing described on {@link #principalIdentity(Principal)} 
&mdash; so the three-field outer
+ * composition is unambiguous exactly as the two-field inner one is, even if a 
field happened to contain a NUL.
  *
  * <p>
  * <b>Principal-bound AAD (TODO-325).</b> {@link #seal}/{@link #unseal} fold 
the caller's authenticated
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache.java
new file mode 100644
index 0000000000..8fdfa04e64
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * Built-in default {@link ReplayCache}: a per-process, {@link 
ConcurrentHashMap}-backed seen-{@code jti} set
+ * with self-eviction bounded by each record's own {@code expiresAtMs}.
+ *
+ * <p>
+ * <b>Not shareable across process instances by design</b> &mdash; mirrors 
{@link EphemeralKeyProvider}'s own
+ * "per-process, not restart-durable" precedent exactly, just for replay 
tracking instead of key material. Two
+ * separate JVMs (or two instances behind a load balancer) each holding their 
own {@link InMemoryReplayCache}
+ * will not see each other's recorded {@code jti}s, so a token resumed against 
one instance and then replayed
+ * against the other is <b>not</b> caught. Operators who need cross-node 
single-use enforcement must supply a
+ * {@link ReplayCache} backed by a store shared across every node instead of 
relying on this default.
+ *
+ * <p>
+ * <b>Memory bound (size honestly).</b> A {@code jti} is retained only until 
its own {@code expiresAtMs}, so the
+ * map's steady-state size is bounded by the product of the <i>distinct-token 
submission rate</i> and the token
+ * {@link McpMrtrConfig#getTtlMs() TTL} &mdash; i.e. roughly the number of 
distinct, still-unexpired tokens
+ * outstanding at once, plus at most one TTL's worth of already-expired 
records awaiting the next sweep. Under a
+ * high pause/resume rate with a long TTL this is not inherently small; 
operators should size accordingly, or
+ * supply a store-backed {@link ReplayCache} with server-side expiry. There is 
deliberately <b>no</b> hard cap
+ * that drops still-unexpired {@code jti}s: doing so would silently weaken 
replay protection by letting an
+ * evicted-but-valid token be replayed.
+ *
+ * <p>
+ * <b>Eviction is throttled.</b> Sweeping out already-expired records is an 
O(n) scan, so it runs at most once
+ * per {@link #DEFAULT_SWEEP_INTERVAL_MS} rather than on every call (which 
would make {@link #checkAndRecord}
+ * effectively O(n) per invocation under load). An already-expired token is 
separately rejected by the
+ * dispatcher's own expiry check before a {@link ReplayCache} is ever 
consulted (see
+ * {@code McpRevision#resolveMrtrContext}), so no correctness depends on the 
sweep running promptly &mdash; it
+ * exists purely to bound memory.
+ *
+ * <p>
+ * <b>Thread-safety.</b> {@link #checkAndRecord(String, long)} is safe for 
concurrent invocation: the
+ * check-and-record step is a single {@link ConcurrentHashMap#putIfAbsent} 
call, so of any two concurrent calls
+ * with the same {@code jti}, exactly one observes a first-seen 
(<jk>true</jk>) outcome. The throttled sweep is
+ * guarded by an atomic compare-and-set so at most one thread sweeps per 
interval.
+ *
+ * @since 10.0.0
+ */
+public class InMemoryReplayCache implements ReplayCache {
+
+       /** Default minimum interval, in milliseconds, between opportunistic 
expired-record sweeps. */
+       public static final long DEFAULT_SWEEP_INTERVAL_MS = 1000L;
+
+       private final ConcurrentHashMap<String,Long> seen = new 
ConcurrentHashMap<>();
+       private final long sweepIntervalMs;
+       private final AtomicLong nextSweepAtMs = new AtomicLong();
+
+       /**
+        * Constructor using the {@link #DEFAULT_SWEEP_INTERVAL_MS default} 
sweep interval.
+        */
+       public InMemoryReplayCache() {
+               this(DEFAULT_SWEEP_INTERVAL_MS);
+       }
+
+       /**
+        * Constructor.
+        *
+        * @param sweepIntervalMs The minimum interval, in milliseconds, 
between opportunistic expired-record sweeps.
+        *      {@code 0} sweeps on every call (useful in tests that assert 
eviction behavior). Must be {@code >= 0}.
+        */
+       InMemoryReplayCache(long sweepIntervalMs) {
+               if (sweepIntervalMs < 0)
+                       throw new IllegalArgumentException("sweepIntervalMs " + 
sweepIntervalMs + " must be >= 0");
+               this.sweepIntervalMs = sweepIntervalMs;
+       }
+
+       @Override /* ReplayCache */
+       public boolean checkAndRecord(String jti, long expiresAtMs) {
+               assertArgNotNull("jti", jti);
+               maybeEvictExpired();
+               return seen.putIfAbsent(jti, expiresAtMs) == null;
+       }
+
+       /**
+        * Sweeps expired records at most once per {@link #sweepIntervalMs}. 
The atomic compare-and-set on
+        * {@code nextSweepAtMs} both throttles the sweep and ensures a single 
sweeper per window; a losing thread
+        * simply skips the sweep (its own {@code putIfAbsent} still runs, so 
no {@code jti} is ever missed).
+        */
+       private void maybeEvictExpired() {
+               var now = System.currentTimeMillis();
+               var next = nextSweepAtMs.get();
+               if (now < next)
+                       return;
+               if (! nextSweepAtMs.compareAndSet(next, now + sweepIntervalMs))
+                       return;
+               seen.values().removeIf(expiresAt -> expiresAt <= now);
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig.java
index 4d291d51cb..bbb1b3e439 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig.java
@@ -33,8 +33,9 @@ package org.apache.juneau.rest.server.mcp.v20260728;
  *
  * <p>
  * Defaults: {@link AeadRequestStateCodec} (a fresh instance per {@link 
McpMrtrConfig}), a 5-minute
- * {@code requestState} TTL, and a max-rounds cap of 10. Operators inject a 
shared/rotating-key
- * {@link RequestStateCodec} here for multi-instance or restart-durable 
resumption.
+ * {@code requestState} TTL, a max-rounds cap of 10, and no {@link 
ReplayCache} (replay rejection is opt-in; see
+ * {@link #setReplayCache(ReplayCache)}). Operators inject a 
shared/rotating-key {@link RequestStateCodec} here
+ * for multi-instance or restart-durable resumption.
  *
  * <h5 class='section'>Trust model:</h5>
  * <ul>
@@ -42,10 +43,13 @@ package org.apache.juneau.rest.server.mcp.v20260728;
  *             the paused operation until it expires; the token is not bound 
to a caller identity. It must be
  *             transported only over authenticated TLS and <b>never logged</b> 
(it is opaque ciphertext, but logging
  *             it hands a replayable credential to anyone with log access). 
See {@link RequestStateCodec}.
- *     <li><b>Resume side effects must be idempotent.</b> A captured token can 
be replayed any number of times
- *             within its {@link #getTtlMs() TTL}. The {@link #getMaxRounds() 
max-rounds cap} bounds the <i>depth</i> of
- *             a single resume chain, not the total number of times a given 
token can be re-submitted, so a handler
- *             must not treat a resume as a once-only event.
+ *     <li><b>Resume side effects must be idempotent by default.</b> A 
captured token can be replayed any number
+ *             of times within its {@link #getTtlMs() TTL} <i>unless</i> a 
{@link ReplayCache} is configured (see
+ *             {@link #setReplayCache(ReplayCache)}) to enforce single-use 
&mdash; the built-in
+ *             {@link InMemoryReplayCache} is per-process only, so cross-node 
single-use requires a shared
+ *             implementation. The {@link #getMaxRounds() max-rounds cap} 
bounds the <i>depth</i> of a single resume
+ *             chain, not the total number of times a given token can be 
re-submitted, so a handler must not treat a
+ *             resume as a once-only event unless it knows single-use is 
enforced.
  * </ul>
  */
 public class McpMrtrConfig {
@@ -59,6 +63,7 @@ public class McpMrtrConfig {
        private RequestStateCodec codec = new AeadRequestStateCodec();
        private long ttlMs = DEFAULT_TTL_MS;
        private int maxRounds = DEFAULT_MAX_ROUNDS;
+       private ReplayCache replayCache;
 
        /**
         * The codec used to seal/unseal {@code requestState} tokens.
@@ -152,4 +157,36 @@ public class McpMrtrConfig {
                maxRounds = value;
                return this;
        }
+
+       /**
+        * The {@link ReplayCache} used to reject reuse of a consumed {@code 
requestState} token, or <jk>null</jk> if
+        * none is configured.
+        *
+        * <p>
+        * <b>Unset (<jk>null</jk>) by default &mdash; replay rejection is 
opt-in.</b> A dispatcher only performs a
+        * replay check when this returns non-<jk>null</jk> (see {@code 
McpRevision#resolveMrtrContext}); with no
+        * {@link ReplayCache} configured, a {@code requestState} token remains 
multi-use within its TTL, exactly as
+        * documented on {@link RequestStateCodec}.
+        *
+        * @return The configured replay cache, or <jk>null</jk> if replay 
rejection is disabled (the default).
+        */
+       public ReplayCache getReplayCache() {
+               return replayCache;
+       }
+
+       /**
+        * Sets the replay cache, enabling replay rejection.
+        *
+        * <p>
+        * Passing <jk>null</jk> is allowed and explicitly reverts to the 
default, opt-out behavior (no replay check;
+        * a token stays multi-use within its TTL) &mdash; unlike {@link 
#setCodec(RequestStateCodec)}, there is no
+        * always-non-null invariant to preserve here, since "no replay cache" 
is itself the documented default.
+        *
+        * @param value The replay cache to use, or <jk>null</jk> to disable 
replay rejection.
+        * @return This object.
+        */
+       public McpMrtrConfig setReplayCache(ReplayCache value) {
+               replayCache = value;
+               return this;
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRequestState.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRequestState.java
index abd57cb89e..7b8ab8a47f 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRequestState.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRequestState.java
@@ -53,6 +53,18 @@ package org.apache.juneau.rest.server.mcp.v20260728;
  *     by the dispatcher against the follow-up request's own method as an 
extra sanity check.
  * @param round The 1-based round counter: 1 on the first PAUSE, incremented 
on every subsequent PAUSE.
  * @param expiresAtMs Absolute expiry timestamp in epoch milliseconds.
+ * @param jti A fresh, per-token random identifier minted on every PAUSE (see 
{@code McpRevision#pause}), used
+ *     only to detect replay of THIS exact token. Opt-in: consulted by the 
dispatcher only when an operator has
+ *     wired a {@link ReplayCache} via {@link 
McpMrtrConfig#setReplayCache(ReplayCache)}; otherwise it rides in the
+ *     sealed plaintext unused. Not wire-visible on its own &mdash; like every 
other field on this record, it only
+ *     ever travels inside the opaque, AEAD-sealed {@code requestState} string.
+ * @param argumentsHash A canonical hash of the original request's {@code 
arguments} (RFC 8785 JSON
+ *     Canonicalization Scheme, then SHA-256, then base64url), captured at 
PAUSE time and re-verified against the
+ *     RESUME request's own {@code arguments} on every subsequent round (see 
{@code McpRevision#resolveMrtrContext}).
+ *     Always populated (unlike {@link #jti()}, this check is not opt-in): a 
call with absent or empty
+ *     {@code arguments} (for example {@code resources/read}'s exact-path 
branch, which takes none) hashes the
+ *     canonical empty object {@code "{}"} rather than leaving this field 
unset, so PAUSE and RESUME agree even when
+ *     there is nothing to hash.
  */
-public record McpRequestState(Object continuation, String method, int round, 
long expiresAtMs) {
+public record McpRequestState(Object continuation, String method, int round, 
long expiresAtMs, String jti, String argumentsHash) {
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
index 5c590c1a47..c892015da4 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
@@ -20,8 +20,11 @@ import static 
org.apache.juneau.commons.utils.AssertionUtils.*;
 import static org.apache.juneau.commons.utils.Shorts.*;
 import static org.apache.juneau.commons.utils.StringUtils.*;
 
-import java.security.Principal;
+import java.nio.charset.StandardCharsets;
+import java.security.*;
 import java.util.*;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 
 import org.apache.juneau.bean.jsonrpc.*;
 import org.apache.juneau.bean.mcp.v20260728.*;
@@ -31,7 +34,11 @@ import org.apache.juneau.http.tracing.TraceContextCarrier;
 import org.apache.juneau.marshall.collections.*;
 import org.apache.juneau.rest.server.RestRequest;
 import org.apache.juneau.rest.server.tracing.*;
+import org.apache.juneau.marshall.jcs.JcsSerializer;
+import org.apache.juneau.marshall.json.JsonParser;
+import org.apache.juneau.marshall.marshaller.Jcs;
 import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.marshall.serializer.SerializeException;
 import org.apache.juneau.rest.server.mcp.McpCompletionRef;
 import org.apache.juneau.rest.server.mcp.McpCompletionRequest;
 import org.apache.juneau.rest.server.mcp.McpCompletionResult;
@@ -172,12 +179,47 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
         */
        public static final int CODE_TOO_MANY_SUBSCRIPTIONS = -32024;
 
+       /**
+        * MRTR error code: an operator-configured {@link ReplayCache} reports 
that the echoed {@code requestState}
+        * has already been consumed once before. Only ever thrown when {@link 
McpMrtrConfig#getReplayCache()} is
+        * non-<jk>null</jk> &mdash; replay rejection is opt-in (same 
range/rationale as
+        * {@link #CODE_REQUEST_STATE_EXPIRED}).
+        */
+       public static final int CODE_REQUEST_STATE_REPLAYED = -32025;
+
+       /**
+        * MRTR error code: the current request's {@code arguments} do not hash 
to the same value as the arguments
+        * sealed at PAUSE time &mdash; the client has resumed with a different 
argument set than the one the paused
+        * operation originally authorized. Always checked, unlike {@link 
#CODE_REQUEST_STATE_REPLAYED} (same
+        * range/rationale as {@link #CODE_REQUEST_STATE_EXPIRED}).
+        */
+       public static final int CODE_REQUEST_STATE_ARGUMENTS_MISMATCH = -32026;
+
        /** Default server name reported by {@code server/discover} when the 
config supplies no server identity. */
        public static final String DEFAULT_SERVER_NAME = 
"juneau-rest-server-mcp";
 
        private static final String META_KEY = "_meta";
        private static final String PARAM_ARGUMENTS = "arguments";
 
+       private static final Logger LOG = 
Logger.getLogger(McpRevision.class.getName());
+
+       // 16 random bytes -> 22 base64url chars (unpadded): matches 
EphemeralKeyProvider's keyId minting pattern,
+       // sized generously since a jti only ever travels inside the 
AEAD-sealed plaintext, never on its own.
+       private static final int JTI_BYTES = 16;
+       private static final SecureRandom JTI_RANDOM = new SecureRandom();
+       private static final Base64.Encoder B64URL = 
Base64.getUrlEncoder().withoutPadding();
+       private static final Base64.Decoder B64URL_DECODER = 
Base64.getUrlDecoder();
+
+       // RFC 8785 canonicalizer for the argument hash, deliberately 
configured for UNBOUNDED depth with recursion
+       // detection: JcsSerializer's inherited maxDepth default (100) SILENTLY 
truncates over-depth nodes to null,
+       // which would let two argument sets differing only below the limit 
hash identically while the handler still
+       // received the divergent subtree. maxDepth(Integer.MAX_VALUE) makes 
the hash cover the full subtree, and
+       // detectRecursions() fails-fast (rather than looping) on a 
pathological cyclic structure. The complementary
+       // JsonValueSafety.check in argumentsHash(...) is what actually bounds 
attacker-controlled depth/size/CPU;
+       // this canonicalizer only guarantees that whatever passes that guard 
is hashed in full.
+       private static final Jcs JCS = new Jcs(
+               
JcsSerializer.create().maxDepth(Integer.MAX_VALUE).detectRecursions().build(), 
JsonParser.DEFAULT);
+
        /**
         * This revision instance's binding-owned MRTR (Multi-Round-Trip 
Request) configuration, as supplied at
         * construction time.
@@ -631,7 +673,7 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                        validateStructuredOutput(outcome);
                        return McpWire.toWire(outcome);
                } catch (McpInputRequiredSignal signal) {
-                       return pause(signal, McpMethods.TOOLS_CALL, p, 
mrtr.currentRound(), ctx);
+                       return pause(signal, McpMethods.TOOLS_CALL, p, 
mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx);
                }
        }
 
@@ -668,7 +710,7 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                try (var store = mrtr.store()) {
                        return McpWire.toWire(handler.get(args, store));
                } catch (McpInputRequiredSignal signal) {
-                       return pause(signal, McpMethods.PROMPTS_GET, p, 
mrtr.currentRound(), ctx);
+                       return pause(signal, McpMethods.PROMPTS_GET, p, 
mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx);
                }
        }
 
@@ -697,7 +739,7 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                        try (var store = mrtr.store()) {
                                return 
applyCache(McpWire.toWire(exact.get().read(uri, store)), readHint(uri));
                        } catch (McpInputRequiredSignal signal) {
-                               return pause(signal, McpMethods.RESOURCES_READ, 
p, mrtr.currentRound(), ctx);
+                               return pause(signal, McpMethods.RESOURCES_READ, 
p, mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx);
                        }
                }
                var match = config.resolveResourceTemplate(uri);
@@ -773,8 +815,21 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
         * so call sites can close it in a try-with-resources block: any bean a 
handler resolves through it is tracked
         * for {@code @PreDestroy} cleanup on <i>this</i> store, not the 
caller-owned {@code ctx} it wraps (see
         * {@link BasicBeanStore#close()}), so it must be closed once the 
handler invocation completes.
+        *
+        * <p>
+        * {@code argumentsHash} is the canonical hash of the current request's 
{@code arguments} (see
+        * {@link #argumentsHash(Map)}), computed <i>once</i> here &mdash; 
before the handler runs &mdash; and threaded
+        * to {@link #pause} so the next token seals the hash of what the 
client actually sent, not a hash recomputed
+        * from the live {@code arguments} map after a handler may have mutated 
it in place.
+        *
+        * <p>
+        * {@code target} is the operation target resolved by {@link 
#mrtrTarget(String, Map)} (the tool/prompt
+        * {@code name} or resource {@code uri}), captured at the same 
pre-handler point as {@code argumentsHash} and
+        * for the same reason: {@link #pause} seals it into the next token's 
{@link #aad(String, String) AAD}
+        * unchanged, so a handler that mutates the live {@code params} map in 
place cannot shift which
+        * tool/prompt/resource the next token is bound to.
         */
-       private record MrtrContext(WritableBeanStore store, int currentRound) {}
+       private record MrtrContext(WritableBeanStore store, int currentRound, 
String argumentsHash, String target) {}
 
        /**
         * Builds the {@link BeanStore} an in-scope handler receives and, on a 
RESUME, validates the echoed
@@ -784,16 +839,37 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
         * Always wraps {@code ctx} with a {@link McpMrtrCapabilityContext} so 
a handler can pre-check the client's
         * advertised {@code elicitation} capability (spec &sect;4). When the 
request carries a {@code requestState}
         * (a RESUME), it is unsealed under the canonical AAD and validated for 
integrity, method agreement, expiry,
-        * and the max-rounds cap &mdash; any failure raises the mapped {@link 
McpException} <i>before</i> the handler
-        * is re-invoked &mdash; then the decoded continuation and the client's 
{@code inputResponses} are exposed via
+        * the max-rounds cap, argument-hash agreement (always; see {@link 
#argumentsHash(Map)}), and replay (only when
+        * {@link McpMrtrConfig#getReplayCache()} is configured; see {@link 
#checkReplay(ReplayCache, String, long)})
+        * &mdash; any failure raises the mapped {@link McpException} 
<i>before</i> the handler is re-invoked &mdash;
+        * then the decoded continuation and the client's {@code 
inputResponses} are exposed via
         * {@link McpMrtrResumeContext}.
         *
+        * <p>
+        * <b>Check ordering is deliberate.</b> The stateless argument-hash 
comparison runs <i>before</i> the
+        * (stateful) replay {@code checkAndRecord}, so a submission carrying 
the wrong {@code arguments} is rejected
+        * without ever consuming the token's {@code jti} &mdash; an attacker 
holding only a leaked token (but not the
+        * arguments) cannot burn a victim's in-flight resume, and an honest 
client that sends slightly-wrong arguments
+        * does not destroy its own token. The argument hash is also computed 
<i>once</i> here, before the handler
+        * runs, and threaded to {@link #pause} (see {@link MrtrContext}).
+        *
         * @param method The in-scope JSON-RPC method. Must not be 
<jk>null</jk>.
         * @param params The request params map. Must not be <jk>null</jk>.
         * @param ctx The request-scoped bean store to wrap. Must not be 
<jk>null</jk>.
         * @return The resolved MRTR context. Never <jk>null</jk>.
         */
        private MrtrContext resolveMrtrContext(String method, 
Map<String,Object> params, BeanStore ctx) {
+               // Computed once, up front, from the CURRENT request's 
arguments: reused both for the RESUME-time
+               // comparison below and (threaded through MrtrContext) for the 
hash pause(...) seals into the next token,
+               // so a handler that mutates the live arguments map in place 
cannot make the next token seal a hash the
+               // client can no longer reproduce. Also applies the 
bounded-traversal + canonicalization guards (see
+               // argumentsHash), so structurally-hostile arguments become a 
-32602 here on both first-round and RESUME.
+               var argumentsHash = 
argumentsHash(McpParamUtils.mapParam(params, PARAM_ARGUMENTS));
+               // Captured once, up front, alongside argumentsHash and for the 
same reason: threaded through MrtrContext
+               // to pause(...) so the AAD sealed into the next token binds 
the target actually resolved for THIS
+               // request, immune to a handler mutating params in place before 
pause(...) runs (see MrtrContext's
+               // javadoc above).
+               var target = mrtrTarget(method, params);
                var requestState = McpParamUtils.strParam(params, 
"requestState");
                if (requestState == null) {
                        @SuppressWarnings({
@@ -801,12 +877,12 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                        })
                        var wrapped = new BasicBeanStore(ctx)
                                .addBean(McpMrtrCapabilityContext.class, new 
McpMrtrCapabilityContext(clientElicitationSupported(params)));
-                       return new MrtrContext(wrapped, 0);
+                       return new MrtrContext(wrapped, 0, argumentsHash, 
target);
                }
                // Validate the echoed requestState before constructing the 
BasicBeanStore below: every failure here
                // throws, and a BasicBeanStore built earlier would never reach 
a caller's try-with-resources to be
                // closed (java:S2095). Deferring construction until validation 
succeeds means no path leaks it.
-               var sealed = mrtrConfig.getCodec().unseal(requestState, 
aad(method), principal(ctx))
+               var sealed = mrtrConfig.getCodec().unseal(requestState, 
aad(method, target), principal(ctx))
                        .orElseThrow(() -> new 
McpException(CODE_INVALID_PARAMS, "Invalid or tampered requestState"));
                if (! method.equals(sealed.method()))
                        throw new McpException(CODE_INVALID_PARAMS, 
"requestState method mismatch");
@@ -815,6 +891,26 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                if (sealed.round() >= mrtrConfig.getMaxRounds())
                        // Client-facing message deliberately omits the 
configured cap value so server config is not leaked.
                        throw new McpException(CODE_MAX_ROUNDS_EXCEEDED, "Max 
MRTR rounds exceeded");
+               // Stateless argument-hash agreement BEFORE the stateful replay 
record (see the ordering note above). A
+               // null sealed hash (e.g. a custom codec that dropped the 
field) fails closed as an ordinary mismatch
+               // here; a malformed (non-base64url) sealed hash is a 
codec-contract violation and argumentsMatch itself
+               // throws CODE_INVALID_PARAMS for it (mirroring the null-jti 
fail-closed check below), rather than
+               // reaching this mismatch branch.
+               if (! argumentsMatch(argumentsHash, sealed.argumentsHash()))
+                       throw new 
McpException(CODE_REQUEST_STATE_ARGUMENTS_MISMATCH, "resumed arguments do not 
match the original request");
+               var replayCache = mrtrConfig.getReplayCache();
+               if (replayCache != null) {
+                       // A null/empty jti reaching the cache is a 
codec/contract violation (a custom RequestStateCodec that
+                       // dropped the field, or a pre-jti in-flight token 
during a rolling deploy), NOT a store outage: fail
+                       // CLOSED here rather than letting a downstream NPE be 
swallowed by checkReplay's fail-OPEN catch,
+                       // which would silently disable replay protection.
+                       if (isEmpty(sealed.jti()))
+                               throw new McpException(CODE_INVALID_PARAMS, 
"Invalid or tampered requestState");
+                       if (! checkReplay(replayCache, sealed.jti(), 
sealed.expiresAtMs()))
+                               // Client-facing message deliberately omits any 
cache/backend detail, mirroring the max-rounds
+                               // message's "don't leak server config" caution 
above.
+                               throw new 
McpException(CODE_REQUEST_STATE_REPLAYED, "requestState has already been used");
+               }
                var inputResponses = McpParamUtils.mapParam(params, 
"inputResponses");
                @SuppressWarnings({
                        "resource" // Ownership transfers to the returned 
MrtrContext; the caller closes it via mrtr.store() in try-with-resources (see 
MrtrContext's javadoc above). Eclipse JDT @Owning warning is by design.
@@ -822,13 +918,14 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
                var wrapped = new BasicBeanStore(ctx)
                        .addBean(McpMrtrCapabilityContext.class, new 
McpMrtrCapabilityContext(clientElicitationSupported(params)))
                        .addBean(McpMrtrResumeContext.class, new 
McpMrtrResumeContext(sealed.continuation(), inputResponses));
-               return new MrtrContext(wrapped, sealed.round());
+               return new MrtrContext(wrapped, sealed.round(), argumentsHash, 
target);
        }
 
        /**
         * Turns a caught {@link McpInputRequiredSignal} into a wire {@link 
InputRequiredResult}: capability-gates the
-        * client, seals a fresh {@code requestState} carrying the incremented 
round counter, and assembles the
-        * requested-inputs map.
+        * client, seals a fresh {@code requestState} carrying the incremented 
round counter, a fresh {@code jti} (see
+        * {@link #mintJti()}), and the {@code argumentsHash} of the current 
request's {@code arguments} (see
+        * {@link #argumentsHash(Map)}), and assembles the requested-inputs map.
         *
         * <p>
         * The capability gate runs <i>before</i> any token is minted &mdash; a 
client that never advertised
@@ -836,18 +933,27 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
         *
         * @param signal The signal thrown by the handler. Must not be 
<jk>null</jk>.
         * @param method The in-scope JSON-RPC method this pause is for. Must 
not be <jk>null</jk>.
-        * @param params The request params map. Must not be <jk>null</jk>.
+        * @param params The request params map, used only for {@link 
#clientElicitationSupported(Map)} here &mdash;
+        *      the AAD's operation target is the caller-supplied {@code 
target}, below, not re-derived from this map.
         * @param currentRound The round decoded on RESUME (0 on a first-round 
pause), incremented into the new token.
+        * @param argumentsHash The hash of the current request's {@code 
arguments}, computed by
+        *      {@link #resolveMrtrContext} <i>before</i> the handler ran and 
threaded here (see {@link MrtrContext}), so a
+        *      handler that mutated the live {@code arguments} map cannot make 
this token seal an unreproducible hash.
+        * @param target The operation target sealed into the next token's 
{@link #aad(String, String) AAD}, resolved
+        *      by {@link #resolveMrtrContext} <i>before</i> the handler ran 
and threaded here (see {@link MrtrContext}),
+        *      so a handler that mutated the live {@code params} map cannot 
shift which tool/prompt/resource the next
+        *      token is bound to.
         * @param ctx The per-request bean store, used to resolve the 
authenticated {@link #principal(BeanStore)} threaded
         *      into the codec's {@link RequestStateCodec#seal seal} 
(READY-312f F4). Must not be <jk>null</jk>.
         * @return The assembled, validated {@code input_required} result. 
Never <jk>null</jk>.
         */
-       private InputRequiredResult pause(McpInputRequiredSignal signal, String 
method, Map<String,Object> params, int currentRound, BeanStore ctx) {
+       private InputRequiredResult pause(McpInputRequiredSignal signal, String 
method, Map<String,Object> params, int currentRound, String argumentsHash, 
String target, BeanStore ctx) {
                if (! clientElicitationSupported(params))
                        throw new 
McpException(CODE_MISSING_REQUIRED_CLIENT_CAPABILITY,
                                "Client does not advertise the elicitation 
capability required for input_required");
-               var state = new McpRequestState(signal.getContinuation(), 
method, currentRound + 1, System.currentTimeMillis() + mrtrConfig.getTtlMs());
-               var result = new 
InputRequiredResult().setRequestState(mrtrConfig.getCodec().seal(state, 
aad(method), principal(ctx)));
+               var state = new McpRequestState(signal.getContinuation(), 
method, currentRound + 1, System.currentTimeMillis() + mrtrConfig.getTtlMs(),
+                       mintJti(), argumentsHash);
+               var result = new 
InputRequiredResult().setRequestState(mrtrConfig.getCodec().seal(state, 
aad(method, target), principal(ctx)));
                // Each inputRequests value is carried to the wire 
byte-for-byte as a raw sub-request object (see
                // McpInputRequiredSignal). The pinned schema models every 
value as an object; a non-map handler value is a
                // programming error that surfaces (as a ClassCastException 
here) via dispatch's -32603 fail-safe.
@@ -858,14 +964,190 @@ public final class McpRevision implements 
org.apache.juneau.rest.server.mcp.McpR
 
        /**
         * The canonical MRTR AAD binding a sealed {@code requestState} to the 
request that produced it:
-        * {@code method + '\u0000' + protocolVersion} (NUL-separated; see 
{@link RequestStateCodec}). NUL cannot
-        * appear in a method name or protocol-version literal, so the 
concatenation is unambiguous.
+        * {@code method + '\u0000' + protocolVersion + '\u0000' + target} 
(NUL-separated; see
+        * {@link RequestStateCodec}).
+        *
+        * <p>
+        * <b>The operation {@code target} is folded in so a token is bound to 
the specific tool/prompt/resource it
+        * paused against, not merely the method.</b> Without it, a token 
paused for tool A could be resumed against
+        * tool B (or resource {@code uri} A against {@code uri} B) &mdash; 
both are {@code tools/call} (or
+        * {@code resources/read}), so the method-only AAD verified 
identically. The {@code target} is the tool
+        * {@code name} for {@code tools/call}, the prompt {@code name} for 
{@code prompts/get}, and the {@code uri}
+        * for {@code resources/read} (see {@link #mrtrTarget(String, Map)}); a 
mismatch now fails the GCM tag check,
+        * surfacing as the existing "invalid or tampered requestState" {@link 
#CODE_INVALID_PARAMS}. Neither
+        * {@code method} nor {@code protocolVersion} can contain a NUL, and 
{@code target} is the terminal field, so
+        * the three-field NUL join is unambiguous (distinct {@code (method, 
version, target)} tuples never collide)
+        * &mdash; the same injectivity property the two-field form relied on, 
extended by one trailing field. The
+        * codec treats this whole string as one opaque AAD field, framing it 
against {@code keyId}/principal with its
+        * own length-prefixed join (see {@link 
AeadRequestStateCodec#lengthPrefixJoin}).
         *
         * @param method The in-scope JSON-RPC method. Must not be 
<jk>null</jk>.
+        * @param target The operation target (tool/prompt {@code name} or 
resource {@code uri}); coalesced to empty
+        *      when <jk>null</jk> (only the three MRTR-wired methods reach 
here, and each has already validated its
+        *      target as non-null at the call site).
         * @return The AAD string. Never <jk>null</jk>.
         */
-       private String aad(String method) {
-               return method + '\u0000' + protocolVersion();
+       private String aad(String method, String target) {
+               return method + '\u0000' + protocolVersion() + '\u0000' + 
ein(target);
+       }
+
+       /**
+        * Derives the operation target folded into the {@link #aad(String, 
String) AAD}: the tool {@code name} for
+        * {@code tools/call}, the prompt {@code name} for {@code prompts/get}, 
and the resource {@code uri} for
+        * {@code resources/read}. Any other method (none of which reach the 
MRTR pause/resume path) yields
+        * <jk>null</jk>.
+        *
+        * @param method The in-scope JSON-RPC method. Must not be 
<jk>null</jk>.
+        * @param params The request params map. Must not be <jk>null</jk>.
+        * @return The operation target, or <jk>null</jk> for a non-MRTR method.
+        */
+       private static String mrtrTarget(String method, Map<String,Object> 
params) {
+               return switch (method) {
+                       case McpMethods.TOOLS_CALL, McpMethods.PROMPTS_GET -> 
McpParamUtils.strParam(params, "name");
+                       case McpMethods.RESOURCES_READ -> 
McpParamUtils.strParam(params, "uri");
+                       default -> null;
+               };
+       }
+
+       /**
+        * Mints a fresh, per-token random {@code jti} for a newly-sealed 
{@code requestState}, used only to detect
+        * replay of this exact token (see {@link #checkReplay(ReplayCache, 
String, long)}).
+        *
+        * <p>
+        * 16 random bytes, base64url-encoded &mdash; the same minting pattern 
{@link EphemeralKeyProvider} already
+        * uses for its own {@code keyId}. Collision probability is negligible 
(128 bits of entropy) and, in any
+        * case, is not a security property this identifier depends on: it 
rides inside the AEAD-sealed plaintext, so
+        * a client can neither forge nor read it without first breaking the 
GCM tag.
+        *
+        * @return A fresh {@code jti}. Never <jk>null</jk>.
+        */
+       private static String mintJti() {
+               var bytes = new byte[JTI_BYTES];
+               JTI_RANDOM.nextBytes(bytes);
+               return B64URL.encodeToString(bytes);
+       }
+
+       /**
+        * Computes the canonical hash of a request's {@code arguments}, sealed 
at PAUSE time and re-verified at
+        * RESUME time (see {@code McpRequestState#argumentsHash()}).
+        *
+        * <p>
+        * First runs the module's shared bounded-traversal guard ({@link 
JsonValueSafety#check(Object, String)}:
+        * {@code MAX_DEPTH=64}, {@code MAX_NODES}, a traversal-time deadline) 
so structurally-hostile {@code arguments}
+        * (excessive depth or node count &mdash; reachable via {@code 
prompts/get}, which has no other input-shape
+        * guard, and schemaless {@code tools/call}) become a deterministic 
{@link #CODE_INVALID_PARAMS} rejection on
+        * BOTH PAUSE and RESUME, rather than a silent hash collision (the JCS 
canonicalizer's inherited depth limit
+        * would otherwise truncate over-depth nodes to {@code null}). It then 
canonicalizes {@code arguments} per RFC
+        * 8785 (JSON Canonicalization Scheme) using an <b>unbounded-depth</b> 
serializer (see {@link #JCS}), SHA-256s
+        * the canonical UTF-8 bytes, and base64url-encodes the digest 
(mirroring {@link EphemeralKeyProvider}'s
+        * existing {@code keyId} encoding convention). An absent or empty 
{@code arguments} map canonicalizes to the
+        * empty JSON object {@code "{}"}, giving a single, consistent sentinel 
hash for calls that take no arguments
+        * (for example {@code resources/read}'s exact-path branch), so PAUSE 
and RESUME agree even when there is
+        * nothing to hash.
+        *
+        * <p>
+        * By design, RFC 8785 canonical equivalences are treated as equal 
&mdash; for example {@code 1} and
+        * {@code 1.0} canonicalize identically, so they hash the same and a 
resume switching between them is not a
+        * mismatch. A canonicalization failure on a 
hostile-but-syntactically-legal value (a non-finite number such
+        * as {@code 1e999}, a lone UTF-16 surrogate, or a {@code 
BigDecimal}/{@code BigInteger} outside IEEE-754
+        * range) is mapped to {@link #CODE_INVALID_PARAMS} rather than 
surfacing as a generic internal error.
+        *
+        * <p>
+        * Package-visible so tests can construct {@link McpRequestState} 
fixtures whose {@code argumentsHash} agrees
+        * with a given (or absent) {@code arguments} map without duplicating 
this derivation.
+        *
+        * @param arguments The request's {@code arguments} map, as returned by
+        *      {@code McpParamUtils.mapParam(params, "arguments")}. Never 
<jk>null</jk> (empty when absent).
+        * @return The canonical hash. Never <jk>null</jk>.
+        * @throws McpException ({@link #CODE_INVALID_PARAMS}) if {@code 
arguments} violates the structural safety
+        *      limits or cannot be canonicalized.
+        */
+       static String argumentsHash(Map<String,Object> arguments) {
+               try {
+                       JsonValueSafety.check(arguments, "arguments");
+               } catch (IllegalArgumentException e) {
+                       throw new McpException(CODE_INVALID_PARAMS, 
e.getMessage());
+               }
+               String canonical;
+               try {
+                       canonical = JCS.write(arguments);
+               } catch (SerializeException e) {
+                       // Message deliberately generic (no echo of the 
offending value), mirroring the other MRTR "don't leak"
+                       // messages: a hostile-but-legal input (non-finite 
number, lone surrogate, out-of-IEEE BigDecimal) must
+                       // not surface as a -32603 internal error.
+                       throw new McpException(CODE_INVALID_PARAMS, "Invalid 
arguments: not canonicalizable");
+               }
+               return 
B64URL.encodeToString(sha256(canonical.getBytes(StandardCharsets.UTF_8)));
+       }
+
+       /**
+        * Constant-time comparison of the current request's {@code 
argumentsHash} against the one sealed into the
+        * echoed token. Both operands are base64url of a SHA-256 digest; they 
are decoded to their raw digest bytes
+        * and compared with {@link MessageDigest#isEqual(byte[], byte[])}. A 
<jk>null</jk> sealed hash (for example a
+        * custom codec that dropped the field) fails closed (returns 
<jk>false</jk>, surfacing as the ordinary
+        * {@link #CODE_REQUEST_STATE_ARGUMENTS_MISMATCH} at the call site).
+        *
+        * <p>
+        * A non-<jk>null</jk> sealed hash that is not valid base64url (for 
example a custom codec that computed
+        * {@code argumentsHash} some other way) is a codec-contract violation 
rather than an ordinary mismatch, and
+        * fails closed the same way the null-{@code jti} codec-contract 
violation does at the call site: a thrown
+        * {@link McpException} ({@link #CODE_INVALID_PARAMS}), not a 
propagated {@link IllegalArgumentException}
+        * from {@link Base64.Decoder#decode(String)} surfacing as a generic 
{@link #CODE_INTERNAL_ERROR}.
+        *
+        * @param currentHash The hash recomputed from the current request's 
{@code arguments}. Never <jk>null</jk>.
+        * @param sealedHash The hash sealed into the echoed token, or 
<jk>null</jk>.
+        * @return <jk>true</jk> if the two hashes match.
+        * @throws McpException ({@link #CODE_INVALID_PARAMS}) if {@code 
sealedHash} is non-<jk>null</jk> but not
+        *      valid base64url.
+        */
+       private static boolean argumentsMatch(String currentHash, String 
sealedHash) {
+               if (sealedHash == null)
+                       return false;
+               byte[] sealedBytes;
+               try {
+                       sealedBytes = B64URL_DECODER.decode(sealedHash);
+               } catch (IllegalArgumentException e) {
+                       // Same fail-closed contract as the null-jti check 
below: an untrusted or misbehaving codec must
+                       // never turn into a raw exception bubbling up as 
-32603 (see resolveMrtrContext's ordering note
+                       // on why this stateless check runs before the stateful 
replay record).
+                       throw new McpException(CODE_INVALID_PARAMS, "Invalid or 
tampered requestState");
+               }
+               return 
MessageDigest.isEqual(B64URL_DECODER.decode(currentHash), sealedBytes);
+       }
+
+       private static byte[] sha256(byte[] input) {
+               try {
+                       return 
MessageDigest.getInstance("SHA-256").digest(input);
+               } catch (NoSuchAlgorithmException e) { // HTT every JDK 
guarantees SHA-256 via the standard JCE provider
+                       throw rex(e, "SHA-256 not available");
+               }
+       }
+
+       /**
+        * Invokes an operator-configured {@link ReplayCache}, applying the 
fail-open policy documented on that SPI:
+        * a thrown exception is logged and treated as first-seen, so a store 
outage degrades to today's
+        * already-documented multi-use-tolerant behavior rather than rejecting 
all MRTR resume traffic.
+        *
+        * <p>
+        * An operator who wants fail-closed-on-store-outage instead must 
implement that themselves by returning
+        * <jk>false</jk> from their own {@link 
ReplayCache#checkAndRecord(String, long)} rather than throwing (see
+        * the SPI's own Javadoc) &mdash; this method applies no policy beyond 
"don't let an unexpected exception take
+        * down resume traffic".
+        *
+        * @param replayCache The configured replay cache. Must not be 
<jk>null</jk> (callers only invoke this when
+        *      {@link McpMrtrConfig#getReplayCache()} is non-<jk>null</jk>).
+        * @param jti The token identifier to check. Must not be <jk>null</jk>.
+        * @param expiresAtMs The sealed token's own absolute expiry, passed 
through unchanged.
+        * @return <jk>true</jk> if the cache reports first-seen (or itself 
failed, fail-open); <jk>false</jk> only
+        *      when the cache affirmatively reports a replay.
+        */
+       private static boolean checkReplay(ReplayCache replayCache, String jti, 
long expiresAtMs) {
+               try {
+                       return replayCache.checkAndRecord(jti, expiresAtMs);
+               } catch (Exception e) {
+                       LOG.log(Level.WARNING, e, () -> 
"ReplayCache.checkAndRecord failed; treating requestState as first-seen 
(fail-open).");
+                       return true;
+               }
        }
 
        /**
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ReplayCache.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ReplayCache.java
new file mode 100644
index 0000000000..83c47fc87f
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ReplayCache.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+/**
+ * SPI for detecting reuse ("replay") of a sealed MCP MRTR {@code 
requestState} resume token (see
+ * {@code McpRevision#resolveMrtrContext}).
+ *
+ * <p>
+ * <b>Opt-in.</b> A {@code requestState} token is, by default, a multi-use 
bearer credential: it may be resumed
+ * any number of times within its TTL (see {@link RequestStateCodec}). Wiring 
a {@link ReplayCache} via
+ * {@link McpMrtrConfig#setReplayCache(ReplayCache)} narrows that default to 
single-use &mdash; the second and
+ * every subsequent submission of the same token is rejected. No {@link 
ReplayCache} is wired by default (see
+ * {@link McpMrtrConfig#getReplayCache()}).
+ *
+ * <p>
+ * <b>Atomic check-and-record (no TOCTOU).</b> {@link #checkAndRecord(String, 
long)} both checks whether
+ * {@code jti} has been seen before <i>and</i> records it as seen, as one 
indivisible operation. A separate
+ * {@code seen(jti)} followed by a separate {@code record(jti)} would leave a 
race window in which two
+ * near-simultaneous submissions of the same token could both observe "not yet 
seen" and both proceed;
+ * implementations must guarantee that of any two concurrent calls with the 
same {@code jti}, at most one
+ * returns <jk>true</jk>.
+ *
+ * <p>
+ * <b>Fail-mode split (deliberate).</b> The dispatcher and this SPI split 
responsibility for what happens when
+ * the backing store cannot answer the question:
+ * <ul>
+ *     <li><b>A thrown exception is fail-open.</b> If {@link 
#checkAndRecord(String, long)} throws, the dispatcher
+ *             catches it, logs it, and treats the token as first-seen &mdash; 
the resume proceeds exactly as it would
+ *             with no {@link ReplayCache} wired at all. This degrades a 
transient store outage to today's
+ *             already-documented multi-use-tolerant behavior rather than 
rejecting all MRTR resume traffic.
+ *     <li><b>An operator who wants fail-closed must return <jk>false</jk>, 
not throw.</b> An implementation backed
+ *             by a store that is unreachable, and whose operator prefers to 
reject rather than degrade, must catch its
+ *             own I/O failures internally and return <jk>false</jk> (a 
"replay" outcome) rather than let the exception
+ *             propagate &mdash; the framework itself applies no fail-closed 
policy and offers no config toggle for one.
+ * </ul>
+ *
+ * <p>
+ * <b>Built-in default is per-process only.</b> The zero-config built-in 
implementation
+ * ({@link InMemoryReplayCache}) is backed by process-local memory: it is not 
shareable across process
+ * instances, so it only enforces single-use within one process. Cross-node 
(horizontally-scaled) single-use
+ * requires an operator-supplied {@link ReplayCache} backed by a store shared 
across every node (for example
+ * Redis), mirroring {@link KeyProvider}'s own SPI-plus-non-shareable-default 
precedent.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ *     <li class='jc'>{@link InMemoryReplayCache}
+ *     <li class='jc'>{@link McpMrtrConfig#setReplayCache(ReplayCache)}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface ReplayCache {
+
+       /**
+        * Atomically checks whether {@code jti} has been observed before and 
records it as seen if not.
+        *
+        * <p>
+        * <b>Implementations own their own timeout.</b> The framework's 
fail-open safety net covers a thrown
+        * exception (see the class Javadoc), not a hang: a call that blocks 
indefinitely (for example a remote store
+        * with no client-side deadline) blocks the resume with it. An 
implementation backed by a network store must
+        * bound its own call so a slow/unreachable backend surfaces as a 
prompt throw (fail-open) or return
+        * (operator-chosen fail-closed), never an unbounded stall.
+        *
+        * @param jti The token identifier read from the sealed {@code 
requestState} plaintext (never the
+        *      client-supplied token string itself). Never <jk>null</jk>.
+        * @param expiresAtMs The sealed token's own absolute expiry, in epoch 
milliseconds. An implementation may
+        *      use this to self-evict the record once it can no longer matter 
&mdash; an already-expired token is
+        *      separately rejected by the dispatcher's own expiry check 
regardless of what this method returns, so a
+        *      record never needs to outlive its token's own TTL.
+        * @return <jk>true</jk> if this is the first time {@code jti} has been 
observed (the caller should proceed);
+        *      <jk>false</jk> if {@code jti} has been observed before (the 
caller should reject the resume as a
+        *      replay).
+        */
+       boolean checkAndRecord(String jti, long expiresAtMs);
+}
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec.java
index 5a02e9fee2..1ee629cb9a 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec.java
@@ -39,30 +39,41 @@ import java.util.*;
  * <b>Trust model &mdash; {@code requestState} is a bearer token.</b> The 
sealed token is a replayable credential:
  * anyone who holds it can resume the paused operation until it expires, and 
it carries no caller identity. A
  * codec (and every call site) must treat it accordingly &mdash; transport 
only over authenticated TLS, and
- * <b>never log</b> a {@code requestState}. Because a captured token can be 
replayed any number of times within
- * its TTL, resume side effects must be idempotent; the max-rounds cap bounds 
chain depth, not replay count (see
- * {@link McpMrtrConfig}).
+ * <b>never log</b> a {@code requestState}. <b>Multi-use within TTL by 
default:</b> a captured token can be
+ * replayed any number of times within its TTL unless an operator configures a 
{@link ReplayCache} (see
+ * {@link McpMrtrConfig#setReplayCache(ReplayCache)}) to enforce single-use 
&mdash; the built-in
+ * {@link InMemoryReplayCache} is per-process only, so cross-node single-use 
requires a shared implementation. A
+ * handler must still write idempotent resume side effects unless it knows a 
single-use {@link ReplayCache} is in
+ * effect; the max-rounds cap bounds chain depth, not replay count (see {@link 
McpMrtrConfig}).
  *
  * <p>
  * <b>Canonical AAD format.</b> The dispatcher binds each token to the request 
that produced it by passing a
- * single canonical AAD string: {@code method + '\u0000' + protocolVersion} 
&mdash; the JSON-RPC method name and
- * the negotiated protocol version joined by a NUL ({@code U+0000}) separator 
(see {@code McpRevision#aad}). NUL
- * is chosen because it can never appear in a method name or a 
protocol-version literal, so the concatenation is
- * unambiguous. An implementation only needs to treat the AAD as opaque bytes; 
the format is documented here so
- * seal and unseal call sites (and any custom codec) agree on exactly what is 
authenticated.
+ * single canonical AAD string: {@code method + '\u0000' + protocolVersion + 
'\u0000' + target} &mdash; the
+ * JSON-RPC method name, the negotiated protocol version, and the operation 
target (the tool {@code name} for
+ * {@code tools/call}, the prompt {@code name} for {@code prompts/get}, or the 
resource {@code uri} for
+ * {@code resources/read}; empty for any other method), all NUL ({@code 
U+0000})-separated (see
+ * {@code McpRevision#aad}). NUL is chosen because it can never appear in a 
method name, a protocol-version
+ * literal, or the trailing target field, so the concatenation is unambiguous. 
The trailing target field binds
+ * the token to the specific operation it paused against, not merely the 
method, so a token minted while paused
+ * on one tool/prompt/resource cannot be resumed against a different one of 
the same kind. An implementation
+ * only needs to treat the AAD as opaque bytes; the format is documented here 
so seal and unseal call sites (and
+ * any custom codec) agree on exactly what is authenticated.
  *
  * <p>
  * <b>Authenticated principal (READY-312f F4).</b> Both {@link 
#seal(McpRequestState, String, Principal)} and
  * {@link #unseal(String, String, Principal)} receive the caller's 
authenticated {@link Principal} &mdash; the same
  * identity the F2 resource-server layer establishes (see {@code 
McpResourceServerSupport#principal}). This is the
- * <i>seam</i> that unblocks TODO-325's principal-bound AAD: a hardened codec 
can fold the caller identity into its
- * authenticated data so a {@code requestState} minted for principal A cannot 
be resumed by principal B. The
- * built-in {@link AeadRequestStateCodec} does <b>not</b> bind the principal 
yet (that binding, plus the choice of
- * <i>which</i> identity attribute to bind &mdash; subject claim, 
issuer+subject, full claim set &mdash; is owned by
- * TODO-325); F4 only guarantees the principal is delivered to the codec at 
both seal and unseal. The principal is
- * <b>nullable</b>: when RS auth is disabled or the caller is anonymous it is 
<jk>null</jk>, and every codec must
- * seal/unseal cleanly (no NPE) in that case. The two-argument {@link 
#seal(McpRequestState, String)} /
- * {@link #unseal(String, String)} convenience overloads simply pass a 
<jk>null</jk> (no-principal) identity.
+ * <i>seam</i> a hardened codec uses to fold the caller identity into its 
authenticated data, so a
+ * {@code requestState} minted for principal A cannot be resumed by principal 
B. The built-in
+ * {@link AeadRequestStateCodec} binds exactly that: it folds a canonical, 
deterministic {@code iss|sub} identity
+ * (see {@link AeadRequestStateCodec#principalIdentity(Principal)}) into its 
AEAD authenticated data, so a
+ * mismatched principal fails the GCM tag check and {@link #unseal} returns 
{@link Optional#empty()}. F4 is what
+ * guarantees the principal is delivered to the codec at both seal and unseal 
in the first place; a custom codec
+ * remains free to bind a different identity attribute (subject claim, 
issuer+subject, full claim set) or none at
+ * all. The principal is <b>nullable</b>: when RS auth is disabled or the 
caller is anonymous it is <jk>null</jk>,
+ * and every codec must seal/unseal cleanly (no NPE) in that case. The 
two-argument
+ * {@link #seal(McpRequestState, String)} / {@link #unseal(String, String)} 
convenience overloads simply pass a
+ * <jk>null</jk> (no-principal) identity.
  */
 public interface RequestStateCodec {
 
@@ -71,7 +82,8 @@ public interface RequestStateCodec {
         *
         * @param state The payload to seal. Must not be <jk>null</jk>.
         * @param aad Additional authenticated data (the dispatcher passes the 
canonical
-        *      {@code method + '\u0000' + protocolVersion} form &mdash; see 
the class Javadoc). Must not be <jk>null</jk>.
+        *      {@code method + '\u0000' + protocolVersion + '\u0000' + target} 
form &mdash; see the class Javadoc). Must
+        *      not be <jk>null</jk>.
         * @param principal The authenticated caller (see the class Javadoc). 
May be <jk>null</jk> for an anonymous
         *      caller or when resource-server auth is disabled.
         * @return The opaque token. Never <jk>null</jk>.
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
index 1d3ae26091..1db5023730 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/AeadRequestStateCodec_Test.java
@@ -39,7 +39,7 @@ class AeadRequestStateCodec_Test {
 
        @Test void a01_roundTripWithMatchingAadRecoversOriginalState() {
                var a = new AeadRequestStateCodec();
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                assertEquals(4, token.split("\\.", 4).length, "wire format must 
be version.keyId.nonce.ciphertext");
                assertTrue(token.startsWith("1."), "version segment must be the 
literal \"1\"");
@@ -50,7 +50,7 @@ class AeadRequestStateCodec_Test {
 
        @Test void a02_tamperedCiphertextByteFailsUnseal() {
                var a = new AeadRequestStateCodec();
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                var parts = token.split("\\.", 4);
                var ciphertext = Base64.getUrlDecoder().decode(parts[3]);
@@ -63,7 +63,7 @@ class AeadRequestStateCodec_Test {
 
        @Test void a03_aadMismatchFailsUnseal() {
                var a = new AeadRequestStateCodec();
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                var c = a.unseal(token, "prompts/get" + '\u0000' + 
"2026-07-28");  // a valid token under a mismatched AAD
                assertTrue(c.isEmpty());
@@ -104,7 +104,7 @@ class AeadRequestStateCodec_Test {
        @Test void a06_perProcessEphemeralKeyPreventsCrossInstanceUnseal() {
                var a = new AeadRequestStateCodec();
                var b = new AeadRequestStateCodec();
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(state, AAD);
                var c = b.unseal(token, AAD);
                assertTrue(c.isEmpty());
@@ -117,7 +117,7 @@ class AeadRequestStateCodec_Test {
                var keyProvider = StaticKeyProvider.of("2026-08-a", sharedKey);
                var a = new AeadRequestStateCodec(keyProvider);
                var b = new AeadRequestStateCodec(keyProvider);
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(state, AAD);
                var c = b.unseal(token, AAD);
                assertTrue(c.isPresent(), "two codecs sharing one 
StaticKeyProvider must unseal each other's tokens");
@@ -131,7 +131,7 @@ class AeadRequestStateCodec_Test {
                var keyB = gen.generateKey();
                var providerBeforeRotation = StaticKeyProvider.of("2026-07-z", 
keyA);
                var codecBeforeRotation = new 
AeadRequestStateCodec(providerBeforeRotation);
-               var oldState = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var oldState = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var oldToken = codecBeforeRotation.seal(oldState, AAD);
 
                var providerAfterRotation = StaticKeyProvider.create()
@@ -145,7 +145,7 @@ class AeadRequestStateCodec_Test {
                assertTrue(recoveredOld.isPresent(), "a token sealed under a 
still-resolvable retired key must still unseal after rotation");
                assertEquals(oldState, recoveredOld.get());
 
-               var newState = new McpRequestState("continuation-value-2", 
"tools/call", 1, 123456789L);
+               var newState = new McpRequestState("continuation-value-2", 
"tools/call", 1, 123456789L, "jti-2", "args-hash-1");
                var newToken = codecAfterRotation.seal(newState, AAD);
                var newKeyId = new 
String(Base64.getUrlDecoder().decode(newToken.split("\\.", 4)[1]), 
StandardCharsets.UTF_8);
                assertEquals("2026-08-a", newKeyId, "new tokens must seal under 
the new current key's keyId");
@@ -162,7 +162,7 @@ class AeadRequestStateCodec_Test {
                        .current("2026-08-a")
                        .build();
                var a = new AeadRequestStateCodec(provider);
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(state, AAD);
                var parts = token.split("\\.", 4);
                var swappedKeyId = 
Base64.getUrlEncoder().withoutPadding().encodeToString("2026-08-b".getBytes(StandardCharsets.UTF_8));
@@ -173,7 +173,7 @@ class AeadRequestStateCodec_Test {
 
        @Test void a12_unknownVersionSegmentFailsUnseal() {
                var a = new AeadRequestStateCodec();
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(state, AAD);
                var parts = token.split("\\.", 4);
                var tampered = "2." + parts[1] + "." + parts[2] + "." + 
parts[3];
@@ -187,7 +187,7 @@ class AeadRequestStateCodec_Test {
                var keyA = gen.generateKey();
                var sealingProvider = StaticKeyProvider.of("2026-08-a", keyA);
                var sealingCodec = new AeadRequestStateCodec(sealingProvider);
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                var token = sealingCodec.seal(state, AAD);
 
                var keyB = gen.generateKey();
@@ -204,7 +204,7 @@ class AeadRequestStateCodec_Test {
        @Test void 
a07_mapValuedContinuationRoundTripsAsGenericJsonMapNotOriginalType() {
                var a = new AeadRequestStateCodec();
                var continuation = Map.of("step", 2, "cursor", "abc");
-               var b = new McpRequestState(continuation, "tools/call", 1, 
123456789L);
+               var b = new McpRequestState(continuation, "tools/call", 1, 
123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                var c = a.unseal(token, AAD);
                assertTrue(c.isPresent());
@@ -249,7 +249,7 @@ class AeadRequestStateCodec_Test {
        @Test void 
a08_beanValuedContinuationRoundTripsAsGenericJsonMapNotOriginalBeanType() {
                var a = new AeadRequestStateCodec();
                var continuation = new 
A08_Continuation().setStep(2).setNote("resume");
-               var b = new McpRequestState(continuation, "tools/call", 1, 
123456789L);
+               var b = new McpRequestState(continuation, "tools/call", 1, 
123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                var c = a.unseal(token, AAD);
                assertTrue(c.isPresent());
@@ -273,7 +273,7 @@ class AeadRequestStateCodec_Test {
                keyGen.init(256);
                var provider = StaticKeyProvider.of(keyId, 
keyGen.generateKey());
                var a = new AeadRequestStateCodec(provider);
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, AAD);
                var c = a.unseal(token, AAD);
                assertTrue(c.isPresent());
@@ -295,7 +295,7 @@ class AeadRequestStateCodec_Test {
         */
        @Test void a15_principalIsBoundSoTokenRejectsDifferentPrincipal() {
                var a = new AeadRequestStateCodec();
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                Principal alice = () -> "alice";
                Principal bob = () -> "bob";
                var token = a.seal(state, AAD, alice);
@@ -317,7 +317,7 @@ class AeadRequestStateCodec_Test {
         */
        @Test void 
a16_anonymousSentinelRoundTripsButNeverCrossesAuthenticatedBoundary() {
                var a = new AeadRequestStateCodec();
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                Principal alice = () -> "alice";
 
                // Anonymous -> anonymous round-trips (the null sentinel is 
deterministic).
@@ -345,7 +345,7 @@ class AeadRequestStateCodec_Test {
         */
        @Test void a17_claimsPrincipalIssuerScopingPreventsCrossIdpResume() {
                var a = new AeadRequestStateCodec();
-               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L);
+               var state = new McpRequestState("continuation-value", 
"tools/call", 1, 123456789L, "jti-1", "args-hash-1");
                Principal idpA = new ClaimsPrincipal("user-1", Map.of("iss", 
"https://idp-a.example.com";, "sub", "user-1"));
                Principal idpB = new ClaimsPrincipal("user-1", Map.of("iss", 
"https://idp-b.example.com";, "sub", "user-1"));
 
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index a95644b80d..09296fea03 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -487,8 +487,11 @@ class Characterization_Test {
        private static final long FAR_FUTURE_MS = 32503680000000L; // ~ year 
3000
        private static final long PAST_MS = 1000L;
 
-       private static String aad(String method) {
-               return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
+       // Mirror of McpRevision#aad(method, target): the sealed AAD binds the 
operation target (tool name / resource
+       // uri) in addition to method+version, so every fixture token below 
must seal under the same target its
+       // resume request names or the GCM tag check fails on unseal.
+       private static String aad(String method, String target) {
+               return method + '\u0000' + McpProtocol.VERSION_2026_07_28 + 
'\u0000' + (target == null ? "" : target);
        }
 
        /**
@@ -508,16 +511,22 @@ class Characterization_Test {
         * {@code seal(...)} fully deterministic (fixed key, fixed nonce), 
regenerating the tampered token on every run
         * via {@code tamper(codec.seal(...))} is equally reproducible and 
avoids hand-maintaining opaque ciphertext.
         */
+       // None of the committed request bodies below carry an "arguments" 
member, so every sealed fixture token
+       // must carry the same empty-arguments sentinel hash the dispatcher 
derives from the (absent) resume request
+       // arguments -- otherwise every RESUME-family fixture that reaches the 
handler would newly fail with the
+       // argument-hash mismatch check.
+       private static final String NO_ARGS_HASH = 
McpRevision.argumentsHash(Map.of());
+
        private static String mrtrToken(String fixture) {
                var codec = new FixedKeyGcmCodec();
                return switch (fixture) {
-                       case "MRTR-resume-complete" -> codec.seal(new 
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS), 
aad("tools/call"));
-                       case "MRTR-resume-input-required-again" -> 
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS), 
aad("tools/call"));
-                       case "MRTR-expired-request-state" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
-                       case "MRTR-max-rounds-exceeded" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, 
FAR_FUTURE_MS), aad("tools/call"));
-                       case "MRTR-tampered-request-state" -> 
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, 
FAR_FUTURE_MS), aad("tools/call")));
+                       case "MRTR-resume-complete" -> codec.seal(new 
McpRequestState("complete-me", "tools/call", 1, FAR_FUTURE_MS, "jti-complete", 
NO_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-resume-input-required-again" -> 
codec.seal(new McpRequestState("pause-again", "tools/call", 1, FAR_FUTURE_MS, 
"jti-pause-again", NO_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-expired-request-state" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", 1, PAST_MS, "jti-expired", 
NO_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-max-rounds-exceeded" -> codec.seal(new 
McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, 
FAR_FUTURE_MS, "jti-max-rounds", NO_ARGS_HASH), aad("tools/call", "ask"));
+                       case "MRTR-tampered-request-state" -> 
tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS, 
"jti-tampered", NO_ARGS_HASH), aad("tools/call", "ask")));
                        case "ELICIT-resume-accept-complete", 
"ELICIT-resume-decline", "ELICIT-resume-cancel" ->
-                               codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, FAR_FUTURE_MS), aad("tools/call"));
+                               codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, FAR_FUTURE_MS, "jti-elicit", NO_ARGS_HASH), aad("tools/call", 
"confirm"));
                        default -> throw new IllegalArgumentException("No MRTR 
token mapping for fixture: " + fixture);
                };
        }
@@ -577,7 +586,7 @@ class Characterization_Test {
                var envelope = Json.to(raw, JsonMap.class);
                var result = (Map<?,?>) envelope.get("result");
                var token = (String) result.get("requestState");
-               var state = new FixedKeyGcmCodec().unseal(token, 
aad("tools/call")).orElseThrow();
+               var state = new FixedKeyGcmCodec().unseal(token, 
aad("tools/call", "ask")).orElseThrow();
                assertEquals(2, state.round());
                assertEquals("cont-2", state.continuation());
        }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache_Test.java
new file mode 100644
index 0000000000..7ffc503a00
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/InMemoryReplayCache_Test.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.ArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Coverage for {@link InMemoryReplayCache}: first-seen/replay outcomes, 
atomicity under concurrent submission of
+ * the same {@code jti}, and self-eviction of already-expired records.
+ */
+class InMemoryReplayCache_Test {
+
+       @Test void a01_firstSeenJtiReturnsTrue() {
+               var cache = new InMemoryReplayCache();
+               assertTrue(cache.checkAndRecord("jti-1", 
System.currentTimeMillis() + 60_000L));
+       }
+
+       @Test void a02_replayOfUnexpiredJtiReturnsFalse() {
+               var cache = new InMemoryReplayCache();
+               var expiresAtMs = System.currentTimeMillis() + 60_000L;
+               assertTrue(cache.checkAndRecord("jti-1", expiresAtMs));
+               assertFalse(cache.checkAndRecord("jti-1", expiresAtMs));
+       }
+
+       @Test void a03_distinctJtisAreIndependent() {
+               var cache = new InMemoryReplayCache();
+               var expiresAtMs = System.currentTimeMillis() + 60_000L;
+               assertTrue(cache.checkAndRecord("jti-1", expiresAtMs));
+               assertTrue(cache.checkAndRecord("jti-2", expiresAtMs));
+       }
+
+       @Test void a04_concurrentSubmitOfSameJti_exactlyOneReturnsTrue() throws 
InterruptedException {
+               // Atomic check-and-record: of many concurrent calls racing on 
the same jti, exactly one must observe
+               // first-seen (true) -- proves the 
ConcurrentHashMap#putIfAbsent-based implementation has no TOCTOU window.
+               var cache = new InMemoryReplayCache();
+               var expiresAtMs = System.currentTimeMillis() + 60_000L;
+               var threadCount = 32;
+               var firstSeenCount = new AtomicInteger();
+               var start = new CountDownLatch(1);
+               var threads = new ArrayList<Thread>();
+               for (var i = 0; i < threadCount; i++) {
+                       var t = new Thread(() -> {
+                               try {
+                                       start.await();
+                               } catch (InterruptedException e) {
+                                       Thread.currentThread().interrupt();
+                                       return;
+                               }
+                               if (cache.checkAndRecord("jti-race", 
expiresAtMs))
+                                       firstSeenCount.incrementAndGet();
+                       });
+                       threads.add(t);
+                       t.start();
+               }
+               start.countDown();
+               for (var t : threads)
+                       t.join();
+               assertEquals(1, firstSeenCount.get());
+       }
+
+       @Test void 
a05_alreadyExpiredRecordIsEvictedAndResubmissionIsFirstSeenAgain() {
+               // A record's own expiresAtMs bounds its retention: once 
expired it is swept out by a later call, and a
+               // jti resubmitted after that sweep is observed as first-seen 
again rather than as a replay. Correctness
+               // does not depend on this -- an expired token is separately 
rejected by the dispatcher's own expiry
+               // check before a ReplayCache is ever consulted -- eviction 
exists purely to bound memory. Uses a
+               // sweep-every-call cache (interval 0) so the eviction is 
deterministic in-test.
+               var cache = new InMemoryReplayCache(0);
+               var now = System.currentTimeMillis();
+               assertTrue(cache.checkAndRecord("jti-1", now - 1_000L));  // 
recorded already-expired
+               // With interval 0 every later call sweeps expired records 
before recording its own.
+               assertTrue(cache.checkAndRecord("jti-2", now + 60_000L));
+               assertTrue(cache.checkAndRecord("jti-1", now - 1_000L));  // 
jti-1 was evicted; this is first-seen again
+       }
+
+       @Test void a06_nullJtiRejected() {
+               // A null jti is a codec/contract violation, never a legitimate 
token: the cache rejects it at the door
+               // with IllegalArgumentException rather than letting it reach 
putIfAbsent (which would NPE and, via the
+               // dispatcher's fail-open catch, silently disable replay 
protection).
+               var cache = new InMemoryReplayCache();
+               assertThrows(IllegalArgumentException.class, () -> 
cache.checkAndRecord(null, System.currentTimeMillis() + 60_000L));
+       }
+
+       @Test void 
a07_evictionIsThrottledExpiredRecordSurvivesUntilSweepWindow() {
+               // The sweep is throttled: with a large interval, an expired 
record is NOT swept on the immediately
+               // following call, so a still-recorded (though expired) jti is 
observed as a replay until the window
+               // elapses. This documents the throttle honestly -- eviction 
bounds memory opportunistically, it is not a
+               // per-call correctness mechanism (the dispatcher's own expiry 
check is).
+               var cache = new InMemoryReplayCache(600_000L);  // 10-minute 
sweep window: no sweep will fire during this test
+               var now = System.currentTimeMillis();
+               assertTrue(cache.checkAndRecord("jti-1", now - 1_000L));  // 
first call arms the window (sweeps an empty map)
+               assertFalse(cache.checkAndRecord("jti-1", now - 1_000L));  // 
still present: throttle suppressed the sweep
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
index 128a56d976..84947dc0d2 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
@@ -315,7 +315,7 @@ class McpBindings_Test extends TestBase {
                var codec2 = rev2.mrtrConfig().getCodec();
                assertSame(codec1, codec2, "the codec (and its AES key) must be 
memoized at the binding level");
 
-               var state = new McpRequestState("resume-here", "tools/call", 1, 
System.currentTimeMillis() + 60_000L);
+               var state = new McpRequestState("resume-here", "tools/call", 1, 
System.currentTimeMillis() + 60_000L, "jti-1", "args-hash-1");
                var token = codec1.seal(state, "tools/call" + '\u0000' + 
"2026-07-28");
                var unsealed = codec2.unseal(token, "tools/call" + '\u0000' + 
"2026-07-28");
                assertTrue(unsealed.isPresent(), "a requestState sealed on one 
request must unseal on the next");
@@ -340,7 +340,7 @@ class McpBindings_Test extends TestBase {
                var codec2 = rev2.mrtrConfig().getCodec();
                assertSame(codec1, codec2, "the codec (and its AES key) must be 
memoized at the binding level");
 
-               var state = new McpRequestState("resume-here", "tools/call", 1, 
System.currentTimeMillis() + 60_000L);
+               var state = new McpRequestState("resume-here", "tools/call", 1, 
System.currentTimeMillis() + 60_000L, "jti-1", "args-hash-1");
                var token = codec1.seal(state, "tools/call" + '\u0000' + 
"2026-07-28");
                var unsealed = codec2.unseal(token, "tools/call" + '\u0000' + 
"2026-07-28");
                assertTrue(unsealed.isPresent(), "a requestState sealed on one 
request must unseal on the next");
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig_Test.java
index b0f22e1c57..6adaeae645 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrConfig_Test.java
@@ -80,7 +80,7 @@ class McpMrtrConfig_Test {
                var a = new McpMrtrConfig().setKeyProvider(provider);
                assertInstanceOf(AeadRequestStateCodec.class, a.getCodec());
                var b = new McpMrtrConfig().setKeyProvider(provider);
-               var state = new McpRequestState("cont-1", "tools/call", 1, 
System.currentTimeMillis() + 60_000L);
+               var state = new McpRequestState("cont-1", "tools/call", 1, 
System.currentTimeMillis() + 60_000L, "jti-1", "args-hash-1");
                var token = a.getCodec().seal(state, "aad");
                var unsealed = b.getCodec().unseal(token, "aad").orElseThrow();
                assertEquals("cont-1", unsealed.continuation());
@@ -103,6 +103,24 @@ class McpMrtrConfig_Test {
                assertEquals("keyProvider must not be null", e.getMessage());
        }
 
+       @Test void a10_defaultReplayCacheIsNull() {
+               // D1 (opt-in): replay rejection must be disabled by default -- 
no ReplayCache is auto-wired.
+               assertNull(new McpMrtrConfig().getReplayCache());
+       }
+
+       @Test void a11_setReplayCacheRoundTripsThroughGetter() {
+               var cache = new InMemoryReplayCache();
+               var a = new McpMrtrConfig().setReplayCache(cache);
+               assertSame(cache, a.getReplayCache());
+       }
+
+       @Test void a12_setReplayCacheNullExplicitlyDisablesIt() {
+               // Unlike setCodec/setKeyProvider, null is a legal (and the 
default) value here -- it explicitly
+               // disables replay rejection rather than being a programming 
error.
+               var a = new McpMrtrConfig().setReplayCache(new 
InMemoryReplayCache()).setReplayCache(null);
+               assertNull(a.getReplayCache());
+       }
+
        // -------- McpRevision four-arg constructor wiring ---------
 
        private static Object validMeta() {
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrDispatch_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrDispatch_Test.java
index 9269beaed6..be3466baa6 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrDispatch_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrDispatch_Test.java
@@ -91,10 +91,19 @@ class McpMrtrDispatch_Test {
                return new McpRevision(null, new McpCacheConfig(), null, 
mrtrConfig);
        }
 
-       private static String aad(String method) {
-               return method + '\u0000' + "2026-07-28";
+       // Mirror of McpRevision#aad(method, target): the sealed AAD now binds 
the operation target (tool/prompt
+       // name or resource uri) in addition to method+version, so every 
hand-sealed fixture below must seal under
+       // the same target it will be resumed against or the GCM tag check 
fails on unseal.
+       private static String aad(String method, String target) {
+               return method + '\u0000' + "2026-07-28" + '\u0000' + (target == 
null ? "" : target);
        }
 
+       // The empty-arguments sentinel hash (see McpRevision#argumentsHash): 
every fixture below that seals a
+       // McpRequestState directly (bypassing pause(...)) and then resumes 
with no "arguments" member in its params
+       // must seal this same sentinel, or the always-on argument-hash check 
added alongside replay-cache support
+       // would newly reject it as a mismatch.
+       private static final String NO_ARGS_HASH = 
McpRevision.argumentsHash(Map.of());
+
        // -------- handler fixtures ---------
 
        private static McpToolOutcome text(String value) {
@@ -164,7 +173,7 @@ class McpMrtrDispatch_Test {
                var config = new McpServerConfig().addTool(tool("ask",
                        (args, c) -> { throw new 
McpInputRequiredSignal(Map.of("q1", reqEntry("elicitation")), "cont-1"); }));
                var result = (InputRequiredResult) send(rev, config, req(1, 
"tools/call", JsonMap.of("name", "ask"), true), hdrs("tools/call", 
"ask")).getResult();
-               var state = codec.unseal(result.getRequestState(), 
aad("tools/call")).orElseThrow();
+               var state = codec.unseal(result.getRequestState(), 
aad("tools/call", "ask")).orElseThrow();
                assertEquals(1, state.round());
                assertEquals("cont-1", state.continuation());
                assertEquals("tools/call", state.method());
@@ -249,7 +258,7 @@ class McpMrtrDispatch_Test {
                        
seen.set(c.getBean(McpMrtrResumeContext.class).orElse(null));
                        return text("done");
                }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
                var result = (CallToolResult) send(rev, config, req(1, 
"tools/call", params, true), hdrs("tools/call", "ask")).getResult();
                assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
@@ -267,10 +276,10 @@ class McpMrtrDispatch_Test {
                                throw new McpInputRequiredSignal(Map.of("q2", 
reqEntry("elicitation")), "cont-2");
                        throw new McpInputRequiredSignal(Map.of("q1", 
reqEntry("elicitation")), "cont-1");
                }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
                var result = (InputRequiredResult) send(rev, config, req(1, 
"tools/call", params, true), hdrs("tools/call", "ask")).getResult();
-               var state = codec.unseal(result.getRequestState(), 
aad("tools/call")).orElseThrow();
+               var state = codec.unseal(result.getRequestState(), 
aad("tools/call", "ask")).orElseThrow();
                assertEquals(2, state.round());
                assertEquals("cont-2", state.continuation());
        }
@@ -280,7 +289,7 @@ class McpMrtrDispatch_Test {
                var rev = revision(mrtr(codec));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var parts = token.split("\\.", 4);
                var ciphertext = Base64.getUrlDecoder().decode(parts[3]);
                ciphertext[0] ^= 1;
@@ -297,7 +306,7 @@ class McpMrtrDispatch_Test {
                var rev = revision(mrtr(codec));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() - 1000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() - 1000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token);
                var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
                assertEquals(-32022, resp.getError().getCode());
@@ -309,7 +318,7 @@ class McpMrtrDispatch_Test {
                var rev = revision(mrtr(codec).setMaxRounds(10));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 10, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 10, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token);
                var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
                assertEquals(-32023, resp.getError().getCode());
@@ -323,7 +332,7 @@ class McpMrtrDispatch_Test {
                var rev = revision(mrtr(codec));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addPrompt(prompt("ask", 
(args, c) -> { calls.incrementAndGet(); return new McpPromptOutcome(); }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token);
                var resp = send(rev, config, req(1, "prompts/get", params, 
true), hdrs("prompts/get", "ask"));
                assertEquals(-32602, resp.getError().getCode());
@@ -348,7 +357,7 @@ class McpMrtrDispatch_Test {
                var rev = revision(mrtr(codec));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addPrompt(prompt("ask", 
(args, c) -> { calls.incrementAndGet(); return new McpPromptOutcome(); }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), "ignored");
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
"ignored");
                var params = JsonMap.of("name", "ask", "requestState", token);
                var resp = send(rev, config, req(1, "prompts/get", params, 
true), hdrs("prompts/get", "ask"));
                assertEquals(-32602, resp.getError().getCode());
@@ -366,7 +375,7 @@ class McpMrtrDispatch_Test {
                        return text("done");
                }));
                var continuation = new 
C08_Continuation().setStep(3).setNote("resume-me");
-               var token = codec.seal(new McpRequestState(continuation, 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codec.seal(new McpRequestState(continuation, 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
                send(rev, config, req(1, "tools/call", params, true), 
hdrs("tools/call", "ask"));
                assertEquals(3, seen.get().getStep());
@@ -383,27 +392,19 @@ class McpMrtrDispatch_Test {
                public C08_Continuation setNote(String value) { note = value; 
return this; }
        }
 
-       @Test void c09_resumeWithAlteredArgumentsIsAcceptedAtMrtrLayer() {
-               // MRTR seals nothing about the per-round arguments, so a 
resume whose arguments differ from the original
-               // round is accepted at the MRTR layer; the handler sees the 
client-supplied arguments while the sealed
-               // continuation stays intact. Pins the trust contract 
documented on McpMrtrResumeContext / McpRequestState
-               // (an args-hash binding is deliberately NOT applied).
+       @Test void 
c09_resumeWithAlteredArgumentsIsRejectedAsArgumentsMismatch() {
+               // The sealed McpRequestState carries an argumentsHash computed 
from the original round's arguments; a
+               // resume whose arguments differ from that sealed hash is 
hard-rejected before the handler is re-invoked.
                var codec = new AeadRequestStateCodec();
                var rev = revision(mrtr(codec));
-               var seenArgs = new AtomicReference<Map<String,Object>>();
-               var seen = new AtomicReference<McpMrtrResumeContext>();
-               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> {
-                       seenArgs.set(args);
-                       
seen.set(c.getBean(McpMrtrResumeContext.class).orElse(null));
-                       return text("done");
-               }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("altered", "value"),
                        "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
-               var result = (CallToolResult) send(rev, config, req(1, 
"tools/call", params, true), hdrs("tools/call", "ask")).getResult();
-               assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
-               assertEquals("value", seenArgs.get().get("altered"));  // 
altered per-round args reach the handler unchanged
-               assertEquals("cont-1", seen.get().continuation());     // the 
sealed continuation is intact
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32026, resp.getError().getCode());
+               assertEquals(0, calls.get());
        }
 
        @Test void 
c10_promptsGet_resumeToComplete_reInvokesHandlerWithResumeContext() {
@@ -414,7 +415,7 @@ class McpMrtrDispatch_Test {
                        
seen.set(c.getBean(McpMrtrResumeContext.class).orElse(null));
                        return new McpPromptOutcome().setDescription("done");
                }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"prompts/get", 1, System.currentTimeMillis() + 60_000L), aad("prompts/get"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"prompts/get", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("prompts/get", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
                var result = (GetPromptResult) send(rev, config, req(1, 
"prompts/get", params, true), hdrs("prompts/get", "ask")).getResult();
                assertEquals("done", result.getDescription());
@@ -434,7 +435,7 @@ class McpMrtrDispatch_Test {
                        
seen.set(c.getBean(McpMrtrResumeContext.class).orElse(null));
                        return new McpResourceOutcome();
                }));
-               var token = codec.seal(new McpRequestState("cont-1", 
"resources/read", 1, System.currentTimeMillis() + 60_000L), 
aad("resources/read"));
+               var token = codec.seal(new McpRequestState("cont-1", 
"resources/read", 1, System.currentTimeMillis() + 60_000L, "jti-1", 
NO_ARGS_HASH), aad("resources/read", "file:///a"));
                var params = JsonMap.of("uri", "file:///a", "requestState", 
token, "inputResponses", JsonMap.of("q1", "answer"));
                var result = (ReadResourceResult) send(rev, config, req(1, 
"resources/read", params, true), hdrs("resources/read", 
"file:///a")).getResult();
                assertNotNull(seen.get());
@@ -487,7 +488,7 @@ class McpMrtrDispatch_Test {
                var revB = revision(mrtr(codecB));
                var calls = new AtomicInteger();
                var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
-               var token = codecA.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L), aad("tools/call"));
+               var token = codecA.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
                var params = JsonMap.of("name", "ask", "requestState", token);
                var resp = send(revB, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
                assertEquals(-32602, resp.getError().getCode());
@@ -565,4 +566,343 @@ class McpMrtrDispatch_Test {
                var result = (CallToolResult) resp.getResult();
                assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
        }
+
+       // -------- replay cache + argument-hash sealing ---------
+
+       @Test void f01_replayOfConsumedToken_rejectedWithWiredReplayCache() {
+               // A wired ReplayCache narrows the documented default multi-use 
tolerance to single-use: resubmitting a
+               // token already consumed by an earlier resume is rejected, and 
the handler is not re-invoked a second time.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec).setReplayCache(new 
InMemoryReplayCache()));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
+               var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+
+               var first = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertNull(first.getError());
+               assertEquals(1, calls.get());
+
+               var second = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32025, second.getError().getCode());
+               assertEquals(1, calls.get());  // not re-invoked a second time
+       }
+
+       @Test void 
f02_sameTokenDoubleSubmit_secondRejectedEvenAfterAdvancingToNextRound() {
+               // The same wired ReplayCache also catches a stale round-N 
token being resubmitted after the client has
+               // already legitimately advanced past it to a later round's 
token -- replay detection is per-jti, not
+               // merely "is this the current round's token".
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec).setReplayCache(new 
InMemoryReplayCache()));
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> {
+                       if (c.getBean(McpMrtrResumeContext.class).isPresent())
+                               throw new McpInputRequiredSignal(Map.of("q2", 
reqEntry("elicitation")), "cont-2");
+                       throw new McpInputRequiredSignal(Map.of("q1", 
reqEntry("elicitation")), "cont-1");
+               }));
+               var token1 = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
+               var params1 = JsonMap.of("name", "ask", "requestState", token1, 
"inputResponses", JsonMap.of("q1", "answer"));
+
+               // Legitimate first use of token1 consumes it and advances to 
round 2.
+               var pausedAgain = (InputRequiredResult) send(rev, config, 
req(1, "tools/call", params1, true), hdrs("tools/call", "ask")).getResult();
+               assertNotNull(pausedAgain.getRequestState());
+
+               // A second submission of the SAME (now-stale) round-1 token is 
a replay.
+               var resp = send(rev, config, req(1, "tools/call", params1, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32025, resp.getError().getCode());
+       }
+
+       @Test void f03_defaultConfigWithoutReplayCache_doesNotRejectReplay() {
+               // Pins D1 (opt-in): with no ReplayCache wired (the default), a 
requestState token remains the documented
+               // multi-use bearer credential -- the exact same token may be 
resubmitted repeatedly within its TTL.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));  // no ReplayCache wired
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
+               var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+
+               for (var i = 0; i < 3; i++)
+                       assertNull(send(rev, config, req(1, "tools/call", 
params, true), hdrs("tools/call", "ask")).getError());
+               assertEquals(3, calls.get());
+       }
+
+       // A ReplayCache whose checkAndRecord always throws (after counting the 
call), proving the dispatcher's
+       // fail-open contract AND that the cache was actually consulted (so f04 
cannot pass vacuously).
+       static final class F04_ThrowingReplayCache implements ReplayCache {
+               final AtomicInteger calls = new AtomicInteger();
+               @Override public boolean checkAndRecord(String jti, long 
expiresAtMs) {
+                       calls.incrementAndGet();
+                       throw new RuntimeException("simulated replay-store 
outage");
+               }
+       }
+
+       @Test void f04_replayCacheThatThrows_failsOpenAndResumeProceeds() {
+               // A ReplayCache that throws is fail-open by contract: the 
dispatcher catches the exception, logs it, and
+               // treats the token as first-seen -- degrading to the same 
behavior as if no ReplayCache were wired at all,
+               // rather than rejecting the resume. An operator who wants 
fail-closed-on-outage must return false instead
+               // of throwing (see ReplayCache's javadoc); the framework 
applies no such policy itself.
+               var codec = new AeadRequestStateCodec();
+               var replayCache = new F04_ThrowingReplayCache();
+               var rev = revision(mrtr(codec).setReplayCache(replayCache));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
+               var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertNull(resp.getError());
+               assertEquals(1, calls.get());
+               assertEquals(1, replayCache.calls.get());  // the throwing 
cache WAS consulted -- fail-open is not vacuous
+       }
+
+       @Test void 
f05_argumentsMutatedBetweenRounds_rejectedAsArgumentsMismatch() {
+               // End-to-end (via pause(...) itself, not a hand-built 
fixture): round 1 is paused and resumed with
+               // faithful arguments, advancing to round 2 -- whose sealed 
argumentsHash is computed from THAT resume
+               // request's arguments. Resuming round 2 with DIFFERENT 
arguments than that round actually saw is rejected.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> {
+                       if (c.getBean(McpMrtrResumeContext.class).isPresent())
+                               throw new McpInputRequiredSignal(Map.of("q2", 
reqEntry("elicitation")), "cont-2");
+                       throw new McpInputRequiredSignal(Map.of("q1", 
reqEntry("elicitation")), "cont-1");
+               }));
+
+               var initialParams = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 1));
+               var paused = (InputRequiredResult) send(rev, config, req(1, 
"tools/call", initialParams, true), hdrs("tools/call", "ask")).getResult();
+
+               // Faithful round-1 resume (same arguments as the original 
request) advances to round 2; round 2's
+               // argumentsHash is sealed from THIS request's arguments.
+               var resume1Params = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 1),
+                       "requestState", paused.getRequestState(), 
"inputResponses", JsonMap.of("q1", "answer"));
+               var pausedAgain = (InputRequiredResult) send(rev, config, 
req(1, "tools/call", resume1Params, true), hdrs("tools/call", 
"ask")).getResult();
+               assertNotNull(pausedAgain.getRequestState());
+
+               // Round-2 resume with arguments that differ from what round 2 
actually saw is rejected before the
+               // handler is re-invoked.
+               var resume2Params = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 2),
+                       "requestState", pausedAgain.getRequestState(), 
"inputResponses", JsonMap.of("q2", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", 
resume2Params, true), hdrs("tools/call", "ask"));
+               assertEquals(-32026, resp.getError().getCode());
+       }
+
+       @Test void f06_happyPath_faithfulResendUnaffectedByEitherCheck() {
+               // A faithful client that never reuses a token and always 
resends identical arguments sees zero
+               // behavioral change with a ReplayCache AND the always-on 
argument-hash check both engaged at once.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec).setReplayCache(new 
InMemoryReplayCache()));
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> {
+                       if (c.getBean(McpMrtrResumeContext.class).isPresent())
+                               return text("done:" + args.get("x"));
+                       throw new McpInputRequiredSignal(Map.of("q1", 
reqEntry("elicitation")), "cont-1");
+               }));
+
+               var initialParams = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 1));
+               var paused = (InputRequiredResult) send(rev, config, req(1, 
"tools/call", initialParams, true), hdrs("tools/call", "ask")).getResult();
+
+               var resumeParams = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 1),
+                       "requestState", paused.getRequestState(), 
"inputResponses", JsonMap.of("q1", "answer"));
+               var result = (CallToolResult) send(rev, config, req(1, 
"tools/call", resumeParams, true), hdrs("tools/call", "ask")).getResult();
+               assertEquals("done:1", ((TextContent) 
result.getContent().get(0)).getText());
+       }
+
+       @Test void 
f07_sharedReplayCache_detectsReplayAcrossIndependentRevisions() {
+               // Direct analog to d04 (shared KeyProvider), but for the 
ReplayCache SPI: a store shared across every
+               // node catches a token replayed against a DIFFERENT 
McpRevision instance than the one that consumed it
+               // first -- the cross-node single-use enforcement design.md 
documents as needing a shared impl.
+               var sharedKeyProvider = StaticKeyProvider.of("2026-08-f07", 
StaticKeyProvider.aesKey(new byte[32]));
+               var sharedReplayCache = new InMemoryReplayCache();
+               var revA = revision(new 
McpMrtrConfig().setKeyProvider(sharedKeyProvider).setReplayCache(sharedReplayCache));
+               var revB = revision(new 
McpMrtrConfig().setKeyProvider(sharedKeyProvider).setReplayCache(sharedReplayCache));
+
+               var pauseConfig = new McpServerConfig().addTool(tool("ask",
+                       (args, c) -> { throw new 
McpInputRequiredSignal(Map.of("q1", reqEntry("elicitation")), "cont-1"); }));
+               var paused = (InputRequiredResult) send(revA, pauseConfig, 
req(1, "tools/call", JsonMap.of("name", "ask"), true), hdrs("tools/call", 
"ask")).getResult();
+               var token = paused.getRequestState();
+               assertNotNull(token);
+
+               var resumeConfig = new McpServerConfig().addTool(tool("ask", 
(args, c) -> text("done")));
+               var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+
+               // First resume, dispatched through the OTHER revision 
instance, consumes the token via the shared cache.
+               var first = send(revB, resumeConfig, req(1, "tools/call", 
params, true), hdrs("tools/call", "ask"));
+               assertNull(first.getError());
+
+               // Replaying the same token back through revision A (the one 
that minted it) is still caught: the
+               // ReplayCache -- shared across both instances -- is what 
recorded the jti as seen, not either revision's
+               // own in-process state.
+               var second = send(revA, resumeConfig, req(1, "tools/call", 
params, true), hdrs("tools/call", "ask"));
+               assertEquals(-32025, second.getError().getCode());
+       }
+
+       @Test void f08_argumentsHashIsPinnedJcsSha256Base64Url() {
+               // Algorithm-pinning: the hash is 
base64url(SHA-256(JCS-canonical UTF-8 bytes)). These literals are the
+               // externally-computed digests of the canonical bytes 
{"a":1,"b":2} and {} respectively -- passing keys
+               // out of order proves JCS key-sorting, and the exact bytes pin 
JCS + SHA-256 + base64url as a unit. This
+               // MUST fail if someone swaps the JCS canonicalizer for a plain 
(insertion-order) serializer.
+               assertEquals("QyWM_3g_5wNtikMDP4MK38YOwDc4JHNUisdCuIgpJ3c", 
McpRevision.argumentsHash(JsonMap.of("b", 2, "a", 1)));
+               assertEquals("RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o", 
McpRevision.argumentsHash(Map.of()));
+               // The empty-object literal is exactly the sentinel every 
no-argument fixture seals.
+               assertEquals("RBNvo1WzZ4oRRq0W9-hknpT7T8If536DEMBg9hyq_4o", 
NO_ARGS_HASH);
+       }
+
+       @Test void f09_resumeReorderingArgumentKeysSucceeds() {
+               // JCS sorts keys, so a resume that reorders argument keys 
hashes identically to the sealed round and is
+               // NOT a mismatch -- the handler is re-invoked normally.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> text("done")));
+               var sealedHash = McpRevision.argumentsHash(JsonMap.of("a", 1, 
"b", 2));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", sealedHash), 
aad("tools/call", "ask"));
+               var params = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("b", 2, "a", 1),
+                       "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertNull(resp.getError());
+               assertEquals("done", ((TextContent) ((CallToolResult) 
resp.getResult()).getContent().get(0)).getText());
+       }
+
+       @Test void f10_typeCoercionArgumentsRejected() {
+               // Canonicalization is type-faithful: a number-vs-string 
coercion (1 vs "1") and a scalar-vs-array
+               // coercion (1 vs [1]) both hash differently from the sealed 
{"x":1} and are rejected as -32026.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var sealedHash = McpRevision.argumentsHash(JsonMap.of("x", 1));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", sealedHash), 
aad("tools/call", "ask"));
+
+               var stringParams = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", "1"),
+                       "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
+               assertEquals(-32026, send(rev, config, req(1, "tools/call", 
stringParams, true), hdrs("tools/call", "ask")).getError().getCode());
+
+               var arrayParams = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", List.of(1)),
+                       "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
+               assertEquals(-32026, send(rev, config, req(1, "tools/call", 
arrayParams, true), hdrs("tools/call", "ask")).getError().getCode());
+
+               assertEquals(0, calls.get());  // neither coercion re-invoked 
the handler
+       }
+
+       @Test void f11_absentNullEmptyArgumentsAreInterchangeable() {
+               // absent "arguments", an explicit null "arguments", and an 
empty {} all resolve to Map.of() and hash to
+               // the same sentinel, so all three faithfully resume a token 
sealed with the sentinel hash. No replay
+               // cache is wired, so the single token stays usable across all 
three submissions.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> text("done")));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "ask"));
+
+               var absent = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+               var explicitNull = JsonMap.of("name", "ask", "arguments", null, 
"requestState", token, "inputResponses", JsonMap.of("q1", "answer"));
+               var empty = JsonMap.of("name", "ask", "arguments", 
JsonMap.of(), "requestState", token, "inputResponses", JsonMap.of("q1", 
"answer"));
+               assertNull(send(rev, config, req(1, "tools/call", absent, 
true), hdrs("tools/call", "ask")).getError());
+               assertNull(send(rev, config, req(1, "tools/call", explicitNull, 
true), hdrs("tools/call", "ask")).getError());
+               assertNull(send(rev, config, req(1, "tools/call", empty, true), 
hdrs("tools/call", "ask")).getError());
+       }
+
+       @Test void f12_tokenPausedForToolAResumedAgainstToolBRejected() {
+               // H2: the operation target (tool name) is bound into the AAD, 
so a token paused for tool A cannot be
+               // resumed against tool B -- the differing target fails the GCM 
tag check, surfacing as -32602. This also
+               // proves the no-argument sentinel no longer lets a token cross 
tools (both seal NO_ARGS_HASH).
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig()
+                       .addTool(tool("toolA", (args, c) -> { 
calls.incrementAndGet(); return text("a"); }))
+                       .addTool(tool("toolB", (args, c) -> { 
calls.incrementAndGet(); return text("b"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", NO_ARGS_HASH), 
aad("tools/call", "toolA"));
+               var params = JsonMap.of("name", "toolB", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "toolB"));
+               assertEquals(-32602, resp.getError().getCode());
+               assertEquals(0, calls.get());
+       }
+
+       @Test void f13_tokenPausedForResourceUriAResumedAgainstUriBRejected() {
+               // H2, resource variant: the resource uri is the bound target 
for resources/read, so a token paused for
+               // uri A cannot be resumed against uri B.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig()
+                       .addResource(resource("file:///a", (u, c) -> { 
calls.incrementAndGet(); return new McpResourceOutcome(); }))
+                       .addResource(resource("file:///b", (u, c) -> { 
calls.incrementAndGet(); return new McpResourceOutcome(); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"resources/read", 1, System.currentTimeMillis() + 60_000L, "jti-1", 
NO_ARGS_HASH), aad("resources/read", "file:///a"));
+               var params = JsonMap.of("uri", "file:///b", "requestState", 
token, "inputResponses", JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "resources/read", params, 
true), hdrs("resources/read", "file:///b"));
+               assertEquals(-32602, resp.getError().getCode());
+               assertEquals(0, calls.get());
+       }
+
+       @Test void 
f14_mismatchedArgumentsLeavesTokenUsableForLaterFaithfulResume() {
+               // H3: the stateless argument-hash check runs BEFORE the 
stateful replay checkAndRecord, so a submission
+               // with wrong arguments is rejected WITHOUT consuming the 
token's jti. A subsequent faithful resume of the
+               // same token therefore still succeeds -- an attacker with only 
a leaked token cannot burn a victim's
+               // in-flight resume, and an honest client's typo does not 
destroy its own token.
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec).setReplayCache(new 
InMemoryReplayCache()));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var sealedHash = McpRevision.argumentsHash(JsonMap.of("x", 1));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", sealedHash), 
aad("tools/call", "ask"));
+
+               var wrongArgs = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 2),
+                       "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
+               assertEquals(-32026, send(rev, config, req(1, "tools/call", 
wrongArgs, true), hdrs("tools/call", "ask")).getError().getCode());
+               assertEquals(0, calls.get());  // rejected before the handler, 
and before the jti was recorded
+
+               var faithful = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", 1),
+                       "requestState", token, "inputResponses", 
JsonMap.of("q1", "answer"));
+               assertNull(send(rev, config, req(1, "tools/call", faithful, 
true), hdrs("tools/call", "ask")).getError());
+               assertEquals(1, calls.get());  // token was NOT burned by the 
earlier mismatch
+       }
+
+       @Test void 
f15_nonFiniteNumberArgumentsRejectedAsInvalidParamsNotInternalError() {
+               // M3: a hostile-but-syntactically-legal value (a non-finite 
number, here 1e999 -> +Infinity) makes the
+               // JCS canonicalizer throw; that is mapped to -32602 (invalid 
params) rather than surfacing as a generic
+               // -32603 internal error. The hash is computed before the 
handler runs, so it is never invoked.
+               var rev = revision(mrtr(new AeadRequestStateCodec()));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var params = JsonMap.of("name", "ask", "arguments", 
JsonMap.of("x", Double.POSITIVE_INFINITY));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32602, resp.getError().getCode());
+               assertEquals(0, calls.get());
+       }
+
+       @Test void f16_nullJtiFailsClosedWithWiredReplayCache() {
+               // M4: a null jti reaching a wired ReplayCache is a 
codec/contract violation, not a store outage -- it
+               // fails CLOSED with -32602 rather than inheriting 
checkReplay's fail-open policy (which a downstream NPE
+               // would otherwise trigger, silently disabling replay 
protection).
+               var codec = new AeadRequestStateCodec();
+               var rev = revision(mrtr(codec).setReplayCache(new 
InMemoryReplayCache()));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var token = codec.seal(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, null, NO_ARGS_HASH), 
aad("tools/call", "ask"));
+               var params = JsonMap.of("name", "ask", "requestState", token, 
"inputResponses", JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32602, resp.getError().getCode());
+               assertEquals(0, calls.get());
+       }
+
+       // A custom RequestStateCodec whose unseal() always succeeds with a 
sealed argumentsHash that is not valid
+       // base64url -- a codec/contract violation distinct from f16's null-jti 
case, exercised the same way (a
+       // hand-built codec double, not a real AEAD token).
+       static final class F17_MalformedArgumentsHashCodec implements 
RequestStateCodec {
+               @Override public String seal(McpRequestState state, String aad, 
Principal principal) {
+                       throw new UnsupportedOperationException("not exercised 
by f17 -- the fixture token is opaque to this codec");
+               }
+               @Override public Optional<McpRequestState> unseal(String token, 
String aad, Principal principal) {
+                       return Optional.of(new McpRequestState("cont-1", 
"tools/call", 1, System.currentTimeMillis() + 60_000L, "jti-1", 
"not!valid-base64url"));
+               }
+       }
+
+       @Test void 
f17_malformedSealedArgumentsHashFailsClosedWithInvalidParamsNotInternalError() {
+               // Low3: a sealed argumentsHash that isn't valid base64url (a 
custom codec's contract violation, mirroring
+               // f16's null-jti case) must fail CLOSED with -32602, not let 
B64URL_DECODER.decode(...)'s raw
+               // IllegalArgumentException surface as a generic -32603 
internal error.
+               var rev = revision(mrtr(new F17_MalformedArgumentsHashCodec()));
+               var calls = new AtomicInteger();
+               var config = new McpServerConfig().addTool(tool("ask", (args, 
c) -> { calls.incrementAndGet(); return text("done"); }));
+               var params = JsonMap.of("name", "ask", "requestState", 
"opaque-token", "inputResponses", JsonMap.of("q1", "answer"));
+               var resp = send(rev, config, req(1, "tools/call", params, 
true), hdrs("tools/call", "ask"));
+               assertEquals(-32602, resp.getError().getCode());
+               assertEquals(0, calls.get());
+       }
 }
diff --git 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec_Test.java
 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec_Test.java
index a5d26f9492..f0ebf4b798 100644
--- 
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec_Test.java
+++ 
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/RequestStateCodec_Test.java
@@ -66,7 +66,7 @@ class RequestStateCodec_Test {
 
        @Test void a01_roundTripWithMatchingAadRecoversOriginalState() {
                var a = new FakeCodec();
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                // NOTE: FakeCodec uses '\u0000' as its own internal 
aad/payload framing separator, so this SPI-contract
                // fake deliberately uses a NUL-free AAD literal (the real 
canonical NUL-separated form is exercised by
                // AeadRequestStateCodec_Test).
@@ -78,7 +78,7 @@ class RequestStateCodec_Test {
 
        @Test void a02_unsealWithMismatchedAadReturnsEmpty() {
                var a = new FakeCodec();
-               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L);
+               var b = new McpRequestState("continuation-value", "tools/call", 
1, 123456789L, "jti-1", "args-hash-1");
                var token = a.seal(b, "tools/call:2026-07-28");
                var c = a.unseal(token, "prompts/get:2026-07-28");
                assertTrue(c.isEmpty());
@@ -102,7 +102,7 @@ class RequestStateCodec_Test {
 
        @Test void b01_twoArgOverloadsDelegateWithNullPrincipal() {
                var a = new B_CapturingCodec();
-               a.seal(new McpRequestState("c", "tools/call", 1, 1L), "aad");
+               a.seal(new McpRequestState("c", "tools/call", 1, 1L, "jti-1", 
"args-hash-1"), "aad");
                a.unseal("t", "aad");
                assertNull(a.sealPrincipal);
                assertNull(a.unsealPrincipal);
@@ -111,7 +111,7 @@ class RequestStateCodec_Test {
        @Test void b02_threeArgMethodsReceiveTheSuppliedPrincipal() {
                var a = new B_CapturingCodec();
                Principal p = () -> "carol";
-               a.seal(new McpRequestState("c", "tools/call", 1, 1L), "aad", p);
+               a.seal(new McpRequestState("c", "tools/call", 1, 1L, "jti-1", 
"args-hash-1"), "aad", p);
                a.unseal("t", "aad", p);
                assertSame(p, a.sealPrincipal);
                assertSame(p, a.unsealPrincipal);

Reply via email to