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.net.*; 020 021import org.apache.juneau.commons.inject.*; 022import org.apache.juneau.microservice.*; 023import org.apache.juneau.microservice.jetty.*; 024import org.eclipse.jetty.ee11.servlet.*; 025import org.eclipse.jetty.server.*; 026 027import jakarta.servlet.*; 028 029/** 030 * Boots {@link SecuredExampleMcpServer} on an embedded Jetty server, alongside an in-process 031 * {@link OfflineAuthorizationServer} that stands in for a real OAuth 2.1 authorization server. 032 * 033 * <p> 034 * Mirrors {@link org.apache.juneau.examples.mcp.ExampleServer} (read that class first — this one only 035 * documents what is different). Run {@link #main(String[]) main} to start both servers on fixed ports and 036 * print everything a {@link SecuredExampleClient} run needs to copy: the endpoint, the offline authorization 037 * server's token endpoint, and the randomly-generated demo client id/secret. The in-process end-to-end test 038 * uses {@link #start(int) start(0)} to boot on an OS-assigned ephemeral port instead. 039 * 040 * <p> 041 * <b>The resource URL is resolved before the secured servlet is even constructed.</b> {@link SecuredExampleMcpServer} 042 * needs its own address up front (see its class javadoc), which is awkward on an OS-assigned ephemeral port 043 * because normally nothing knows that port until Jetty finishes starting. The fix: {@link ServerConnector#open()} 044 * can be, and by Jetty's own convention normally is (see {@code Server.doStart()}'s "open network connector to 045 * ensure ports are available" step), called to bind the listening socket BEFORE the rest of the server starts. 046 * Calling it ourselves, one step earlier than usual, lets us read {@link ServerConnector#getLocalPort()} 047 * immediately — before building {@link SecuredExampleMcpServer} or starting the {@link Microservice} at 048 * all. Jetty's own {@code Server.start()} then finds the connector already open and skips rebinding it. 049 */ 050@SuppressWarnings({ 051 "java:S106", // Example walkthrough intentionally prints to stdout; console output is the demo's deliverable. 052 "resource" // The auth server, Jetty connector and Microservice opened in start() are handed to the returned handle, whose close() stops them. 053}) 054public final class SecuredExampleServer implements AutoCloseable { 055 056 /** Default listen port used by {@link #main(String[])} when none is supplied. */ 057 public static final int DEFAULT_PORT = 10001; 058 059 private final Microservice microservice; 060 private final OfflineAuthorizationServer authServer; 061 private final URI rootUrl; 062 063 private SecuredExampleServer(Microservice microservice, OfflineAuthorizationServer authServer, URI rootUrl) { 064 this.microservice = microservice; 065 this.authServer = authServer; 066 this.rootUrl = rootUrl; 067 } 068 069 /** 070 * Starts the secured example MCP server and its offline authorization server. 071 * 072 * @param port The TCP port the MCP server listens on, or {@code 0} to let the OS assign an ephemeral port. 073 * (The offline authorization server always uses its own separate OS-assigned ephemeral port.) 074 * @return A running server handle. Close it (or call {@link #close()}) to stop both servers. 075 * @throws Exception If either server fails to start. 076 */ 077 public static SecuredExampleServer start(int port) throws Exception { 078 var authServer = OfflineAuthorizationServer.start(); 079 // M9: hoisted above the try so a failure AFTER connector.open() (which has already bound a real OS 080 // socket) can still close it in the catch below - Server.stop() alone will not close a connector that 081 // was opened but never handed to a started Server. 082 ServerConnector connector = null; 083 try { 084 var jetty = new Server(); 085 connector = new ServerConnector(jetty); 086 connector.setPort(port); 087 jetty.addConnector(connector); 088 // Bind the (possibly ephemeral) listen socket now, before the servlet context (and therefore 089 // SecuredExampleMcpServer's RestContext) is built, so the resource URL below is real and final 090 // by the time anything asks the servlet for it. See the class javadoc for why that ordering 091 // matters here specifically. 092 connector.open(); 093 var rootUrl = URI.create("http://localhost:" + connector.getLocalPort() + "/"); 094 095 var handler = new ServletContextHandler(); 096 handler.setContextPath("/"); 097 jetty.setAttribute("ServletContextHandler", handler); 098 jetty.setHandler(handler); 099 100 var securedServlet = new SecuredExampleMcpServer(authServer, rootUrl); 101 102 var beanStore = new BasicBeanStore(); 103 beanStore.addBean(Server.class, jetty); 104 beanStore.addBean(Servlet.class, securedServlet); 105 106 var microservice = Microservice.create() 107 .beanStore(beanStore) 108 .configurations(JettyConfiguration.class) 109 .consoleEnabled(false) 110 .build(); 111 microservice.start(); 112 113 return new SecuredExampleServer(microservice, authServer, rootUrl); 114 } catch (Exception e) { 115 if (connector != null) 116 connector.close(); 117 authServer.close(); 118 throw e; 119 } 120 } 121 122 /** 123 * Returns the root URL the secured MCP server is listening on (e.g. {@code http://localhost:10001/}). 124 * 125 * @return The root URL. Never <jk>null</jk>. 126 */ 127 public URI getRootUrl() { 128 return rootUrl; 129 } 130 131 /** 132 * Returns the offline authorization server backing this instance, so a caller (e.g. the end-to-end test, 133 * or {@link #main(String[])} printing a startup banner) can read its token endpoint and demo credentials. 134 * 135 * @return The offline authorization server. Never <jk>null</jk>. 136 */ 137 public OfflineAuthorizationServer getAuthServer() { 138 return authServer; 139 } 140 141 @Override 142 public void close() throws Exception { 143 try { 144 microservice.stop(); 145 } finally { 146 authServer.close(); 147 } 148 } 149 150 /** 151 * Runs both servers until the process is killed, printing everything {@link SecuredExampleClient} needs. 152 * 153 * <p> 154 * M10: the token endpoint is no longer printed here — {@link SecuredExampleClient} now discovers it 155 * itself via RFC 9728 PRM + RFC 8414 discovery, exactly as a real client would, so only the endpoint and 156 * demo client credentials need to be copied. 157 * 158 * @param args Optional single argument: the port the MCP server listens on (defaults to {@link #DEFAULT_PORT}). 159 * @throws Exception If either server fails to start. 160 */ 161 public static void main(String[] args) throws Exception { 162 var port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT; 163 var server = start(port); 164 var auth = server.getAuthServer(); 165 System.out.println("Juneau SECURED MCP example server is listening at " + server.getRootUrl()); 166 System.out.println("Demo client id: " + auth.clientId()); 167 System.out.println("Demo client secret: " + auth.clientSecret()); 168 System.out.println(); 169 System.out.println("Drive it with: SecuredExampleClient " + server.getRootUrl() + " " 170 + auth.clientId() + " " + auth.clientSecret()); 171 System.out.println("Press Ctrl-C to stop."); 172 Thread.currentThread().join(); 173 } 174}