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;
018
019import java.util.concurrent.atomic.*;
020
021import org.apache.juneau.bean.jsonrpc.*;
022import org.apache.juneau.commons.inject.*;
023import org.apache.juneau.http.Content;
024import org.apache.juneau.marshall.serializer.*;
025import org.apache.juneau.rest.server.*;
026import org.apache.juneau.rest.server.health.*;
027import org.apache.juneau.rest.server.servlet.*;
028
029/**
030 * Drop-in {@link BasicRestServlet} subclass that exposes a single MCP JSON-RPC endpoint at {@code POST /}.
031 *
032 * <p>
033 * Subclasses provide their {@link McpServerConfig} by overriding {@link #createMcpConfig()} (called once at
034 * {@link #init() init} time), or by registering an {@link McpServerConfig} bean in their {@code RestContext}
035 * bean store.
036 *
037 * <h5 class='section'>Example:</h5>
038 * <pre>
039 * @Rest(path="/mcp")
040 * public class MyMcpServlet extends org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet {
041 *     @Override
042 *     protected McpServerConfig createMcpConfig() {
043 *         return new McpServerConfig()
044 *             .setName("my-server").setVersion("1.0.0")
045 *             .addTool(new MyEchoTool());
046 *     }
047 * }
048 * </pre>
049 *
050 * <p>
051 * The servlet enables {@code addBeanTypes} on its serializer so that polymorphic wire types a
052 * revision emits (content blocks, resource payloads) are tagged with their {@code type}
053 * discriminator, and disables URI resolution ({@code uriResolution="NONE"}) so that JSON-Schema
054 * JSON-Pointer {@code $ref} values (e.g. {@code "#/$defs/text"}) and absolute URIs with fragments
055 * are emitted verbatim rather than rewritten against the servlet path.
056 *
057 * @serial exclude
058 */
059@Rest
060@SerializerConfig(addBeanTypes = "true", uriResolution = "NONE")
061public abstract class AbstractMcpRestServlet extends BasicRestServlet implements ReadinessStateAware {
062   private static final long serialVersionUID = 1L;
063
064   private final transient AtomicReference<McpServerConfig> config = new AtomicReference<>();
065
066   /**
067    * Returns the {@link McpServerConfig} backing this servlet.
068    *
069    * <p>
070    * The default implementation lazily caches the value of {@link #createMcpConfig()}; subclasses needing
071    * per-request reconfiguration may override.
072    *
073    * <p>
074    * This is invoked on every request, so the cache is published via a lock-free {@link AtomicReference#compareAndSet}
075    * rather than a {@code synchronized} block, avoiding monitor contention on the hot path. Under a first-access
076    * race, {@link #createMcpConfig()} may be invoked by more than one thread, but only one resulting instance is
077    * ever published to {@link #config} and returned to any caller; the others are discarded. This is safe as long
078    * as {@link #createMcpConfig()} is side-effect-free and idempotent (i.e. simply constructs and returns a new
079    * config), which is the documented contract of that method.
080    *
081    * @return The config. Never {@code null}.
082    */
083   public McpServerConfig getMcpConfig() {
084      var c = config.get();
085      if (c == null) {
086         var nc = createMcpConfig();
087         if (nc == null)
088            throw new IllegalStateException("createMcpConfig() returned null");
089         c = config.compareAndSet(null, nc) ? nc : config.get(); // HTT: the CAS-loses branch requires a genuine concurrent first-access race; untestable deterministically.
090      }
091      return c;
092   }
093
094   /**
095    * Factory method for the {@link McpServerConfig} backing this servlet.
096    *
097    * <p>
098    * Subclasses must override this method to declare their tools, prompts, and resources.
099    *
100    * @return A non-{@code null} server config.
101    */
102   protected abstract McpServerConfig createMcpConfig();
103
104   /**
105    * The MCP protocol revision this servlet speaks.
106    *
107    * <p>
108    * Supplied by the revision-specific subclass a consumer extends (for example
109    * {@code org.apache.juneau.rest.server.mcp.v20250618.McpRestServlet}); consumers do not normally implement this themselves.
110    *
111    * @return The bound revision. Never {@code null}.
112    */
113   protected abstract McpRevision revision();
114
115   /**
116    * Optional subscription broker backing this servlet's {@code subscriptions/listen} support.
117    *
118    * <p>
119    * Returning <jk>null</jk> (the default) means this servlet does not support subscriptions: no
120    * {@link McpSubscriptionBroker} or {@link McpSubscriptions} bean is added to the request-scoped
121    * {@link BeanStore} that {@link #handleMcp} builds, so any handler or dispatch branch that looks one up
122    * via {@code ctx.getBean(McpSubscriptionBroker.class)} sees {@link java.util.Optional#empty()}.
123    *
124    * <p>
125    * A revision binding that implements {@code subscriptions/listen} (for example
126    * {@code org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet}) overrides this to return a
127    * memoized broker instance so every request sees the same live subscription registry.
128    *
129    * @return The subscription broker, or <jk>null</jk> if this servlet does not support subscriptions.
130    */
131   protected McpSubscriptionBroker getSubscriptionBroker() {
132      return null;
133   }
134
135   /**
136    * Closes this servlet's open subscription streams as soon as the embedded server begins shutting down.
137    *
138    * <p>
139    * A {@code subscriptions/listen} stream never finishes on its own, so without this the server's graceful
140    * drain would wait out its entire stop timeout (30s by default) for every connected subscriber before it
141    * could stop.  Closing the subscriptions makes each stream send its terminal frame and complete, letting
142    * the drain finish immediately.
143    *
144    * @param state The per-service readiness state published by the embedded-server lifecycle component.
145    */
146   @SuppressWarnings({
147      "java:S2654" // Not a constructor escape: the lifecycle calls this on a fully constructed servlet, and the callback only closes the broker.
148   })
149   @Override /* ReadinessStateAware */
150   public void acceptReadinessState(ReadinessState state) {
151      state.onOutOfService(this, () -> {
152         var broker = getSubscriptionBroker();
153         if (broker != null)
154            broker.closeAll();
155      });
156   }
157
158   /**
159    * MCP JSON-RPC endpoint.
160    *
161    * @param req The parsed JSON-RPC request envelope.
162    * @param restReq The HTTP request (used to surface the bean store to handlers).
163    * @return The JSON-RPC response, or {@code null} for notifications (which the framework renders as an
164    *         empty body).
165    */
166   @SuppressWarnings({
167      "resource" // Request-scoped scratch BasicBeanStore; lifetime is bounded by this handler invocation, no foreign resources are captured.
168   })
169   @RestPost(path = "/")
170   public Object handleMcp(@Content JsonRpcRequest req, RestRequest restReq) {
171      var bs = new BasicBeanStore(restReq.getContext().getBeanStore())
172         .addBean(RestRequest.class, restReq);
173      var broker = getSubscriptionBroker();
174      if (broker != null)
175         bs.addBean(McpSubscriptionBroker.class, broker).addBean(McpSubscriptions.class, broker);
176      var exchange = new McpExchange(req, n -> restReq.getHeaderParam(n).asString().orElse(null));
177      var result = revision().dispatch(exchange, getMcpConfig(), bs);
178      if (result instanceof McpResponseResult r)
179         return r.response();
180      if (result instanceof McpStreamResult r)
181         return r.stream();
182      return null;
183   }
184}