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
— and the original PAUSE-triggering
+ * call in {@code MRTR-input-required-response.request.json} sends an
explicit empty {@code "arguments":{}}
+ * — 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)}
- * — 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}) — 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)}
— 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> — 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} — 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 — 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
— 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 — 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) — 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 — 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> — 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 — 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 —
before the handler runs — 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 §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 — any failure raises the mapped {@link
McpException} <i>before</i> the handler
- * is re-invoked — 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)})
+ * — any failure raises the mapped {@link McpException}
<i>before</i> the handler is re-invoked —
+ * 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} — 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 — 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 —
+ * 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) —
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)
+ * — 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 — 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 — 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
— 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) — 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 — 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 —
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 — 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
— 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 — {@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 — 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
— 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}
— 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} — 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} — 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 — subject claim,
issuer+subject, full claim set — 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 — see
the class Javadoc). Must not be <jk>null</jk>.
+ * {@code method + '\u0000' + protocolVersion + '\u0000' + target}
form — 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);