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}