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