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 — the secured server's endpoint and the demo 040 * client id/secret — 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 — 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 → {@code resource_metadata} → PRM → {@code authorization_servers} 191 * → RFC 8414 → {@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 §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}