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

commit 3ff2f9d274517f8d412511ffa492d15f4852a761
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 6 11:15:34 2026 -0700

    OAuth 2.1-secured variant of the MCP example (juneau-examples-mcp)
    
    Adds an org.apache.juneau.examples.mcp.secured package: a fully 
offline-runnable
    OAuth 2.1 demo layered on the existing notes-service example.
    
    - SecuredExampleMcpServer extends ExampleMcpServer, overriding only 
createMcpOptions()
      to enable McpResourceServerConfig: RS gate, RFC 8707 audience 
enforcement, JwtTokenValidator
      (offline JWK source; iss/aud/exp/nbf), a baseline mcp.read required 
scope, and SEP-2350
      per-operation step-up (publishNote/deleteNote require mcp.write).
    - OfflineAuthorizationServer: a clearly-marked DEMO-ONLY stand-in AS 
(self-generated RSA key,
      offline JWK source, RFC 6749 client-credentials /token endpoint with a 
fixed scope allowlist,
      and an RFC 8414 .well-known/oauth-authorization-server metadata endpoint) 
so the whole flow
      runs with zero network.
    - SecuredExampleClient drives the full discovery chain: 401 + 
WWW-Authenticate -> RFC 9728 PRM
      -> RFC 8414 AS metadata -> token acquisition via 
juneau-rest-client-mcp-auth's McpTokenProvider
      -> mcp.read read succeeds -> read-only token 403'd on a write -> step up 
to mcp.read mcp.write.
    - SecuredExampleServer launcher + SecuredExampleMcpEndToEnd_Test 
(missing/garbage/wrong-audience/
      expired/wrong-issuer/insufficient-scope negative cases, PRM contents, and 
valid-token round-trips).
    
    Additive only; the module's distrib packaging is unchanged.
    
    Co-authored-by: Cursor <[email protected]>
---
 juneau-examples/juneau-examples-mcp/README.md      |  61 ++++
 .../juneau-examples-mcp/build-overlay/README.md    |  59 ++++
 .../juneau-examples-mcp/build-overlay/pom.xml      |  41 +++
 juneau-examples/juneau-examples-mcp/pom.xml        |  46 +++
 .../mcp/secured/OfflineAuthorizationServer.java    | 391 +++++++++++++++++++++
 .../examples/mcp/secured/SecuredExampleClient.java | 277 +++++++++++++++
 .../mcp/secured/SecuredExampleMcpServer.java       | 150 ++++++++
 .../examples/mcp/secured/SecuredExampleServer.java | 171 +++++++++
 .../secured/SecuredExampleMcpEndToEnd_Test.java    | 380 ++++++++++++++++++++
 9 files changed, 1576 insertions(+)

diff --git a/juneau-examples/juneau-examples-mcp/README.md 
b/juneau-examples/juneau-examples-mcp/README.md
index 31bfd91cc3..9031239293 100644
--- a/juneau-examples/juneau-examples-mcp/README.md
+++ b/juneau-examples/juneau-examples-mcp/README.md
@@ -46,6 +46,7 @@ A single domain — an in-memory `title → body` note store 
(`NoteStore`) — i
 | 
[`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java)
 | Spring Boot variant using `SpringMcpRestServlet` |
 | 
[`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java)
 | `@SpringBootApplication` launcher |
 | 
[`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java)
 | In-process end-to-end proof |
+| [`secured/`](src/main/java/org/apache/juneau/examples/mcp/secured/) | The 
OAuth 2.1-secured variant — see [below](#4-the-oauth-21-secured-variant) |
 
 ## Run it
 
@@ -111,6 +112,60 @@ mvn -f juneau-examples/juneau-examples-mcp/pom.xml test
 `ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and 
drives the real
 client through every surface, asserting each outcome.
 
+### 4. The OAuth 2.1-secured variant
+
+`org.apache.juneau.examples.mcp.secured` wraps the exact same notes service in 
an OAuth 2.1
+resource-server gate — every JSON-RPC call now requires a valid bearer token, 
and the two
+mutating tools (`publishNote`/`deleteNote`) additionally require a step-up 
`mcp.write` scope on
+top of the baseline `mcp.read` (see below). It is entirely **self-contained and
+offline-runnable**: alongside the secured server, an in-process
+[`OfflineAuthorizationServer.java`](src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java)
+stands in for a real authorization server (AS) — it generates its own RSA 
signing key, publishes
+the public half of it directly to the validator (no JWKS HTTP fetch needed), 
and answers a real
+RFC 6749 §4.4 client-credentials token request, as well as a real RFC 8414 
authorization-server
+metadata discovery request. Nothing here talks to the network or requires any 
setup; see that
+class's javadoc for the full design rationale, including exactly what this 
offline stand-in does
+**not** implement (no real scope-authorization decision beyond a fixed 
allowlist, no refresh
+tokens, no user auth).
+
+> **Client secrets don't belong on a command line.** 
`SecuredExampleServer.main` prints the demo
+> client secret to the console purely so this walkthrough has something to 
copy/paste. A real
+> client should read its secret from an environment variable or a file it 
controls, never accept
+> it as a CLI argument (`argv` is visible to every other process on the host 
via `/proc` or `ps`).
+
+Start the secured server (defaults to port `5001`; pass a port to override). 
It prints a startup
+banner with everything the client needs — copy those three values:
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \
+  -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleServer
+```
+
+In another terminal, paste the three printed values into the secured client 
walkthrough:
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \
+  -Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleClient 
\
+  -Dexec.args="<endpoint> <clientId> <clientSecret>"
+```
+
+The client walks through seven beats: (1) a raw, header-level HTTP call 
showing the exact `401` +
+`WWW-Authenticate: Bearer ...` challenge (including the RFC 9728 
`resource_metadata` pointer) an
+unauthenticated request gets; (2) the same call again, this time through the 
real `McpClient` SDK,
+showing the ergonomic failure mode (a bare `IOException`, since the gate's 
rejection body is
+plain text, not a JSON-RPC envelope `McpClient` can parse into a typed 
result); (3) fetching the
+RFC 9728 Protected Resource Metadata document the challenge pointed at;
+(4) RFC 8414 discovery against the authorization server the PRM document 
named, resolving its
+`token_endpoint` (the token endpoint is never hardcoded or passed on the 
command line); (5)
+acquiring a real `mcp.read`-scoped bearer token and successfully reading a 
resource with it; (6)
+the SAME read-only token attempting `publishNote` — a scoped `403 
insufficient_scope` step-up
+challenge naming `mcp.write`; (7) acquiring a second token carrying both 
`mcp.read` and
+`mcp.write` and successfully publishing a note with it.
+
+[`SecuredExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java)
+boots both servers in-process on ephemeral ports and asserts the same 
rejected/accepted paths
+without needing two terminals — run it the same way as 
`ExampleMcpEndToEnd_Test` (part 3 above).
+
 ## How the wiring works
 
 - **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base). 
`createMcpConfig()` lists
@@ -120,3 +175,9 @@ client through every surface, asserting each outcome.
   a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest` 
servlet at `/`.
 - **Client:** `McpClient` (v2) — `connect()` does the mandatory 
`server/discover` handshake;
   
`callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation`
 do the rest.
+- **Security (part 4):** `SecuredExampleMcpServer extends ExampleMcpServer`, 
overriding only
+  `createMcpOptions()` to add `.resourceServer(rs -> rs.setEnabled(true)...)` 
— a `JwtTokenValidator`
+  (from `juneau-rest-server-auth-jwt`) validates the bearer, and the RFC 9728 
well-known metadata
+  route is served automatically once RS auth is enabled. The client side uses
+  `juneau-rest-client-mcp-auth`'s `McpTokenProvider.clientCredentials()` to 
acquire a token and
+  `McpAuthInterceptor` (via `.interceptor(tokens.interceptor())`) to attach it 
to every request.
diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/README.md 
b/juneau-examples/juneau-examples-mcp/build-overlay/README.md
index f358b783e9..f98b8cd0cb 100644
--- a/juneau-examples/juneau-examples-mcp/build-overlay/README.md
+++ b/juneau-examples/juneau-examples-mcp/build-overlay/README.md
@@ -46,6 +46,7 @@ A single domain — an in-memory `title → body` note store 
(`NoteStore`) — i
 | 
[`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java)
 | Spring Boot variant using `SpringMcpRestServlet` |
 | 
[`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java)
 | `@SpringBootApplication` launcher |
 | 
[`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java)
 | In-process end-to-end proof |
+| [`secured/`](src/main/java/org/apache/juneau/examples/mcp/secured/) | The 
OAuth 2.1-secured variant — see [below](#4-the-oauth-21-secured-variant) |
 
 ## Run it
 
@@ -105,6 +106,58 @@ mvn test
 `ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and 
drives the real
 client through every surface, asserting each outcome.
 
+### 4. The OAuth 2.1-secured variant
+
+`org.apache.juneau.examples.mcp.secured` wraps the exact same notes service in 
an OAuth 2.1
+resource-server gate — every JSON-RPC call now requires a valid bearer token, 
and the two
+mutating tools (`publishNote`/`deleteNote`) additionally require a step-up 
`mcp.write` scope on
+top of the baseline `mcp.read` (see below). It is entirely **self-contained and
+offline-runnable**: alongside the secured server, an in-process
+[`OfflineAuthorizationServer.java`](src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java)
+stands in for a real authorization server (AS) — it generates its own RSA 
signing key, publishes
+the public half of it directly to the validator (no JWKS HTTP fetch needed), 
and answers a real
+RFC 6749 §4.4 client-credentials token request, as well as a real RFC 8414 
authorization-server
+metadata discovery request. Nothing here talks to the network or requires any 
setup; see that
+class's javadoc for the full design rationale, including exactly what this 
offline stand-in does
+**not** implement (no real scope-authorization decision beyond a fixed 
allowlist, no refresh
+tokens, no user auth).
+
+> **Client secrets don't belong on a command line.** 
`SecuredExampleServer.main` prints the demo
+> client secret to the console purely so this walkthrough has something to 
copy/paste. A real
+> client should read its secret from an environment variable or a file it 
controls, never accept
+> it as a CLI argument (`argv` is visible to every other process on the host 
via `/proc` or `ps`).
+
+Start the secured server (defaults to port `5001`; pass a port to override). 
It prints a startup
+banner with everything the client needs — copy those three values:
+
+```bash
+mvn exec:java 
-Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleServer
+```
+
+In another terminal, paste the three printed values into the secured client 
walkthrough:
+
+```bash
+mvn exec:java 
-Dexec.mainClass=org.apache.juneau.examples.mcp.secured.SecuredExampleClient \
+  -Dexec.args="<endpoint> <clientId> <clientSecret>"
+```
+
+The client walks through seven beats: (1) a raw, header-level HTTP call 
showing the exact `401` +
+`WWW-Authenticate: Bearer ...` challenge (including the RFC 9728 
`resource_metadata` pointer) an
+unauthenticated request gets; (2) the same call again, this time through the 
real `McpClient` SDK,
+showing the ergonomic failure mode (a bare `IOException`, since the gate's 
rejection body is
+plain text, not a JSON-RPC envelope `McpClient` can parse into a typed 
result); (3) fetching the
+RFC 9728 Protected Resource Metadata document the challenge pointed at;
+(4) RFC 8414 discovery against the authorization server the PRM document 
named, resolving its
+`token_endpoint` (the token endpoint is never hardcoded or passed on the 
command line); (5)
+acquiring a real `mcp.read`-scoped bearer token and successfully reading a 
resource with it; (6)
+the SAME read-only token attempting `publishNote` — a scoped `403 
insufficient_scope` step-up
+challenge naming `mcp.write`; (7) acquiring a second token carrying both 
`mcp.read` and
+`mcp.write` and successfully publishing a note with it.
+
+[`SecuredExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java)
+boots both servers in-process on ephemeral ports and asserts the same 
rejected/accepted paths
+without needing two terminals — run it the same way as 
`ExampleMcpEndToEnd_Test` (part 3 above).
+
 ## How the wiring works
 
 - **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base). 
`createMcpConfig()` lists
@@ -114,3 +167,9 @@ client through every surface, asserting each outcome.
   a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest` 
servlet at `/`.
 - **Client:** `McpClient` (v2) — `connect()` does the mandatory 
`server/discover` handshake;
   
`callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation`
 do the rest.
+- **Security (part 4):** `SecuredExampleMcpServer extends ExampleMcpServer`, 
overriding only
+  `createMcpOptions()` to add `.resourceServer(rs -> rs.setEnabled(true)...)` 
— a `JwtTokenValidator`
+  (from `juneau-rest-server-auth-jwt`) validates the bearer, and the RFC 9728 
well-known metadata
+  route is served automatically once RS auth is enabled. The client side uses
+  `juneau-rest-client-mcp-auth`'s `McpTokenProvider.clientCredentials()` to 
acquire a token and
+  `McpAuthInterceptor` (via `.interceptor(tokens.interceptor())`) to attach it 
to every request.
diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml 
b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
index 242e19c0d7..00b2f36702 100644
--- a/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
+++ b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
@@ -87,6 +87,47 @@
                        </exclusions>
                </dependency>
 
+               <!--
+                       Backs the OAuth 2.1-secured variant 
(org.apache.juneau.examples.mcp.secured):  JwtTokenValidator,
+                       the JWKS-backed TokenValidator the RS config wires in.  
Declared explicitly because it is marked
+                       <optional> (provided) in 
juneau-rest-server-mcp-v20260728, so it does not transit to consumers.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-auth-jwt</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       juneau-rest-server-auth-jwt's sole runtime dependency, 
deliberately declared <provided> (non-transitive)
+                       upstream so JWT support stays opt-in.  The secured 
example DOES use JWT validation, so it must pull
+                       this in itself (pinned to the same version 
juneau-rest-server-auth-jwt itself uses).
+               -->
+               <dependency>
+                       <groupId>com.nimbusds</groupId>
+                       <artifactId>nimbus-jose-jwt</artifactId>
+                       <version>${nimbus-jose-jwt.version}</version>
+               </dependency>
+
+               <!-- Client-side OAuth 2.1 token acquisition (McpTokenProvider, 
McpProtectedResourceMetadataClient, ...)
+                       backing the secured client demo. -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-client-mcp-auth</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       juneau-rest-client-mcp-auth's sole runtime dependency, 
also <provided> (non-transitive) upstream for
+                       the same opt-in reason as nimbus-jose-jwt above; the 
secured client demo exercises the real
+                       client-credentials flow, so this must be declared 
explicitly too.
+               -->
+               <dependency>
+                       <groupId>com.nimbusds</groupId>
+                       <artifactId>oauth2-oidc-sdk</artifactId>
+                       <version>${nimbus-oauth2-oidc-sdk.version}</version>
+               </dependency>
+
                <!-- Test: JUnit Jupiter, for the in-process end-to-end test. 
-->
                <dependency>
                        <groupId>org.junit.jupiter</groupId>
diff --git a/juneau-examples/juneau-examples-mcp/pom.xml 
b/juneau-examples/juneau-examples-mcp/pom.xml
index ec222c9e24..fdbe2836a9 100644
--- a/juneau-examples/juneau-examples-mcp/pom.xml
+++ b/juneau-examples/juneau-examples-mcp/pom.xml
@@ -34,6 +34,11 @@
                
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
                <!-- Default exec:java target; override with 
-Dexec.mainClass=... for ExampleClient or the Spring launcher. -->
                
<exec.mainClass>org.apache.juneau.examples.mcp.ExampleServer</exec.mainClass>
+               <!-- Pins mirror juneau-rest-server-auth-jwt / 
juneau-rest-client-mcp-auth's own <properties> exactly -
+                       both declare their Nimbus dependency 'provided' 
(non-transitive), so a consumer that actually runs
+                       JWT validation or the OAuth client flows (as the 
secured example does) must re-declare them itself. -->
+               <nimbus-jose-jwt.version>10.3</nimbus-jose-jwt.version>
+               
<nimbus-oauth2-oidc-sdk.version>11.37.2</nimbus-oauth2-oidc-sdk.version>
        </properties>
 
        <dependencies>
@@ -87,6 +92,47 @@
                        </exclusions>
                </dependency>
 
+               <!--
+                       Backs the OAuth 2.1-secured variant 
(org.apache.juneau.examples.mcp.secured):  JwtTokenValidator,
+                       the JWKS-backed TokenValidator the RS config wires in.  
Declared explicitly because it is marked
+                       <optional> (provided) in 
juneau-rest-server-mcp-v20260728, so it does not transit to consumers.
+               -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-server-auth-jwt</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       juneau-rest-server-auth-jwt's sole runtime dependency, 
deliberately declared <provided> (non-transitive)
+                       upstream so JWT support stays opt-in.  The secured 
example DOES use JWT validation, so it must pull
+                       this in itself (pinned to the same version 
juneau-rest-server-auth-jwt itself uses).
+               -->
+               <dependency>
+                       <groupId>com.nimbusds</groupId>
+                       <artifactId>nimbus-jose-jwt</artifactId>
+                       <version>${nimbus-jose-jwt.version}</version>
+               </dependency>
+
+               <!-- Client-side OAuth 2.1 token acquisition (McpTokenProvider, 
McpProtectedResourceMetadataClient, ...)
+                       backing the secured client demo. -->
+               <dependency>
+                       <groupId>org.apache.juneau</groupId>
+                       <artifactId>juneau-rest-client-mcp-auth</artifactId>
+                       <version>${project.version}</version>
+               </dependency>
+
+               <!--
+                       juneau-rest-client-mcp-auth's sole runtime dependency, 
also <provided> (non-transitive) upstream for
+                       the same opt-in reason as nimbus-jose-jwt above; the 
secured client demo exercises the real
+                       client-credentials flow, so this must be declared 
explicitly too.
+               -->
+               <dependency>
+                       <groupId>com.nimbusds</groupId>
+                       <artifactId>oauth2-oidc-sdk</artifactId>
+                       <version>${nimbus-oauth2-oidc-sdk.version}</version>
+               </dependency>
+
                <!-- Test: JUnit Jupiter, for the in-process end-to-end test. 
-->
                <dependency>
                        <groupId>org.junit.jupiter</groupId>
diff --git 
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java
 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java
new file mode 100644
index 0000000000..644927e28b
--- /dev/null
+++ 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/OfflineAuthorizationServer.java
@@ -0,0 +1,391 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.examples.mcp.secured;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.security.*;
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.marshall.marshaller.Json;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jose.jwk.*;
+import com.nimbusds.jose.jwk.gen.*;
+import com.nimbusds.jose.jwk.source.*;
+import com.nimbusds.jose.proc.*;
+import com.nimbusds.jwt.*;
+import com.sun.net.httpserver.*;
+
+/**
+ * A minimal, self-contained, in-process stand-in for a real OAuth 2.1 
authorization server (AS), so the
+ * {@code secured} MCP example (and its end-to-end test) run entirely offline 
&mdash; no external IdP, no
+ * network access, no pre-shared secrets checked into source control.
+ *
+ * <p><b>DEMO ONLY &mdash; NOT SUITABLE FOR PRODUCTION USE.</b></p>
+ *
+ * <p>
+ * This is deliberately NOT a general-purpose or spec-complete authorization 
server. It exists purely to make
+ * {@link SecuredExampleMcpServer}'s {@code JwtTokenValidator}-backed 
resource-server gate and
+ * {@link SecuredExampleClient}'s {@code McpTokenProvider}-backed 
client-credentials flow both have something
+ * real to talk to. Concretely, on {@link #start()} it:
+ *
+ * <ol>
+ *     <li>Generates a fresh RSA-2048 signing key (a new key every run &mdash; 
nothing is persisted, so restarting
+ *             the example invalidates any previously-issued token, which is 
exactly the offline-demo property we want).
+ *     <li>Publishes only the <b>public</b> half of that key as a {@link 
JWKSource}, which
+ *             {@link SecuredExampleMcpServer} feeds directly to {@code 
JwtTokenValidator.jwkSource(...)} &mdash; no JWKS
+ *             HTTP endpoint is needed because the server and "AS" share this 
JVM.
+ *     <li>Starts a tiny {@link HttpServer} exposing two routes:
+ *             <ul>
+ *                     <li>{@code POST /token} &mdash; the RFC 6749 &sect;4.4 
client-credentials grant against one fixed,
+ *                             randomly-generated demo client id/secret pair 
(see {@link #clientId()} / {@link #clientSecret()}).
+ *                             This is a REAL HTTP round trip: the client-side 
{@code OAuthClientCredentialsFlow} used by
+ *                             {@code McpTokenProvider} talks to it exactly as 
it would talk to a production IdP's token
+ *                             endpoint, just on {@code localhost}.
+ *                     <li>{@code GET /.well-known/oauth-authorization-server} 
&mdash; a minimal RFC 8414 Authorization
+ *                             Server Metadata document ({@code issuer} + 
{@code token_endpoint} only), so a real client can
+ *                             perform genuine discovery against {@link 
#issuerUri()} instead of being handed the token
+ *                             endpoint out of band. See {@link 
SecuredExampleClient} for the client half of that handshake.
+ *             </ul>
+ * </ol>
+ *
+ * <p>
+ * <b>What this class intentionally does NOT implement</b> (called out here 
and in the README so nobody mistakes
+ * this demo for a template for a real deployment):
+ * <ul>
+ *     <li>No real scope-authorization decision &mdash; {@link #handleToken} 
only checks the requested scope
+ *             against a fixed allowlist ({@code mcp.read}/{@code mcp.write}); 
it never asks a resource owner or policy
+ *             engine whether the client is ALLOWED that scope, which is what 
a real AS's consent/policy step does.
+ *     <li>No refresh tokens, no consent screen, no user authentication of any 
kind &mdash; only the
+ *             machine-to-machine client-credentials grant, which is all an 
MCP server-to-server OAuth demo needs.
+ *     <li>No persistence, replay protection beyond the JWT {@code exp}/{@code 
nbf} window, or key rotation. The
+ *             RFC 8414 document above is likewise minimal: no {@code 
jwks_uri}, no {@code scopes_supported}, no
+ *             dynamic client registration &mdash; this AS's public key is 
handed directly to the validator in-process
+ *             (see {@link #jwkSource()}) rather than published for a real 
JWKS fetch.
+ * </ul>
+ *
+ * <p>
+ * <b>TLS caveat:</b> this class and {@link SecuredExampleServer} talk plain 
{@code http} only because both
+ * ends are loopback-only ({@code 127.0.0.1}/{@code localhost}). OAuth 2.1 / 
RFC 9728 require {@code https} for
+ * any non-loopback resource identifier or token endpoint; a bearer token sent 
over plaintext to a non-loopback
+ * host is exposed to anyone on the network path.
+ *
+ * <p>
+ * A real deployment would point {@code JwtTokenValidator} at an actual IdP's 
JWKS URL and issuer, and would
+ * never generate or hand out client secrets like {@link #start()} does here.
+ *
+ * @since 10.0.0
+ */
+public final class OfflineAuthorizationServer implements AutoCloseable {
+
+       /** The scope minted onto a token when the client's request omits one. 
*/
+       public static final String DEFAULT_SCOPE = "mcp.read";
+
+       /**
+        * The fixed allowlist of scopes this offline AS will grant (H2): a 
client requesting anything outside this
+        * set is rejected with RFC 6749 &sect;5.2 {@code invalid_scope}, 
rather than being handed back whatever it
+        * asked for.
+        */
+       public static final Set<String> GRANTABLE_SCOPES = Set.of("mcp.read", 
"mcp.write");
+
+       /** How long a minted access token is valid for. */
+       private static final Duration TOKEN_TTL = Duration.ofMinutes(5);
+
+       /** RFC 8414 Authorization Server Metadata well-known path. */
+       private static final String WELL_KNOWN_AUTHORIZATION_SERVER = 
"/.well-known/oauth-authorization-server";
+
+       private final RSAKey signingKey;
+       private final String clientId;
+       private final String clientSecret;
+       private final HttpServer httpServer;
+       private final URI issuer;
+       private final AtomicInteger tokenRequestCount = new AtomicInteger();
+
+       private OfflineAuthorizationServer(RSAKey signingKey, String clientId, 
String clientSecret, HttpServer httpServer, URI issuer) {
+               this.signingKey = signingKey;
+               this.clientId = clientId;
+               this.clientSecret = clientSecret;
+               this.httpServer = httpServer;
+               this.issuer = issuer;
+       }
+
+       /**
+        * Generates a fresh signing key and demo client credentials, and 
starts the {@code /token} and RFC 8414
+        * discovery endpoints on an OS-assigned loopback-only port.
+        *
+        * @return A running instance. Close it (or call {@link #close()}) to 
stop the HTTP endpoint.
+        * @throws Exception If key generation or the HTTP endpoint fails to 
start.
+        */
+       public static OfflineAuthorizationServer start() throws Exception {
+               var signingKey = new 
RSAKeyGenerator(2048).keyID("demo-key-1").algorithm(JWSAlgorithm.RS256).generate();
+               var clientId = "demo-client";
+               var clientSecret = randomSecret();
+
+               var httpServer = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+               // H4/M10: the issuer is THIS instance's own loopback base URL, 
known as soon as the (possibly
+               // OS-assigned) listen socket is bound - a constructor-supplied 
instance field, not a shared constant,
+               // so two independently-started instances naturally have two 
different issuers (see
+               // SecuredExampleMcpEndToEnd_Test's wrong-issuer test).
+               var issuer = URI.create("http://127.0.0.1:"; + 
httpServer.getAddress().getPort());
+               var self = new OfflineAuthorizationServer(signingKey, clientId, 
clientSecret, httpServer, issuer);
+               httpServer.createContext("/token", self::handleToken);
+               httpServer.createContext(WELL_KNOWN_AUTHORIZATION_SERVER, 
self::handleAuthorizationServerMetadata);
+               httpServer.start();
+               return self;
+       }
+
+       /** Generates a random, never-checked-in demo client secret (32 bytes, 
URL-safe base64). */
+       private static String randomSecret() {
+               var bytes = new byte[32];
+               new SecureRandom().nextBytes(bytes);
+               return 
Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
+       }
+
+       /**
+        * Returns a {@link JWKSource} publishing only the public half of this 
server's signing key, ready to hand
+        * straight to {@code JwtTokenValidator.jwkSource(...)}.
+        *
+        * @return The public-key JWK source. Never <jk>null</jk>.
+        */
+       public JWKSource<SecurityContext> jwkSource() {
+               return new ImmutableJWKSet<>(new 
JWKSet(signingKey.toPublicJWK()));
+       }
+
+       /**
+        * Returns this authorization server's issuer identity (its own 
loopback base URL) &mdash; used as the JWT
+        * {@code iss} claim, the PRM document's {@code authorization_servers} 
entry, and the RFC 8414 discovery
+        * document's {@code issuer} field.
+        *
+        * @return The issuer URI. Never <jk>null</jk>.
+        */
+       public URI issuerUri() {
+               return issuer;
+       }
+
+       /**
+        * Returns the loopback URL of the {@code /token} client-credentials 
endpoint.
+        *
+        * @return The token endpoint URL. Never <jk>null</jk>.
+        */
+       public URI tokenEndpoint() {
+               return URI.create(issuer + "/token");
+       }
+
+       /**
+        * Returns the fixed demo client id a caller must present (via HTTP 
Basic) to acquire a token.
+        *
+        * @return The client id. Never <jk>null</jk>.
+        */
+       public String clientId() {
+               return clientId;
+       }
+
+       /**
+        * Returns the randomly-generated demo client secret a caller must 
present (via HTTP Basic) to acquire a
+        * token. Generated fresh on every {@link #start()}; never persisted 
anywhere &mdash; {@link SecuredExampleServer#main}
+        * prints it to the console purely so a reader running the demo by hand 
has something to copy/paste.
+        *
+        * @return The client secret. Never <jk>null</jk>.
+        */
+       public String clientSecret() {
+               return clientSecret;
+       }
+
+       /**
+        * Returns how many HTTP requests have hit the {@code /token} endpoint 
so far (M7): a test seam proving a
+        * caching {@code McpTokenProvider} genuinely reuses an acquired token 
across multiple dispatches instead of
+        * silently re-requesting one per call.
+        *
+        * @return The number of {@code /token} requests handled since {@link 
#start()}.
+        */
+       public int tokenRequestCount() {
+               return tokenRequestCount.get();
+       }
+
+       @Override
+       public void close() {
+               httpServer.stop(0);
+       }
+
+       /**
+        * Implements RFC 6749 &sect;4.4 (client-credentials grant) against the 
fixed demo client id/secret,
+        * minting a signed JWT whose {@code aud} claim is exactly the RFC 8707 
{@code resource} indicator the
+        * caller supplied &mdash; this is what lets {@code JwtTokenValidator} 
enforce the audience check on the
+        * receiving end.
+        */
+       private void handleToken(HttpExchange exchange) throws IOException {
+               // DEMO ONLY: see class javadoc - not a production AS.
+               tokenRequestCount.incrementAndGet();
+               try {
+                       if (! "POST".equals(exchange.getRequestMethod())) {
+                               exchange.getResponseHeaders().add("Allow", 
"POST");
+                               sendJson(exchange, 405, 
error("invalid_request", "must POST to /token"));
+                               return;
+                       }
+                       if (! authenticateClient(exchange)) {
+                               
exchange.getResponseHeaders().add("WWW-Authenticate", "Basic 
realm=\"offline-authorization-server\"");
+                               sendJson(exchange, 401, error("invalid_client", 
"unknown client id or secret"));
+                               return;
+                       }
+                       var form = parseForm(exchange.getRequestBody());
+                       if (! 
"client_credentials".equals(form.get("grant_type"))) {
+                               sendJson(exchange, 400, 
error("unsupported_grant_type", "only client_credentials is supported"));
+                               return;
+                       }
+                       var resource = form.get("resource");
+                       if (resource == null || resource.isBlank()) {
+                               sendJson(exchange, 400, error("invalid_target", 
"a resource indicator (RFC 8707) is required"));
+                               return;
+                       }
+                       var scope = form.getOrDefault("scope", DEFAULT_SCOPE);
+                       if (! isGrantable(scope)) {
+                               sendJson(exchange, 400, error("invalid_scope", 
"requested scope must be a subset of " + GRANTABLE_SCOPES));
+                               return;
+                       }
+                       var token = mintAccessToken(resource, scope, 
Instant.now(), TOKEN_TTL);
+                       exchange.getResponseHeaders().add("Cache-Control", 
"no-store");
+                       exchange.getResponseHeaders().add("Pragma", "no-cache");
+                       sendJson(exchange, 200, Json.of(Map.<String,Object>of(
+                               "access_token", token,
+                               "token_type", "Bearer",
+                               "expires_in", TOKEN_TTL.toSeconds(),
+                               "scope", scope)));
+               } catch (RuntimeException | JOSEException e) {
+                       sendJson(exchange, 500, error("server_error", 
e.getMessage() == null ? "internal error" : e.getMessage()));
+               }
+       }
+
+       /** Serves a minimal RFC 8414 Authorization Server Metadata document 
(M10): {@code issuer} + {@code token_endpoint} only. */
+       private void handleAuthorizationServerMetadata(HttpExchange exchange) 
throws IOException {
+               if (! "GET".equals(exchange.getRequestMethod())) {
+                       exchange.getResponseHeaders().add("Allow", "GET");
+                       exchange.sendResponseHeaders(405, -1);
+                       return;
+               }
+               sendJson(exchange, 200, Json.of(Map.of(
+                       "issuer", issuer.toString(),
+                       "token_endpoint", tokenEndpoint().toString())));
+       }
+
+       /** Returns whether every space-delimited token in {@code scope} is in 
the {@link #GRANTABLE_SCOPES} allowlist (H2). */
+       private static boolean isGrantable(String scope) {
+               for (var s : scope.split("\\s+"))
+                       if (! s.isBlank() && ! GRANTABLE_SCOPES.contains(s))
+                               return false;
+               return true;
+       }
+
+       /** Validates the RFC 6749 HTTP Basic client-authentication header 
against the fixed demo credentials. */
+       private boolean authenticateClient(HttpExchange exchange) {
+               // DEMO ONLY: see class javadoc - not a production AS.
+               var header = 
exchange.getRequestHeaders().getFirst("Authorization");
+               if (header == null || ! header.startsWith("Basic "))
+                       return false;
+               byte[] decodedBytes;
+               try {
+                       decodedBytes = 
Base64.getDecoder().decode(header.substring("Basic ".length()));
+               } catch (IllegalArgumentException e) { // L1: malformed base64 
-> a clean 401, not a 500
+                       return false;
+               }
+               var decoded = new String(decodedBytes, StandardCharsets.UTF_8);
+               var sep = decoded.indexOf(':');
+               if (sep < 0)
+                       return false;
+               var presentedId = URLDecoder.decode(decoded.substring(0, sep), 
StandardCharsets.UTF_8);
+               var presentedSecret = URLDecoder.decode(decoded.substring(sep + 
1), StandardCharsets.UTF_8);
+               // M6: constant-time comparison (both operands, combined with 
'&' rather than '&&') so neither the
+               // id nor the secret check can leak timing information about 
how many leading bytes matched.
+               var idMatches = 
MessageDigest.isEqual(clientId.getBytes(StandardCharsets.UTF_8), 
presentedId.getBytes(StandardCharsets.UTF_8));
+               var secretMatches = 
MessageDigest.isEqual(clientSecret.getBytes(StandardCharsets.UTF_8), 
presentedSecret.getBytes(StandardCharsets.UTF_8));
+               return idMatches & secretMatches;
+       }
+
+       /**
+        * Signs a fresh RS256 access token carrying the mandatory claims 
{@code JwtTokenValidator} requires.
+        *
+        * <p>
+        * Package-private test seam (H4): exposing {@code issuedAt}/{@code 
ttl} lets
+        * {@link SecuredExampleMcpEndToEnd_Test} mint an already-expired token 
directly, without needing a real
+        * clock to actually elapse.
+        *
+        * @param audience The RFC 8707 {@code resource} indicator to mint the 
token's {@code aud} claim for.
+        * @param scope The (space-delimited) {@code scope} claim.
+        * @param issuedAt The token's {@code iat}/{@code nbf} instant.
+        * @param ttl How long after {@code issuedAt} the token expires.
+        * @return The signed, serialized JWT.
+        * @throws JOSEException If signing fails.
+        */
+       String mintAccessToken(String audience, String scope, Instant issuedAt, 
Duration ttl) throws JOSEException {
+               var claims = new JWTClaimsSet.Builder()
+                       // M4: a client-credentials grant has no resource owner 
- the subject IS the client, not a
+                       // placeholder end user.
+                       .subject(clientId)
+                       .issuer(issuer.toString())
+                       .audience(audience)
+                       .claim("scope", scope)
+                       .issueTime(Date.from(issuedAt))
+                       .notBeforeTime(Date.from(issuedAt))
+                       .expirationTime(Date.from(issuedAt.plus(ttl)))
+                       .build();
+               var header = new 
JWSHeader.Builder(JWSAlgorithm.RS256).keyID(signingKey.getKeyID()).build();
+               var jwt = new SignedJWT(header, claims);
+               jwt.sign(new RSASSASigner(signingKey));
+               return jwt.serialize();
+       }
+
+       /** Parses an {@code application/x-www-form-urlencoded} request body 
into a key/value map. */
+       private static Map<String,String> parseForm(InputStream body) throws 
IOException {
+               var raw = new String(body.readAllBytes(), 
StandardCharsets.UTF_8);
+               var out = new LinkedHashMap<String,String>();
+               for (var pair : raw.split("&")) {
+                       if (pair.isEmpty())
+                               continue;
+                       var eq = pair.indexOf('=');
+                       var key = eq < 0 ? pair : pair.substring(0, eq);
+                       var value = eq < 0 ? "" : pair.substring(eq + 1);
+                       out.put(URLDecoder.decode(key, StandardCharsets.UTF_8), 
URLDecoder.decode(value, StandardCharsets.UTF_8));
+               }
+               return out;
+       }
+
+       /**
+        * Builds an RFC 6749 &sect;5.2 JSON error body.
+        *
+        * <p>
+        * M2/M3: built as a {@link Map} and serialized via {@link 
Json#of(Object)} (the same marshaller
+        * {@link SecuredExampleClient} already uses) instead of 
hand-concatenating strings, so a client-controlled
+        * {@code error_description} containing a quote or backslash can no 
longer reshape the JSON structure or
+        * produce invalid JSON.
+        */
+       private static String error(String code, String description) {
+               return Json.of(Map.of("error", code, "error_description", 
description));
+       }
+
+       private static void sendJson(HttpExchange exchange, int status, String 
json) throws IOException {
+               var bytes = json.getBytes(StandardCharsets.UTF_8);
+               exchange.getResponseHeaders().add("Content-Type", 
"application/json");
+               exchange.sendResponseHeaders(status, bytes.length);
+               try (var os = exchange.getResponseBody()) {
+                       os.write(bytes);
+               }
+       }
+}
diff --git 
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java
 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java
new file mode 100644
index 0000000000..ad94d50023
--- /dev/null
+++ 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleClient.java
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.examples.mcp.secured;
+
+import java.io.*;
+import java.net.*;
+import java.net.http.*;
+import java.util.*;
+
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.examples.mcp.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.client.mcp.auth.*;
+import org.apache.juneau.rest.client.mcp.v20260728.*;
+
+/**
+ * A guided walkthrough of calling {@link SecuredExampleMcpServer}, showing 
the secured request/response
+ * cycle a newcomer to MCP OAuth 2.1 needs to see: a rejected unauthenticated 
call (twice &mdash; once at the
+ * raw wire level, once through the real {@link McpClient} SDK), the full RFC 
9728 &rarr; RFC 8414 discovery
+ * chain, a successful read-only call, a scoped step-up rejection, and finally 
a successful write once a token
+ * carrying the write scope has been acquired.
+ *
+ * <p>
+ * Run {@link #main(String[]) main} with three arguments &mdash; the secured 
server's endpoint and the demo
+ * client id/secret &mdash; all three of which {@link 
SecuredExampleServer#main(String[])} prints on startup
+ * (they cannot be hardcoded here: the demo client secret is freshly randomly 
generated on every server run, by
+ * design; see {@link OfflineAuthorizationServer}). Unlike an earlier version 
of this walkthrough, the token
+ * endpoint is no longer a fourth argument (M10): this client discovers it 
itself, the same way a real client
+ * would.
+ *
+ * @serial exclude
+ */
+public final class SecuredExampleClient {
+
+       private SecuredExampleClient() {}
+
+       /**
+        * Runs the walkthrough against a running {@link SecuredExampleServer}.
+        *
+        * @param args Three required arguments: endpoint, client id, client 
secret &mdash; copy these from
+        *      {@link SecuredExampleServer#main(String[])}'s startup banner.
+        * @throws Exception If any step fails unexpectedly (a REJECTED call is 
expected and handled, not thrown).
+        */
+       public static void main(String[] args) throws Exception {
+               if (args.length < 3) {
+                       System.out.println("Usage: SecuredExampleClient 
<endpoint> <clientId> <clientSecret>");
+                       System.out.println("Copy these three values from the 
SecuredExampleServer startup banner.");
+                       return;
+               }
+               run(args[0], args[1], args[2]);
+       }
+
+       /**
+        * Executes each numbered step of the walkthrough.
+        *
+        * @param endpoint The secured MCP server's endpoint URL.
+        * @param clientId The demo OAuth client id.
+        * @param clientSecret The demo OAuth client secret.
+        * @throws Exception If an unexpected (non-auth-related) failure occurs.
+        */
+       public static void run(String endpoint, String clientId, String 
clientSecret) throws Exception {
+
+               section("1. Unauthenticated call, at the raw wire level — a 401 
challenge");
+               var challenge = rawUnauthenticatedCall(endpoint);
+
+               section("2. The same unauthenticated call through the real 
McpClient SDK");
+               mcpClientUnauthenticatedCall(endpoint);
+
+               section("3. RFC 9728 discovery — fetch the Protected Resource 
Metadata the 401 pointed at");
+               McpProtectedResourceMetadata prm = null;
+               if (challenge != null && 
challenge.resourceMetadata().isPresent())
+                       prm = discoverProtectedResourceMetadata(endpoint, 
challenge.resourceMetadata().get());
+               else
+                       System.out.println("   (no resource_metadata pointer 
found on the challenge; skipping)");
+
+               section("4. RFC 8414 discovery — resolve the authorization 
server's token endpoint");
+               var tokenEndpoint = discoverTokenEndpoint(prm);
+
+               section("5. Acquire a read-only (mcp.read) bearer token and 
call again — success");
+               readOnlyCall(endpoint, tokenEndpoint, clientId, clientSecret);
+
+               section("6. The SAME read-only token attempting a write — a 
scoped 403 step-up challenge");
+               insufficientScopeCall(endpoint, tokenEndpoint, clientId, 
clientSecret);
+
+               section("7. Step up to mcp.read + mcp.write and publish a note 
— success");
+               writeCall(endpoint, tokenEndpoint, clientId, clientSecret);
+
+               System.out.println("\nWalkthrough complete.");
+       }
+
+       /**
+        * Step 1: a raw HTTP POST with no {@code Authorization} header at all, 
bypassing the {@link McpClient} SDK
+        * entirely so the actual {@code 401} status and {@code 
WWW-Authenticate} response header are directly
+        * visible. This is necessary because the resource-server gate's {@code 
401}/{@code 403} rejections are
+        * plain-text bodies, not JSON-RPC envelopes — {@code 
AbstractMcpClient.send(...)} cannot parse one into a
+        * typed result and instead surfaces the failure as a bare {@link 
IOException} whose message embeds the
+        * status code as text. There is no way to read the status code, let 
alone the challenge header, through
+        * the SDK itself; only at the wire level.
+        */
+       private static WwwAuthenticateChallenge rawUnauthenticatedCall(String 
endpoint) throws IOException, InterruptedException {
+               var request = HttpRequest.newBuilder(URI.create(endpoint))
+                       .header("Content-Type", "application/json")
+                       .header("Accept", "application/json")
+                       .header("Mcp-Method", "server/discover")
+                       .header("Mcp-Name", "")
+                       
.POST(HttpRequest.BodyPublishers.ofString(discoverRequestBody()))
+                       .build();
+               var response = HttpClient.newHttpClient().send(request, 
HttpResponse.BodyHandlers.ofString());
+               System.out.println("   HTTP status: " + response.statusCode());
+               var header = 
response.headers().firstValue("WWW-Authenticate").orElse(null);
+               System.out.println("   WWW-Authenticate: " + header);
+               var challenge = 
WwwAuthenticateChallenge.parse(header).orElse(null);
+               if (challenge != null) {
+                       System.out.println("   parsed scope hint:       " + 
challenge.scopes());
+                       System.out.println("   parsed resource_metadata: " + 
challenge.resourceMetadata().orElse(null));
+               }
+               return challenge;
+       }
+
+       /**
+        * Step 2: the same unauthenticated call, but through {@link 
McpClient#connect(String)} — the ergonomic
+        * failure mode a real caller actually sees: a thrown {@link 
IOException} (see {@link #rawUnauthenticatedCall}
+        * above for why it is a bare {@link IOException} and not a typed 
exception) out of the mandatory
+        * {@code server/discover} handshake.
+        */
+       private static void mcpClientUnauthenticatedCall(String endpoint) {
+               try (var ignored = McpClient.connect(endpoint)) {
+                       throw new IllegalStateException("expected the 
unauthenticated connect() to fail, but it succeeded");
+               } catch (IOException e) {
+                       System.out.println("   rejected as expected: " + 
e.getMessage());
+               }
+       }
+
+       /**
+        * Step 3: fetches the RFC 9728 Protected Resource Metadata document 
the challenge pointed at, showing what
+        * a client learns from it — notably WHICH authorization server to 
acquire a token from, discovered from
+        * the response rather than hardcoded into this client.
+        */
+       private static McpProtectedResourceMetadata 
discoverProtectedResourceMetadata(String endpoint, URI resourceMetadataUrl) {
+               var prm = McpProtectedResourceMetadataClient.create()
+                       .expectedResource(URI.create(endpoint))
+                       .build()
+                       .fetch(resourceMetadataUrl);
+               System.out.println("   resource:               " + 
prm.resource());
+               System.out.println("   authorization_servers:  " + 
prm.authorizationServers());
+               System.out.println("   scopes_supported:       " + 
prm.scopesSupported());
+               return prm;
+       }
+
+       /**
+        * Step 4 (M10): performs real RFC 8414 discovery against the 
authorization server the PRM document
+        * advertised, via {@link 
McpProtectedResourceMetadataClient#discoverAuthorizationServer}, and returns the
+        * discovered {@code token_endpoint} — nothing here is hardcoded or 
passed in on the command line. This
+        * completes the full 401 &rarr; {@code resource_metadata} &rarr; PRM 
&rarr; {@code authorization_servers}
+        * &rarr; RFC 8414 &rarr; {@code token_endpoint} chain a compliant 
client walks end-to-end.
+        */
+       private static URI discoverTokenEndpoint(McpProtectedResourceMetadata 
prm) {
+               if (prm == null)
+                       throw new IllegalStateException("no Protected Resource 
Metadata was discovered in step 3");
+               var as = 
McpProtectedResourceMetadataClient.create().build().discoverAuthorizationServer(prm);
+               System.out.println("   issuer:         " + as.issuer());
+               System.out.println("   token_endpoint: " + as.tokenEndpoint());
+               return as.tokenEndpoint();
+       }
+
+       /**
+        * Step 5: acquires a real bearer token scoped to {@code mcp.read} only 
via
+        * {@link McpTokenProvider#clientCredentials()} (a genuine RFC 6749 
&sect;4.4 HTTP round trip to the
+        * discovered token endpoint), wires it into a fresh {@link McpClient} 
via
+        * {@link McpTokenProvider#interceptor()}, and shows a read succeeding 
transparently.
+        */
+       private static void readOnlyCall(String endpoint, URI tokenEndpoint, 
String clientId, String clientSecret) throws IOException {
+               var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, 
clientId, clientSecret);
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(endpoint)
+                               .clientInfo(new 
Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0"))
+                               .interceptor(tokens.interceptor()))) {
+                       System.out.println("   server/discover succeeded: " + 
Json.of(client.discoveredServer().getServerInfo()));
+                       var read = client.readResource(NoteStore.SCHEME + 
"index");
+                       System.out.println("   " + Json.of(read.getContents()));
+               }
+       }
+
+       /**
+        * Step 6 (H3): the SAME read-only-scoped token from step 5's flavor, 
now attempting {@code publishNote} —
+        * a mutating tool that {@link SecuredExampleMcpServer} step-up-gates 
behind {@code mcp.write}. Demonstrates
+        * that holding a merely-valid, correctly-scoped-for-reads token is 
deliberately NOT enough: the call is
+        * rejected with a {@code 403} naming the missing {@code mcp.write} 
scope, not silently allowed. As with
+        * step 1/2 above, the 403's plain-text body surfaces as a bare {@link 
IOException}, not a typed exception.
+        */
+       private static void insufficientScopeCall(String endpoint, URI 
tokenEndpoint, String clientId, String clientSecret) throws IOException {
+               var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, 
clientId, clientSecret);
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(endpoint)
+                               .clientInfo(new 
Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0"))
+                               .interceptor(tokens.interceptor()))) {
+                       client.callToolText("publishNote", Map.of("title", 
"should-fail", "body", "should never be stored"));
+                       throw new IllegalStateException("expected the write 
with a read-only token to fail, but it succeeded");
+               } catch (IOException e) {
+                       System.out.println("   rejected as expected: " + 
e.getMessage());
+               }
+       }
+
+       /**
+        * Step 7: acquires a SECOND token, this time carrying both {@code 
mcp.read} and {@code mcp.write}, and
+        * shows the previously-rejected {@code publishNote} call (and a 
confirming read) now succeeding.
+        */
+       private static void writeCall(String endpoint, URI tokenEndpoint, 
String clientId, String clientSecret) throws IOException {
+               var tokens = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(tokenEndpoint)
+                       .clientId(clientId)
+                       .clientSecret(clientSecret)
+                       .resource(URI.create(endpoint))
+                       .scope(SecuredExampleMcpServer.READ_SCOPE, 
SecuredExampleMcpServer.WRITE_SCOPE)
+                       .build();
+
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(endpoint)
+                               .clientInfo(new 
Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0"))
+                               .interceptor(tokens.interceptor()))) {
+                       System.out.println("   -> " + 
client.callToolText("publishNote",
+                               Map.of("title", "secured", "body", "Hello from 
behind OAuth 2.1")));
+
+                       var read = 
client.readResource(NoteStore.uriFor("secured"));
+                       System.out.println("   " + Json.of(read.getContents()));
+               }
+       }
+
+       /** Builds a token provider scoped to {@link 
SecuredExampleMcpServer#READ_SCOPE} only. */
+       private static McpTokenProvider readOnlyTokenProvider(String endpoint, 
URI tokenEndpoint, String clientId, String clientSecret) {
+               return McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(tokenEndpoint)
+                       .clientId(clientId)
+                       .clientSecret(clientSecret)
+                       .resource(URI.create(endpoint))
+                       .scope(SecuredExampleMcpServer.READ_SCOPE)
+                       .build();
+       }
+
+       /**
+        * Builds a plausible (but not necessarily complete) {@code 
server/discover} JSON-RPC request body using
+        * the real wire beans, purely so step 1's raw HTTP call looks like a 
genuine MCP request on the wire. The
+        * resource-server bearer gate rejects it before the body is ever 
parsed, so its exact contents do not
+        * affect the outcome — but a reader should see real MCP traffic here, 
not an arbitrary placeholder.
+        */
+       private static String discoverRequestBody() {
+               var params = new RequestParamsOnly().setMeta(new RequestMeta()
+                       .setProtocolVersion(McpProtocol.VERSION_2026_07_28)
+                       .setClientInfo(new 
Implementation().setName("juneau-secured-notes-example-client").setVersion("1.0.0"))
+                       .setClientCapabilities(new ClientCapabilities()));
+               var req = new JsonRpcRequest()
+                       .setJsonrpc(McpProtocol.JSON_RPC_2_0)
+                       .setId(1)
+                       .setMethod("server/discover")
+                       .setParams(params);
+               return Json.of(req);
+       }
+
+       private static void section(String title) {
+               System.out.println("\n=== " + title + " ===");
+       }
+}
diff --git 
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java
 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java
new file mode 100644
index 0000000000..a46fb49b97
--- /dev/null
+++ 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpServer.java
@@ -0,0 +1,150 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.examples.mcp.secured;
+
+import java.net.*;
+
+import org.apache.juneau.examples.mcp.*;
+import org.apache.juneau.rest.server.auth.jwt.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+
+/**
+ * The OAuth 2.1-secured variant of {@link ExampleMcpServer}: identical 
notes-service surface (tools, prompt,
+ * resources, resource template, subscriptions), but every {@code POST /} 
JSON-RPC call now requires a valid
+ * bearer token.
+ *
+ * <p>
+ * This class changes nothing about <i>what</i> the server exposes &mdash; it 
inherits
+ * {@link ExampleMcpServer#createMcpConfig()} unmodified, so the same {@code 
publishNote}/{@code deleteNote}
+ * tools, {@code summarize} prompt, and {@code note:///...} resources are 
still there. The only override is
+ * {@link #createMcpOptions()}, which layers an {@code 
McpResourceServerConfig} on top of the parent's
+ * capabilities. That single override is the entire security story:
+ *
+ * <ul>
+ *     <li><b>{@code setEnabled(true)}</b> &mdash; turns on the RS 
(resource-server) gate at all. Off by default,
+ *             so an ordinary {@link ExampleMcpServer} is completely 
unaffected by this class existing.
+ *     <li><b>{@code setResource(...)}</b> &mdash; this server's own canonical 
URL (RFC 9728 {@code resource} /
+ *             RFC 8707 default audience). A bearer token's {@code aud} claim 
must include this exact URL or it is
+ *             rejected &mdash; the confused-deputy defense: a token minted 
for some OTHER resource cannot be replayed
+ *             here even if it is otherwise perfectly valid and signed by a 
trusted issuer.
+ *     <li><b>{@code setTokenValidator(...)}</b> &mdash; a {@link 
JwtTokenValidator} that checks the token's
+ *             signature (against {@link 
OfflineAuthorizationServer#jwkSource()}, offline &mdash; no JWKS HTTP fetch),
+ *             {@code iss} (must equal {@link 
OfflineAuthorizationServer#issuerUri()}), and {@code aud}/{@code exp}/
+ *             {@code nbf} (validated against the resource URL above and 
wall-clock time).
+ *     <li><b>{@code addAuthorizationServer(...)}</b> &mdash; advertised in 
the RFC 9728 Protected Resource
+ *             Metadata (PRM) document a client fetches from the well-known 
{@code .well-known/oauth-protected-resource}
+ *             path this framework serves automatically once RS auth is 
enabled. This is how a compliant client
+ *             discovers WHICH authorization server to go get a token from, 
without that URL being hardcoded into the
+ *             client at all.
+ *     <li><b>{@code addRequiredScope(...)}</b> &mdash; the coarse, 
endpoint-wide baseline scope ({@link #READ_SCOPE})
+ *             every request must carry (on top of a merely valid, 
correctly-audienced token) before any JSON-RPC
+ *             method dispatches. A token missing it gets a {@code 403} with a 
{@code WWW-Authenticate} challenge
+ *             naming the missing scope, not a silent failure.
+ *     <li><b>{@code addOperationScope(...)}</b> &mdash; SEP-2350 
per-operation step-up (H3): {@code publishNote}
+ *             and {@code deleteNote} additionally require {@link 
#WRITE_SCOPE} on top of the baseline. A token
+ *             carrying only {@link #READ_SCOPE} can discover the server and 
read resources, but a mutating tool call
+ *             gets its own scoped {@code 403 insufficient_scope} naming 
{@link #WRITE_SCOPE} specifically &mdash; the
+ *             baseline scope alone is never enough to invoke either write 
tool.
+ * </ul>
+ *
+ * <p>
+ * The net effect on the wire: an unauthenticated (or wrongly-audienced, or 
expired, or insufficiently-scoped)
+ * {@code POST /} now gets {@code 401}/{@code 403} plus a {@code 
WWW-Authenticate: Bearer ...} challenge
+ * instead of ever reaching {@link ExampleMcpServer}'s tool/prompt/resource 
handlers; a request bearing a valid
+ * bearer token dispatches exactly as it always did.
+ *
+ * <h5 class='section'>The one wrinkle this class works around:</h5>
+ * <p>
+ * {@link #getResource() The resource URL} is this server's OWN address, which 
is a problem when
+ * {@link SecuredExampleServer} boots on an OS-assigned ephemeral port (as the 
end-to-end test does): naively,
+ * that address is not knowable until AFTER Jetty has bound its socket. It is 
tempting to assume
+ * {@link #createMcpOptions()} (like {@code createMcpConfig()}) is called 
lazily on the first incoming MCP
+ * request, late enough to bind the resource URL once the port is known 
&mdash; but it is NOT: the REST
+ * framework eagerly walks every {@code @Bean}-annotated method declared on 
the resource (including this
+ * inherited {@code getMcpOptions()}) while building the servlet's {@code 
RestContext}, i.e. during Jetty's
+ * OWN servlet initialization, strictly before {@code Server.start()} returns. 
A constructor-supplied resource
+ * URL is therefore required; see {@link SecuredExampleServer#start(int)} for 
how it opens the Jetty connector
+ * (and thus learns the real ephemeral port) before this servlet is even 
constructed.
+ *
+ * @serial exclude
+ */
+public class SecuredExampleMcpServer extends ExampleMcpServer {
+
+       private static final long serialVersionUID = 1L;
+
+       /**
+        * The baseline OAuth scope every request to this server must carry, on 
top of a valid, correctly-audienced
+        * token. Advertised in the PRM document's {@code scopes_supported} and 
required via
+        * {@code McpResourceServerConfig.addRequiredScope(...)}.
+        */
+       public static final String READ_SCOPE = "mcp.read";
+
+       /**
+        * The additional step-up scope {@code publishNote}/{@code deleteNote} 
require on top of {@link #READ_SCOPE}
+        * (H3, SEP-2350 per-operation scoping), via {@code 
McpResourceServerConfig.addOperationScope(...)}.
+        */
+       public static final String WRITE_SCOPE = "mcp.write";
+
+       private final transient OfflineAuthorizationServer authServer;
+       private final transient URI resource;
+
+       /**
+        * Constructor.
+        *
+        * @param authServer The offline authorization server this instance 
validates bearer tokens against
+        *      (its {@link OfflineAuthorizationServer#jwkSource() public key} 
and
+        *      {@link OfflineAuthorizationServer#issuerUri() issuer}). Must 
not be <jk>null</jk>.
+        * @param resource This server's own canonical root URL (e.g. {@code 
http://localhost:5001/}), known and
+        *      fixed before construction &mdash; see the class javadoc's "one 
wrinkle" section for why this cannot be
+        *      supplied later. Must not be <jk>null</jk>.
+        */
+       public SecuredExampleMcpServer(OfflineAuthorizationServer authServer, 
URI resource) {
+               if (authServer == null)
+                       throw new IllegalArgumentException("authServer must not 
be null");
+               if (resource == null)
+                       throw new IllegalArgumentException("resource must not 
be null");
+               this.authServer = authServer;
+               this.resource = resource;
+       }
+
+       /**
+        * Returns this server's own canonical resource URL, as supplied to the 
constructor.
+        *
+        * @return The resource URL. Never <jk>null</jk>.
+        */
+       public URI getResource() {
+               return resource;
+       }
+
+       @Override
+       protected McpOptions createMcpOptions() {
+               var validator = JwtTokenValidator.create()
+                       .jwkSource(authServer.jwkSource())
+                       .issuer(authServer.issuerUri().toString())
+                       .audience(resource.toString())
+                       .build();
+               return super.createMcpOptions().resourceServer(rs -> rs
+                       .setEnabled(true)
+                       .setResource(resource)
+                       .setTokenValidator(validator)
+                       .addAuthorizationServer(authServer.issuerUri())
+                       .addRequiredScope(READ_SCOPE)
+                       // H3: publishNote/deleteNote step up to WRITE_SCOPE on 
top of the READ_SCOPE baseline above.
+                       .addOperationScope("tools/call", "publishNote", 
WRITE_SCOPE)
+                       .addOperationScope("tools/call", "deleteNote", 
WRITE_SCOPE));
+       }
+}
diff --git 
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java
 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java
new file mode 100644
index 0000000000..113ebecb4f
--- /dev/null
+++ 
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/secured/SecuredExampleServer.java
@@ -0,0 +1,171 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.examples.mcp.secured;
+
+import java.net.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.microservice.jetty.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Boots {@link SecuredExampleMcpServer} on an embedded Jetty server, 
alongside an in-process
+ * {@link OfflineAuthorizationServer} that stands in for a real OAuth 2.1 
authorization server.
+ *
+ * <p>
+ * Mirrors {@link org.apache.juneau.examples.mcp.ExampleServer} (read that 
class first &mdash; this one only
+ * documents what is different). Run {@link #main(String[]) main} to start 
both servers on fixed ports and
+ * print everything a {@link SecuredExampleClient} run needs to copy: the 
endpoint, the offline authorization
+ * server's token endpoint, and the randomly-generated demo client id/secret. 
The in-process end-to-end test
+ * uses {@link #start(int) start(0)} to boot on an OS-assigned ephemeral port 
instead.
+ *
+ * <p>
+ * <b>The resource URL is resolved before the secured servlet is even 
constructed.</b> {@link SecuredExampleMcpServer}
+ * needs its own address up front (see its class javadoc), which is awkward on 
an OS-assigned ephemeral port
+ * because normally nothing knows that port until Jetty finishes starting. The 
fix: {@link ServerConnector#open()}
+ * can be, and by Jetty's own convention normally is (see {@code 
Server.doStart()}'s "open network connector to
+ * ensure ports are available" step), called to bind the listening socket 
BEFORE the rest of the server starts.
+ * Calling it ourselves, one step earlier than usual, lets us read {@link 
ServerConnector#getLocalPort()}
+ * immediately &mdash; before building {@link SecuredExampleMcpServer} or 
starting the {@link Microservice} at
+ * all. Jetty's own {@code Server.start()} then finds the connector already 
open and skips rebinding it.
+ */
+public final class SecuredExampleServer implements AutoCloseable {
+
+       /** Default listen port used by {@link #main(String[])} when none is 
supplied. */
+       public static final int DEFAULT_PORT = 5001;
+
+       private final Microservice microservice;
+       private final OfflineAuthorizationServer authServer;
+       private final URI rootUrl;
+
+       private SecuredExampleServer(Microservice microservice, 
OfflineAuthorizationServer authServer, URI rootUrl) {
+               this.microservice = microservice;
+               this.authServer = authServer;
+               this.rootUrl = rootUrl;
+       }
+
+       /**
+        * Starts the secured example MCP server and its offline authorization 
server.
+        *
+        * @param port The TCP port the MCP server listens on, or {@code 0} to 
let the OS assign an ephemeral port.
+        *      (The offline authorization server always uses its own separate 
OS-assigned ephemeral port.)
+        * @return A running server handle. Close it (or call {@link #close()}) 
to stop both servers.
+        * @throws Exception If either server fails to start.
+        */
+       @SuppressWarnings("resource") // The bean store is handed to (and 
closed by) the Microservice lifecycle.
+       public static SecuredExampleServer start(int port) throws Exception {
+               var authServer = OfflineAuthorizationServer.start();
+               // M9: hoisted above the try so a failure AFTER 
connector.open() (which has already bound a real OS
+               // socket) can still close it in the catch below - 
Server.stop() alone will not close a connector that
+               // was opened but never handed to a started Server.
+               ServerConnector connector = null;
+               try {
+                       var jetty = new Server();
+                       connector = new ServerConnector(jetty);
+                       connector.setPort(port);
+                       jetty.addConnector(connector);
+                       // Bind the (possibly ephemeral) listen socket now, 
before the servlet context (and therefore
+                       // SecuredExampleMcpServer's RestContext) is built, so 
the resource URL below is real and final
+                       // by the time anything asks the servlet for it. See 
the class javadoc for why that ordering
+                       // matters here specifically.
+                       connector.open();
+                       var rootUrl = URI.create("http://localhost:"; + 
connector.getLocalPort() + "/");
+
+                       var handler = new ServletContextHandler();
+                       handler.setContextPath("/");
+                       jetty.setAttribute("ServletContextHandler", handler);
+                       jetty.setHandler(handler);
+
+                       var securedServlet = new 
SecuredExampleMcpServer(authServer, rootUrl);
+
+                       var beanStore = new BasicBeanStore();
+                       beanStore.addBean(Server.class, jetty);
+                       beanStore.addBean(Servlet.class, securedServlet);
+
+                       var microservice = Microservice.create()
+                               .beanStore(beanStore)
+                               .configurations(JettyConfiguration.class)
+                               .consoleEnabled(false)
+                               .build();
+                       microservice.start();
+
+                       return new SecuredExampleServer(microservice, 
authServer, rootUrl);
+               } catch (Exception e) {
+                       if (connector != null)
+                               connector.close();
+                       authServer.close();
+                       throw e;
+               }
+       }
+
+       /**
+        * Returns the root URL the secured MCP server is listening on (e.g. 
{@code http://localhost:5001/}).
+        *
+        * @return The root URL. Never <jk>null</jk>.
+        */
+       public URI getRootUrl() {
+               return rootUrl;
+       }
+
+       /**
+        * Returns the offline authorization server backing this instance, so a 
caller (e.g. the end-to-end test,
+        * or {@link #main(String[])} printing a startup banner) can read its 
token endpoint and demo credentials.
+        *
+        * @return The offline authorization server. Never <jk>null</jk>.
+        */
+       public OfflineAuthorizationServer getAuthServer() {
+               return authServer;
+       }
+
+       @Override
+       public void close() throws Exception {
+               try {
+                       microservice.stop();
+               } finally {
+                       authServer.close();
+               }
+       }
+
+       /**
+        * Runs both servers until the process is killed, printing everything 
{@link SecuredExampleClient} needs.
+        *
+        * <p>
+        * M10: the token endpoint is no longer printed here &mdash; {@link 
SecuredExampleClient} now discovers it
+        * itself via RFC 9728 PRM + RFC 8414 discovery, exactly as a real 
client would, so only the endpoint and
+        * demo client credentials need to be copied.
+        *
+        * @param args Optional single argument: the port the MCP server 
listens on (defaults to {@link #DEFAULT_PORT}).
+        * @throws Exception If either server fails to start.
+        */
+       public static void main(String[] args) throws Exception {
+               var port = args.length > 0 ? Integer.parseInt(args[0]) : 
DEFAULT_PORT;
+               var server = start(port);
+               var auth = server.getAuthServer();
+               System.out.println("Juneau SECURED MCP example server is 
listening at " + server.getRootUrl());
+               System.out.println("Demo client id:     " + auth.clientId());
+               System.out.println("Demo client secret: " + 
auth.clientSecret());
+               System.out.println();
+               System.out.println("Drive it with:  SecuredExampleClient " + 
server.getRootUrl() + " "
+                       + auth.clientId() + " " + auth.clientSecret());
+               System.out.println("Press Ctrl-C to stop.");
+               Thread.currentThread().join();
+       }
+}
diff --git 
a/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java
 
b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java
new file mode 100644
index 0000000000..e0311ba874
--- /dev/null
+++ 
b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/secured/SecuredExampleMcpEndToEnd_Test.java
@@ -0,0 +1,380 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.examples.mcp.secured;
+
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.examples.mcp.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.client.mcp.auth.*;
+import org.apache.juneau.rest.client.mcp.v20260728.*;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jwt.*;
+
+/**
+ * Proves the OAuth 2.1-secured variant actually works: boots {@link 
SecuredExampleServer} (which brings up
+ * its own in-process {@link OfflineAuthorizationServer}) on an ephemeral port 
and drives both a raw
+ * {@link HttpClient} (to inspect the exact {@code 401}/{@code 403}/{@code 
WWW-Authenticate} wire behavior) and
+ * the real {@link McpClient} SDK (to prove a genuinely-acquired bearer token 
round-trips a tool call
+ * end-to-end) against it.
+ *
+ * <p>
+ * Follows the same fixture shape as {@code ExampleMcpEndToEnd_Test} (share 
one server/AS pair across the
+ * class; each test that mutates note state uses its own distinctly-titled 
note) and the same assertion style
+ * as {@code McpResourceServerBinding_Test} (challenge header parsing via 
{@link WwwAuthenticateChallenge}).
+ *
+ * <p>
+ * Covers, in order: rejected calls 
(missing/garbage/wrong-audience/expired/wrong-issuer/{@code alg=none}
+ * tokens, section {@code a}), RFC 9728 discovery (section {@code b}), H3's 
per-operation
+ * {@code mcp.write} step-up (section {@code c}), a successfully-dispatched 
call plus the M7 token-caching
+ * proof (section {@code d}), and a full {@link SecuredExampleClient#run} 
walkthrough (section {@code e}).
+ */
+class SecuredExampleMcpEndToEnd_Test extends TestBase {
+
+       private static SecuredExampleServer server;
+       private static HttpClient http;
+
+       @BeforeAll
+       static void setUp() throws Exception {
+               server = SecuredExampleServer.start(0);
+               http = HttpClient.newHttpClient();
+       }
+
+       @AfterAll
+       static void tearDown() throws Exception {
+               if (server != null)
+                       server.close();
+       }
+
+       /** A request builder pre-populated with the headers a real v2 MCP 
JSON-RPC POST requires. */
+       private static HttpRequest.Builder discoverRequest() {
+               return HttpRequest.newBuilder(server.getRootUrl())
+                       .header("Content-Type", "application/json")
+                       .header("Accept", "application/json")
+                       .header("Mcp-Method", "server/discover")
+                       .header("Mcp-Name", "");
+       }
+
+       /** A request builder for a {@code tools/call} POST invoking {@code 
toolName}. */
+       private static HttpRequest.Builder toolsCallRequest(String toolName) {
+               return HttpRequest.newBuilder(server.getRootUrl())
+                       .header("Content-Type", "application/json")
+                       .header("Accept", "application/json")
+                       .header("Mcp-Method", "tools/call")
+                       .header("Mcp-Name", toolName);
+       }
+
+       /** A real {@code tools/call} JSON-RPC request body for {@code 
toolName}, using the actual wire beans. */
+       private static String toolsCallBody(String toolName, Map<String,Object> 
arguments) {
+               var params = new CallToolRequest()
+                       .setName(toolName)
+                       .setArguments(arguments)
+                       .setMeta(new RequestMeta()
+                               
.setProtocolVersion(McpProtocol.VERSION_2026_07_28)
+                               .setClientInfo(new 
Implementation().setName("test-client").setVersion("1.0.0"))
+                               .setClientCapabilities(new 
ClientCapabilities()));
+               var req = new JsonRpcRequest()
+                       .setJsonrpc(McpProtocol.JSON_RPC_2_0)
+                       .setId(1)
+                       .setMethod("tools/call")
+                       .setParams(params);
+               return Json.of(req);
+       }
+
+       private static WwwAuthenticateChallenge challenge(HttpResponse<?> 
response) {
+               return 
WwwAuthenticateChallenge.parse(response.headers().firstValue("WWW-Authenticate").orElse(null))
+                       .orElseThrow(() -> new AssertionError("no 
WWW-Authenticate header on response: " + response));
+       }
+
+       // -------- a: rejected - no token / garbage / wrong-audience / expired 
/ wrong-issuer / alg=none --------
+
+       @Test
+       void a01_missingToken_401WithBearerChallengeAndScopeHint() throws 
Exception {
+               var response = 
http.send(discoverRequest().POST(HttpRequest.BodyPublishers.ofString("{}")).build(),
+                       HttpResponse.BodyHandlers.ofString());
+               assertEquals(401, response.statusCode());
+               var c = challenge(response);
+               assertTrue(c.isBearer());
+               assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE), 
c.scopes());
+               // N3 (RFC 6750 §3): no credentials were presented at all, so 
the challenge must carry no error code -
+               // that is reserved for a credential that WAS presented but 
rejected (a02/a03 below).
+               assertTrue(c.error().isEmpty(), "a credential-less challenge 
must not carry an error code");
+               // N2: assert the actual resource_metadata value, not merely 
that one is present.
+               
assertEquals(Optional.of(server.getRootUrl().resolve(".well-known/oauth-protected-resource")),
 c.resourceMetadata());
+       }
+
+       @Test
+       void a02_garbageToken_401WithInvalidTokenError() throws Exception {
+               var response = 
http.send(discoverRequest().header("Authorization", "Bearer not-a-real-jwt")
+                       
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(401, response.statusCode());
+               assertEquals(Optional.of("invalid_token"), 
challenge(response).error());
+       }
+
+       @Test
+       void a03_wrongAudienceToken_401WithInvalidTokenError() throws Exception 
{
+               // A token minted for a DIFFERENT resource must be rejected 
even though it is otherwise perfectly
+               // valid and correctly signed by the trusted offline 
authorization server - RFC 8707 audience
+               // enforcement (the confused-deputy defense).
+               var auth = server.getAuthServer();
+               var wrongAudienceToken = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       
.resource(URI.create("http://localhost:1/a-different-resource";))
+                       .build()
+                       .get();
+               var response = 
http.send(discoverRequest().header("Authorization", "Bearer " + 
wrongAudienceToken)
+                       
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(401, response.statusCode());
+               assertEquals(Optional.of("invalid_token"), 
challenge(response).error());
+       }
+
+       @Test
+       void a04_expiredToken_401WithInvalidTokenError() throws Exception {
+               // H4 test seam: mintAccessToken(...) lets us mint an 
already-expired token directly, without a real
+               // clock needing to actually elapse five minutes.
+               var auth = server.getAuthServer();
+               var expiredToken = 
auth.mintAccessToken(server.getRootUrl().toString(), 
SecuredExampleMcpServer.READ_SCOPE,
+                       Instant.now().minus(Duration.ofHours(1)), 
Duration.ofMinutes(5));
+               var response = 
http.send(discoverRequest().header("Authorization", "Bearer " + expiredToken)
+                       
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(401, response.statusCode());
+               assertEquals(Optional.of("invalid_token"), 
challenge(response).error());
+       }
+
+       @Test
+       void a05_wrongIssuerToken_401WithInvalidTokenError() throws Exception {
+               // H4: the issuer is now a constructor-supplied instance field 
(not a shared static constant), so a
+               // second, independently-started offline AS naturally has both 
a different issuer AND a different
+               // signing key from the one this server's JwtTokenValidator 
trusts - a token it mints must be rejected
+               // exactly like any other untrusted issuer's token.
+               try (var otherAuth = OfflineAuthorizationServer.start()) {
+                       var wrongIssuerToken = 
McpTokenProvider.clientCredentials()
+                               .tokenEndpoint(otherAuth.tokenEndpoint())
+                               .clientId(otherAuth.clientId())
+                               .clientSecret(otherAuth.clientSecret())
+                               .resource(server.getRootUrl())
+                               .scope(SecuredExampleMcpServer.READ_SCOPE)
+                               .build()
+                               .get();
+                       var response = 
http.send(discoverRequest().header("Authorization", "Bearer " + 
wrongIssuerToken)
+                               
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+                       assertEquals(401, response.statusCode());
+                       assertEquals(Optional.of("invalid_token"), 
challenge(response).error());
+               }
+       }
+
+       @Test
+       void a06_algNoneToken_401WithInvalidTokenError() throws Exception {
+               // N5 (teaching artifact): JwtTokenValidator explicitly rejects 
unsigned/alg=none JWTs outright (it
+               // never reaches signature verification), regardless of how 
plausible the claims otherwise look.
+               var auth = server.getAuthServer();
+               var claims = new JWTClaimsSet.Builder()
+                       .subject(auth.clientId())
+                       .issuer(auth.issuerUri().toString())
+                       .audience(server.getRootUrl().toString())
+                       .claim("scope", SecuredExampleMcpServer.READ_SCOPE)
+                       .issueTime(Date.from(Instant.now()))
+                       
.expirationTime(Date.from(Instant.now().plus(Duration.ofMinutes(5))))
+                       .build();
+               var algNoneToken = new PlainJWT(claims).serialize();
+               var response = 
http.send(discoverRequest().header("Authorization", "Bearer " + algNoneToken)
+                       
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(401, response.statusCode());
+               assertEquals(Optional.of("invalid_token"), 
challenge(response).error());
+       }
+
+       // -------- b: RFC 9728 Protected Resource Metadata --------
+
+       @Test
+       void b01_wellKnownPrm_advertisesResourceAndOfflineAuthorizationServer() 
throws Exception {
+               var prmUrl = 
server.getRootUrl().resolve(".well-known/oauth-protected-resource");
+               var response = 
http.send(HttpRequest.newBuilder(prmUrl).GET().build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(200, response.statusCode());
+               var prm = 
McpProtectedResourceMetadataClient.create().build().parse(response.body(), 
prmUrl);
+               assertEquals(server.getRootUrl(), prm.resource());
+               assertEquals(List.of(server.getAuthServer().issuerUri()), 
prm.authorizationServers());
+               // H3: addOperationScope(...) also advertises mcp.write as 
supported, on top of the mcp.read baseline.
+               assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE, 
SecuredExampleMcpServer.WRITE_SCOPE), prm.scopesSupported());
+       }
+
+       // -------- c: H3/H4 - baseline scope and per-operation mcp.write 
step-up enforcement --------
+
+       @Test
+       void c01_insufficientBaselineScope_403WithInsufficientScopeError() 
throws Exception {
+               // A token that is otherwise perfectly valid but was never 
granted the endpoint-wide mcp.read
+               // baseline is 403'd before any JSON-RPC method (even 
server/discover) dispatches.
+               var auth = server.getAuthServer();
+               var noBaselineToken = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       .resource(server.getRootUrl())
+                       .scope(SecuredExampleMcpServer.WRITE_SCOPE)
+                       .build()
+                       .get();
+               var response = 
http.send(discoverRequest().header("Authorization", "Bearer " + noBaselineToken)
+                       
.POST(HttpRequest.BodyPublishers.ofString("{}")).build(), 
HttpResponse.BodyHandlers.ofString());
+               assertEquals(403, response.statusCode());
+               var c = challenge(response);
+               assertEquals(Optional.of("insufficient_scope"), c.error());
+               assertEquals(Set.of(SecuredExampleMcpServer.READ_SCOPE), 
c.scopes());
+       }
+
+       @Test
+       void c02_readOnlyToken_canReadButCannotWrite() throws Exception {
+               var auth = server.getAuthServer();
+
+               // The mcp.read baseline alone is enough to discover the server 
and read a resource.
+               var readTokens = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       .resource(server.getRootUrl())
+                       .scope(SecuredExampleMcpServer.READ_SCOPE)
+                       .build();
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(server.getRootUrl().toString())
+                               .interceptor(readTokens.interceptor()))) {
+                       assertNotNull(client.readResource(NoteStore.SCHEME + 
"index"));
+               }
+
+               // H3/H4: but that SAME mcp.read-only token is 403'd, naming 
mcp.write specifically, on EITHER
+               // step-up-gated mutating tool.
+               var readOnlyToken = readTokens.get();
+               for (var tool : List.of("publishNote", "deleteNote")) {
+                       var response = http.send(toolsCallRequest(tool)
+                               .header("Authorization", "Bearer " + 
readOnlyToken)
+                               
.POST(HttpRequest.BodyPublishers.ofString(toolsCallBody(tool, Map.of("title", 
"step-up-probe", "body", "x"))))
+                               .build(), HttpResponse.BodyHandlers.ofString());
+                       assertEquals(403, response.statusCode(), tool + " must 
require mcp.write on top of the mcp.read baseline");
+                       var c = challenge(response);
+                       assertEquals(Optional.of("insufficient_scope"), 
c.error());
+                       
assertEquals(Set.of(SecuredExampleMcpServer.WRITE_SCOPE), c.scopes());
+               }
+       }
+
+       @Test
+       void c03_readWriteToken_dispatchesWrites() throws Exception {
+               // A token carrying BOTH the baseline and the step-up scope 
dispatches publishNote/deleteNote normally.
+               // deleteNote advertises an elicitation (confirm) round trip, 
so the client must both advertise the
+               // elicitation capability and answer it - unrelated to the 
OAuth scoping this test targets, but
+               // required for the call to reach a successful outcome at all.
+               var auth = server.getAuthServer();
+               var tokens = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       .resource(server.getRootUrl())
+                       .scope(SecuredExampleMcpServer.READ_SCOPE, 
SecuredExampleMcpServer.WRITE_SCOPE)
+                       .build();
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(server.getRootUrl().toString())
+                               .clientCapabilities(new 
ClientCapabilities().setElicitation(new ElicitationCapability()))
+                               .interceptor(tokens.interceptor()))) {
+                       var stored = client.callTool("publishNote", 
Map.of("title", "step-up-note", "body", "hello"));
+                       assertEquals("Stored note 'step-up-note' (5 chars).", 
stored.firstText());
+                       var deleted = 
client.callToolWithElicitation("deleteNote", Map.of("title", "step-up-note"),
+                               requests -> {
+                                       var answers = new 
LinkedHashMap<String,ElicitResult>();
+                                       requests.keySet().forEach(id -> 
answers.put(id,
+                                               new 
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
+                                       return answers;
+                               });
+                       assertNotNull(deleted);
+               }
+       }
+
+       // -------- d: accepted - a real token acquired from the offline 
authorization server --------
+
+       @Test
+       void d01_validToken_dispatchesAndRoundTripsANote() throws Exception {
+               var auth = server.getAuthServer();
+               var tokens = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       .resource(server.getRootUrl())
+                       .scope(SecuredExampleMcpServer.READ_SCOPE, 
SecuredExampleMcpServer.WRITE_SCOPE)
+                       .build();
+
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(server.getRootUrl().toString())
+                               .interceptor(tokens.interceptor()))) {
+                       assertBean(client.discoveredServer(), 
"serverInfo{name}", "{juneau-notes-example}");
+
+                       var stored = client.callTool("publishNote", 
Map.of("title", "secured-note", "body", "hello"));
+                       assertEquals("Stored note 'secured-note' (5 chars).", 
stored.firstText());
+
+                       var read = 
client.readResource(NoteStore.uriFor("secured-note"));
+                       assertEquals("hello", ((TextResourceContents) 
read.getContents().get(0)).getText());
+               }
+       }
+
+       @Test
+       void d02_validToken_singleTokenRequestReusedAcrossCalls() throws 
Exception {
+               // M7: proves the caching McpTokenProvider genuinely reuses ONE 
acquired token across multiple
+               // dispatches on the same connection, by counting actual /token 
HTTP round trips on the offline AS -
+               // not merely asserting the calls happen to succeed (which 
would also be true of a provider that
+               // re-requested a token on every single call).
+               var auth = server.getAuthServer();
+               var before = auth.tokenRequestCount();
+               var tokens = McpTokenProvider.clientCredentials()
+                       .tokenEndpoint(auth.tokenEndpoint())
+                       .clientId(auth.clientId())
+                       .clientSecret(auth.clientSecret())
+                       .resource(server.getRootUrl())
+                       .scope(SecuredExampleMcpServer.READ_SCOPE, 
SecuredExampleMcpServer.WRITE_SCOPE)
+                       .build();
+
+               try (var client = McpClient.connect(McpClient.builder()
+                               .endpoint(server.getRootUrl().toString())
+                               .interceptor(tokens.interceptor()))) {
+                       client.callTool("publishNote", Map.of("title", 
"cached-note", "body", "hi"));
+                       var contents = client.readResource(NoteStore.SCHEME + 
"index").getContents();
+                       assertTrue(((TextResourceContents) 
contents.get(0)).getText().contains("cached-note"));
+                       client.readResource(NoteStore.uriFor("cached-note"));
+               }
+
+               assertEquals(before + 1, auth.tokenRequestCount(),
+                       "a single cached token must be reused across all three 
dispatches above, not re-requested per call");
+       }
+
+       // -------- e: M8 - SecuredExampleClient's own end-to-end walkthrough 
--------
+
+       @Test
+       void e01_run_completesWithoutThrowing() throws Exception {
+               // A dedicated, independently-started server/AS pair (own port, 
own notes, own demo credentials) so
+               // this walkthrough - which publishes/reads its own notes and 
deliberately triggers a rejected call -
+               // cannot collide with any test above sharing the class-level 
fixture.
+               try (var standalone = SecuredExampleServer.start(0)) {
+                       var auth = standalone.getAuthServer();
+                       assertDoesNotThrow(() -> 
SecuredExampleClient.run(standalone.getRootUrl().toString(), auth.clientId(), 
auth.clientSecret()));
+               }
+       }
+}

Reply via email to