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.rest.server.mcp.v20260728; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.util.concurrent.atomic.AtomicReference; 022 023import org.apache.juneau.commons.inject.Bean; 024import org.apache.juneau.rest.server.*; 025import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker; 026import org.apache.juneau.rest.server.tracing.TraceContextExtractor; 027 028import jakarta.servlet.http.HttpServletRequest; 029import jakarta.servlet.http.HttpServletResponse; 030 031/** 032 * Abstract MCP servlet bound to protocol revision {@code 2026-07-28}. 033 * 034 * <p> 035 * Subclass this (rather than the neutral {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet}) to 036 * expose a {@code 2026-07-28} endpoint at {@code POST /}; the revision binding is a compile-time choice 037 * made by which class you extend. 038 * 039 * <p> 040 * URI and polymorphic-type serializer policy ({@code addBeanTypes} and {@code uriResolution="NONE"}) is 041 * inherited centrally from the neutral {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet}; the only 042 * revision-specific behavior added here is notification empty-body processing, below. 043 * 044 * <p> 045 * Registers {@link McpNotificationResponseProcessor} so a {@code null} dispatch result (a JSON-RPC 046 * notification) renders as a genuinely empty HTTP body instead of the framework's default four-byte 047 * {@code null} JSON literal. 048 * 049 * <h5 class='section'>Example:</h5> 050 * <pre> 051 * @Rest(path="/mcp") 052 * public class MyMcpServlet extends McpRestServlet { 053 * @Override 054 * protected McpServerConfig createMcpConfig() { 055 * return new McpServerConfig().setName("my-server").setVersion("1.0.0").addTool(new MyEchoTool()); 056 * } 057 * @Override // optional 058 * protected McpOptions createMcpOptions() { 059 * return new McpOptions() 060 * .setInstructions("Use tool 'echo' to test.") 061 * .cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(60_000))) 062 * .subscriptions(s -> s.setQueueSize(2048)); 063 * } 064 * } 065 * </pre> 066 * 067 * @serial exclude 068 */ 069@SuppressWarnings({ 070 "java:S110" // Inherent to extending the RestServlet hierarchy. 071}) 072@Rest(responseProcessors = McpNotificationResponseProcessor.class) 073public abstract class McpRestServlet extends org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet { 074 private static final long serialVersionUID = 1L; 075 076 private final transient AtomicReference<McpOptions> mcpOptions = new AtomicReference<>(); 077 078 @Override /* AbstractMcpRestServlet */ 079 protected final org.apache.juneau.rest.server.mcp.McpRevision revision() { 080 var o = getMcpOptions(); 081 return new McpRevision(o.getCapabilities(), o.getCache(), o.getInstructions(), o.getMrtr()); 082 } 083 084 /** 085 * Publishes this revision's stable {@link TraceContextExtractor} so an active {@code TracerHook} 086 * (via {@code RestOpInvoker}) recognizes this endpoint's resolved {@code JsonRpcRequest} argument 087 * and its {@code params._meta} carrier before span creation. 088 * 089 * <p> 090 * A no-op when no {@code TracerHook} bean is registered — see the no-tracer fast-path 091 * contract documented on {@code TracerHook}. 092 * 093 * @return This revision's shared {@link TraceContextExtractor}. Never <jk>null</jk>. 094 */ 095 @Bean 096 public final TraceContextExtractor mcpTraceContextExtractor() { 097 return McpRevision.TRACE_CONTEXT_EXTRACTOR; 098 } 099 100 /** 101 * Factory method for the behavior configuration published by {@link #getMcpOptions()}. 102 * 103 * <p> 104 * Override to customize any of capabilities/instructions/cache/mrtr/subscriptions/broker in one place. The 105 * default returns a plain {@link McpOptions} (all defaults). 106 * 107 * <p> 108 * Runs <b>eagerly during servlet/{@code RestContext} initialization, not lazily on first request</b>: 109 * {@link #getMcpOptions()} is itself a {@code @Bean} hook, so {@code RestContext}'s constructor invokes it 110 * — and therefore this method — while walking the resource's {@code @Bean} methods at startup, 111 * strictly before the embedded server's {@code start()} returns. Do not write an override that depends on 112 * runtime request state or on a port/address only known after {@code start()} (e.g. a Jetty ephemeral 113 * port); that state does not exist yet when this hook runs. 114 * 115 * <p> 116 * <b>Must be side-effect-free and idempotent</b>, exactly like 117 * {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet#createMcpConfig()}: {@link #getMcpOptions()} 118 * calls this hook at most once per servlet instance and publishes the result via a lock-free 119 * {@link java.util.concurrent.atomic.AtomicReference#compareAndSet}, so under a first-access race it may be 120 * invoked by more than one thread, but only one resulting instance is ever published and returned. 121 * 122 * @return A non-<jk>null</jk> options instance. 123 */ 124 protected McpOptions createMcpOptions() { 125 return new McpOptions(); 126 } 127 128 /** 129 * Returns this servlet's binding-owned {@link McpOptions}, built eagerly during initialization. 130 * 131 * <p> 132 * Because this method is annotated {@code @Bean}, {@code RestContext}'s constructor invokes it (and 133 * therefore {@link #createMcpOptions()}) while the servlet is initializing — strictly before the 134 * embedded server's {@code start()} returns. This is <b>not</b> deferred to the first incoming HTTP 135 * request, despite the internal caching below; a {@link #createMcpOptions()} override must not depend on 136 * runtime request/port state, since none exists yet at that point. The {@link AtomicReference}-backed 137 * publish below exists only to make repeat/racing invocations of this {@code @Bean} method idempotent 138 * — every call after the first returns the same instance, mirroring exactly how 139 * {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet#getMcpConfig() getMcpConfig()} memoizes 140 * — not to defer construction itself. 141 * Also published as a {@code @Bean} so downstream plumbing ({@link #getSubscriptionBroker()}, 142 * {@code subscriptions/listen} dispatch) can resolve it uniformly through the {@code BeanStore}. This 143 * {@code @Bean} publication is authoritative and takes precedence over any Spring {@code @Bean McpOptions} 144 * in the app context: a Spring user who wants their configuration honored must inject it INTO 145 * {@link #createMcpOptions()}'s override (for example an {@code @Autowired} field read from there), not 146 * publish a bare {@code @Bean McpOptions} in their Spring context and expect it to be picked up instead. 147 * 148 * @return The options. Never <jk>null</jk>. 149 * @throws IllegalStateException If {@link #createMcpOptions()} returns <jk>null</jk>. 150 */ 151 @Bean 152 public final McpOptions getMcpOptions() { 153 var o = mcpOptions.get(); 154 if (o == null) { 155 var no = createMcpOptions(); 156 if (no == null) 157 throw isex("createMcpOptions() returned null"); 158 o = mcpOptions.compareAndSet(null, no) ? no : mcpOptions.get(); // HTT: the CAS-loses branch requires a genuine concurrent first-access race; untestable deterministically. 159 } 160 return o; 161 } 162 163 /** 164 * Returns this servlet's lazily-published, binding-owned subscription broker. 165 * 166 * <p> 167 * Reads {@link #getMcpOptions()}'s memoized {@link McpOptions#getSubscriptionBroker()}: an explicitly 168 * configured broker is returned as-is; otherwise one is derived and memoized per-binding, sized from 169 * {@link McpOptions#getSubscriptions()}'s queue bound (see {@link McpOptions#resolveSubscriptionBroker()}). 170 * 171 * @return The subscription broker. Never <jk>null</jk>. 172 */ 173 @Override 174 public final McpSubscriptionBroker getSubscriptionBroker() { 175 return getMcpOptions().resolveSubscriptionBroker(); 176 } 177 178 /** 179 * OAuth 2.1 resource-server bearer gate for the MCP {@code POST /} endpoint. 180 * 181 * <p> 182 * A no-op unless {@link McpResourceServerConfig#isEnabled() RS auth is enabled}; when enabled, every request that is 183 * a {@code POST}, or that claims one (the dedicated MCP servlet's only writable surface) must present a valid bearer 184 * token or it is rejected with {@code 401} / {@code 403} and a {@code WWW-Authenticate: Bearer ...} challenge (see 185 * {@link McpResourceServerSupport#gateMcpServlet(McpResourceServerConfig, HttpServletRequest, HttpServletResponse)}). 186 * The gate treats a {@code POST} claim on any override channel ({@code ?method=} or any of the four override 187 * headers, any value) as a {@code POST}, rather than reading only the raw servlet method, so a method-override cannot 188 * slip past it. The well-known {@code GET} routes below are 189 * intentionally left unauthenticated so a client can discover the PRM document. 190 * 191 * @param req The raw HTTP request. 192 * @param res The raw HTTP response (used to emit the challenge header on rejection). 193 */ 194 @RestStartCall 195 public final void mcpResourceServerAuth(HttpServletRequest req, HttpServletResponse res) { 196 McpResourceServerSupport.gateMcpServlet(getMcpOptions().getResourceServer(), req, res); 197 } 198 199 /** 200 * Serves the RFC 9728 Protected Resource Metadata document at the root well-known location (SEP-2351 fallback). 201 * 202 * <p> 203 * This route (and its path-inserted sibling) is registered unconditionally, but returns {@code 404} whenever RS auth 204 * is {@link McpResourceServerConfig#isEnabled() disabled} — so a non-opted-in servlet claims the well-known 205 * namespace but exposes no observable metadata. 206 * 207 * @param req The REST request. 208 * @return The PRM document. 209 */ 210 @RestGet(path = "/.well-known/oauth-protected-resource") 211 public McpProtectedResourceMetadata mcpProtectedResourceMetadataRoot(RestRequest req) { 212 return McpResourceServerSupport.metadataForWellKnownRoot(getMcpOptions().getResourceServer(), req); 213 } 214 215 /** 216 * Serves the RFC 9728 Protected Resource Metadata document at the path-inserted well-known location (SEP-2351), but 217 * only when the requested suffix matches this resource's expected well-known path; otherwise {@code 404}. 218 * 219 * @param req The REST request. 220 * @return The PRM document. 221 */ 222 @RestGet(path = "/.well-known/oauth-protected-resource/*") 223 public McpProtectedResourceMetadata mcpProtectedResourceMetadataPathInserted(RestRequest req) { 224 return McpResourceServerSupport.metadataForWellKnownPathInserted(getMcpOptions().getResourceServer(), req); 225 } 226}