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 <[email protected]>
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 <[email protected]>
---
 .../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 &mdash; 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> &mdash; 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 &mdash; 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} &mdash; 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 
&mdash; 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} &mdash; the same
+ * identity the F2 resource-server layer establishes (see {@code 
McpResourceServerSupport#principal}). This is the
+ * <i>seam</i> that unblocks TODO-325's principal-bound AAD: a hardened codec 
can fold the caller identity into its
+ * authenticated data so a {@code requestState} minted for principal A cannot 
be resumed by principal B. The
+ * built-in {@link AeadRequestStateCodec} does <b>not</b> bind the principal 
yet (that binding, plus the choice of
+ * <i>which</i> identity attribute to bind &mdash; subject claim, 
issuer+subject, full claim set &mdash; is owned by
+ * TODO-325); F4 only guarantees the principal is delivered to the codec at 
both seal and unseal. The principal is
+ * <b>nullable</b>: when RS auth is disabled or the caller is anonymous it is 
<jk>null</jk>, and every codec must
+ * seal/unseal cleanly (no NPE) in that case. The two-argument {@link 
#seal(McpRequestState, String)} /
+ * {@link #unseal(String, String)} convenience overloads simply pass a 
<jk>null</jk> (no-principal) identity.
  */
 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 &mdash; see 
the class Javadoc). Must not be <jk>null</jk>.
+        * @param principal The authenticated caller (see the class Javadoc). 
May be <jk>null</jk> for an anonymous
+        *      caller or when resource-server auth is disabled.
         * @return The opaque token. Never <jk>null</jk>.
         */
-       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 &mdash; 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 &mdash; 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 &mdash; 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);
+       }
 }

Reply via email to