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 &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);
+	}
 }