This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch docs
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/docs by this push:
     new 64a7fb1a6c docs: MCP MRTR KeyProvider — shared-key scaling + rotation 
(TODO-324)
64a7fb1a6c is described below

commit 64a7fb1a6cd34167eab296674257b520f4a0fcd9
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 00:32:27 2026 -0700

    docs: MCP MRTR KeyProvider — shared-key scaling + rotation (TODO-324)
    
    Adds MRTR key-management recipes (shared sealing key for horizontal scaling,
    static rotation, bring-your-own-vault KeyProvider), updates the REST server
    MCP reference to describe the pluggable KeyProvider and versioned 4-segment
    wire format, and adds the 10.0.0 release-notes entry.
    
    Co-authored-by: Cursor <[email protected]>
---
 pages/release-notes/10.0.0.md             | 13 ++++++
 pages/topics/11.03.JuneauMcpRecipes.md    | 73 +++++++++++++++++++++++++++++++
 pages/topics/11.04.JuneauRestServerMcp.md | 26 +++++++++--
 3 files changed, 109 insertions(+), 3 deletions(-)

diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index 3d1780a7b5..a30a27078d 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -943,6 +943,19 @@ Because `McpOptions` and the v2 adapter are never-shipped 
10.0.0 APIs, this is a
 
 See 
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#configuring-the-endpoint-mcpoptions-mcp-2026-07-28)
 and 
[juneau-rest-client-mcp](/docs/topics/JuneauRestClientMcp#elicitation-mcp-2026-07-28-sep-2322)
 for the full topics.
 
+### MCP `2026-07-28` — MRTR `KeyProvider` SPI for shared/rotating sealing keys 
(TODO-324)
+
+`2026-07-28`-only. Introduces a key-provider abstraction beneath the MRTR 
`AeadRequestStateCodec` (shipped un-released in TODO-318, above) so operators 
can supply a stable shared sealing key — fixing horizontally-scaled RESUME — 
and roll keys via a `keyId`, while keeping today's per-process ephemeral key as 
the zero-config default.
+
+- **New `KeyProvider` SPI** (`org.apache.juneau.rest.server.mcp.v20260728`) — 
`currentKey(): KeyedSecret` (the key to seal new tokens with, plus its `keyId`) 
and `resolveKey(keyId): Optional<SecretKey>` (never throws; empty on an 
unknown/retired `keyId`). `KeyedSecret` is a small `keyId`/`SecretKey` record 
with non-blank/length-bounded `keyId` validation.
+- **Two built-ins.** `EphemeralKeyProvider` preserves today's exact 
zero-config behavior (a random AES-256 key **and** a random `keyId` per 
instance, still not restart-durable or cross-instance-shareable). 
`StaticKeyProvider` is an immutable, statically-configured keyring for the 
shared/rotating case — a `StaticKeyProvider.of(keyId, key)` one-liner, or a 
builder (`create().addKey(...).current(...).build()`) supporting overlapping 
keys during a rollover — plus an `aesKey(byte[] | String)` [...]
+- **`AeadRequestStateCodec` now takes a `KeyProvider`.** The no-arg 
constructor is unchanged behaviorally (`this(new EphemeralKeyProvider())`); a 
new `AeadRequestStateCodec(KeyProvider)` constructor is the extension point. 
The sealed-token wire format grows from two to four dot-joined segments — 
`version . b64url(keyId) . b64url(nonce) . b64url(ciphertext+tag)` — with 
`keyId` authenticated (folded into the AEAD's AAD) but not encrypted (it must 
be readable before decryption selects the k [...]
+- **`McpMrtrConfig.setKeyProvider(KeyProvider)` (new convenience).** Sugar for 
`setCodec(new AeadRequestStateCodec(value))`, reachable through the 
`McpOptions` aggregate via `new McpOptions().mrtr(m -> m.setKeyProvider(kp))`. 
Last-wins against `setCodec(...)`; no getter for the provider (`getCodec()` 
remains the sole accessor).
+- **Error mapping unchanged.** The never-throw `unseal` contract and the 
existing `-32602`/`-32022`/`-32023` error codes are untouched — an 
unknown/retired `keyId` flows through the same `Optional.empty()` path as any 
other unseal failure, surfacing as the standard `-32602` "Invalid or tampered 
requestState".
+- **Scope fence, explicitly out of this change** — the OAuth/OIDC 
authorization SEPs, TODO-325's MRTR hardenings (principal-in-AAD, replay cache, 
argument-hash binding), and the pre-existing `-32602`/`-32002` missing-resource 
v1 error-code mismatch are all tracked separately and untouched here.
+
+See 
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#key-management-keyprovider)
 and the [MRTR key management 
recipes](/docs/topics/JuneauMcpRecipes#mrtr-key-management-v2-only) for the 
full topics.
+
 ### Bug Fixes
 
 - **Fixed RRPC method calls never dispatching over POST.** Every HTTP POST to 
an `@RestOp(method="RRPC")` operation previously returned a 404 instead of 
reaching the target method. `RrpcRestOpSession` derived the RRPC method key by 
splitting the request path on the last `/`, but RRPC keys are of the form 
`methodName/(paramTypes)` and themselves contain a `/`, so the method name was 
stripped off and the lookup always fell through to `NotFound`. The key is now 
derived from the already-comp [...]
diff --git a/pages/topics/11.03.JuneauMcpRecipes.md 
b/pages/topics/11.03.JuneauMcpRecipes.md
index 3151bf9255..8ecaa6fee4 100644
--- a/pages/topics/11.03.JuneauMcpRecipes.md
+++ b/pages/topics/11.03.JuneauMcpRecipes.md
@@ -11,6 +11,7 @@ Copy-pasteable snippets for common MCP tasks, targeting 
revision `2026-07-28` un
 - [Serving a resource template with 
completions](#serving-a-resource-template-with-completions)
 - [Error handling](#error-handling)
 - [Elicitation / Multi-Round-Trip Requests (v2 
only)](#elicitation--multi-round-trip-requests-v2-only)
+- [MRTR key management (v2 only)](#mrtr-key-management-v2-only)
 - [Subscriptions (v2 only)](#subscriptions-v2-only)
 - [Client: call, read, and handle errors](#client-call-read-and-handle-errors)
 
@@ -228,6 +229,78 @@ McpToolHandler confirmDeleteTool = McpToolHandler.of(
 
 This requires no special server config beyond a v2 servlet/mixin — MRTR 
support is on by default (see `McpOptions.mrtr(Consumer<McpMrtrConfig>)` in the 
[server reference](/docs/topics/JuneauRestServerMcp) to customize the codec, 
TTL, or round cap).
 
+## MRTR key management (v2 only)
+
+By default, MRTR `requestState` tokens are sealed under a random key generated 
once per process (`EphemeralKeyProvider`, the zero-config default beneath 
`AeadRequestStateCodec`) — deliberately **not shareable across instances and 
not durable across a restart** (see the [server 
reference](/docs/topics/JuneauRestServerMcp#key-management-keyprovider)). A 
load-balanced deployment, where RESUME can land on a different node than the 
PAUSE, needs a shared sealing key instead.
+
+### Shared sealing key for horizontally-scaled RESUME
+
+Supply a `StaticKeyProvider` loaded from config/secret via 
`setKeyProvider(...)`:
+
+```java
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+
+byte[] secretBytes = loadSharedKeyFromSecretStore(); // your config/secret 
source, 32 bytes for AES-256
+KeyProvider sharedKeyProvider = StaticKeyProvider.of("2026-08-a", 
StaticKeyProvider.aesKey(secretBytes));
+
+// servlet path
+@Override
+protected McpOptions createMcpOptions() {
+    return new McpOptions().mrtr(m -> m.setKeyProvider(sharedKeyProvider));
+}
+```
+
+Every process that constructs `setKeyProvider(sharedKeyProvider)` — even 
independently, on separate nodes — mints and resolves tokens under the same 
key, so a token PAUSEd on one node RESUMEs successfully on another. To roll the 
key without invalidating tokens already in flight, use the multi-key builder 
instead: add the new key as `current` while keeping the old one resolvable 
until every token sealed under it has expired (bounded by your configured 
`ttlMs`):
+
+```java
+KeyProvider rollingKeyProvider = StaticKeyProvider.create()
+    .addKey("2026-08-a", StaticKeyProvider.aesKey(oldSecretBytes))
+    .addKey("2026-08-b", StaticKeyProvider.aesKey(newSecretBytes))
+    .current("2026-08-b")
+    .build();
+```
+
+### Bring-your-own vault `KeyProvider`
+
+For a real secrets manager (AWS Secrets Manager, HashiCorp Vault, etc.), 
implement `KeyProvider` directly against your secret store instead of the 
static built-in:
+
+```java
+import java.util.Optional;
+
+import javax.crypto.SecretKey;
+
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+
+public class VaultKeyProvider implements KeyProvider {
+
+    private final MySecretStoreClient vault; // your client
+
+    public VaultKeyProvider(MySecretStoreClient vault) {
+        this.vault = vault;
+    }
+
+    @Override
+    public KeyedSecret currentKey() {
+        var current = vault.currentMrtrKey(); // keyId + key bytes, your lookup
+        return new KeyedSecret(current.keyId(), 
StaticKeyProvider.aesKey(current.keyBytes()));
+    }
+
+    @Override
+    public Optional<SecretKey> resolveKey(String keyId) {
+        return vault.findMrtrKey(keyId).map(k -> 
StaticKeyProvider.aesKey(k.keyBytes()));
+    }
+}
+```
+
+```java
+@Override
+protected McpOptions createMcpOptions() {
+    return new McpOptions().mrtr(m -> m.setKeyProvider(new 
VaultKeyProvider(vaultClient)));
+}
+```
+
+`resolveKey(keyId)` must return `Optional.empty()` for an unknown or retired 
`keyId` rather than throwing — an unresolvable `keyId` fails the RESUME closed 
as the standard `-32602` "Invalid or tampered requestState" error, exactly like 
a tampered token. Rotate by having `currentKey()` start returning a new 
`keyId`/key pair while `resolveKey(...)` keeps resolving the previous one until 
every token sealed under it has expired.
+
 ## Subscriptions (v2 only)
 
 Subscriptions (SEP-2575) let a server push resource/list-changed notifications 
to a client over a long-held `subscriptions/listen` stream. This is v2-only and 
requires `juneau-rest-server-reactive` on the server's classpath for the async 
held-open SSE transport.
diff --git a/pages/topics/11.04.JuneauRestServerMcp.md 
b/pages/topics/11.04.JuneauRestServerMcp.md
index 11102bba9c..687d00faa6 100644
--- a/pages/topics/11.04.JuneauRestServerMcp.md
+++ b/pages/topics/11.04.JuneauRestServerMcp.md
@@ -694,7 +694,27 @@ Only the sealed `continuation` is tamper-evident across 
rounds; the per-round `a
 
 ### Sealing `requestState`: the codec SPI
 
-`RequestStateCodec` is the pluggable seal/unseal SPI (mirroring the 
client-side `McpResponseCache` "SPI + built-in default" shape). The built-in 
default, `AeadRequestStateCodec`, is **AES-256-GCM with a per-process ephemeral 
key**: it is deliberately **not restart-durable and not shareable across 
process instances** — a token sealed by one instance can never be unsealed by 
another, or by the same process after a restart. Operators who need 
cross-restart or multi-instance resumption suppl [...]
+`RequestStateCodec` is the pluggable seal/unseal SPI (mirroring the 
client-side `McpResponseCache` "SPI + built-in default" shape). The built-in 
default, `AeadRequestStateCodec`, is AES-256-GCM; the key material it 
seals/unseals under is itself pluggable via a `KeyProvider` (see below) — the 
codec is AEAD-specific, `KeyProvider` is the layer that varies by deployment. 
The canonical AAD binding each token to its originating request is `method + 
'\u0000' + protocolVersion` (NUL-separated); [...]
+
+The sealed-token wire format is four dot-joined segments: `version . 
b64url(keyId) . b64url(nonce) . b64url(ciphertext+tag)`. `keyId` travels in the 
clear (it must be read before decryption can select the key) but is folded into 
the AEAD's authenticated data, so a swapped `keyId` fails the integrity check 
exactly like a tampered ciphertext.
+
+### Key management: `KeyProvider`
+
+`AeadRequestStateCodec` resolves the AES key it seals/unseals under from a 
`KeyProvider`:
+
+```java
+public interface KeyProvider {
+    KeyedSecret currentKey();                    // the key to seal new tokens 
with, plus its keyId
+    Optional<SecretKey> resolveKey(String keyId); // resolve an incoming 
token's keyId; empty if unknown/retired
+}
+```
+
+Two built-ins, both in `org.apache.juneau.rest.server.mcp.v20260728`:
+
+- **`EphemeralKeyProvider`** — the zero-config default beneath `new 
AeadRequestStateCodec()`: a random AES-256 key and a random `keyId`, generated 
once per instance. Deliberately **not restart-durable and not shareable across 
process instances** — a token sealed by one instance can never be unsealed by 
another, or by the same process after a restart.
+- **`StaticKeyProvider`** — an immutable, statically-configured keyring for 
the shared/rotating-key case: `StaticKeyProvider.of(keyId, key)` for a single 
shared key, or the builder 
(`StaticKeyProvider.create().addKey(...).current(...).build()`) to keep an 
older key resolvable while sealing new tokens under a newer one during a 
rollover. `StaticKeyProvider.aesKey(byte[])` / `aesKey(String base64)` build 
the `SecretKey` from raw key material.
+
+Supply either via the `McpMrtrConfig.setKeyProvider(KeyProvider)` convenience 
— sugar for `setCodec(new AeadRequestStateCodec(keyProvider))` — or implement 
`KeyProvider` directly against a real secrets manager (see the [MRTR key 
management recipes](/docs/topics/JuneauMcpRecipes#mrtr-key-management-v2-only) 
for both patterns). `setKeyProvider(...)` and `setCodec(...)` are last-wins 
(both assign the same underlying `codec` field); there is no getter for the 
provider itself — `getCodec()` r [...]
 
 ### Configuring MRTR: `McpMrtrConfig`
 
@@ -704,13 +724,13 @@ Only the sealed `continuation` is tamper-evident across 
rounds; the per-round `a
 @Override
 protected McpOptions createMcpOptions() {
     return new McpOptions().mrtr(m -> m
-        .setCodec(new AeadRequestStateCodec())   // or a shared/rotating-key 
implementation
+        .setKeyProvider(StaticKeyProvider.of("2026-08-a", 
StaticKeyProvider.aesKey(sharedSecretBytes)))   // or leave unset for the 
ephemeral default
         .setTtlMs(5 * 60 * 1000L)
         .setMaxRounds(10));
 }
 ```
 
-`McpOptions` (and hence `mrtr`) is memoized once per binding and treated as 
read-only afterward (each `AeadRequestStateCodec` holds a distinct random key, 
so exactly one instance must be published per binding — the mixin path gets its 
own per-binding key too, with no sharing across separate endpoint instances).
+`McpOptions` (and hence `mrtr`) is memoized once per binding and treated as 
read-only afterward. With the ephemeral default, each `AeadRequestStateCodec` 
holds a distinct random key, so exactly one instance must be published per 
binding — the mixin path gets its own per-binding key too, with no sharing 
across separate endpoint instances. Supplying a shared `KeyProvider` via 
`setKeyProvider(...)` is the supported way to opt back into 
cross-binding/cross-instance sharing for a load-balance [...]
 
 ### Error codes
 

Reply via email to