001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.examples.mcp.secured;
018
019import java.io.*;
020import java.net.*;
021import java.net.http.*;
022import java.util.*;
023
024import org.apache.juneau.bean.jsonrpc.*;
025import org.apache.juneau.bean.mcp.v20260728.*;
026import org.apache.juneau.examples.mcp.*;
027import org.apache.juneau.marshall.marshaller.Json;
028import org.apache.juneau.rest.client.mcp.auth.*;
029import org.apache.juneau.rest.client.mcp.v20260728.*;
030
031/**
032 * A guided walkthrough of calling {@link SecuredExampleMcpServer}, showing the secured request/response
033 * cycle a newcomer to MCP OAuth 2.1 needs to see: a rejected unauthenticated call (twice — once at the
034 * raw wire level, once through the real {@link McpClient} SDK), the full RFC 9728 → RFC 8414 discovery
035 * chain, a successful read-only call, a scoped step-up rejection, and finally a successful write once a token
036 * carrying the write scope has been acquired.
037 *
038 * <p>
039 * Run {@link #main(String[]) main} with three arguments &mdash; the secured server's endpoint and the demo
040 * client id/secret &mdash; all three of which {@link SecuredExampleServer#main(String[])} prints on startup
041 * (they cannot be hardcoded here: the demo client secret is freshly randomly generated on every server run, by
042 * design; see {@link OfflineAuthorizationServer}). Unlike an earlier version of this walkthrough, the token
043 * endpoint is no longer a fourth argument (M10): this client discovers it itself, the same way a real client
044 * would.
045 *
046 * @serial exclude
047 */
048@SuppressWarnings({
049   "java:S106", // Example walkthrough intentionally prints to stdout; console output is the demo's deliverable.
050   "java:S112" // main() and run() declare throws Exception so the demo walkthrough can propagate any client/auth failure without wrapping
051})
052public final class SecuredExampleClient {
053
054   /** The demo client's advertised {@code clientInfo.name}, shared by every {@link McpClient} connection below. */
055   private static final String CLIENT_NAME = "juneau-secured-notes-example-client";
056
057   /** The demo client's advertised {@code clientInfo.version}, shared by every {@link McpClient} connection below. */
058   private static final String CLIENT_VERSION = "1.0.0";
059
060   // Shared across calls rather than one HttpClient per request (S2095): HttpClient instances are heavyweight
061   // (they own a connection pool and a selector thread), and the JDK explicitly recommends reusing a single
062   // instance for the lifetime of the application rather than churning through short-lived ones.
063   private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient();
064
065   private SecuredExampleClient() {}
066
067   /**
068    * Runs the walkthrough against a running {@link SecuredExampleServer}.
069    *
070    * @param args Three required arguments: endpoint, client id, client secret &mdash; copy these from
071    *    {@link SecuredExampleServer#main(String[])}'s startup banner.
072    * @throws Exception If any step fails unexpectedly (a REJECTED call is expected and handled, not thrown).
073    */
074   public static void main(String[] args) throws Exception {
075      if (args.length < 3) {
076         System.out.println("Usage: SecuredExampleClient <endpoint> <clientId> <clientSecret>");
077         System.out.println("Copy these three values from the SecuredExampleServer startup banner.");
078         return;
079      }
080      run(args[0], args[1], args[2]);
081   }
082
083   /**
084    * Executes each numbered step of the walkthrough.
085    *
086    * @param endpoint The secured MCP server's endpoint URL.
087    * @param clientId The demo OAuth client id.
088    * @param clientSecret The demo OAuth client secret.
089    * @throws Exception If an unexpected (non-auth-related) failure occurs.
090    */
091   public static void run(String endpoint, String clientId, String clientSecret) throws Exception {
092
093      section("1. Unauthenticated call, at the raw wire level — a 401 challenge");
094      var challenge = rawUnauthenticatedCall(endpoint);
095
096      section("2. The same unauthenticated call through the real McpClient SDK");
097      mcpClientUnauthenticatedCall(endpoint);
098
099      section("3. RFC 9728 discovery — fetch the Protected Resource Metadata the 401 pointed at");
100      // Capture the Optional ONCE and guard/read the SAME instance, rather than calling
101      // challenge.resourceMetadata() a second time inside the if-block to call get() on a fresh,
102      // unguarded Optional. Without a discovered resource_metadata pointer there is no PRM to feed step 4,
103      // so the walkthrough stops here instead of calling into step 4 with nothing to discover a token
104      // endpoint from.
105      var resourceMetadata = challenge == null ? Optional.<URI>empty() : challenge.resourceMetadata();
106      if (resourceMetadata.isEmpty()) {
107         System.out.println("   (no resource_metadata pointer found on the challenge; walkthrough cannot continue)");
108         return;
109      }
110      var prm = discoverProtectedResourceMetadata(endpoint, resourceMetadata.get());
111
112      section("4. RFC 8414 discovery — resolve the authorization server's token endpoint");
113      var tokenEndpoint = discoverTokenEndpoint(prm);
114
115      section("5. Acquire a read-only (mcp.read) bearer token and call again — success");
116      readOnlyCall(endpoint, tokenEndpoint, clientId, clientSecret);
117
118      section("6. The SAME read-only token attempting a write — a scoped 403 step-up challenge");
119      insufficientScopeCall(endpoint, tokenEndpoint, clientId, clientSecret);
120
121      section("7. Step up to mcp.read + mcp.write and publish a note — success");
122      writeCall(endpoint, tokenEndpoint, clientId, clientSecret);
123
124      System.out.println("\nWalkthrough complete.");
125   }
126
127   /**
128    * Step 1: a raw HTTP POST with no {@code Authorization} header at all, bypassing the {@link McpClient} SDK
129    * entirely so the actual {@code 401} status and {@code WWW-Authenticate} response header are directly
130    * visible. This is necessary because the resource-server gate's {@code 401}/{@code 403} rejections are
131    * plain-text bodies, not JSON-RPC envelopes — {@code AbstractMcpClient.send(...)} cannot parse one into a
132    * typed result and instead surfaces the failure as a bare {@link IOException} whose message embeds the
133    * status code as text. There is no way to read the status code, let alone the challenge header, through
134    * the SDK itself; only at the wire level.
135    */
136   private static WwwAuthenticateChallenge rawUnauthenticatedCall(String endpoint) throws IOException, InterruptedException {
137      var request = HttpRequest.newBuilder(URI.create(endpoint))
138         .header("Content-Type", "application/json")
139         .header("Accept", "application/json")
140         .header("Mcp-Method", "server/discover")
141         .header("Mcp-Name", "")
142         .POST(HttpRequest.BodyPublishers.ofString(discoverRequestBody()))
143         .build();
144      var response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
145      System.out.println("   HTTP status: " + response.statusCode());
146      var header = response.headers().firstValue("WWW-Authenticate").orElse(null);
147      System.out.println("   WWW-Authenticate: " + header);
148      var challenge = WwwAuthenticateChallenge.parse(header).orElse(null);
149      if (challenge != null) {
150         System.out.println("   parsed scope hint:       " + challenge.scopes());
151         System.out.println("   parsed resource_metadata: " + challenge.resourceMetadata().orElse(null));
152      }
153      return challenge;
154   }
155
156   /**
157    * Step 2: the same unauthenticated call, but through {@link McpClient#connect(String)} — the ergonomic
158    * failure mode a real caller actually sees: a thrown {@link IOException} (see {@link #rawUnauthenticatedCall}
159    * above for why it is a bare {@link IOException} and not a typed exception) out of the mandatory
160    * {@code server/discover} handshake.
161    */
162   private static void mcpClientUnauthenticatedCall(String endpoint) {
163      try (var ignored = McpClient.connect(endpoint)) {
164         throw new IllegalStateException("expected the unauthenticated connect() to fail, but it succeeded");
165      } catch (IOException e) {
166         System.out.println("   rejected as expected: " + e.getMessage());
167      }
168   }
169
170   /**
171    * Step 3: fetches the RFC 9728 Protected Resource Metadata document the challenge pointed at, showing what
172    * a client learns from it — notably WHICH authorization server to acquire a token from, discovered from
173    * the response rather than hardcoded into this client.
174    */
175   private static McpProtectedResourceMetadata discoverProtectedResourceMetadata(String endpoint, URI resourceMetadataUrl) {
176      var prm = McpProtectedResourceMetadataClient.create()
177         .expectedResource(URI.create(endpoint))
178         .build()
179         .fetch(resourceMetadataUrl);
180      System.out.println("   resource:               " + prm.resource());
181      System.out.println("   authorization_servers:  " + prm.authorizationServers());
182      System.out.println("   scopes_supported:       " + prm.scopesSupported());
183      return prm;
184   }
185
186   /**
187    * Step 4 (M10): performs real RFC 8414 discovery against the authorization server the PRM document
188    * advertised, via {@link McpProtectedResourceMetadataClient#discoverAuthorizationServer}, and returns the
189    * discovered {@code token_endpoint} — nothing here is hardcoded or passed in on the command line. This
190    * completes the full 401 &rarr; {@code resource_metadata} &rarr; PRM &rarr; {@code authorization_servers}
191    * &rarr; RFC 8414 &rarr; {@code token_endpoint} chain a compliant client walks end-to-end.
192    */
193   private static URI discoverTokenEndpoint(McpProtectedResourceMetadata prm) {
194      var as = McpProtectedResourceMetadataClient.create().build().discoverAuthorizationServer(prm);
195      System.out.println("   issuer:         " + as.issuer());
196      System.out.println("   token_endpoint: " + as.tokenEndpoint());
197      return as.tokenEndpoint();
198   }
199
200   /**
201    * Step 5: acquires a real bearer token scoped to {@code mcp.read} only via
202    * {@link McpTokenProvider#clientCredentials()} (a genuine RFC 6749 &sect;4.4 HTTP round trip to the
203    * discovered token endpoint), wires it into a fresh {@link McpClient} via
204    * {@link McpTokenProvider#interceptor()}, and shows a read succeeding transparently.
205    */
206   private static void readOnlyCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) throws IOException {
207      var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, clientId, clientSecret);
208      try (var client = McpClient.connect(McpClient.builder()
209            .endpoint(endpoint)
210            .clientInfo(new Implementation().setName(CLIENT_NAME).setVersion(CLIENT_VERSION))
211            .interceptor(tokens.interceptor()))) {
212         System.out.println("   server/discover succeeded: " + Json.of(client.discoveredServer().getServerInfo()));
213         var read = client.readResource(NoteStore.SCHEME + "index");
214         System.out.println("   " + Json.of(read.getContents()));
215      }
216   }
217
218   /**
219    * Step 6 (H3): the SAME read-only-scoped token from step 5's flavor, now attempting {@code publishNote} —
220    * a mutating tool that {@link SecuredExampleMcpServer} step-up-gates behind {@code mcp.write}. Demonstrates
221    * that holding a merely-valid, correctly-scoped-for-reads token is deliberately NOT enough: the call is
222    * rejected with a {@code 403} naming the missing {@code mcp.write} scope, not silently allowed. As with
223    * step 1/2 above, the 403's plain-text body surfaces as a bare {@link IOException}, not a typed exception.
224    */
225   private static void insufficientScopeCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) {
226      var tokens = readOnlyTokenProvider(endpoint, tokenEndpoint, clientId, clientSecret);
227      try (var client = McpClient.connect(McpClient.builder()
228            .endpoint(endpoint)
229            .clientInfo(new Implementation().setName(CLIENT_NAME).setVersion(CLIENT_VERSION))
230            .interceptor(tokens.interceptor()))) {
231         client.callToolText("publishNote", Map.of("title", "should-fail", "body", "should never be stored"));
232         throw new IllegalStateException("expected the write with a read-only token to fail, but it succeeded");
233      } catch (IOException e) {
234         System.out.println("   rejected as expected: " + e.getMessage());
235      }
236   }
237
238   /**
239    * Step 7: acquires a SECOND token, this time carrying both {@code mcp.read} and {@code mcp.write}, and
240    * shows the previously-rejected {@code publishNote} call (and a confirming read) now succeeding.
241    */
242   private static void writeCall(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) throws IOException {
243      var tokens = McpTokenProvider.clientCredentials()
244         .tokenEndpoint(tokenEndpoint)
245         .clientId(clientId)
246         .clientSecret(clientSecret)
247         .resource(URI.create(endpoint))
248         .scope(SecuredExampleMcpServer.READ_SCOPE, SecuredExampleMcpServer.WRITE_SCOPE)
249         .build();
250
251      try (var client = McpClient.connect(McpClient.builder()
252            .endpoint(endpoint)
253            .clientInfo(new Implementation().setName(CLIENT_NAME).setVersion(CLIENT_VERSION))
254            .interceptor(tokens.interceptor()))) {
255         System.out.println("   -> " + client.callToolText("publishNote",
256            Map.of("title", "secured", "body", "Hello from behind OAuth 2.1")));
257
258         var read = client.readResource(NoteStore.uriFor("secured"));
259         System.out.println("   " + Json.of(read.getContents()));
260      }
261   }
262
263   /** Builds a token provider scoped to {@link SecuredExampleMcpServer#READ_SCOPE} only. */
264   private static McpTokenProvider readOnlyTokenProvider(String endpoint, URI tokenEndpoint, String clientId, String clientSecret) {
265      return McpTokenProvider.clientCredentials()
266         .tokenEndpoint(tokenEndpoint)
267         .clientId(clientId)
268         .clientSecret(clientSecret)
269         .resource(URI.create(endpoint))
270         .scope(SecuredExampleMcpServer.READ_SCOPE)
271         .build();
272   }
273
274   /**
275    * Builds a plausible (but not necessarily complete) {@code server/discover} JSON-RPC request body using
276    * the real wire beans, purely so step 1's raw HTTP call looks like a genuine MCP request on the wire. The
277    * resource-server bearer gate rejects it before the body is ever parsed, so its exact contents do not
278    * affect the outcome — but a reader should see real MCP traffic here, not an arbitrary placeholder.
279    */
280   private static String discoverRequestBody() {
281      var params = new RequestParamsOnly().setMeta(new RequestMeta()
282         .setProtocolVersion(McpProtocol.VERSION_2026_07_28)
283         .setClientInfo(new Implementation().setName(CLIENT_NAME).setVersion(CLIENT_VERSION))
284         .setClientCapabilities(new ClientCapabilities()));
285      var req = new JsonRpcRequest()
286         .setJsonrpc(McpProtocol.JSON_RPC_2_0)
287         .setId(1)
288         .setMethod("server/discover")
289         .setParams(params);
290      return Json.of(req);
291   }
292
293   private static void section(String title) {
294      System.out.println("\n=== " + title + " ===");
295   }
296}