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 5615f15460 docs: MCP OAuth 2.1 client + server resource-server +
DCR/scope step-up (TODO-312f); v1 resource-not-found error code (TODO-332)
5615f15460 is described below
commit 5615f15460afce1eedb01c052c54077564f0289a
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 11:18:57 2026 -0700
docs: MCP OAuth 2.1 client + server resource-server + DCR/scope step-up
(TODO-312f); v1 resource-not-found error code (TODO-332)
Co-authored-by: Cursor <[email protected]>
---
pages/release-notes/10.0.0.md | 30 +++-
pages/topics/11.03.JuneauMcpRecipes.md | 288 ++++++++++++++++++++++++++++++
pages/topics/11.04.JuneauRestServerMcp.md | 78 +++++++-
pages/topics/11.05.JuneauRestClientMcp.md | 84 +++++++++
4 files changed, 477 insertions(+), 3 deletions(-)
diff --git a/pages/release-notes/10.0.0.md b/pages/release-notes/10.0.0.md
index a30a27078d..4afad79718 100644
--- a/pages/release-notes/10.0.0.md
+++ b/pages/release-notes/10.0.0.md
@@ -805,7 +805,7 @@ A new adapter module, `juneau-rest-server-mcp-v20250618`
(package `org.apache.ju
### New Classes
-- **`org.apache.juneau.rest.server.mcp.v20250618.McpRevision`** — the
`McpRevision` implementation for protocol revision `2025-06-18`. Replaces the
old `McpDispatcher`/`Mcp` façade: owns the JSON-RPC method table (`initialize`,
`ping`, `tools/list|call`, `prompts/list|get`, `resources/list|read`) and the
error-code table. The error-code table intentionally preserves a known-wrong
mapping carried over unmodified from the pre-re-layering dispatcher —
unknown-method, tool-not-found, prompt- [...]
+- **`org.apache.juneau.rest.server.mcp.v20250618.McpRevision`** — the
`McpRevision` implementation for protocol revision `2025-06-18`. Replaces the
old `McpDispatcher`/`Mcp` façade: owns the JSON-RPC method table (`initialize`,
`ping`, `tools/list|call`, `prompts/list|get`, `resources/list|read`) and the
error-code table. The error-code table preserves a known-wrong mapping carried
over unmodified from the pre-re-layering dispatcher for three of the four "not
found" kinds — unknown-metho [...]
- **`org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet`** — concrete
abstract servlet for this revision; subclass it (instead of the core
`McpRestServlet`) and implement `createMcpConfig()`. Exposes a `protected
ServerCapabilities capabilities()` hook (returns `null` by default) for
explicitly overriding the `initialize` capabilities advertisement instead of
relying on auto-derivation from the registered tool/prompt/resource lists.
- **`org.apache.juneau.rest.server.mcp.v20250618.McpEndpoint`** — mixin
interface for this revision, at parity with
`org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet`: implement
`getMcpConfig()`, and optionally override its `default ServerCapabilities
capabilities()` hook.
- **`McpTypedPromptHandler<A>`**, **`McpTypedHandlers`** (prompt-only) — the
typed *prompt* sugar layer stays here, since it's genuinely revision-specific
by construction: it binds arguments into this revision's wire-bean argument
types and adapts the result to the neutral `McpPromptHandler` raw interface the
core registry holds.
@@ -952,10 +952,36 @@ See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#configuring-the-en
- **`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.
+- **Scope fence, explicitly out of this change** — the OAuth/OIDC
authorization SEPs and TODO-325's MRTR hardenings (principal-in-AAD, replay
cache, argument-hash binding) are tracked separately and untouched here; the
pre-existing `-32602`/`-32002` missing-resource v1 error-code mismatch is now
resolved — see the resource-not-found error-code correction (TODO-332) below.
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.
+### MCP `2026-07-28` — OAuth 2.1 authorization: client acquisition + server
resource server (TODO-312f)
+
+`2026-07-28`-only, and **off by default** — an endpoint that doesn't opt in
behaves exactly as before. 10.0.0 lands the OAuth 2.1 / MCP authorization
baseline in three slices: client-side token acquisition (F1), a server-side
resource-server (F2), and dynamic client registration + authorization hardening
(F3). Together they let a Juneau MCP client authenticate to, and a Juneau MCP
endpoint protect, a `2026-07-28` MCP resource end to end, covering the MCP
authorization SEPs at a baseline [...]
+
+- **New client module `juneau-rest-client-mcp-auth`** (package
`org.apache.juneau.rest.client.mcp.auth`, on the Nimbus OAuth 2.0 SDK) — feeds
the existing `McpAuthInterceptor` token seam (a `Supplier<String>`):
+ - **`McpTokenProvider`** — a thread-safe headless token manager in three
modes: pre-provisioned static (`ofStaticToken`), client-credentials
(`clientCredentials()`, RFC 6749 §4.4), and refresh-token (`refreshToken(...)`,
RFC 6749 §6 / SEP-2207 with rotation capture via `currentRefreshToken()` and
terminal-`invalid_grant` latching). The dynamic modes require `resource(URI)`
(RFC 8707), carried on every token request; `interceptor()` wires it straight
into a client builder.
+ - **`McpAuthorizationCodeAcquirer`** — interactive authorization-code + PKCE
(`S256`) for CLI/native use: opens a `LoopbackRedirectReceiver`, launches the
authorization URL (pluggable `browserLauncher`), validates the callback
(single-use `state` CSRF, SEP-2468 `iss` — `expectedIssuer(URI)` required
unless `skipIssuerValidation(true)`), and exchanges the code.
`offlineAccess(true)` requests a refresh token.
+ - **`McpProtectedResourceMetadataClient`** — RFC 9728 PRM discovery with an
`expectedResource(URI)` §3.3 identity check and an `https`-by-default scheme
requirement (loopback exempt), plus `discoverAuthorizationServer(...)`;
`WwwAuthenticateChallenge` parses the `401`/`403` `Bearer` challenge to recover
the `resource_metadata` pointer.
+ - **`OidcDiscoveryClient`** — RFC 8414 Authorization Server Metadata with
OIDC Discovery fallback (both required by the spec), each performing the
issuer-identity check.
+- **New server RS baseline on `juneau-rest-server-mcp-v20260728`** —
`McpResourceServerConfig`, folded into `McpOptions` as the
`resourceServer(Consumer<McpResourceServerConfig>)` nested config. When enabled
it requires a valid RFC 6750 bearer token on the MCP `POST` endpoint
(validation mode chosen by the supplied `TokenValidator`:
`OAuthIntrospectionValidator` for opaque tokens, `JwtTokenValidator` for JWTs),
serves an RFC 9728 PRM document from `.well-known/oauth-protected-resource`,
[...]
+- **Origin-root mount constraint.** Because RFC 9728 requires the PRM document
at an origin-root well-known location — the exact URL advertised in the
`resource_metadata` challenge — RS auth requires the MCP endpoint to be mounted
at the origin root (no context path, no non-root servlet path, no
`@Mixin(path=...)` re-mount prefix). A non-root or re-mounted mount **fails
fast with a `500`** carrying an `origin-root` diagnostic (on both the `POST`
gate and the well-known route) rather than [...]
+
+**F3 — dynamic client registration + authorization hardening.** The third
slice completes the client story with RFC 7591 / OIDC Dynamic Client
Registration, issuer-keyed credential persistence (SEP-2352), and scoped
step-up authorization (SEP-2350, both halves), all in
`juneau-rest-client-mcp-auth` except the server step-up gate:
+
+- **Dynamic Client Registration (SEP-837 / RFC 7591 / OIDC).**
`McpDynamicClientRegistrar` POSTs client metadata to an AS
`registration_endpoint` and returns an immutable, secret-redacting
`McpClientRegistration`. `McpApplicationType` is a Juneau-owned `NATIVE`/`WEB`
enum (native is the SEP-837 SHOULD for CLI/desktop/`localhost` clients) so the
public API does not leak the Nimbus `provided` type. Loopback redirects are
built with `LoopbackRedirectUris`: `portAgnostic(path)` is the RFC 82 [...]
+- **Issuer-keyed credential binding (SEP-2352).** Persisted credentials
**MUST** be keyed by the AS `issuer` and never reused across authorization
servers. `McpClientRegistrationStore` is the persistence SPI;
`InMemoryMcpClientRegistrationStore` is the thread-safe, secret-redacting
default. `McpClientRegistrationManager` orchestrates mechanism selection,
issuer-keying, and migration: on-demand (no store), store-hit reuse, or
re-registration when discovery indicates a different issuer — s [...]
+- **Scoped step-up authorization (SEP-2350, both halves).** Server side,
`McpResourceServerConfig.addOperationScope(operation, scopes…)` declares
per-operation required scopes; the RS answers an under-scoped call with `403
insufficient_scope` + a `WWW-Authenticate` `scope=` hint, and the baseline
`401` now also carries the `scope` hint when a baseline required-scope set is
configured. Both the baseline and per-operation gates are **hierarchy-aware**
(a broader granted scope satisfies a n [...]
+
+See the [OAuth 2.1
recipe](/docs/topics/JuneauMcpRecipes#securing-an-mcp-client-and-server-with-oauth-21-v2-only)
(end-to-end client + server), the [DCR + step-up
recipes](/docs/topics/JuneauMcpRecipes#dynamic-client-registration-and-step-up-v2-only),
the [juneau-rest-client-mcp auth
reference](/docs/topics/JuneauRestClientMcp#oauth-21-authorization-juneau-rest-client-mcp-auth),
and the [juneau-rest-server-mcp resource-server
reference](/docs/topics/JuneauRestServerMcp#oauth-21-resource- [...]
+
+### MCP `2025-06-18` — resource-not-found error code corrected to `-32002`
(TODO-332)
+
+`2025-06-18`-only bug fix. A `resources/read` for a URI the server does not
serve now returns the JSON-RPC error code **`-32002`** ("Resource not found") —
the `2025-06-18` spec's dedicated missing-resource code — instead of the
previous generic **`-32601`** ("method not found"). This was one of the four
"not found" kinds that the re-layered `2025-06-18` `McpRevision` initially
collapsed onto `-32601` (see the `juneau-rest-server-mcp-v20250618` module
notes above); resource-not-found is [...]
+
+See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#resource-templates-reads-and-completions)
for the resolution-order and error-code details.
+
### 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 8ecaa6fee4..4942e7f7c6 100644
--- a/pages/topics/11.03.JuneauMcpRecipes.md
+++ b/pages/topics/11.03.JuneauMcpRecipes.md
@@ -14,6 +14,8 @@ Copy-pasteable snippets for common MCP tasks, targeting
revision `2026-07-28` un
- [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)
+- [Securing an MCP client and server with OAuth 2.1 (v2
only)](#securing-an-mcp-client-and-server-with-oauth-21-v2-only)
+- [Dynamic client registration and step-up (v2
only)](#dynamic-client-registration-and-step-up-v2-only)
## Writing a tool
@@ -409,6 +411,292 @@ try (var client =
McpClient.connect("http://localhost:8080/mcp")) {
`McpClient` is `Closeable`, so try-with-resources closes the underlying
transport when the block exits, on either the success or error path. Every
typed call (`callTool`, `getPrompt`, `readResource`, `listTools`,
`listPrompts`, `listResources`, `listResourceTemplates`, `complete`) throws
`McpException` for a JSON-RPC error response and `IOException` for a
transport/(de)serialization failure — there's no single unified exception type,
so a caller that needs both should catch both, as shown.
+## Securing an MCP client and server with OAuth 2.1 (v2 only)
+
+This recipe walks both sides of an OAuth 2.1 / MCP `2026-07-28` deployment as
one story: turn a v2 MCP endpoint into a protected resource server, then
acquire a token on the client and call it. Authorization is **v2-only** and
**off by default** — an endpoint that doesn't opt in behaves exactly as before.
+
+**Dependencies.** On the server, add a token-validator module alongside
`juneau-rest-server-mcp-v20260728`: `juneau-rest-server-auth-jwt` (JWT/JWKS
validation) or `juneau-rest-server-auth-oauth` (RFC 7662 introspection). On the
client, add `juneau-rest-client-mcp-auth` alongside
`juneau-rest-client-mcp-v20260728`:
+
+```xml
+<!-- client -->
+<dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-client-mcp-auth</artifactId>
+ <version>${juneau.version}</version>
+</dependency>
+```
+
+### Server: turn on the resource server
+
+RS auth is configured on the v2 `McpOptions` via its
`resourceServer(Consumer<McpResourceServerConfig>)` block. The minimal working
setup enables it, declares the canonical resource identifier, and supplies a
`TokenValidator`:
+
+```java
+import java.net.URI;
+
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.auth.jwt.JwtTokenValidator;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+import org.apache.juneau.rest.server.servlet.*;
+
+@Rest // root-mounted host: MCP is served at POST /mcp on the origin root
(see the origin-root callout below)
+public class SecureApi extends BasicRestServlet implements McpEndpoint {
+
+ @Override
+ public McpServerConfig getMcpConfig() {
+ return new
McpServerConfig().setName("my-server").setVersion("1.0.0").addTool(new
EchoTool());
+ }
+
+ @Override
+ public McpOptions getMcpOptions() {
+ var validator = JwtTokenValidator.create()
+
.jwksUrl(URI.create("https://login.example.com/realms/api/protocol/openid-connect/certs"))
+ .issuer("https://login.example.com/realms/api")
+ .audience("https://api.example.com/mcp")
+ .build();
+ return new McpOptions().resourceServer(rs -> rs
+ .setEnabled(true)
+ .setResource(URI.create("https://api.example.com/mcp"))
// RFC 9728 resource identity / RFC 8707 audience default
+ .setTokenValidator(validator)
// JWT mode; use OAuthIntrospectionValidator for opaque tokens
+
.addAuthorizationServer(URI.create("https://login.example.com/realms/api"))
+ .addRequiredScope("mcp.read"));
+ }
+}
+```
+
+Once enabled, the endpoint:
+
+- **Serves an RFC 9728 Protected Resource Metadata (PRM) document** —
unauthenticated — from the origin-root well-known location
`/.well-known/oauth-protected-resource[/<resource-path>]`, advertising the
`authorization_servers`, `scopes_supported`, and `bearer_methods_supported` you
configured. (While RS auth is disabled, that route returns `404`.)
+- **Requires a valid RFC 6750 bearer token** on the MCP `POST` endpoint. A
missing token is answered with `401` + `WWW-Authenticate: Bearer realm="mcp",
resource_metadata="…"`; an invalid/expired token or audience mismatch adds
`error="invalid_token"`; a token missing a required scope is `403` +
`error="insufficient_scope", scope="…"`. Every challenge carries the
`resource_metadata` pointer so a client can discover where to get a token.
+- **Enforces RFC 8707 audience matching** against the resource identifier (or
an explicit `setAudience(...)`). By default (`requireAudienceClaim = true`) a
validated principal that exposes no audience claims is rejected — set
`setRequireAudienceClaim(false)` only when the validator itself verifies `aud`
internally (e.g. a `JwtTokenValidator` with `audience(...)`).
+
+:::caution The MCP endpoint must be mounted at the origin root
+RFC 9728 requires the PRM document to be discoverable at the **origin-root**
well-known location
(`https://host/.well-known/oauth-protected-resource[/path]`) — which is exactly
the URL advertised in the `resource_metadata` challenge. Juneau registers the
well-known routes servlet-relative, so a **context path, a non-root servlet
path, or an `@Mixin(path=...)` re-mount prefix** would move the advertised URL
somewhere it can't be reached. Rather than silently advertising an unreachable
doc [...]
+:::
+
+### Client: acquire a token (easy path first)
+
+All token acquisition funnels through the `McpAuthInterceptor` token seam — a
`Supplier<String>` that yields the current bearer token per request.
`McpTokenProvider` implements that supplier and exposes `interceptor()` to
attach it to a client builder.
+
+**1. Pre-provisioned / static token** — the simplest path when you already
hold a token:
+
+```java
+import org.apache.juneau.rest.client.mcp.auth.*;
+import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
+
+var tokens = McpTokenProvider.ofStaticToken("eyJhbGciOi…");
+var client = McpClient.connect(McpClient.builder()
+ .endpoint("https://api.example.com/mcp")
+ .interceptor(tokens.interceptor()));
+```
+
+**2. Client-credentials** (RFC 6749 §4.4) — headless service-to-service;
re-acquired automatically on expiry:
+
+```java
+import java.net.URI;
+
+import org.apache.juneau.rest.client.mcp.auth.*;
+
+var tokens = McpTokenProvider.clientCredentials()
+
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
+ .clientId("my-client")
+ .clientSecret("s3cr3t")
+ .resource(URI.create("https://api.example.com/mcp")) // RFC 8707 —
REQUIRED for the dynamic modes
+ .scope("mcp.read")
+ .build();
+```
+
+**3. Refresh-token** (RFC 6749 §6, SEP-2207) — long-lived, with automatic
rotation capture:
+
+```java
+var tokens = McpTokenProvider.refreshToken(savedRefreshToken)
+
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
+ .clientId("my-client")
+ .resource(URI.create("https://api.example.com/mcp"))
+ .build();
+
+// After each request, persist a rotated refresh token so a restart can resume:
+tokens.currentRefreshToken().ifPresent(this::saveRefreshToken);
+```
+
+A rotated refresh token the IdP returns is captured and used for the next
refresh with no re-consent. A terminal `invalid_grant` **latches** the
provider: every later `get()` throws an `McpAuthException` (re-authorization
required) rather than re-hammering the IdP with a dead token.
+
+**4. Interactive authorization-code + PKCE** (SEP-837 loopback, SEP-2468 `iss`
check) — for a CLI/native app where a human authorizes in a browser:
+
+```java
+import java.net.URI;
+import java.time.Duration;
+
+import org.apache.juneau.rest.client.mcp.auth.*;
+
+var acquirer = McpAuthorizationCodeAcquirer.create()
+
.authorizationEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/auth"))
+
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
+ .clientId("my-native-client")
+ .resource(URI.create("https://api.example.com/mcp")) //
RFC 8707 — REQUIRED
+ .expectedIssuer(URI.create("https://login.example.com/realms/api")) //
SEP-2468 — REQUIRED (or skipIssuerValidation(true))
+ .offlineAccess(true) //
add offline_access → the IdP issues a refresh token
+ .scope("mcp.read")
+ .build(); // default browserLauncher prints the URL; supply
browserLauncher(...) to open a real browser
+
+var token = acquirer.acquire(Duration.ofMinutes(2)); // opens a loopback
listener, waits for the redirect, exchanges the code
+```
+
+The acquirer opens a `LoopbackRedirectReceiver` on an ephemeral `127.0.0.1`
port, hands the authorization URL to the browser launcher, validates the
callback (single-use `state` for CSRF, `iss` against `expectedIssuer`), and
exchanges the code with PKCE `S256`. Because `offlineAccess(true)` requested a
refresh token, hand it to a refresh-mode provider so the client stays
authenticated without re-prompting:
+
+```java
+var tokens = McpTokenProvider.refreshToken(token.refreshToken().orElseThrow())
+
.tokenEndpoint(URI.create("https://login.example.com/realms/api/protocol/openid-connect/token"))
+ .clientId("my-native-client")
+ .resource(URI.create("https://api.example.com/mcp"))
+ .build();
+```
+
+### Let PRM discovery drive authorization-server selection
+
+Instead of hardcoding the token/authorization endpoints, let the server's PRM
document point the way (RFC 9728 → RFC 8414 / OIDC Discovery):
+
+```java
+import java.net.URI;
+
+import org.apache.juneau.rest.client.mcp.auth.*;
+
+var prmClient = McpProtectedResourceMetadataClient.create()
+ .expectedResource(URI.create("https://api.example.com/mcp")) // RFC 9728
§3.3 identity check — rejects a PRM minted for a different resource
+ .build();
+
+// Fetch the PRM from the origin-root well-known location...
+var prm =
prmClient.fetch(URI.create("https://api.example.com/.well-known/oauth-protected-resource/mcp"));
+
+// ...or recover the pointer from a 401 challenge header:
+//
WwwAuthenticateChallenge.parse(wwwAuthenticateHeader).flatMap(WwwAuthenticateChallenge::resourceMetadata)
+
+// Discover + validate the authorization server the PRM delegates to (tries
RFC 8414, falls back to OIDC Discovery):
+var as = prmClient.discoverAuthorizationServer(prm);
+
+var tokens = McpTokenProvider.clientCredentials()
+ .tokenEndpoint(as.tokenEndpoint())
+ .clientId("my-client")
+ .clientSecret("s3cr3t")
+ .resource(prm.resource())
+ .scope("mcp.read")
+ .build();
+```
+
+`discoverAuthorizationServer(...)` selects the first advertised
`authorization_servers` entry, discovers its metadata, and verifies the
discovered `issuer` exactly matches the requested one. By default the PRM URL
and any discovered AS URL must use `https` (loopback hosts are always allowed
for local testing); `allowInsecureHttp(true)` disables that check.
+
+### Putting it together
+
+With a provider wired into the interceptor, an authenticated call is
indistinguishable from an unauthenticated one — the bearer header is attached
transparently on every request:
+
+```java
+import java.util.Map;
+
+import org.apache.juneau.rest.client.mcp.v20260728.McpClient;
+
+try (var client = McpClient.connect(McpClient.builder()
+ .endpoint("https://api.example.com/mcp")
+ .interceptor(tokens.interceptor()))) {
+ var result = client.callTool("echo", Map.of("text", "hello"));
+ System.out.println(result.getContent());
+}
+```
+
+If the token is missing, expired, or under-scoped, the server answers with the
`401`/`403` challenge above and the call surfaces the failure — recover by
re-acquiring (or re-authorizing) and retrying. See the [juneau-rest-client-mcp
auth
reference](/docs/topics/JuneauRestClientMcp#oauth-21-authorization-juneau-rest-client-mcp-auth)
and the [juneau-rest-server-mcp resource-server
reference](/docs/topics/JuneauRestServerMcp#oauth-21-resource-server-mcp-2026-07-28)
for the full API surface.
+
+## Dynamic client registration and step-up (v2 only)
+
+Two authorization-hardening recipes for the `2026-07-28` client: registering a
client on the fly (RFC 7591 / OIDC DCR, SEP-837 / SEP-2352) and re-authorizing
with broader scopes when the server asks (SEP-2350). Both build on the [OAuth
2.1 recipe](#securing-an-mcp-client-and-server-with-oauth-21-v2-only) above.
+
+### Register a client on the fly (DCR)
+
+When you have no pre-provisioned `client_id`, register one against the AS
`registration_endpoint`, then bridge the result straight into the interactive
acquirer. `portAgnostic(...)` is the easy default — the receiver may bind any
port:
+
+```java
+import java.net.URI;
+import java.time.Duration;
+
+import org.apache.juneau.rest.client.mcp.auth.*;
+
+var registration = McpDynamicClientRegistrar.create()
+ .registrationEndpoint(URI.create("https://as.example.com/register"))
+ .issuer(URI.create("https://as.example.com"))
+ .applicationType(McpApplicationType.NATIVE) // SEP-837 SHOULD
for CLI/desktop/loopback clients
+ .addRedirectUris(LoopbackRedirectUris.portAgnostic("/callback"))
+ .scope("mcp.read", "mcp.write")
+ .clientName("my-cli")
+ .register(); // POSTs metadata;
returns a secret-redacting McpClientRegistration
+
+// Bridge the DCR result into the interactive auth-code + PKCE flow
(credential provenance only — resource/iss unchanged):
+var acquirer = McpClientRegistrations.configure(
+ McpAuthorizationCodeAcquirer.create()
+
.authorizationEndpoint(URI.create("https://as.example.com/authorize"))
+ .tokenEndpoint(URI.create("https://as.example.com/token"))
+ .resource(URI.create("https://api.example.com/mcp"))
+ .expectedIssuer(URI.create("https://as.example.com")),
+ registration)
+ .build();
+
+var token = acquirer.acquire(Duration.ofMinutes(2)); // opens the
loopback receiver, launches the browser, exchanges the code
+```
+
+**Strict exact-match AS?** Register a fixed port with `forPort(...)` instead;
`configure(...)` carries that port onto the acquirer so the receiver binds
exactly what the AS was told:
+
+```java
+int port = 49215; // any free loopback port you reserve
+var registration = McpDynamicClientRegistrar.create()
+ .registrationEndpoint(URI.create("https://as.example.com/register"))
+ .issuer(URI.create("https://as.example.com"))
+ .applicationType(McpApplicationType.NATIVE)
+ .addRedirectUris(LoopbackRedirectUris.forPort(port, "/callback"))
+ .register();
+// McpClientRegistrations.configure(acquirerBuilder, registration) now also
wires redirectPort(port).
+```
+
+**Persist across runs (SEP-2352).** Let `McpClientRegistrationManager` key
credentials by issuer and re-register on AS migration, backed by a store:
+
+```java
+var manager = McpClientRegistrationManager.create()
+ .store(new InMemoryMcpClientRegistrationStore()) // or a durable,
at-rest-protected implementation
+ .redirectUris(LoopbackRedirectUris.portAgnostic("/callback"))
+ .scope("mcp.read")
+ .clientName("my-cli")
+ .build();
+
+// 'as' is the discovered OidcMetadata (its extras carry
registration_endpoint) from PRM discovery above.
+var registration = manager.resolve(as); // store-hit reuse,
or a fresh DCR keyed under as.issuer()
+```
+
+After an AS migration you must build a **new** `McpTokenProvider` for the new
issuer — the manager migrates the client-registration half, but never carries a
token across authorization servers.
+
+### Re-authorize with broader scopes on step-up
+
+When the server answers a privileged operation with `403 insufficient_scope`,
wrap the call in an `McpStepUpAuthorizer`: it unions the challenged scopes onto
what you already requested, re-authorizes, and retries under a bounded attempt
cap.
+
+```java
+import java.net.URI;
+
+import org.apache.juneau.rest.client.mcp.auth.*;
+
+var authorizer = McpStepUpAuthorizer.create()
+ .resource(URI.create("https://api.example.com/mcp"))
+ .issuer(URI.create("https://as.example.com"))
+ .previouslyRequestedScopes("mcp.read")
+ .reauthorizer(scopes -> {
+ // Re-acquire a token for the widened scope union and install it on
the client.
+ // For a user-delegation client this is a fresh auth-code + PKCE
round-trip; for M2M, a client_credentials re-acquire.
+ })
+ .build();
+
+var result = authorizer.execute("tools/call:deploy", () -> {
+ // Run the MCP call. On a 403 insufficient_scope, throw
McpInsufficientScopeException(challenge)
+ // (parse the WWW-Authenticate header via
WwwAuthenticateChallenge.parse(...)) so the authorizer can step up and retry.
+ return client.callTool("deploy", args);
+});
+```
+
+A successful `execute(...)` clears that operation's attempt counter, so a
later legitimate step-up starts fresh; call `reset("tools/call:deploy")` (or
`resetAll()`) to recover an operation that hit the cap after you fix the
underlying grant. A `client_credentials` client can be configured with
`attemptStepUp(false)` to abort immediately instead of retrying.
+
## Next steps
- **[juneau-rest-server-mcp reference](/docs/topics/JuneauRestServerMcp)** —
capabilities, pagination, cache hints, tracing, MRTR internals.
diff --git a/pages/topics/11.04.JuneauRestServerMcp.md
b/pages/topics/11.04.JuneauRestServerMcp.md
index 687d00faa6..d435934971 100644
--- a/pages/topics/11.04.JuneauRestServerMcp.md
+++ b/pages/topics/11.04.JuneauRestServerMcp.md
@@ -458,7 +458,7 @@ For each `resources/read`:
1. **Exact resources always win** — `McpResourceHandler` string-equality
lookup runs first, unconditionally, even if a more-literal template would also
match.
2. If no exact resource matches, every reverse-matchable template is
evaluated, and the winner is selected by, in order: more literal URI octets
outside `{...}` (after percent normalization) → fewer declared variables →
earlier registration index. Operator type, capture width, and descriptor
name/title are never tie-breakers.
3. The winning handler receives the **original concrete URI**, an **immutable,
insertion-ordered map of decoded variables**, and the same per-request
`BeanStore` an exact resource handler would get.
-4. No candidate, or the winning handler's `read(...)` returning `null`, is
resource-not-found — `-32601` on `2025-06-18` (preserving that revision's
existing known-wrong error-code mapping) and `RESOURCE_NOT_FOUND` (`-32602`) on
`2026-07-28`. A handler throwing is `-32603` on both.
+4. No candidate, or the winning handler's `read(...)` returning `null`, is
resource-not-found — `-32002` (the `2025-06-18` spec's dedicated
missing-resource code) on `2025-06-18` and `RESOURCE_NOT_FOUND` (`-32602`) on
`2026-07-28`. A handler throwing is `-32603` on both.
On `2026-07-28`, a template-backed read applies the adapter's existing
cache-hint precedence (`resourceReadOverrides` → `resourcesRead` →
`defaultHint`) using the **original concrete request URI** — the same
precedence and lookup key an exact-resource read already uses; C4 adds no new
cache concept.
@@ -541,6 +541,82 @@ This internal `@Bean` publication is **authoritative** and
takes precedence over
`getSubscriptionBroker()` defaults to `null`, meaning "framework-derived": the
binding lazily derives a `BasicMcpSubscriptionBroker` sized from
`subscriptions`' `queueSize` the first time one is needed, and memoizes that
single derived instance for the binding's lifetime. Call
`setSubscriptionBroker(...)` to supply a custom broker instead (for example,
one deliberately shared across multiple bindings).
+## OAuth 2.1 resource server (MCP `2026-07-28`)
+
+**v2-only.** `McpResourceServerConfig`
(`org.apache.juneau.rest.server.mcp.v20260728`) turns a `2026-07-28` MCP
endpoint into an OAuth 2.1 protected resource server. It's one of the nested
configs on [`McpOptions`](#configuring-the-endpoint-mcpoptions-mcp-2026-07-28)
— mutate it in place via the
`resourceServer(Consumer<McpResourceServerConfig>)` configure-block. For a
copy-pasteable end-to-end (server + client) walkthrough, see the [OAuth 2.1
recipe](/docs/topics/JuneauMcpRecipes#securi [...]
+
+**Off by default.** `isEnabled()` is `false` unless `setEnabled(true)`, so an
endpoint that doesn't opt in behaves exactly as before — no bearer requirement,
and the well-known route returns `404`. When enabled, `validateEnabled()`
requires a `resource` identifier (an absolute URI with scheme + authority) and
a `TokenValidator`, or the binding fails.
+
+### Configuration surface
+
+```java
+@Override
+public McpOptions getMcpOptions() {
+ var validator = JwtTokenValidator.create()
+
.jwksUrl(URI.create("https://login.example.com/realms/api/protocol/openid-connect/certs"))
+ .issuer("https://login.example.com/realms/api")
+ .audience("https://api.example.com/mcp")
+ .build();
+ return new McpOptions().resourceServer(rs -> rs
+ .setEnabled(true)
+ .setResource(URI.create("https://api.example.com/mcp"))
+ .setTokenValidator(validator)
+
.addAuthorizationServer(URI.create("https://login.example.com/realms/api"))
+ .addScopeSupported("mcp.read")
+ .addRequiredScope("mcp.read")
+ .setRealm("mcp"));
+}
+```
+
+| Setting | Meaning |
+|---|---|
+| `setEnabled(boolean)` | Master switch. Default `false`. |
+| `setResource(URI)` | The canonical RFC 9728 `resource` identifier and
(unless overridden) the RFC 8707 audience. Required when enabled. |
+| `setTokenValidator(TokenValidator)` | Selects the validation mode. Required
when enabled. `OAuthIntrospectionValidator` (`juneau-rest-server-auth-oauth`,
RFC 7662 introspection of opaque tokens) or `JwtTokenValidator`
(`juneau-rest-server-auth-jwt`, JWKS/issuer JWT verification). |
+| `addAuthorizationServer(URI)` | Advertised `authorization_servers` in the
PRM document. |
+| `addScopeSupported(String)` / `addRequiredScope(String)` |
`scopes_supported` advertisement, and the baseline scopes a token must carry (a
required scope is also advertised as supported). |
+| `addOperationScope(String method, String name, String... scopes)` | SEP-2350
per-operation step-up scopes (see
[below](#per-operation-step-up-scopes-sep-2350)). A blank/`null` `name`
registers a method-wide entry; a specific name wins over it. Each scope is also
advertised as supported. |
+| `setOperationScopeResolver(McpOperationScopeResolver)` | Overrides the
static per-operation map with a dynamic resolver (seam for argument-derived
scopes; not required for 10.0). |
+| `setAudience(String)` | Explicit RFC 8707 audience, overriding the
resource-identifier default. |
+| `setRequireAudienceClaim(boolean)` | Default `true`: a validated principal
exposing no audience claims is rejected (`401 invalid_token`). Set `false` only
when the validator enforces `aud` internally (e.g. a `JwtTokenValidator` with
`audience(...)`, or an introspection validator that legitimately omits `aud`). |
+| `setRealm(String)` | The `WWW-Authenticate: Bearer realm="…"` challenge
realm. Default `mcp`. |
+| `getBearerMethodsSupported()` | Advertised `bearer_methods_supported`.
Defaults to `[header]`. |
+
+### Runtime behavior
+
+- **PRM discovery.** Both bindings register unauthenticated `@RestGet` routes
at `/.well-known/oauth-protected-resource` (SEP-2351 root fallback) and
`/.well-known/oauth-protected-resource/*` (RFC 9728 path-inserted, matched
against this resource's expected path). They serve the RFC 9728 document built
from the config, or `404` when RS auth is disabled.
+- **Bearer gate + challenges.** Bearer extraction and token validation are
delegated to the reusable `OAuthFilter` (RFC 6750) from
`juneau-rest-server-auth-oauth`, memoized once per config. A missing token →
`401` + `WWW-Authenticate: Bearer realm="…", resource_metadata="…"`; an
invalid/expired token or audience mismatch → `401` + `error="invalid_token"`; a
token missing a required scope → `403` + `error="insufficient_scope",
scope="…"`. On success the authenticated principal is stashed [...]
+- **Audience enforcement (RFC 8707).** The validated principal's audience
claims must include the configured audience; see `setRequireAudienceClaim(...)`
above for the claims-less-principal policy.
+
+:::caution Origin-root mount requirement
+RS auth requires the MCP endpoint to be mounted at the **origin root** — no
context path, no non-root servlet path, and no `@Mixin(path=...)` re-mount
prefix. RFC 9728 requires the PRM document at an origin-root well-known
location, which is exactly the URL advertised in the `resource_metadata`
challenge; Juneau registers the well-known routes servlet-relative, so any
non-root mount would advertise a URL that resolves elsewhere and `404`s. Rather
than advertise an unreachable document, R [...]
+:::
+
+**Effectively immutable once published** — same mutable-setup idiom as
`McpMrtrConfig`/`McpCacheConfig`: configure it fully via the
`createMcpOptions()` / `getMcpOptions()` override, then never mutate it
afterward.
+
+### Per-operation step-up scopes (SEP-2350)
+
+Beyond the endpoint-wide baseline (`addRequiredScope`), you can require
*additional* scopes for *specific* operations — e.g. a `delete_repo` tool that
needs `repo.delete` on top of the baseline `mcp.read`:
+
+```java
+return new McpOptions().resourceServer(rs -> rs
+ .setEnabled(true)
+ .setResource(URI.create("https://api.example.com/mcp"))
+ .setTokenValidator(validator)
+ .addRequiredScope("mcp.read") // baseline
for every call
+ .addOperationScope("tools/call", "delete_repo", "repo.delete") // extra
for one tool
+);
+```
+
+Because the required MCP method (`tools/call` vs `resources/read`) — and thus
the operation identity — isn't known until the JSON-RPC body is parsed, this
enforcement runs at a **POST-parse point in the dispatch layer**, not at the
pre-parse bearer gate. A caller whose token satisfies the baseline but not the
operation's scopes gets a scoped step-up challenge:
+
+```http
+HTTP/1.1 403 Forbidden
+WWW-Authenticate: Bearer realm="mcp", error="insufficient_scope",
scope="repo.delete",
resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"
+```
+
+All of the operation's required scopes are emitted in a single `scope=` param
(the SEP-2350 "single challenge" SHOULD). Sufficiency is **hierarchy-aware**
per the server MUST: a broader granted scope implies its narrower children
(granted `repo` satisfies required `repo.delete` / `repo:read`, but granted
`repo.read` does *not* satisfy required `repo`). Operations with no configured
per-operation scope are unaffected — the baseline alone governs them. The
client half of the flow (union th [...]
+
## Cache Hints (MCP `2026-07-28`, SEP-2549)
The `2026-07-28` adapter (`org.apache.juneau.rest.server.mcp.v20260728`) adds
two capabilities on top of the neutral core: SEP-2549 cache hints on five
list/read results, and a `resources/templates/list` endpoint. Both are
configured statically, at server-construction time — there is no
dynamic/per-request cache callback, and neither concept exists on the neutral
`juneau-rest-server-mcp` core or the `2025-06-18` adapter.
diff --git a/pages/topics/11.05.JuneauRestClientMcp.md
b/pages/topics/11.05.JuneauRestClientMcp.md
index eb0ac42497..a6ef98a67c 100644
--- a/pages/topics/11.05.JuneauRestClientMcp.md
+++ b/pages/topics/11.05.JuneauRestClientMcp.md
@@ -63,6 +63,90 @@ See
[juneau-rest-server-mcp](/docs/topics/JuneauRestServerMcp#elicitation-mcp-20
`McpAuthInterceptor` is the client-side auth seam. It injects `Authorization:
Bearer ...` from a supplier callback at request-init time. OAuth/OIDC flows
plug in through the supplier implementation.
+`McpAuthInterceptor` is a `RestCallInterceptor`: on `onInit(...)` it reads the
current token from its `Supplier<String>` and sets `Authorization: Bearer
<token>`. A `null` token suppresses the header (the call proceeds
unauthenticated); a supplier that throws aborts the call before it is sent.
`McpAuthInterceptor.ofStaticBearer(token)` is the fixed-token shortcut. Attach
it via the client builder's `interceptor(...)`:
+
+```java
+var client = McpClient.connect(McpClient.builder()
+ .endpoint("https://api.example.com/mcp")
+ .interceptor(McpAuthInterceptor.ofStaticBearer("eyJ…")));
+```
+
+## OAuth 2.1 authorization (`juneau-rest-client-mcp-auth`)
+
+**v2-only.** The `juneau-rest-client-mcp-auth` module supplies the OAuth 2.1
token-acquisition machinery that feeds the [auth seam](#auth-seam) above. It is
an add-on to the client stack (it depends on the [Nimbus OAuth 2.0
SDK](https://connect2id.com/products/nimbus-oauth-openid-connect-sdk)); nothing
here is on the classpath unless you add it. For a copy-pasteable end-to-end
walkthrough see the [OAuth 2.1
recipe](/docs/topics/JuneauMcpRecipes#securing-an-mcp-client-and-server-with-oaut
[...]
+
+### `McpTokenProvider`
+
+A thread-safe headless `Supplier<String>` that acquires, caches, and refreshes
a bearer token. Three modes:
+
+- **Static** — `McpTokenProvider.ofStaticToken(token)` always returns a fixed,
non-blank token (issues no token request, takes no resource indicator).
+- **Client-credentials** — `McpTokenProvider.clientCredentials()` (RFC 6749
§4.4); re-acquired on expiry.
+- **Refresh-token** — `McpTokenProvider.refreshToken(initialRefreshToken)`
(RFC 6749 §6, SEP-2207); a rotated refresh token returned by the IdP is
captured for the next refresh with no re-consent, and `currentRefreshToken()`
exposes the most-recently-rotated value so callers can persist it across
restarts.
+
+Both dynamic modes are builder-configured (`tokenEndpoint`, `clientId`,
`clientSecret`/`clientSecretSupplier`, `scope`, `expirySkew`, `httpTimeout`,
`httpRequestConfigurator`) and **require `resource(URI)`** — the RFC 8707
resource indicator carried on every token request, binding the token's audience
to the target MCP server (`build()` throws without it). `get()` never returns a
blank token; a terminal `invalid_grant` in refresh mode latches the provider so
every later `get()` throws a [...]
+
+### `McpAuthorizationCodeAcquirer`
+
+Interactive authorization-code + PKCE (`S256`) acquisition for CLI/native use,
built via `McpAuthorizationCodeAcquirer.create()`. `acquire(Duration)` opens a
`LoopbackRedirectReceiver`, generates the `state` and PKCE verifier, hands the
authorization URL to the `browserLauncher` (default: prints the URL), waits for
the loopback redirect, validates it, and exchanges the code for an
`OAuthToken`. Builder essentials:
+
+- `authorizationEndpoint`, `tokenEndpoint`, `clientId` — required;
`clientSecret` optional (omit for a public PKCE-only client).
+- **`resource(URI)`** — required (RFC 8707 audience binding).
+- **`expectedIssuer(URI)`** — required by default (SEP-2468 `iss` / RFC 9207
check, gating both success and error callbacks); opt out with
`skipIssuerValidation(true)`, or tighten with
`requireIssuerResponseParameter(true)` when the AS advertises
`authorization_response_iss_parameter_supported`.
+- `offlineAccess(true)` — adds the `offline_access` scope so the IdP issues a
refresh token (feed it to a refresh-mode `McpTokenProvider`).
+- `browserLauncher(Consumer<URI>)`, `redirectPath`, `scope`, `store`,
`httpTimeout`, `httpRequestConfigurator`.
+
+Static helpers `validateAuthorizationResponse(...)` perform the pure SEP-2468
/ CSRF validation step for callers driving their own user-agent interaction.
+
+### `LoopbackRedirectReceiver`
+
+A minimal `AutoCloseable` loopback (`http://127.0.0.1:<ephemeral>`) HTTP
listener that captures the single authorization-code redirect (SEP-837).
`redirectUri()` is the URI to register with the IdP; `awaitCallback(Duration)`
blocks for the redirect and returns the full callback URI (or throws
`McpAuthException` on timeout). `McpAuthorizationCodeAcquirer` uses it
internally; it's public for callers driving the flow manually.
+
+### `McpProtectedResourceMetadataClient` + `WwwAuthenticateChallenge`
+
+`McpProtectedResourceMetadataClient` fetches and parses an RFC 9728 PRM
document and resolves the authorization server a protected MCP resource
delegates to. `fetch(prmUrl)` / `parse(json, source)` return an
`McpProtectedResourceMetadata` (`resource()`, `authorizationServers()`,
`firstAuthorizationServer()`, `scopesSupported()`, `extras()`). When
`expectedResource(URI)` is set (or passed per-call), the document's `resource`
field is validated against it (RFC 9728 §3.3 exact-match) — defe [...]
+
+`WwwAuthenticateChallenge.parse(headerValue)` parses a `WWW-Authenticate:
Bearer …` challenge (RFC 6750 §3 / RFC 9728 §5.1); `resourceMetadata()`
recovers the PRM pointer, `scopes()` the challenged step-up scopes, `error()`
the error code.
+
+### `OidcDiscoveryClient`
+
+Fetches an authorization server's metadata as an `OidcMetadata` record
(`issuer`, `tokenEndpoint`, `authorizationEndpoint`, `introspectionEndpoint`,
`jwksUri`, `supportedScopes`, `extras`, plus
`authorizationResponseIssParameterSupported()`). `discover()` tries the RFC
8414 OAuth Authorization Server Metadata endpoint
(`/.well-known/oauth-authorization-server`) first and falls back to OIDC
Discovery (`/.well-known/openid-configuration`) — the MCP `2026-07-28` spec
requires both — and bot [...]
+
+:::note Roadmap
+The full SEP-2351 path-insertion well-known-URI construction (path-bearing
issuers, ordered fallbacks) is a deferred enhancement; `OidcDiscoveryClient`
currently implements the two-endpoint (RFC 8414 → OIDC) fallback the baseline
requires. An OIDC ID token returned alongside an access token is surfaced
verbatim on `OAuthToken.idToken()` but is **not validated** in this baseline —
do not trust its claims for auth decisions.
+:::
+
+### Dynamic Client Registration (SEP-837)
+
+When you have no pre-registered `client_id`, `McpDynamicClientRegistrar`
performs RFC 7591 / OIDC Dynamic Client Registration against an authorization
server's `registration_endpoint`, built via
`McpDynamicClientRegistrar.create()`. `register()` POSTs the client metadata
and returns an immutable `McpClientRegistration` (`clientId`, `clientSecret`,
`clientSecretExpiresAt`, `registrationAccessToken`, `registrationClientUri`,
`issuer`, `redirectUris`, `applicationType`, `extras`) whose `toS [...]
+
+- `registrationEndpoint`, `issuer` — required; the endpoint must be `https`
(loopback exempt).
+- `applicationType(McpApplicationType)` — `NATIVE` (the default for
CLI/desktop/loopback clients) or `WEB`; a Juneau-owned enum so the public API
does not leak the Nimbus `provided` type. Per SEP-837 native apps (including
CLI tools and `localhost`-accessed web apps) **SHOULD** register as `native`.
+- `addRedirectUris(...)` / `addRedirectUri(...)` — build loopback redirects
with `LoopbackRedirectUris`: `portAgnostic(path)` →
`[http://127.0.0.1/callback, http://localhost/callback]` (RFC 8252 §7.3
port-agnostic matching) is the **easy, spec-blessed default** — the receiver
may bind any ephemeral port and still match. `forPort(port, path)` is the
**bind-first** strategy for strict exact-match ASes: register a specific fixed
port, then have the acquirer bind that same port via `redirect [...]
+- `scope(...)`, `addGrantType(...)`, `addResponseType(...)`, `clientName`,
`confidential(...)`, `initialAccessToken(...)` (bearer for a protected
registration endpoint, redacted in `toString()`).
+
+`McpClientRegistrations` bridges a registration into the F1 flows:
`McpClientRegistrations.configure(acquirerBuilder, registration)` and
`configure(tokenProviderBuilder, registration)` apply the `clientId`,
`clientSecret`, and registered loopback redirect so a DCR-obtained client
drives `McpAuthorizationCodeAcquirer` / `McpTokenProvider` unchanged — still
carrying the RFC 8707 `resource=` indicator and RFC 9207 `iss` validation (DCR
changes only credential provenance). When the registrat [...]
+
+### Issuer-bound credentials + re-registration (SEP-2352)
+
+Per SEP-2352, credentials that are persisted **MUST** be keyed by the
authorization server's `issuer`, kept separate per AS, and never reused across
authorization servers. `McpClientRegistrationStore` is the persistence SPI
(`find(issuer)` / `put(issuer, registration)` / `remove(issuer)`);
`InMemoryMcpClientRegistrationStore` is the thread-safe default (redacts
secrets in `toString()`). A caller may supply a durable implementation — it is
responsible for at-rest protection of secrets and [...]
+
+`McpClientRegistrationManager` orchestrates mechanism selection,
issuer-keying, and migration. Given a discovered `OidcMetadata` (whose `extras`
carry `registration_endpoint`) and an optional store, `resolve(...)` returns a
usable `McpClientRegistration`:
+
+- **On-demand mode** (no store) registers on every call — fully compliant
(persistence is SHOULD-level).
+- **Store hit** — reuses the issuer-keyed entry without a second DCR
round-trip.
+- **Migration** — when discovery now indicates a *different* issuer than a
stored/pre-registered credential, the manager **MUST NOT** reuse the cross-AS
credential: it re-registers via DCR (and stores under the new issuer), or, for
pre-registered credentials, surfaces an `McpAuthException` rather than silently
using a mismatched credential. A defensive issuer-mismatch check also guards
against a buggy durable store returning an entry keyed under the wrong issuer.
+- An AS advertising neither a `registration_endpoint` nor pre-registered
credentials yields an `McpAuthException`.
+
+The manager migrates only the **registration** (client credentials) half. The
**token** half of SEP-2352 is caller discipline: after an AS migration you must
build a **new** `McpTokenProvider` (and re-run any interactive acquisition) for
the new issuer — a token minted for the old AS is never carried across.
+
+### Scope accumulation on step-up (SEP-2350, client half)
+
+When the resource server answers an operation with `403 insufficient_scope`,
the client re-authorizes with a **broader** grant and retries.
`McpScopeAccumulator.union(previouslyRequested, challengeScopes[, …opt-in
contributors])` computes the mandated-minimum union of `{previously-requested}
∪ {challenge}` (order-preserving, exact-string dedup — scopes are opaque, so no
hierarchy dedup client-side); optional contributors (granted-token scopes, PRM
`scopes_supported`, client-metadata scop [...]
+
+`McpStepUpAuthorizer` is the step-up state machine, built via
`McpStepUpAuthorizer.create()`. `execute(String operation, ScopedCall<T> call)`
runs an MCP call under the given operation identifier; on an
`McpInsufficientScopeException` (raised from a parsed
`WwwAuthenticateChallenge`) it computes the new union, drives a caller-supplied
`Reauthorizer` (a fresh auth-code+PKCE round-trip for user-delegation clients,
or an M2M re-acquire), installs the new token, and retries — bounded by a ha
[...]
+
+**Attempt-counter lifetime.** The per-`(resource, operation)` attempt counter
is cleared automatically on a **successful** `execute(...)`, so a later
legitimate step-up for the same operation starts fresh rather than being
permanently refused by a long-lived authorizer. To recover an operation that
hit the cap (e.g. after fixing the underlying grant), call `reset(operation)`;
`resetAll()` clears every operation's counter for the authorizer's resource.
+
## Response cache seam
`McpResponseCache` is opt-in and off by default. `InMemoryMcpResponseCache` is
the built-in default implementation. The v2 adapter applies protocol cache
hints (`ttlMs`, `cacheScope`) when available.