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 1dd9ea59c5 MCP 2026-07-28 MRTR requestState KeyProvider SPI (TODO-324)
1dd9ea59c5 is described below
commit 1dd9ea59c584d2bb4a059811a3b2a2feea2a0c37
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 00:32:23 2026 -0700
MCP 2026-07-28 MRTR requestState KeyProvider SPI (TODO-324)
Introduces a pluggable KeyProvider SPI beneath AeadRequestStateCodec so an
operator can supply a stable, shared sealing key (fixing horizontally-scaled
RESUME behind a load balancer) and rotate keys via a keyId, while the
zero-config EphemeralKeyProvider default preserves today's per-process
behavior. Adds StaticKeyProvider (shared key + static rotation), a versioned
4-segment wire format with keyId folded into the GCM AAD and fail-closed
unseal, and McpMrtrConfig.setKeyProvider wiring, with dispatch- and
integration-level cross-instance-resume proofs.
Co-authored-by: Cursor <[email protected]>
---
.../mcp/v20260728/McpMrtrIntegration_Test.java | 103 ++++++++++++-
.../mcp/v20260728/AeadRequestStateCodec.java | 135 ++++++++++------
.../server/mcp/v20260728/EphemeralKeyProvider.java | 76 +++++++++
.../rest/server/mcp/v20260728/KeyProvider.java | 78 ++++++++++
.../rest/server/mcp/v20260728/KeyedSecret.java | 62 ++++++++
.../rest/server/mcp/v20260728/McpMrtrConfig.java | 20 +++
.../server/mcp/v20260728/StaticKeyProvider.java | 171 +++++++++++++++++++++
.../mcp/v20260728/AeadRequestStateCodec_Test.java | 137 +++++++++++++++--
.../mcp/v20260728/EphemeralKeyProvider_Test.java | 51 ++++++
.../server/mcp/v20260728/KeyProvider_Test.java | 76 +++++++++
.../server/mcp/v20260728/KeyedSecret_Test.java | 74 +++++++++
.../server/mcp/v20260728/McpMrtrConfig_Test.java | 31 ++++
.../server/mcp/v20260728/McpMrtrDispatch_Test.java | 33 +++-
.../rest/server/mcp/v20260728/McpOptions_Test.java | 6 +
.../mcp/v20260728/StaticKeyProvider_Test.java | 111 +++++++++++++
15 files changed, 1099 insertions(+), 65 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 9436b300d3..e39019b652 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
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.client.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
+import java.security.SecureRandom;
import java.util.*;
import org.apache.juneau.*;
@@ -66,6 +67,18 @@ class McpMrtrIntegration_Test extends TestBase {
static final AeadRequestStateCodec CODEC = new AeadRequestStateCodec();
+ // A 32-byte AES-256 key shared by exactly the two fixture bindings
below (KeyProviderFixtureA/B) -- the
+ // live-wire analog of the in-process McpMrtrDispatch_Test#d04 proof:
two independently-constructed
+ // AeadRequestStateCodec instances (one per binding, each built fresh
by its own createMcpOptions() call)
+ // share only this KeyProvider object, never the codec object.
+ static final KeyProvider SHARED_KEY_PROVIDER =
StaticKeyProvider.of("2026-08-it",
StaticKeyProvider.aesKey(randomAesKeyBytes()));
+
+ private static byte[] randomAesKeyBytes() {
+ var bytes = new byte[32];
+ new SecureRandom().nextBytes(bytes);
+ return bytes;
+ }
+
private static String aad(String method) {
return method + '\u0000' + McpProtocol.VERSION_2026_07_28;
}
@@ -111,9 +124,56 @@ class McpMrtrIntegration_Test extends TestBase {
}
}
+ private static McpToolHandler askShared() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new
McpToolSpec().setName("askShared").setDescription("Pauses once under a shared
KeyProvider, then completes"); }
+ @Override public McpToolOutcome call(Map<String,Object>
arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw new
McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")),
"cont-shared");
+ return McpToolOutcome.text("resumed-shared:" +
resume.get().inputResponses().get("q1"));
+ }
+ };
+ }
+
+ // Two independently-constructed bindings sharing only
SHARED_KEY_PROVIDER: each createMcpOptions() call below
+ // constructs its own fresh AeadRequestStateCodec (via setKeyProvider's
"return setCodec(new
+ // AeadRequestStateCodec(value))"), so /kp-a and /kp-b never share a
codec instance, only the key material.
+ @Rest(path="/kp-a", serializers = JsonSerializer.class, parsers =
JsonParser.class, defaultAccept = "application/json")
+ public static class KeyProviderFixtureA extends
org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected McpServerConfig createMcpConfig() {
+ return new
McpServerConfig().setName("it-mrtr-kp-a").setVersion("1.0.0").addTool(askShared());
+ }
+
+ @Override
+ protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m ->
m.setKeyProvider(SHARED_KEY_PROVIDER));
+ }
+ }
+
+ @Rest(path="/kp-b", serializers = JsonSerializer.class, parsers =
JsonParser.class, defaultAccept = "application/json")
+ public static class KeyProviderFixtureB extends
org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected McpServerConfig createMcpConfig() {
+ return new
McpServerConfig().setName("it-mrtr-kp-b").setVersion("1.0.0").addTool(askShared());
+ }
+
+ @Override
+ protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m ->
m.setKeyProvider(SHARED_KEY_PROVIDER));
+ }
+ }
+
@Configuration
public static class FixtureConfig {
- @Bean public Servlet mcpServlet() { return new Fixture(); }
+ @Bean(name="mcpServlet") public Servlet mcpServlet() { return
new Fixture(); }
+ @Bean(name="mcpKeyProviderServletA") public Servlet
mcpKeyProviderServletA() { return new KeyProviderFixtureA(); }
+ @Bean(name="mcpKeyProviderServletB") public Servlet
mcpKeyProviderServletB() { return new KeyProviderFixtureB(); }
}
@RegisterExtension
@@ -129,6 +189,15 @@ class McpMrtrIntegration_Test extends TestBase {
.clientCapabilities(caps);
}
+ private static McpClient.Builder clientBuilderAt(String path, boolean
withElicitation) {
+ var caps = new ClientCapabilities();
+ if (withElicitation)
+ caps.setElicitation(new ElicitationCapability());
+ return McpClient.builder()
+ .endpoint(fixture.getRootUrl() + path)
+ .clientCapabilities(caps);
+ }
+
//
=================================================================================================================
// A: full PAUSE -> RESUME -> complete loop across the real wire.
//
=================================================================================================================
@@ -188,14 +257,40 @@ class McpMrtrIntegration_Test extends TestBase {
var token = (String) paused.get("requestState");
// Flip one ciphertext byte to corrupt the AEAD tag; a
final-character flip can land on an unpadded
// base64url "don't-care" low bit and decode to
identical bytes, leaving the tag (and test) flaky.
- var parts = token.split("\\.", 2);
- var ciphertext =
Base64.getUrlDecoder().decode(parts[1]);
+ var parts = token.split("\\.", 4);
+ var ciphertext =
Base64.getUrlDecoder().decode(parts[3]);
ciphertext[0] ^= 1;
- var tampered = parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ var tampered = parts[0] + "." + parts[1] + "." +
parts[2] + "."
+ +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
var e = assertThrows(McpException.class,
() -> client.callRaw(McpMethods.TOOLS_CALL,
new
CallToolRequest().setName("ask").setRequestState(tampered).setInputResponses(Map.of("q1",
"answer"))));
assertEquals(org.apache.juneau.rest.server.mcp.v20260728.McpRevision.CODE_INVALID_PARAMS,
e.getCode());
}
}
+
+ //
=================================================================================================================
+ // C: shared KeyProvider (TODO-324) -- resume succeeds across two
independently-constructed server bindings.
+ //
=================================================================================================================
+
+ @Test void
c01_sharedKeyProviderResume_succeedsAcrossIndependentBindings() throws
Exception {
+ String token;
+ try (var clientA = clientBuilderAt("/kp-a/", true).build()) {
+ var paused = clientA.callRaw(McpMethods.TOOLS_CALL, new
CallToolRequest().setName("askShared").setArguments(Map.of()));
+ assertEquals("input_required",
paused.get("resultType"));
+ token = (String) paused.get("requestState");
+ assertNotNull(token);
+ }
+
+ // Resume against a DIFFERENT binding (/kp-b), backed by an
independently-constructed
+ // AeadRequestStateCodec that shares only SHARED_KEY_PROVIDER
with /kp-a's codec, never the codec object
+ // itself -- the live-wire analog of McpMrtrDispatch_Test#d04.
+ try (var clientB = clientBuilderAt("/kp-b/", true).build()) {
+ var completed = clientB.callRaw(McpMethods.TOOLS_CALL,
+ new
CallToolRequest().setName("askShared").setRequestState(token).setInputResponses(Map.of("q1",
"answer")));
+ assertEquals("complete", completed.get("resultType"));
+ var result = Json.to(Json.of(completed),
CallToolResult.class);
+ assertEquals("resumed-shared:answer", ((TextContent)
result.getContent().get(0)).getText());
+ }
+ }
}
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 be7c84fe3c..552df8603d 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
@@ -24,85 +24,118 @@ import java.util.Base64;
import java.util.Optional;
import javax.crypto.Cipher;
-import javax.crypto.KeyGenerator;
-import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import org.apache.juneau.marshall.marshaller.Json;
/**
- * Built-in default {@link RequestStateCodec}: AES-256-GCM with a per-process
ephemeral key.
+ * Built-in default {@link RequestStateCodec}: AES-256-GCM keyed by a
pluggable {@link KeyProvider}.
*
* <p>
- * <b>Not restart-durable and not shareable across process instances by
design.</b> Each instance generates
- * its own random key at construction time; a token sealed by one instance can
never be unsealed by another
+ * The no-arg constructor uses a fresh, per-process {@link
EphemeralKeyProvider} — <b>not restart-durable
+ * and not shareable across process instances by design.</b> Each instance
generates its own random key and
+ * {@code keyId} at construction time; a token sealed by one instance can
never be unsealed by another
* (including the same process after a restart). Operators who need
cross-restart or multi-instance resumption
- * must supply a shared/rotating-key {@link RequestStateCodec} implementation
via {@link McpMrtrConfig} instead
- * of relying on this default. This is a documented, tested property (see
{@code AeadRequestStateCodec_Test}),
- * not an accidental limitation.
+ * must supply a shared/rotating {@link KeyProvider} (e.g. {@link
StaticKeyProvider}) via
+ * {@link #AeadRequestStateCodec(KeyProvider)} instead of relying on this
default. This is a documented, tested
+ * property (see {@code AeadRequestStateCodec_Test}), not an accidental
limitation.
*
* <p>
- * Sealed-token format (all opaque to callers): {@code base64(nonce) '.'
base64(ciphertext+tag)}. The
- * plaintext is the JSON serialization of the {@link McpRequestState} record.
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
- * AAD (see {@code McpRevision#aad}).
+ * Sealed-token format (all opaque to callers, versioned so it can evolve):
+ * {@code version '.' base64url(keyId) '.' base64url(nonce) '.'
base64url(ciphertext+tag)}. The current
+ * {@code version} literal is {@code "1"}; an unrecognized version fails
{@link #unseal} closed. The
+ * {@code keyId} segment is cleartext base64url of the UTF-8 {@code keyId}
bytes — it has to be read
+ * before the key it names can be resolved for decryption — but it is
folded into the AEAD's authenticated
+ * data, so a swapped {@code keyId} fails the GCM tag check exactly like any
other tamper. The plaintext is the
+ * JSON serialization of the {@link McpRequestState} record.
*
* <p>
- * {@link #unseal} is wired to untrusted, client-supplied {@code requestState}
strings, so it applies a
- * defensive length guard (a plausibly-sized token only) before any base64
decode; an out-of-bounds input
- * returns {@link Optional#empty()} rather than allocating from an
attacker-controlled length.
+ * 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
appends {@code '\u0000' + keyId}
+ * to that value before passing it to the cipher, per {@link KeyProvider}'s
implicit {@code keyId}-authentication
+ * contract.
+ *
+ * <p>
+ * Random per-seal nonces are safe up to roughly 2^32 seals under a single key
(the AES-GCM birthday bound). The
+ * ephemeral default's key is per-process and never approaches that. A
long-lived custom {@link KeyProvider} key
+ * (e.g. a {@link StaticKeyProvider} entry left in place for months) is the
case where this bound actually
+ * matters — rotate to a fresh {@code keyId} well before it, not after.
+ *
+ * <p>
+ * {@link #unseal} is wired to untrusted, client-supplied {@code requestState}
strings, so it applies defensive
+ * length guards (a plausibly-sized token, and a bounded {@code keyId}
segment) before any base64 decode; an
+ * out-of-bounds input returns {@link Optional#empty()} rather than allocating
from an attacker-controlled
+ * length.
*/
public class AeadRequestStateCodec implements RequestStateCodec {
private static final String ALGORITHM = "AES/GCM/NoPadding";
- private static final int KEY_BITS = 256;
private static final int GCM_TAG_BITS = 128;
private static final int NONCE_BYTES = 12;
- // Exact unpadded base64url length of the 12-byte nonce prefix
(ceil(12*4/3)=16). The nonce part of a
+ // Wire-format version literal (see class Javadoc). Bumping this is how
the format could evolve; unseal
+ // rejects any other value.
+ private static final String VERSION = "1";
+
+ // Exact unpadded base64url length of the 12-byte nonce segment
(ceil(12*4/3)=16). The nonce segment of a
// well-formed token is always exactly this many chars, checked after
the split in unseal.
private static final int NONCE_B64_CHARS = 16;
- // Defensive bounds on the untrusted, client-supplied token string (see
unseal). The real floor of a
- // well-formed token is NONCE_B64_CHARS + '.' + base64url of at least
the 16-byte GCM tag (22 chars) = 39, so
- // anything shorter cannot be a valid token; 64KB is orders of
magnitude above any legitimate sealed
- // McpRequestState.
- private static final int MIN_TOKEN_CHARS = NONCE_B64_CHARS + 1 + 22;
+ // Defensive ceiling on the base64url'd keyId segment of an untrusted,
client-supplied token (see unseal).
+ // Derived from KeyedSecret's MAX_KEY_ID_CHARS=128 producer limit
(measured in UTF-16 code units, no
+ // charset restriction) at the worst case of 3 UTF-8 bytes/char:
128*3=384 bytes, whose unpadded base64url
+ // encoding is ceil(384/3)*4=512 chars. Set to that worst case so it
dominates any legitimately-issued
+ // keyId while still capping an attacker-inflated segment before base64
decode.
+ private static final int MAX_KEY_ID_B64_CHARS = 512;
+
+ // Defensive floor on the untrusted, client-supplied token string as a
whole (see unseal). The real floor of
+ // a well-formed token is VERSION(1) + '.' + smallest possible keyId
segment (2, for a 1-byte keyId) + '.' +
+ // NONCE_B64_CHARS + '.' + base64url of at least the 16-byte GCM tag
(22 chars). 64KB is orders of magnitude
+ // above any legitimate sealed McpRequestState.
+ private static final int MIN_TOKEN_CHARS = VERSION.length() + 1 + 2 + 1
+ NONCE_B64_CHARS + 1 + 22;
private static final int MAX_TOKEN_CHARS = 64 * 1024;
private static final Base64.Encoder B64URL =
Base64.getUrlEncoder().withoutPadding();
- private final SecretKey key;
+ private final KeyProvider keyProvider;
private final SecureRandom random = new SecureRandom();
/**
- * Constructor. Generates a fresh, per-instance AES-256 key.
+ * Constructor. Uses a fresh, per-process {@link EphemeralKeyProvider}
— the zero-config, non-shareable,
+ * non-durable default described in the class Javadoc.
*/
public AeadRequestStateCodec() {
- try {
- var gen = KeyGenerator.getInstance("AES");
- gen.init(KEY_BITS);
- key = gen.generateKey();
- } catch (Exception e) { // HTT every JDK guarantees AES-256 key
generation via the standard JCE provider
- throw rex(e, "Failed to generate AES-GCM key");
- }
+ this(new EphemeralKeyProvider());
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param keyProvider The source of sealing/resolving keys. Must not be
<jk>null</jk>.
+ */
+ public AeadRequestStateCodec(KeyProvider keyProvider) {
+ if (keyProvider == null)
+ throw iaex("keyProvider must not be null");
+ this.keyProvider = keyProvider;
}
@Override /* RequestStateCodec */
public String seal(McpRequestState state, String aad) {
try {
+ var ks = keyProvider.currentKey();
// Random 96-bit nonce per seal. AES-GCM's birthday
bound makes random nonces safe up to roughly 2^32
- // seals under a single key; the built-in default's key
is per-process and ephemeral, so it never
- // approaches that, but a long-lived custom-codec key
should be rotated well before 2^32 seals.
+ // seals under a single key; see the class Javadoc for
why this matters more for a long-lived custom
+ // KeyProvider key than for the per-process ephemeral
default.
var nonce = new byte[NONCE_BYTES];
random.nextBytes(nonce);
var cipher = Cipher.getInstance(ALGORITHM);
- cipher.init(Cipher.ENCRYPT_MODE, key, new
GCMParameterSpec(GCM_TAG_BITS, nonce));
- cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ cipher.init(Cipher.ENCRYPT_MODE, ks.key(), new
GCMParameterSpec(GCM_TAG_BITS, nonce));
+ cipher.updateAAD((aad + '\u0000' +
ks.keyId()).getBytes(StandardCharsets.UTF_8));
var plaintext =
Json.of(state).getBytes(StandardCharsets.UTF_8);
var ciphertext = cipher.doFinal(plaintext);
- return B64URL.encodeToString(nonce) + "." +
B64URL.encodeToString(ciphertext);
+ var keyIdB64 =
B64URL.encodeToString(ks.keyId().getBytes(StandardCharsets.UTF_8));
+ return VERSION + "." + keyIdB64 + "." +
B64URL.encodeToString(nonce) + "." + B64URL.encodeToString(ciphertext);
} catch (Exception e) { // HTT encryption with a
freshly-generated 12-byte nonce cannot fail under the standard JCE provider
throw rex(e, "Failed to seal requestState");
}
@@ -113,21 +146,29 @@ public class AeadRequestStateCodec implements
RequestStateCodec {
try {
if (token.length() < MIN_TOKEN_CHARS || token.length()
> MAX_TOKEN_CHARS)
return Optional.empty();
- var parts = token.split("\\.", 2);
- // A well-formed token is exactly base64url(12-byte
nonce) '.' base64url(ciphertext+tag); reject any
- // token missing the separator or whose nonce part is
not the expected length before any base64 decode.
- if (parts.length != 2 || parts[0].length() !=
NONCE_B64_CHARS)
+ var parts = token.split("\\.", 4);
+ // A well-formed token is exactly version '.'
base64url(keyId) '.' base64url(12-byte nonce) '.'
+ // base64url(ciphertext+tag); reject any token with the
wrong segment count, an unrecognized version,
+ // an empty/oversized keyId segment, or a nonce segment
that isn't the expected length before any
+ // base64 decode.
+ if (parts.length != 4 || !VERSION.equals(parts[0]) ||
parts[1].isEmpty() || parts[1].length() > MAX_KEY_ID_B64_CHARS
+ || parts[2].length() != NONCE_B64_CHARS)
+ return Optional.empty();
+ var keyId = new
String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8);
+ var nonce = Base64.getUrlDecoder().decode(parts[2]);
+ var ciphertext =
Base64.getUrlDecoder().decode(parts[3]);
+ var resolved = keyProvider.resolveKey(keyId);
+ if (resolved.isEmpty())
return Optional.empty();
- var nonce = Base64.getUrlDecoder().decode(parts[0]);
- var ciphertext =
Base64.getUrlDecoder().decode(parts[1]);
var cipher = Cipher.getInstance(ALGORITHM);
- cipher.init(Cipher.DECRYPT_MODE, key, new
GCMParameterSpec(GCM_TAG_BITS, nonce));
- cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
+ cipher.init(Cipher.DECRYPT_MODE, resolved.get(), new
GCMParameterSpec(GCM_TAG_BITS, nonce));
+ cipher.updateAAD((aad + '\u0000' +
keyId).getBytes(StandardCharsets.UTF_8));
var plaintext = cipher.doFinal(ciphertext);
return Optional.of(Json.to(new String(plaintext,
StandardCharsets.UTF_8), McpRequestState.class));
} catch (@SuppressWarnings("unused") Exception e) {
- // Any failure (bad base64, AEAD tag mismatch from
tamper/AAD mismatch, malformed JSON) is a
- // verification failure per the RequestStateCodec
contract, not an exceptional condition to propagate.
+ // Any failure (bad base64, unknown/retired keyId, AEAD
tag mismatch from tamper/AAD/keyId mismatch,
+ // malformed JSON) is a verification failure per the
RequestStateCodec contract, not an exceptional
+ // condition to propagate.
return Optional.empty();
}
}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider.java
new file mode 100644
index 0000000000..59424a9d44
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider.java
@@ -0,0 +1,76 @@
+/*
+ * 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.Shorts.*;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Optional;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+/**
+ * Built-in default {@link KeyProvider}: a random AES-256 key with a random
{@code keyId}, both fixed for the
+ * lifetime of the instance.
+ *
+ * <p>
+ * <b>Not restart-durable and not shareable across process instances by
design</b> — mirrors
+ * {@code AeadRequestStateCodec}'s pre-{@code KeyProvider} ephemeral-key
behavior exactly, including the
+ * cross-instance-unseal-fails guarantee (see {@code
AeadRequestStateCodec_Test}'s {@code a06}), just relocated
+ * one layer down. Operators who need cross-restart or multi-instance RESUME
must supply {@link StaticKeyProvider}
+ * (or a custom {@link KeyProvider}) instead of relying on this default.
+ *
+ * @since 10.0.0
+ */
+public class EphemeralKeyProvider implements KeyProvider {
+
+ private static final int KEY_BITS = 256;
+ // 8 random bytes -> 11 base64url chars (unpadded): short but
collision-safe for a per-process identifier.
+ private static final int KEY_ID_BYTES = 8;
+
+ private static final Base64.Encoder B64URL =
Base64.getUrlEncoder().withoutPadding();
+
+ private final KeyedSecret current;
+
+ /**
+ * Constructor. Generates a fresh, per-instance AES-256 key and a
fresh, per-instance random {@code keyId}.
+ */
+ public EphemeralKeyProvider() {
+ try {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(KEY_BITS);
+ var key = gen.generateKey();
+ var idBytes = new byte[KEY_ID_BYTES];
+ new SecureRandom().nextBytes(idBytes);
+ current = new
KeyedSecret(B64URL.encodeToString(idBytes), key);
+ } catch (Exception e) { // HTT every JDK guarantees AES-256 key
generation via the standard JCE provider
+ throw rex(e, "Failed to generate AES key");
+ }
+ }
+
+ @Override /* KeyProvider */
+ public KeyedSecret currentKey() {
+ return current;
+ }
+
+ @Override /* KeyProvider */
+ public Optional<SecretKey> resolveKey(String keyId) {
+ return keyId.equals(current.keyId()) ?
Optional.of(current.key()) : Optional.empty();
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider.java
new file mode 100644
index 0000000000..3bb0fe2d09
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider.java
@@ -0,0 +1,78 @@
+/*
+ * 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 java.util.Optional;
+
+import javax.crypto.SecretKey;
+
+/**
+ * SPI for supplying the key(s) used to seal/unseal MCP MRTR {@code
requestState} continuation tokens (see
+ * {@code AeadRequestStateCodec}).
+ *
+ * <p>
+ * A dedicated abstraction beneath the AEAD codec so an operator can supply a
stable, shared sealing key (fixing
+ * horizontally-scaled RESUME behind a load balancer) and rotate keys via a
{@code keyId}, while the codec's
+ * zero-config default ({@link EphemeralKeyProvider}) keeps today's
per-process ephemeral-key behavior unchanged.
+ *
+ * <p>
+ * <b>Thread-safety.</b> A single provider instance is shared across all
requests against a binding (the codec
+ * is per-binding), so implementations must be safe for concurrent calls to
both {@link #currentKey()} and
+ * {@link #resolveKey(String)}.
+ *
+ * <p>
+ * <b>Never-throw contract.</b> {@link #resolveKey(String)} is called on the
unseal path with an untrusted,
+ * client-supplied {@code keyId}. Implementations must treat any unknown or
retired {@code keyId} as
+ * {@link Optional#empty()} and must never throw — the caller ({@code
AeadRequestStateCodec#unseal}) has no
+ * exception-handling path for this call.
+ *
+ * <p>
+ * Not annotated {@code @FunctionalInterface}: unlike single-method SPIs such
as
+ * {@link org.apache.juneau.rest.server.auth.ApiKeyStore}, this SPI declares
two abstract methods.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link KeyedSecret}
+ * <li class='jc'>{@link EphemeralKeyProvider}
+ * <li class='jc'>{@link StaticKeyProvider}
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+public interface KeyProvider {
+
+ /**
+ * The key to seal new tokens with, together with its identifier.
+ *
+ * <p>
+ * May return a different value over time — a changing return
value is the sanctioned mechanism for
+ * live runtime key rotation in a custom provider. Built-in providers
({@link EphemeralKeyProvider},
+ * {@link StaticKeyProvider}) return a fixed value for the lifetime of
the instance.
+ *
+ * @return The current sealing key. Never <jk>null</jk>.
+ */
+ KeyedSecret currentKey();
+
+ /**
+ * Resolves a {@code keyId} seen on an incoming token to the key that
sealed it.
+ *
+ * @param keyId The key identifier read from the untrusted,
client-supplied token, before decryption. Never
+ * <jk>null</jk>.
+ * @return The resolved key, or {@link Optional#empty()} if {@code
keyId} is unknown or retired. Never
+ * throws.
+ */
+ Optional<SecretKey> resolveKey(String keyId);
+}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret.java
new file mode 100644
index 0000000000..a45731a169
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret.java
@@ -0,0 +1,62 @@
+/*
+ * 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 javax.crypto.SecretKey;
+
+/**
+ * A key paired with the identifier an operator chose for it, as returned by
{@link KeyProvider#currentKey()}
+ * and resolved by {@link KeyProvider#resolveKey(String)}.
+ *
+ * <p>
+ * <b>Never log {@code key}.</b> Mirrors {@link RequestStateCodec}'s "never
log" contract for the sealed token:
+ * this record holds the actual sealing key, so {@link #toString()}
deliberately redacts {@code key} rather than
+ * relying on the record's auto-generated form, which would print whatever a
custom/HSM {@link SecretKey} impl's
+ * own {@code toString()} legally chooses to include.
+ *
+ * @param keyId An opaque, operator-chosen short identifier for {@code key}
(e.g. {@code "2026-08-a"}). Must not
+ * be <jk>null</jk> or blank, and is length-bounded so a sealed token's
cleartext {@code keyId} wire segment
+ * (see {@code AeadRequestStateCodec}'s versioned wire format) stays
bounded.
+ * @param key The key material. Must not be <jk>null</jk>. The AEAD codec is
AES-GCM-specific by design, so
+ * non-AES key material fails at cipher-init time in {@code
AeadRequestStateCodec}, not here.
+ * @since 10.0.0
+ */
+public record KeyedSecret(String keyId, SecretKey key) {
+
+ // Bounds the base64url'd keyId wire segment so an oversized keyId
cannot inflate a sealed token.
+ private static final int MAX_KEY_ID_CHARS = 128;
+
+ /**
+ * Compact constructor — validates {@code keyId} is
non-null/non-blank/length-bounded and {@code key}
+ * is non-null.
+ */
+ public KeyedSecret {
+ assertArgNotNullOrBlank("keyId", keyId);
+ assertArg(keyId.length() <= MAX_KEY_ID_CHARS, "Argument 'keyId'
length (%s) exceeds max of %s chars.", keyId.length(), MAX_KEY_ID_CHARS);
+ assertArgNotNull("key", key);
+ }
+
+ /**
+ * Redacts {@code key} so key material never reaches logs via this
record's {@code toString()}.
+ */
+ @Override
+ public String toString() {
+ return "KeyedSecret[keyId=" + keyId + ", key=<redacted>]";
+ }
+}
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 949b38bda9..4d291d51cb 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
@@ -83,6 +83,26 @@ public class McpMrtrConfig {
return this;
}
+ /**
+ * Sets the key provider, wrapping it in a fresh {@link
AeadRequestStateCodec}.
+ *
+ * <p>
+ * Sugar for the common "keep AES-GCM, just supply my keys" case
— equivalent to
+ * {@code setCodec(new AeadRequestStateCodec(value))}. {@link
#setKeyProvider} and {@link #setCodec} are
+ * last-wins: both assign the same {@code codec} field, so whichever is
called more recently determines the
+ * effective codec. There is no getter for the provider itself —
{@link #getCodec()} remains the sole
+ * accessor, since the codec (not the provider) is the source of truth
once either setter has been called.
+ *
+ * @param value The new value. Must not be {@code null}.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code value} is {@code null}.
+ */
+ public McpMrtrConfig setKeyProvider(KeyProvider value) {
+ if (value == null)
+ throw new IllegalArgumentException("keyProvider must
not be null");
+ return setCodec(new AeadRequestStateCodec(value));
+ }
+
/**
* The {@code requestState} time-to-live in milliseconds.
*
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider.java
new file mode 100644
index 0000000000..031c3cd0bd
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider.java
@@ -0,0 +1,171 @@
+/*
+ * 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.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Built-in, immutable, static-at-startup {@link KeyProvider}: an
operator-supplied keyring of one or more AES
+ * keys, one of which is designated the current sealing key. Unlike {@link
EphemeralKeyProvider}, the keyring is
+ * fixed at construction time (via {@link Builder} or {@link #of(String,
SecretKey)}) rather than generated
+ * randomly, so it can be shared across process instances — the fix for
horizontally-scaled MRTR RESUME
+ * (see {@code AeadRequestStateCodec}).
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * KeyProvider <jv>kp</jv> = StaticKeyProvider.<jsm>create</jsm>()
+ * .addKey(<js>"2026-08-a"</js>, <jv>k1</jv>)
+ * .current(<js>"2026-08-a"</js>)
+ * .addKey(<js>"2026-07-z"</js>, <jv>kOld</jv>)
+ * .build();
+ *
+ * <jc>// Or, for the common single-shared-key case:</jc>
+ * KeyProvider <jv>kp2</jv> =
StaticKeyProvider.<jsm>of</jsm>(<js>"2026-08-a"</js>, <jv>k1</jv>);
+ * </p>
+ *
+ * @since 10.0.0
+ */
+public final class StaticKeyProvider implements KeyProvider {
+
+ private final KeyedSecret current;
+ private final Map<String,SecretKey> keysById;
+
+ private StaticKeyProvider(KeyedSecret current, Map<String,SecretKey>
keysById) {
+ this.current = current;
+ // Defensive copy: keysById may be the Builder's own live map,
and the Builder is not consumed by
+ // build() (an operator can keep calling addKey(...)/build() to
mint successive keyrings), so this
+ // instance must not observe later mutations of that map.
+ this.keysById = Collections.unmodifiableMap(new
HashMap<>(keysById));
+ }
+
+ /**
+ * Creates a new {@link Builder}.
+ *
+ * @return A new builder. Never <jk>null</jk>.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * One-liner for the common single-shared-key case: a provider with
exactly one key, designated current.
+ *
+ * @param keyId The key's identifier. Must not be <jk>null</jk> or
blank.
+ * @param key The key material. Must not be <jk>null</jk>.
+ * @return A new, immutable {@link StaticKeyProvider} holding just this
one key. Never <jk>null</jk>.
+ * @throws IllegalArgumentException If {@code keyId} is
<jk>null</jk>/blank or {@code key} is <jk>null</jk>.
+ */
+ public static StaticKeyProvider of(String keyId, SecretKey key) {
+ return create().addKey(keyId, key).current(keyId).build();
+ }
+
+ /**
+ * Builds an AES {@link SecretKey} from raw key bytes.
+ *
+ * @param value The raw AES key bytes (16/24/32 bytes for
AES-128/192/256). Must not be <jk>null</jk>.
+ * @return A new AES {@link SecretKey} wrapping {@code value}. Never
<jk>null</jk>.
+ */
+ public static SecretKey aesKey(byte[] value) {
+ assertArgNotNull("value", value);
+ return new SecretKeySpec(value, "AES");
+ }
+
+ /**
+ * Builds an AES {@link SecretKey} from base64-encoded key bytes.
+ *
+ * @param base64 The base64-encoded AES key bytes. Must not be
<jk>null</jk> or blank.
+ * @return A new AES {@link SecretKey} wrapping the decoded bytes.
Never <jk>null</jk>.
+ */
+ public static SecretKey aesKey(String base64) {
+ assertArgNotNullOrBlank("base64", base64);
+ return aesKey(Base64.getDecoder().decode(base64));
+ }
+
+ @Override /* KeyProvider */
+ public KeyedSecret currentKey() {
+ return current;
+ }
+
+ @Override /* KeyProvider */
+ public Optional<SecretKey> resolveKey(String keyId) {
+ return Optional.ofNullable(keysById.get(keyId));
+ }
+
+ /**
+ * Builder for {@link StaticKeyProvider}.
+ */
+ public static final class Builder {
+
+ private final Map<String,SecretKey> keys = new HashMap<>();
+ private String currentKeyId;
+
+ private Builder() {}
+
+ /**
+ * Adds a resolvable key.
+ *
+ * @param keyId The key's identifier. Must not be <jk>null</jk>
or blank.
+ * @param key The key material. Must not be <jk>null</jk>.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code keyId} is
<jk>null</jk>/blank or {@code key} is
+ * <jk>null</jk>.
+ */
+ public Builder addKey(String keyId, SecretKey key) {
+ assertArgNotNullOrBlank("keyId", keyId);
+ assertArgNotNull("key", key);
+ keys.put(keyId, key);
+ return this;
+ }
+
+ /**
+ * Designates the current sealing key by identifier. Must name
a key previously added via
+ * {@link #addKey(String, SecretKey)}.
+ *
+ * @param keyId The identifier of the key to designate as
current. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ * @throws IllegalArgumentException If {@code keyId} is
<jk>null</jk> or blank.
+ */
+ public Builder current(String keyId) {
+ assertArgNotNullOrBlank("keyId", keyId);
+ currentKeyId = keyId;
+ return this;
+ }
+
+ /**
+ * Builds an immutable {@link StaticKeyProvider} snapshot of
this builder's current state.
+ *
+ * @return A new, immutable {@link StaticKeyProvider}. Never
<jk>null</jk>.
+ * @throws IllegalArgumentException If no current key was
designated via {@link #current(String)}, or
+ * the designated current key was never added via {@link
#addKey(String, SecretKey)}.
+ */
+ public StaticKeyProvider build() {
+ assertArg(currentKeyId != null, "No current key
designated; call current(keyId) before build().");
+ var key = keys.get(currentKeyId);
+ assertArg(key != null, "current(''%s'') does not name a
key added via addKey(...).", currentKeyId);
+ return new StaticKeyProvider(new
KeyedSecret(currentKeyId, key), keys);
+ }
+ }
+}
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 3ec98fde28..e582a87776 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
@@ -18,9 +18,12 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
+import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
+import javax.crypto.KeyGenerator;
+
import org.apache.juneau.marshall.collections.JsonMap;
import org.junit.jupiter.api.Test;
@@ -36,6 +39,8 @@ class AeadRequestStateCodec_Test {
var a = new AeadRequestStateCodec();
var b = new McpRequestState("continuation-value", "tools/call",
1, 123456789L);
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\"");
var c = a.unseal(token, AAD);
assertTrue(c.isPresent());
assertEquals(b, c.get());
@@ -45,10 +50,11 @@ class AeadRequestStateCodec_Test {
var a = new AeadRequestStateCodec();
var b = new McpRequestState("continuation-value", "tools/call",
1, 123456789L);
var token = a.seal(b, AAD);
- var parts = token.split("\\.", 2);
- var ciphertext = Base64.getUrlDecoder().decode(parts[1]);
+ var parts = token.split("\\.", 4);
+ var ciphertext = Base64.getUrlDecoder().decode(parts[3]);
ciphertext[0] ^= 1;
- var tampered = parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ var tampered = parts[0] + "." + parts[1] + "." + parts[2] + "."
+ +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
var c = a.unseal(tampered, AAD);
assertTrue(c.isEmpty());
}
@@ -62,24 +68,27 @@ class AeadRequestStateCodec_Test {
}
@Test void a04_malformedTokenMissingSeparatorFailsUnseal() {
- // Long enough (>= MIN_TOKEN_CHARS) to clear the length guard
and actually reach the separator check.
+ // Long enough (>= MIN_TOKEN_CHARS) to clear the length guard
and actually reach the segment-count check.
var a = new AeadRequestStateCodec();
- var c = a.unseal("a".repeat(40), AAD);
+ var c = a.unseal("a".repeat(50), AAD);
assertTrue(c.isEmpty());
}
@Test void a05_malformedTokenInvalidBase64FailsUnseal() {
- // A correctly-shaped token (16-char nonce part '.' body, >=
MIN_TOKEN_CHARS) whose nonce part is not valid
- // base64url: clears the length and separator/nonce-length
guards and fails inside the base64 decode.
+ // A correctly-shaped token (version '.' keyId '.' 16-char
nonce '.' body, >= MIN_TOKEN_CHARS) whose nonce
+ // segment is not valid base64url: clears the length,
segment-count, and nonce-length guards and fails
+ // inside the base64 decode.
var a = new AeadRequestStateCodec();
- var c = a.unseal("!".repeat(16) + "." + "a".repeat(22), AAD);
+ var keyId =
Base64.getUrlEncoder().withoutPadding().encodeToString("k".getBytes(StandardCharsets.UTF_8));
+ var c = a.unseal("1." + keyId + "." + "!".repeat(16) + "." +
"a".repeat(22), AAD);
assertTrue(c.isEmpty());
}
@Test void a05b_malformedTokenWrongNonceLengthFailsUnseal() {
- // Long enough to clear the length guard and reach the
nonce-length check, with a nonce part != 16 chars.
+ // Long enough to clear the length guard and reach the
nonce-length check, with a nonce segment != 16 chars.
var a = new AeadRequestStateCodec();
- var c = a.unseal("aaaa" + "." + "a".repeat(40), AAD);
+ var keyId =
Base64.getUrlEncoder().withoutPadding().encodeToString("k".getBytes(StandardCharsets.UTF_8));
+ var c = a.unseal("1." + keyId + "." + "aaaa" + "." +
"a".repeat(40), AAD);
assertTrue(c.isEmpty());
}
@@ -87,7 +96,7 @@ class AeadRequestStateCodec_Test {
// Below MIN_TOKEN_CHARS: rejected by the defensive length
guard before any split/decode allocation.
var a = new AeadRequestStateCodec();
assertTrue(a.unseal("short", AAD).isEmpty());
- assertTrue(a.unseal("!!!.!!!", AAD).isEmpty());
+ assertTrue(a.unseal("1.a.bbbb.cccc", AAD).isEmpty());
}
@Test void a06_perProcessEphemeralKeyPreventsCrossInstanceUnseal() {
@@ -99,6 +108,93 @@ class AeadRequestStateCodec_Test {
assertTrue(c.isEmpty());
}
+ @Test void a09_sharedKeyProviderAllowsCrossInstanceUnseal() throws
Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ var sharedKey = gen.generateKey();
+ 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 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");
+ assertEquals(state, c.get());
+ }
+
+ @Test void
a10_rotatingKeyProviderUnsealsRetiredKeyAndSealsUnderNewCurrent() throws
Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ var keyA = gen.generateKey();
+ 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 oldToken = codecBeforeRotation.seal(oldState, AAD);
+
+ var providerAfterRotation = StaticKeyProvider.create()
+ .addKey("2026-07-z", keyA)
+ .addKey("2026-08-a", keyB)
+ .current("2026-08-a")
+ .build();
+ var codecAfterRotation = new
AeadRequestStateCodec(providerAfterRotation);
+
+ var recoveredOld = codecAfterRotation.unseal(oldToken, AAD);
+ 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 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");
+ }
+
+ @Test void a11_swappedKeyIdSegmentFailsUnseal() throws Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ var keyA = gen.generateKey();
+ var keyB = gen.generateKey();
+ var provider = StaticKeyProvider.create()
+ .addKey("2026-08-a", keyA)
+ .addKey("2026-08-b", keyB)
+ .current("2026-08-a")
+ .build();
+ var a = new AeadRequestStateCodec(provider);
+ var state = new McpRequestState("continuation-value",
"tools/call", 1, 123456789L);
+ var token = a.seal(state, AAD);
+ var parts = token.split("\\.", 4);
+ var swappedKeyId =
Base64.getUrlEncoder().withoutPadding().encodeToString("2026-08-b".getBytes(StandardCharsets.UTF_8));
+ var tampered = parts[0] + "." + swappedKeyId + "." + parts[2] +
"." + parts[3];
+ var c = a.unseal(tampered, AAD);
+ assertTrue(c.isEmpty(), "swapping the keyId wire segment
selects the wrong key and AAD, so the GCM tag check must fail");
+ }
+
+ @Test void a12_unknownVersionSegmentFailsUnseal() {
+ var a = new AeadRequestStateCodec();
+ var state = new McpRequestState("continuation-value",
"tools/call", 1, 123456789L);
+ var token = a.seal(state, AAD);
+ var parts = token.split("\\.", 4);
+ var tampered = "2." + parts[1] + "." + parts[2] + "." +
parts[3];
+ var c = a.unseal(tampered, AAD);
+ assertTrue(c.isEmpty(), "an unrecognized version literal must
fail closed");
+ }
+
+ @Test void a13_unknownKeyIdFailsUnseal() throws Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ 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 token = sealingCodec.seal(state, AAD);
+
+ var keyB = gen.generateKey();
+ var unsealingProvider = StaticKeyProvider.of("2026-08-b",
keyB); // does not know "2026-08-a"
+ var unsealingCodec = new
AeadRequestStateCodec(unsealingProvider);
+ var c = unsealingCodec.unseal(token, AAD);
+ assertTrue(c.isEmpty(), "a keyId the provider cannot resolve
must fail closed");
+ }
+
/**
* Pins the continuation type-fidelity contract documented on {@link
McpRequestState}: a {@code Map}-valued
* continuation round-trips as generic JSON ({@link JsonMap}), never
the original {@code Map} implementation.
@@ -162,4 +258,23 @@ class AeadRequestStateCodec_Test {
assertEquals(2, recoveredMap.get("step"));
assertEquals("resume", recoveredMap.get("note"));
}
+
+ /**
+ * Pins the fix for the latent {@code MAX_KEY_ID_B64_CHARS} bug: a
legitimately-issued keyId at
+ * {@code KeyedSecret}'s max length (128 UTF-16 code units), made
entirely of non-ASCII, 3-byte-UTF-8
+ * characters, must still round-trip through {@code seal}/{@code
unseal} rather than getting rejected by
+ * the keyId-segment length guard before {@code resolveKey} is ever
consulted.
+ */
+ @Test void a14_maxLengthNonAsciiKeyIdRoundTrips() throws Exception {
+ var keyId = "\u4e2d".repeat(128);
+ var keyGen = KeyGenerator.getInstance("AES");
+ 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 token = a.seal(b, AAD);
+ var c = a.unseal(token, AAD);
+ assertTrue(c.isPresent());
+ assertEquals(b, c.get());
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider_Test.java
new file mode 100644
index 0000000000..289bc68057
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/EphemeralKeyProvider_Test.java
@@ -0,0 +1,51 @@
+/*
+ * 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.Arrays;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Coverage for {@link EphemeralKeyProvider}: self-resolve, foreign-keyId
miss, and cross-instance distinctness
+ * (the provider-level counterpart of {@code AeadRequestStateCodec_Test}'s
{@code a06}).
+ */
+class EphemeralKeyProvider_Test {
+
+ @Test void a01_resolveOwnKeyIdSucceeds() {
+ var a = new EphemeralKeyProvider();
+ var b = a.resolveKey(a.currentKey().keyId());
+ assertTrue(b.isPresent());
+ assertEquals(a.currentKey().key(), b.get());
+ }
+
+ @Test void a02_resolveForeignKeyIdReturnsEmpty() {
+ var a = new EphemeralKeyProvider();
+ assertTrue(a.resolveKey("not-my-key-id").isEmpty());
+ }
+
+ @Test void a03_twoInstancesHaveDistinctKeyIdsAndKeys() {
+ var a = new EphemeralKeyProvider();
+ var b = new EphemeralKeyProvider();
+ assertNotEquals(a.currentKey().keyId(), b.currentKey().keyId());
+ // Compare encoded key material directly rather than relying on
SecretKey#equals(), which not every
+ // JCE provider's key implementation overrides meaningfully.
+ assertFalse(Arrays.equals(a.currentKey().key().getEncoded(),
b.currentKey().key().getEncoded()));
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider_Test.java
new file mode 100644
index 0000000000..4e2b5ec6f4
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyProvider_Test.java
@@ -0,0 +1,76 @@
+/*
+ * 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.Optional;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Interface-contract coverage for {@link KeyProvider}, proving the SPI shape
itself is sound via a trivial fake
+ * implementation, before {@link EphemeralKeyProvider} / {@link
StaticKeyProvider}'s real behavior exists.
+ */
+class KeyProvider_Test {
+
+ private static SecretKey aesKey() throws Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ return gen.generateKey();
+ }
+
+ private static final class FakeKeyProvider implements KeyProvider {
+
+ private final KeyedSecret current;
+
+ FakeKeyProvider(KeyedSecret current) {
+ this.current = current;
+ }
+
+ @Override /* KeyProvider */
+ public KeyedSecret currentKey() {
+ return current;
+ }
+
+ @Override /* KeyProvider */
+ public Optional<SecretKey> resolveKey(String keyId) {
+ return keyId.equals(current.keyId()) ?
Optional.of(current.key()) : Optional.empty();
+ }
+ }
+
+ @Test void a01_currentKeyIsNeverNull() throws Exception {
+ var a = new FakeKeyProvider(new KeyedSecret("k1", aesKey()));
+ assertNotNull(a.currentKey());
+ }
+
+ @Test void a02_resolveKeyMissReturnsEmptyAndNeverThrows() throws
Exception {
+ var a = new FakeKeyProvider(new KeyedSecret("k1", aesKey()));
+ assertDoesNotThrow(() ->
assertTrue(a.resolveKey("unknown-key-id").isEmpty()));
+ }
+
+ @Test void a03_resolveKeyHitReturnsKey() throws Exception {
+ var key = aesKey();
+ var a = new FakeKeyProvider(new KeyedSecret("k1", key));
+ var b = a.resolveKey("k1");
+ assertTrue(b.isPresent());
+ assertArrayEquals(key.getEncoded(), b.get().getEncoded());
+ }
+}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret_Test.java
new file mode 100644
index 0000000000..3a45838d32
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/KeyedSecret_Test.java
@@ -0,0 +1,74 @@
+/*
+ * 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.Base64;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Coverage for {@link KeyedSecret}'s compact-constructor guards.
+ */
+class KeyedSecret_Test {
+
+ private static SecretKey aesKey() throws Exception {
+ var gen = KeyGenerator.getInstance("AES");
+ gen.init(256);
+ return gen.generateKey();
+ }
+
+ @Test void a01_validConstructionExposesKeyIdAndKey() throws Exception {
+ var a = aesKey();
+ var b = new KeyedSecret("2026-08-a", a);
+ assertEquals("2026-08-a", b.keyId());
+ assertEquals(a, b.key());
+ }
+
+ @Test void a02_nullKeyIdThrows() throws Exception {
+ var a = aesKey();
+ assertThrows(IllegalArgumentException.class, () -> new
KeyedSecret(null, a));
+ }
+
+ @Test void a03_blankKeyIdThrows() throws Exception {
+ var a = aesKey();
+ assertThrows(IllegalArgumentException.class, () -> new
KeyedSecret(" ", a));
+ }
+
+ @Test void a04_overLongKeyIdThrows() throws Exception {
+ var a = aesKey();
+ assertThrows(IllegalArgumentException.class, () -> new
KeyedSecret("x".repeat(129), a));
+ }
+
+ @Test void a05_nullKeyThrows() {
+ assertThrows(IllegalArgumentException.class, () -> new
KeyedSecret("2026-08-a", null));
+ }
+
+ @Test void a06_toStringRedactsKeyMaterial() {
+ var key = new SecretKeySpec(new byte[32], "AES");
+ var a = new KeyedSecret("my-key-id-sentinel", key);
+ var s = a.toString();
+ assertTrue(s.contains("my-key-id-sentinel"));
+ assertTrue(s.contains("redacted"));
+
assertFalse(s.contains(Base64.getEncoder().encodeToString(key.getEncoded())));
+ }
+}
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 7798a74578..b0f22e1c57 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
@@ -72,6 +72,37 @@ class McpMrtrConfig_Test {
assertEquals(3, a.getMaxRounds());
}
+ @Test void a07_setKeyProviderWiresAeadCodecSharingTheProvider() {
+ // "behaves": prove the KeyProvider is actually threaded
through, not just type-checked -- two
+ // independently-constructed McpMrtrConfig instances sharing
one StaticKeyProvider unseal each other's
+ // tokens, the config-level analog of the dispatch-level d04
test added in Task 11.
+ var provider = StaticKeyProvider.of("k1",
StaticKeyProvider.aesKey(new byte[32]));
+ 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 token = a.getCodec().seal(state, "aad");
+ var unsealed = b.getCodec().unseal(token, "aad").orElseThrow();
+ assertEquals("cont-1", unsealed.continuation());
+ }
+
+ @Test void a08_setKeyProviderAndSetCodecAreLastWins() {
+ var provider = StaticKeyProvider.of("k1",
StaticKeyProvider.aesKey(new byte[32]));
+ var explicitCodec = new AeadRequestStateCodec();
+ // setCodec(...) called after setKeyProvider(...): the explicit
codec wins.
+ var a = new
McpMrtrConfig().setKeyProvider(provider).setCodec(explicitCodec);
+ assertSame(explicitCodec, a.getCodec());
+ // setKeyProvider(...) called after setCodec(...): the
provider-wrapping codec wins.
+ var b = new
McpMrtrConfig().setCodec(explicitCodec).setKeyProvider(provider);
+ assertNotSame(explicitCodec, b.getCodec());
+ assertInstanceOf(AeadRequestStateCodec.class, b.getCodec());
+ }
+
+ @Test void a09_setKeyProviderNullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new
McpMrtrConfig().setKeyProvider(null));
+ assertEquals("keyProvider must not be null", e.getMessage());
+ }
+
// -------- 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 41b72db577..ef2dc56236 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
@@ -280,10 +280,11 @@ class McpMrtrDispatch_Test {
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 parts = token.split("\\.", 2);
- var ciphertext = Base64.getUrlDecoder().decode(parts[1]);
+ var parts = token.split("\\.", 4);
+ var ciphertext = Base64.getUrlDecoder().decode(parts[3]);
ciphertext[0] ^= 1;
- var tampered = parts[0] + "." +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
+ var tampered = parts[0] + "." + parts[1] + "." + parts[2] + "."
+ +
Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext);
var params = JsonMap.of("name", "ask", "requestState",
tampered);
var resp = send(rev, config, req(1, "tools/call", params,
true), hdrs("tools/call", "ask"));
assertEquals(-32602, resp.getError().getCode());
@@ -491,4 +492,30 @@ class McpMrtrDispatch_Test {
assertEquals(-32602, resp.getError().getCode());
assertEquals(0, calls.get());
}
+
+ @Test void
d04_sharedStaticKeyProviderResumeSucceedsAcrossIndependentRevisions() {
+ // Direct contrast to d03: two independently-constructed
McpRevision instances (via two independently-
+ // constructed McpMrtrConfig -> AeadRequestStateCodec
instances) wired to the SAME StaticKeyProvider DO
+ // resume each other's tokens -- the horizontal-scaling proof
design.md §2/§7 exists to enable.
+ var sharedKeyProvider = StaticKeyProvider.of("2026-08-d04",
StaticKeyProvider.aesKey(new byte[32]));
+ var revA = revision(new
McpMrtrConfig().setKeyProvider(sharedKeyProvider));
+ var revB = revision(new
McpMrtrConfig().setKeyProvider(sharedKeyProvider));
+
+ // Pause dispatched through revision A: mints a requestState
sealed by A's own, independently-constructed
+ // codec instance.
+ 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);
+
+ // Resume dispatched through revision B (a DIFFERENT
McpRevision, DIFFERENT McpMrtrConfig, DIFFERENT
+ // AeadRequestStateCodec instance -- sharing only
sharedKeyProvider) succeeds.
+ var resumeConfig = new McpServerConfig().addTool(tool("ask",
(args, c) -> text("done")));
+ var params = JsonMap.of("name", "ask", "requestState", token,
"inputResponses", JsonMap.of("q1", "answer"));
+ var resp = send(revB, resumeConfig, req(1, "tools/call",
params, true), hdrs("tools/call", "ask"));
+ assertNull(resp.getError());
+ var result = (CallToolResult) resp.getResult();
+ assertEquals("done", ((TextContent)
result.getContent().get(0)).getText());
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
index 5f55bb31e3..00d9ef492e 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
@@ -122,6 +122,12 @@ class McpOptions_Test {
assertEquals("consumer must not be null", e.getMessage());
}
+ @Test void
c06_mrtrConsumer_setKeyProviderReachableThroughConfigureBlock() {
+ var provider = StaticKeyProvider.of("k1",
StaticKeyProvider.aesKey(new byte[32]));
+ var o = new McpOptions().mrtr(m -> m.setKeyProvider(provider));
+ assertInstanceOf(AeadRequestStateCodec.class,
o.getMrtr().getCodec());
+ }
+
// -------- subscriptions: nested config, replace-outright, and
Consumer configure-block ---------
@Test void d01_subscriptions_defaultsToNonNullFrameworkOwnedInstance() {
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider_Test.java
new file mode 100644
index 0000000000..a0056db44d
--- /dev/null
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/StaticKeyProvider_Test.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.junit.jupiter.api.Assertions.*;
+
+import java.security.SecureRandom;
+import java.util.Base64;
+
+import javax.crypto.SecretKey;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Coverage for {@link StaticKeyProvider}: the {@code aesKey(...)} helpers,
builder current-designation and
+ * multi-key resolve, unknown-keyId miss, {@code build()} guards, the {@code
of(...)} one-liner, and
+ * post-{@code build()} immutability.
+ */
+class StaticKeyProvider_Test {
+
+ private static SecretKey randomAesKey() {
+ var b = new byte[32];
+ new SecureRandom().nextBytes(b);
+ return StaticKeyProvider.aesKey(b);
+ }
+
+ @Test void a01_aesKeyFromBytesProducesAesSecretKey() {
+ var a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16};
+ var b = StaticKeyProvider.aesKey(a);
+ assertEquals("AES", b.getAlgorithm());
+ assertArrayEquals(a, b.getEncoded());
+ }
+
+ @Test void a02_aesKeyFromBase64ProducesAesSecretKey() {
+ var a = new byte[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,
14, 15, 16};
+ var b =
StaticKeyProvider.aesKey(Base64.getEncoder().encodeToString(a));
+ assertEquals("AES", b.getAlgorithm());
+ assertArrayEquals(a, b.getEncoded());
+ }
+
+ @Test void b01_currentDesignationAndMultiKeyResolve() {
+ var a = randomAesKey();
+ var b = randomAesKey();
+ var c = StaticKeyProvider.create().addKey("2026-08-a",
a).current("2026-08-a").addKey("2026-07-z", b).build();
+ assertEquals("2026-08-a", c.currentKey().keyId());
+ assertEquals(a, c.currentKey().key());
+ assertEquals(a, c.resolveKey("2026-08-a").orElseThrow());
+ assertEquals(b, c.resolveKey("2026-07-z").orElseThrow());
+ }
+
+ @Test void b02_unknownKeyIdResolvesToEmpty() {
+ var a = StaticKeyProvider.create().addKey("2026-08-a",
randomAesKey()).current("2026-08-a").build();
+ assertTrue(a.resolveKey("unknown").isEmpty());
+ }
+
+ @Test void c01_buildRejectsMissingCurrent() {
+ assertThrows(IllegalArgumentException.class, () ->
StaticKeyProvider.create().addKey("2026-08-a", randomAesKey()).build());
+ }
+
+ @Test void c02_buildRejectsDanglingCurrent() {
+ assertThrows(IllegalArgumentException.class, () ->
StaticKeyProvider.create().addKey("2026-08-a",
randomAesKey()).current("does-not-exist").build());
+ }
+
+ @Test void c03_addKeyRejectsNullOrBlankKeyId() {
+ var a = StaticKeyProvider.create();
+ var b = randomAesKey();
+ assertThrows(IllegalArgumentException.class, () ->
a.addKey(null, b));
+ assertThrows(IllegalArgumentException.class, () -> a.addKey("
", b));
+ }
+
+ @Test void c04_addKeyRejectsNullKey() {
+ var a = StaticKeyProvider.create();
+ assertThrows(IllegalArgumentException.class, () ->
a.addKey("2026-08-a", null));
+ }
+
+ @Test void c05_currentRejectsNullOrBlankKeyId() {
+ var a = StaticKeyProvider.create();
+ assertThrows(IllegalArgumentException.class, () ->
a.current(null));
+ assertThrows(IllegalArgumentException.class, () -> a.current("
"));
+ }
+
+ @Test void d01_ofOneLinerBuildsSingleKeyProvider() {
+ var a = randomAesKey();
+ var b = StaticKeyProvider.of("2026-08-a", a);
+ assertEquals("2026-08-a", b.currentKey().keyId());
+ assertEquals(a, b.resolveKey("2026-08-a").orElseThrow());
+ }
+
+ @Test void e01_builderReuseAfterBuildDoesNotMutatePriorInstance() {
+ var a = randomAesKey();
+ var b = randomAesKey();
+ var builder = StaticKeyProvider.create().addKey("2026-08-a",
a).current("2026-08-a");
+ var c = builder.build();
+ builder.addKey("2026-07-z", b);
+ assertTrue(c.resolveKey("2026-07-z").isEmpty(), "mutating the
builder after build() must not affect the already-built instance");
+ }
+}