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