You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/05 19:47:45 UTC
(juneau) branch master updated: MCP 2026-07-28 MRTR requestState principal seam for AeadRequestStateCodec (TODO-312f F4, unblocks TODO-325)
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 1c35f35c77 MCP 2026-07-28 MRTR requestState principal seam for AeadRequestStateCodec (TODO-312f F4, unblocks TODO-325)
1c35f35c77 is described below
commit 1c35f35c776d5832a6b4aea907a058116fb96a66
Author: James Bognar <ja...@apache.org>
AuthorDate: Wed Aug 5 12:47:38 2026 -0700
MCP 2026-07-28 MRTR requestState principal seam for AeadRequestStateCodec (TODO-312f F4, unblocks TODO-325)
Co-authored-by: Cursor <cu...@cursor.com>
---
.../mcp/v20260728/Characterization_Test.java | 5 +-
.../mcp/v20260728/AeadRequestStateCodec.java | 19 ++++-
.../mcp/v20260728/McpResourceServerSupport.java | 28 ++++++-
.../rest/server/mcp/v20260728/McpRevision.java | 37 +++++++--
.../server/mcp/v20260728/RequestStateCodec.java | 58 ++++++++++++--
.../mcp/v20260728/AeadRequestStateCodec_Test.java | 33 ++++++++
.../mcp/v20260728/Characterization_Test.java | 5 +-
.../server/mcp/v20260728/McpMrtrDispatch_Test.java | 55 ++++++++++++-
.../v20260728/McpResourceServerBinding_Test.java | 89 ++++++++++++++++++++++
.../v20260728/McpResourceServerSupport_Test.java | 28 +++++++
.../mcp/v20260728/RequestStateCodec_Test.java | 32 +++++++-
11 files changed, 362 insertions(+), 27 deletions(-)
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 f7042e32a0..f0e39e6537 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
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
+import java.security.*;
import java.util.*;
import java.util.function.*;
@@ -380,7 +381,7 @@ class Characterization_Test {
private static final byte[] NONCE = new byte[12];
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
- @Override public String seal(McpRequestState state, String aad) {
+ @Override public String seal(McpRequestState state, String aad, Principal principal) {
try {
var cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, KEY, new GCMParameterSpec(128, NONCE));
@@ -392,7 +393,7 @@ class Characterization_Test {
}
}
- @Override public Optional<McpRequestState> unseal(String token, String aad) {
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
try {
var parts = token.split("\\.", 2);
var nonce = Base64.getUrlDecoder().decode(parts[0]);
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 552df8603d..b18c54aa71 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
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.apache.juneau.commons.utils.Shorts.*;
import java.nio.charset.StandardCharsets;
+import java.security.Principal;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Optional;
@@ -57,6 +58,14 @@ import org.apache.juneau.marshall.marshaller.Json;
* contract.
*
* <p>
+ * <b>Authenticated principal (READY-312f F4).</b> {@link #seal}/{@link #unseal} receive the caller's authenticated
+ * {@link Principal} (nullable) per the {@link RequestStateCodec} contract, but this built-in codec does <b>not</b>
+ * yet fold it into the AEAD's authenticated data — the token is not principal-bound. Adding that binding
+ * (and choosing which identity attribute to bind) is owned by TODO-325; the {@code seal}/{@code unseal} bodies
+ * carry a {@code TODO-325} marker at the exact fold point. F4's guarantee is only that the principal reaches the
+ * codec at both seal and unseal.
+ *
+ * <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
@@ -121,7 +130,7 @@ public class AeadRequestStateCodec implements RequestStateCodec {
}
@Override /* RequestStateCodec */
- public String seal(McpRequestState state, String aad) {
+ public String seal(McpRequestState state, String aad, Principal principal) {
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
@@ -131,6 +140,9 @@ public class AeadRequestStateCodec implements RequestStateCodec {
random.nextBytes(nonce);
var cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, ks.key(), new GCMParameterSpec(GCM_TAG_BITS, nonce));
+ // TODO-325: the authenticated principal is exposed here (READY-312f F4) but not yet folded into the AEAD's
+ // authenticated data. Binding it (e.g. appending a chosen identity attribute to the updateAAD(...) input
+ // below, mirroring the keyId append) is owned by TODO-325, along with the choice of which identity to bind.
cipher.updateAAD((aad + '\u0000' + ks.keyId()).getBytes(StandardCharsets.UTF_8));
var plaintext = Json.of(state).getBytes(StandardCharsets.UTF_8);
var ciphertext = cipher.doFinal(plaintext);
@@ -142,7 +154,7 @@ public class AeadRequestStateCodec implements RequestStateCodec {
}
@Override /* RequestStateCodec */
- public Optional<McpRequestState> unseal(String token, String aad) {
+ public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
try {
if (token.length() < MIN_TOKEN_CHARS || token.length() > MAX_TOKEN_CHARS)
return Optional.empty();
@@ -162,6 +174,9 @@ public class AeadRequestStateCodec implements RequestStateCodec {
return Optional.empty();
var cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, resolved.get(), new GCMParameterSpec(GCM_TAG_BITS, nonce));
+ // TODO-325: the authenticated principal is exposed here (READY-312f F4) but not yet folded into the AEAD's
+ // authenticated data. When TODO-325 binds it at seal time, this call site must fold the SAME identity into
+ // updateAAD(...) below so a token minted for principal A fails the GCM tag check under principal B.
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));
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
index 1308a6d745..eceb88d301 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport.java
@@ -44,7 +44,8 @@ import jakarta.servlet.http.*;
* driven by an {@link McpResourceServerConfig}.
*
* <p>
- * Bearer extraction and token validation are delegated to the reusable {@link OAuthFilter} (RFC 6750) from
+ * Bearer extraction and token validation are delegated to the reusable
+ * {@link org.apache.juneau.rest.server.auth.oauth.OAuthFilter} (RFC 6750) from
* {@code juneau-rest-server-auth-oauth}; this class layers on the MCP-specific {@code resource_metadata} challenge
* parameter, RFC 8707 audience matching ({@link McpAudienceValidator}), and baseline required-scope enforcement.
*
@@ -432,6 +433,31 @@ public final class McpResourceServerSupport {
return v instanceof Set ? (Set<String>)v : Set.of();
}
+ /**
+ * Returns the authenticated {@link Principal} {@link #authenticate} stashed for this request (READY-312f F4).
+ *
+ * <p>
+ * Reads the same framework-standard {@link RestServerConstants#PRINCIPAL_ATTR} attribute {@link #authenticate}
+ * writes on success, so this is the single source of truth for "who is calling this MCP request". It is the seam
+ * the {@code 2026-07-28} dispatcher threads into {@link RequestStateCodec#seal}/{@link RequestStateCodec#unseal}
+ * to unblock TODO-325's principal-bound {@code requestState} AAD. Mirrors {@link #grantedScopes(HttpServletRequest)}:
+ * a <jk>null</jk> request, an absent attribute, or a non-{@link Principal} value all return <jk>null</jk> — the
+ * anonymous / RS-auth-disabled path, which every caller must handle without an NPE. Unlike
+ * {@link #grantedScopes(HttpServletRequest)}, which falls back to an empty {@link Set} in that same situation, this
+ * returns <jk>null</jk> rather than an empty value in that case; the divergence is intentional — a scalar
+ * identity has no natural "empty" representative the way a collection does.
+ *
+ * @param req The HTTP request. May be <jk>null</jk>.
+ * @return The authenticated principal, or <jk>null</jk> when none was stashed (RS auth disabled / gate not run /
+ * anonymous caller).
+ */
+ public static Principal principal(HttpServletRequest req) {
+ if (req == null)
+ return null;
+ var v = req.getAttribute(RestServerConstants.PRINCIPAL_ATTR);
+ return v instanceof Principal p ? p : null;
+ }
+
/**
* Enforces SEP-2350 per-operation step-up scopes at the POST-parse dispatch point.
*
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 26e3b797fa..5c590c1a47 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,6 +20,7 @@ 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.util.*;
import org.apache.juneau.bean.jsonrpc.*;
@@ -630,7 +631,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());
+ return pause(signal, McpMethods.TOOLS_CALL, p, mrtr.currentRound(), ctx);
}
}
@@ -667,7 +668,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());
+ return pause(signal, McpMethods.PROMPTS_GET, p, mrtr.currentRound(), ctx);
}
}
@@ -696,7 +697,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());
+ return pause(signal, McpMethods.RESOURCES_READ, p, mrtr.currentRound(), ctx);
}
}
var match = config.resolveResourceTemplate(uri);
@@ -805,7 +806,7 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
// 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))
+ var sealed = mrtrConfig.getCodec().unseal(requestState, aad(method), 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");
@@ -837,14 +838,16 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
* @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 currentRound The round decoded on RESUME (0 on a first-round pause), incremented into the new token.
+ * @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) {
+ private InputRequiredResult pause(McpInputRequiredSignal signal, String method, Map<String,Object> params, int currentRound, 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)));
+ var result = new InputRequiredResult().setRequestState(mrtrConfig.getCodec().seal(state, aad(method), 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.
@@ -865,6 +868,28 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
return method + '\u0000' + protocolVersion();
}
+ /**
+ * Resolves the authenticated caller {@link Principal} for the current request, threaded into the
+ * {@link RequestStateCodec} at seal and unseal so a hardened codec can bind the {@code requestState} to who
+ * requested it (READY-312f F4; unblocks TODO-325's principal-bound AAD).
+ *
+ * <p>
+ * Reads the F2 resource-server principal via {@link McpResourceServerSupport#principal} — the same
+ * {@code RestRequest -> HttpServletRequest} lookup {@link #enforceStepUpScopes} uses for granted scopes, so the
+ * two stay in lock-step. Returns <jk>null</jk> when there is no bound {@link RestRequest} (a direct-dispatch unit
+ * test), when RS auth is disabled, or when the caller is anonymous — the codec must seal/unseal cleanly in
+ * that case.
+ *
+ * @param ctx The per-request bean store. Must not be <jk>null</jk>.
+ * @return The authenticated principal, or <jk>null</jk> when none is available.
+ */
+ private static Principal principal(BeanStore ctx) {
+ return ctx.getBean(RestRequest.class)
+ .map(RestRequest::getHttpServletRequest)
+ .map(McpResourceServerSupport::principal)
+ .orElse(null);
+ }
+
/**
* Reads whether the request advertised the client {@code elicitation} capability, from the opaque
* {@code _meta.clientCapabilities} map directly (mirroring {@link #validateMeta}'s opaque-map style rather than
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 d0d3fc0539..5a02e9fee2 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
@@ -16,6 +16,7 @@
*/
package org.apache.juneau.rest.server.mcp.v20260728;
+import java.security.Principal;
import java.util.*;
/**
@@ -49,21 +50,37 @@ import java.util.*;
* 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.
+ *
+ * <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.
*/
public interface RequestStateCodec {
/**
- * Seals a payload into an opaque token.
+ * Seals a payload into an opaque token, exposing the authenticated caller identity to the codec.
*
* @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>.
+ * @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>.
*/
- String seal(McpRequestState state, String aad);
+ String seal(McpRequestState state, String aad, Principal principal);
/**
- * Unseals a token, verifying integrity against the supplied AAD.
+ * Unseals a token, verifying integrity against the supplied AAD, with the authenticated caller identity
+ * available to the codec.
*
* <p>
* <b>The {@code token} is arbitrary, untrusted client input.</b> It arrives verbatim from the follow-up
@@ -71,12 +88,37 @@ public interface RequestStateCodec {
* it must not allocate buffers sized from the raw token, and it must not throw — any malformed, oversized,
* tampered, or AAD-mismatched input must return {@link Optional#empty()} (see the class Javadoc contract).
*
- * @param token The token previously returned by {@link #seal(McpRequestState, String)}. Must not be
+ * @param token The token previously returned by {@link #seal(McpRequestState, String, Principal)}. Must not be
* <jk>null</jk>.
- * @param aad The AAD to verify against. Must match the value passed to {@link #seal(McpRequestState, String)}
- * exactly, or verification fails.
+ * @param aad The AAD to verify against. Must match the value passed to
+ * {@link #seal(McpRequestState, String, Principal)} exactly, or verification fails.
+ * @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. A codec that binds the principal (TODO-325) must fail
+ * verification when it differs from the sealing principal.
* @return The original payload, or {@link Optional#empty()} if verification fails for any reason (tamper,
- * AAD mismatch, malformed token).
+ * AAD mismatch, malformed token, principal mismatch).
+ */
+ Optional<McpRequestState> unseal(String token, String aad, Principal principal);
+
+ /**
+ * No-principal convenience overload of {@link #seal(McpRequestState, String, Principal)} (anonymous caller).
+ *
+ * @param state The payload to seal. Must not be <jk>null</jk>.
+ * @param aad Additional authenticated data. Must not be <jk>null</jk>.
+ * @return The opaque token. Never <jk>null</jk>.
+ */
+ default String seal(McpRequestState state, String aad) {
+ return seal(state, aad, null);
+ }
+
+ /**
+ * No-principal convenience overload of {@link #unseal(String, String, Principal)} (anonymous caller).
+ *
+ * @param token The token previously returned by {@link #seal(McpRequestState, String)}. Must not be <jk>null</jk>.
+ * @param aad The AAD to verify against.
+ * @return The original payload, or {@link Optional#empty()} if verification fails for any reason.
*/
- Optional<McpRequestState> unseal(String token, String aad);
+ default Optional<McpRequestState> unseal(String token, String aad) {
+ return unseal(token, aad, null);
+ }
}
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 e582a87776..3ced9355ff 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
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
+import java.security.Principal;
import java.util.Base64;
import java.util.Map;
@@ -277,4 +278,36 @@ class AeadRequestStateCodec_Test {
assertTrue(c.isPresent());
assertEquals(b, c.get());
}
+
+ /**
+ * Pins the current (READY-312f F4) contract documented on {@link AeadRequestStateCodec}'s class Javadoc and at
+ * the {@code TODO-325} markers in {@link AeadRequestStateCodec#seal} / {@link AeadRequestStateCodec#unseal}: the
+ * principal reaches the codec at both seal and unseal, but is <b>not yet</b> folded into the AEAD's authenticated
+ * data. A real {@link AeadRequestStateCodec} seal uses a random per-seal nonce (see the class Javadoc), so two
+ * {@code seal} calls are never byte-identical even with everything else held fixed — the built-in codec has
+ * no fixed-nonce affordance (unlike {@code Characterization_Test.FixedKeyGcmCodec}, a wholly separate, hardcoded
+ * fixture implementation, not this class). So this proves the equivalent invariant directly on ONE sealed token:
+ * it unseals successfully under the sealing principal, under a completely different principal, and under a
+ * <jk>null</jk> (anonymous) principal alike — i.e. the principal has no bearing on seal/unseal validity yet.
+ * Once TODO-325 binds the principal into the AAD, unsealing under {@code bob} or <jk>null</jk> here must start
+ * failing, which is exactly the regression this test is meant to catch.
+ */
+ @Test void a15_principalIsNotYetBoundSoTokenUnsealsUnderAnyPrincipal() {
+ var a = new AeadRequestStateCodec();
+ var state = new McpRequestState("continuation-value", "tools/call", 1, 123456789L);
+ Principal alice = () -> "alice";
+ Principal bob = () -> "bob";
+ var token = a.seal(state, AAD, alice);
+ var underSamePrincipal = a.unseal(token, AAD, alice);
+ var underDifferentPrincipal = a.unseal(token, AAD, bob);
+ var underNullPrincipal = a.unseal(token, AAD, null);
+ assertTrue(underSamePrincipal.isPresent(), "round trip under the sealing principal must still succeed");
+ assertEquals(state, underSamePrincipal.get());
+ assertTrue(underDifferentPrincipal.isPresent(),
+ "principal is not yet bound to the AAD (TODO-325), so a different principal must still unseal");
+ assertEquals(state, underDifferentPrincipal.get());
+ assertTrue(underNullPrincipal.isPresent(),
+ "principal is not yet bound to the AAD (TODO-325), so a null (anonymous) principal must still unseal");
+ assertEquals(state, underNullPrincipal.get());
+ }
}
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 ab785c41ea..a95644b80d 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
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.io.*;
import java.nio.charset.*;
import java.nio.file.*;
+import java.security.*;
import java.util.*;
import java.util.function.*;
@@ -382,7 +383,7 @@ class Characterization_Test {
private static final byte[] NONCE = new byte[12];
private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding();
- @Override public String seal(McpRequestState state, String aad) {
+ @Override public String seal(McpRequestState state, String aad, Principal principal) {
try {
var cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, KEY, new GCMParameterSpec(128, NONCE));
@@ -394,7 +395,7 @@ class Characterization_Test {
}
}
- @Override public Optional<McpRequestState> unseal(String token, String aad) {
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
try {
var parts = token.split("\\.", 2);
var nonce = Base64.getUrlDecoder().decode(parts[0]);
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 ef2dc56236..9269beaed6 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
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
+import java.security.*;
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.function.*;
@@ -202,8 +203,8 @@ class McpMrtrDispatch_Test {
final RequestStateCodec delegate = new AeadRequestStateCodec();
int sealCalls;
- @Override public String seal(McpRequestState state, String aad) { sealCalls++; return delegate.seal(state, aad); }
- @Override public Optional<McpRequestState> unseal(String token, String aad) { return delegate.unseal(token, aad); }
+ @Override public String seal(McpRequestState state, String aad, Principal principal) { sealCalls++; return delegate.seal(state, aad, principal); }
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) { return delegate.unseal(token, aad, principal); }
}
@Test void b01_pauseWithoutElicitationCapability_rejectedAndNothingSealed() {
@@ -332,8 +333,8 @@ class McpMrtrDispatch_Test {
// A codec that ignores AAD, so a sealed method survives unseal and the dispatcher's own sealed.method()
// equality check (isolated from the codec's AAD binding) is exercised directly.
static final class C07_NoAadCodec implements RequestStateCodec {
- @Override public String seal(McpRequestState state, String aad) { return Json.of(state); }
- @Override public Optional<McpRequestState> unseal(String token, String aad) {
+ @Override public String seal(McpRequestState state, String aad, Principal principal) { return Json.of(state); }
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
try {
return Optional.of(Json.to(token, McpRequestState.class));
} catch (@SuppressWarnings("unused") Exception e) {
@@ -493,6 +494,52 @@ class McpMrtrDispatch_Test {
assertEquals(0, calls.get());
}
+ // -------- F4 (READY-312f): principal exposure to the codec seam ---------
+
+ // Records the principal (and whether it was called at all) the dispatcher threads into seal/unseal.
+ static final class E_PrincipalCapturingCodec implements RequestStateCodec {
+ final RequestStateCodec delegate = new AeadRequestStateCodec();
+ boolean sealCalled;
+ boolean unsealCalled;
+ Principal sealPrincipal;
+ Principal unsealPrincipal;
+
+ @Override public String seal(McpRequestState state, String aad, Principal principal) {
+ sealCalled = true;
+ sealPrincipal = principal;
+ return delegate.seal(state, aad, principal);
+ }
+
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
+ unsealCalled = true;
+ unsealPrincipal = principal;
+ return delegate.unseal(token, aad, principal);
+ }
+ }
+
+ @Test void e01_noBoundRestRequest_principalIsNullAtSealAndUnsealAndStillRoundTrips() {
+ // The direct-dispatch harness binds no RestRequest, so the dispatcher resolves NO principal (anonymous /
+ // RS-auth-disabled path). seal and unseal must both be invoked with a null principal and the token must
+ // still round-trip cleanly (no NPE) -- the contract the F4 seam must preserve.
+ var codec = new E_PrincipalCapturingCodec();
+ var rev = revision(mrtr(codec));
+ var config = new McpServerConfig().addTool(tool("ask", (args, c) -> {
+ if (c.getBean(McpMrtrResumeContext.class).isPresent())
+ return text("done");
+ throw new McpInputRequiredSignal(Map.of("q1", reqEntry("elicitation")), "cont-1");
+ }));
+ var paused = (InputRequiredResult) send(rev, config, req(1, "tools/call", JsonMap.of("name", "ask"), true), hdrs("tools/call", "ask")).getResult();
+ assertTrue(codec.sealCalled);
+ assertNull(codec.sealPrincipal);
+ assertNotNull(paused.getRequestState());
+
+ var resumeParams = JsonMap.of("name", "ask", "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", ((TextContent) result.getContent().get(0)).getText());
+ assertTrue(codec.unsealCalled);
+ assertNull(codec.unsealPrincipal);
+ }
+
@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
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
index 0698614757..d7c0dda92e 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerBinding_Test.java
@@ -329,6 +329,95 @@ class McpResourceServerBinding_Test extends TestBase {
assertNull(ctx.params().get("text")); // the footgun: reading an argument off params directly is null.
}
+ // ---------------------------------------------------------------------------------------------
+ // F4 (READY-312f): the F2-authenticated principal is threaded into the RequestStateCodec seal/unseal seam so
+ // TODO-325 can later bind the requestState to who requested it. A capturing codec records the principal it
+ // receives; an end-to-end pause (seal) then resume (unseal), both under the same bearer token, proves the
+ // authenticated principal reaches the codec at BOTH points.
+ // ---------------------------------------------------------------------------------------------
+
+ static final class J_CapturingCodec implements RequestStateCodec {
+ final RequestStateCodec delegate = new AeadRequestStateCodec();
+ final AtomicReference<String> sealPrincipal = new AtomicReference<>("<unset>");
+ final AtomicReference<String> unsealPrincipal = new AtomicReference<>("<unset>");
+
+ @Override public String seal(McpRequestState state, String aad, Principal principal) {
+ sealPrincipal.set(principal == null ? null : principal.getName());
+ return delegate.seal(state, aad, principal);
+ }
+
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
+ unsealPrincipal.set(principal == null ? null : principal.getName());
+ return delegate.unseal(token, aad, principal);
+ }
+ }
+
+ static final J_CapturingCodec J_CODEC = new J_CapturingCodec();
+
+ private static Object validMetaElicit() {
+ return JsonMap.of(
+ RequestMeta.KEY_PROTOCOL_VERSION, "2026-07-28",
+ RequestMeta.KEY_CLIENT_INFO, JsonMap.of("name", "fixture-client", "version", "1.0"),
+ RequestMeta.KEY_CLIENT_CAPABILITIES, JsonMap.of("elicitation", JsonMap.of()));
+ }
+
+ private static String bodyElicit(Object id, String method, Object params) {
+ var p = params instanceof Map<?,?> m ? new JsonMap(m) : new JsonMap();
+ p.put("_meta", validMetaElicit());
+ return org.apache.juneau.marshall.marshaller.Json.of(new JsonRpcRequest().setJsonrpc(McpProtocol.JSON_RPC_2_0).setId(id).setMethod(method).setParams(p));
+ }
+
+ // Pauses on the first call (emits input_required -> seals) and completes on resume (unseals first, then returns).
+ private static McpToolHandler pausingAsk() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("ask"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ if (ctx.getBean(McpMrtrResumeContext.class).isPresent())
+ return McpToolOutcome.text("done");
+ throw new McpInputRequiredSignal(Map.of("q1", Map.of("type", "elicitation")), "cont-1");
+ }
+ };
+ }
+
+ private static McpOptions rsEnabledWithCapturingCodec() {
+ return rsEnabled().mrtr(m -> m.setCodec(J_CODEC));
+ }
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class J extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig().setName("test").setVersion("1.0.0").addTool(pausingAsk());
+ }
+ @Override protected McpOptions createMcpOptions() { return rsEnabledWithCapturingCodec(); }
+ }
+
+ private MockRestClient clientJ() {
+ return MockRestClient.create(J.class).json().contentType("application/json").accept("application/json").ignoreErrors().build();
+ }
+
+ @Test void j01_authenticatedPrincipalReachesCodecAtSealAndUnseal() throws Exception {
+ J_CODEC.sealPrincipal.set("<unset>");
+ J_CODEC.unsealPrincipal.set("<unset>");
+
+ // Round 1: pause -> seal. The bearer 'good' authenticates as principal 'alice' (see VALIDATOR).
+ var pauseJson = clientJ().post("/").contentString(bodyElicit(1, "tools/call", JsonMap.of("name", "ask")))
+ .header("Mcp-Method", "tools/call").header("Mcp-Name", "ask")
+ .header("Authorization", "Bearer good")
+ .run().assertStatus(200).getContent().asString();
+ var token = org.apache.juneau.marshall.marshaller.Json.to(pauseJson, JsonMap.class).getMap("result").getString("requestState");
+ assertNotNull(token);
+ assertEquals("alice", J_CODEC.sealPrincipal.get()); // the authenticated principal reached seal
+
+ // Round 2: resume -> unseal, same bearer token / same principal.
+ var resumeParams = JsonMap.of("name", "ask", "requestState", token, "inputResponses", JsonMap.of("q1", "answer"));
+ clientJ().post("/").contentString(bodyElicit(2, "tools/call", resumeParams))
+ .header("Mcp-Method", "tools/call").header("Mcp-Name", "ask")
+ .header("Authorization", "Bearer good")
+ .run().assertStatus(200);
+ assertEquals("alice", J_CODEC.unsealPrincipal.get()); // the authenticated principal reached unseal
+ }
+
// ---------------------------------------------------------------------------------------------
// Servlet path with RS auth DISABLED (default) - behavior unchanged.
// ---------------------------------------------------------------------------------------------
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
index fb019d1725..eaa05b87a6 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpResourceServerSupport_Test.java
@@ -29,6 +29,7 @@ import org.apache.juneau.http.response.Forbidden;
import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.rest.mock.MockServletRequest;
import org.apache.juneau.rest.server.RestRequest;
+import org.apache.juneau.rest.server.RestServerConstants;
import org.apache.juneau.rest.server.auth.TokenValidator;
import org.apache.juneau.rest.server.mcp.McpEndpointMixin;
import org.apache.juneau.rest.server.util.UrlPath;
@@ -267,4 +268,31 @@ class McpResourceServerSupport_Test {
assertTrue(McpResourceServerSupport.grantedScopes(MockServletRequest.create()).isEmpty());
assertTrue(McpResourceServerSupport.grantedScopes(null).isEmpty());
}
+
+ // ---------------------------------------------------------------------------------------------
+ // F4 (TODO-312f): principal(req) exposes the F2-authenticated principal (stashed under PRINCIPAL_ATTR by
+ // authenticate(...)) so the dispatcher can thread it into the RequestStateCodec seal/unseal seam (unblocking
+ // TODO-325's principal-bound AAD). Mirrors grantedScopes(req): present -> the principal; absent/null/wrong-type
+ // -> null (the anonymous / RS-auth-disabled path).
+ // ---------------------------------------------------------------------------------------------
+
+ @Test void g01_principal_presentReturnsStashedPrincipal() {
+ Principal p = () -> "bob";
+ var req = MockServletRequest.create().attribute(RestServerConstants.PRINCIPAL_ATTR, p);
+ assertSame(p, McpResourceServerSupport.principal(req));
+ }
+
+ @Test void g02_principal_absentAttributeIsNull() {
+ assertNull(McpResourceServerSupport.principal(MockServletRequest.create()));
+ }
+
+ @Test void g03_principal_nullRequestIsNull() {
+ assertNull(McpResourceServerSupport.principal(null));
+ }
+
+ @Test void g04_principal_nonPrincipalAttributeIsNull() {
+ // Defensive: a stashed value that is not a Principal must not be cast/returned.
+ var req = MockServletRequest.create().attribute(RestServerConstants.PRINCIPAL_ATTR, "not-a-principal");
+ assertNull(McpResourceServerSupport.principal(req));
+ }
}
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 7a7fb106e0..a5d26f9492 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
@@ -19,6 +19,7 @@ package org.apache.juneau.rest.server.mcp.v20260728;
import static org.junit.jupiter.api.Assertions.*;
import java.nio.charset.StandardCharsets;
+import java.security.Principal;
import java.util.Base64;
import java.util.Optional;
@@ -40,13 +41,13 @@ class RequestStateCodec_Test {
private static final class FakeCodec implements RequestStateCodec {
@Override /* RequestStateCodec */
- public String seal(McpRequestState state, String aad) {
+ public String seal(McpRequestState state, String aad, Principal principal) {
var plaintext = aad + "\u0000" + Json.of(state);
return Base64.getEncoder().encodeToString(plaintext.getBytes(StandardCharsets.UTF_8));
}
@Override /* RequestStateCodec */
- public Optional<McpRequestState> unseal(String token, String aad) {
+ public Optional<McpRequestState> unseal(String token, String aad, Principal principal) {
byte[] decoded;
try {
decoded = Base64.getDecoder().decode(token);
@@ -88,4 +89,31 @@ class RequestStateCodec_Test {
var c = a.unseal("!!!not-base64!!!", "tools/call:2026-07-28");
assertTrue(c.isEmpty());
}
+
+ // F4 (READY-312f): the principal-bearing 3-arg methods are the canonical SPI; the 2-arg convenience overloads
+ // delegate to them with a null (no-principal) identity, and an explicit principal is passed through verbatim.
+ private static final class B_CapturingCodec implements RequestStateCodec {
+ Principal sealPrincipal = () -> "<unset>";
+ Principal unsealPrincipal = () -> "<unset>";
+
+ @Override public String seal(McpRequestState state, String aad, Principal principal) { sealPrincipal = principal; return "t"; }
+ @Override public Optional<McpRequestState> unseal(String token, String aad, Principal principal) { unsealPrincipal = principal; return Optional.empty(); }
+ }
+
+ @Test void b01_twoArgOverloadsDelegateWithNullPrincipal() {
+ var a = new B_CapturingCodec();
+ a.seal(new McpRequestState("c", "tools/call", 1, 1L), "aad");
+ a.unseal("t", "aad");
+ assertNull(a.sealPrincipal);
+ assertNull(a.unsealPrincipal);
+ }
+
+ @Test void b02_threeArgMethodsReceiveTheSuppliedPrincipal() {
+ var a = new B_CapturingCodec();
+ Principal p = () -> "carol";
+ a.seal(new McpRequestState("c", "tools/call", 1, 1L), "aad", p);
+ a.unseal("t", "aad", p);
+ assertSame(p, a.sealPrincipal);
+ assertSame(p, a.unsealPrincipal);
+ }
}