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.v20250618;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020import static org.apache.juneau.commons.utils.StringUtils.*;
021
022import java.lang.reflect.*;
023import java.util.*;
024
025import org.apache.juneau.bean.jsonrpc.*;
026import org.apache.juneau.bean.mcp.v20250618.*;
027import org.apache.juneau.commons.inject.*;
028import org.apache.juneau.commons.utils.JsonValueSafety;
029import org.apache.juneau.marshall.collections.*;
030import org.apache.juneau.rest.server.mcp.McpCompletionRef;
031import org.apache.juneau.rest.server.mcp.McpCompletionRequest;
032import org.apache.juneau.rest.server.mcp.McpCompletionResult;
033import org.apache.juneau.rest.server.mcp.McpCursor;
034import org.apache.juneau.rest.server.mcp.McpDispatchResult;
035import org.apache.juneau.rest.server.mcp.McpErrorKind;
036import org.apache.juneau.rest.server.mcp.McpExchange;
037import org.apache.juneau.rest.server.mcp.McpParamUtils;
038import org.apache.juneau.rest.server.mcp.McpPromptHandler;
039import org.apache.juneau.rest.server.mcp.McpResourceHandler;
040import org.apache.juneau.rest.server.mcp.McpResourceTemplateHandler;
041import org.apache.juneau.rest.server.mcp.McpResponseResult;
042import org.apache.juneau.rest.server.mcp.McpSchema;
043import org.apache.juneau.rest.server.mcp.McpServerConfig;
044import org.apache.juneau.rest.server.mcp.McpToolHandler;
045import org.apache.juneau.rest.server.mcp.McpToolOutcome;
046
047/**
048 * {@link McpRevision} implementation for MCP revision {@code 2025-06-18}.
049 *
050 * <p>
051 * Owns this revision's JSON-RPC method table and error-code table. Note the error-code table
052 * deliberately reproduces a known-wrong mapping: {@link McpErrorKind#UNKNOWN_METHOD},
053 * {@link McpErrorKind#TOOL_NOT_FOUND} and {@link McpErrorKind#PROMPT_NOT_FOUND} all report
054 * {@code -32601}, which is only actually correct for the first. Preserving that is intentional —
055 * the corrective fix for those two is tracked separately and is not part of this re-layering.
056 * {@link McpErrorKind#RESOURCE_NOT_FOUND} is not part of that known-wrong grouping: it correctly
057 * reports {@code -32002}, the {@code 2025-06-18} spec's dedicated missing-resource error code.
058 *
059 * <p>
060 * <b>Constructed per binding, not shared as a singleton (Correction C8).</b> The bound servlet or
061 * mixin builds a fresh instance on every {@code revision()} call, passing its own
062 * {@code capabilities()} hook result into the constructor — see {@link McpRestServlet} and
063 * {@link McpEndpoint}. That is how this revision learns which explicit capabilities (if any)
064 * to advertise, with no channel back into core: this class is otherwise entirely stateless, so
065 * constructing a new one per call costs nothing.
066 */
067@SuppressWarnings({
068   "java:S2176" // Settled, intentional name shadow against the neutral org.apache.juneau.rest.server.mcp.McpRevision SPI
069                // (evaluated and rejected renaming this to e.g. AbstractMcpRevision): every dated adapter binding
070                // class is deliberately de-versioned to plain "McpRevision" and differentiated from its siblings only by
071                // package, matching the revision-string-free naming already used throughout each vNNNNNNNN module.
072})
073public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpRevision {
074
075   private final ServerCapabilities capabilities;
076
077   /**
078    * Constructor.
079    *
080    * @param capabilities Explicit capabilities to advertise on {@code initialize}, or <jk>null</jk>
081    *    to auto-derive from the registered tool/prompt/resource lists (today's default behavior).
082    */
083   public McpRevision(ServerCapabilities capabilities) {
084      this.capabilities = capabilities;
085   }
086
087   /** JSON-RPC error code: parse error. Never reported by this revision (see {@link McpErrorKind#PARSE_ERROR}). */
088   public static final int CODE_PARSE_ERROR = -32700;
089
090   /** JSON-RPC error code: invalid request. */
091   public static final int CODE_INVALID_REQUEST = -32600;
092
093   /** JSON-RPC error code: method not found. */
094   public static final int CODE_METHOD_NOT_FOUND = -32601;
095
096   /** JSON-RPC error code: invalid params. */
097   public static final int CODE_INVALID_PARAMS = -32602;
098
099   /** JSON-RPC error code: internal error. */
100   public static final int CODE_INTERNAL_ERROR = -32603;
101
102   /** JSON-RPC error code: resource not found (MCP-specific, per the {@code 2025-06-18} spec). */
103   public static final int CODE_RESOURCE_NOT_FOUND = -32002;
104
105   /** Default server name reported by {@code initialize} when the config supplies no server identity. */
106   public static final String DEFAULT_SERVER_NAME = "juneau-rest-server-mcp";
107
108   private static final String PARAM_ARGUMENTS = "arguments";
109
110   private static final Set<String> SUPPORTED_SCHEMA_KEYWORDS =
111      Set.of("type", "properties", "required", "additionalProperties", "items", "$defs");
112
113   private static final Set<McpServerConfig> VALIDATED =
114      Collections.synchronizedSet(Collections.newSetFromMap(new WeakHashMap<>()));
115
116   /**
117    * Verifies that every registered tool's input <em>and</em> output schema is expressible in this
118    * revision's wire schema type.
119    *
120    * <p>
121    * The neutral {@link McpSchema} is an unconstrained JSON object carrier, but this revision's
122    * {@code JsonSchema} bean supports exactly six keywords and models only object schemas. Each
123    * schema role is therefore checked twice: its root must declare {@code "type":"object"}, and the
124    * complete graph reachable from that root may use only this revision's six keywords. Rather than
125    * silently dropping an unsupported keyword — or an incompatible root shape — on the wire, a config
126    * carrying one is rejected, naming the tool, the offending role ({@code inputSchema} /
127    * {@code outputSchema}), and the offending shape or keyword.
128    *
129    * <p>
130    * Before walking, each schema is passed through {@link JsonValueSafety} to bound nesting depth,
131    * node count, traversal time, and cyclic/shared references, so a pathological config cannot stall
132    * or exhaust the walk.
133    *
134    * <p>
135    * Note this cannot be a complete guarantee against every possible wire shape: {@code JsonSchema}
136    * is {@code public} and non-{@code final}, and Juneau's reflection-based marshalling will
137    * serialize a getter added by a subclass. No such subclass exists in this codebase, and none can
138    * be constructed through the public MCP API, but the check covers what a caller can express
139    * through {@link McpSchema}, not what reflection can reach.
140    *
141    * @param config The config to validate. Never {@code null}.
142    * @throws IllegalArgumentException If any tool's input or output schema has a non-object root or uses an unsupported keyword.
143    */
144   public static void validateSchemas(McpServerConfig config) {
145      config.getTools().forEach(handler -> {
146         var spec = handler.descriptor();
147         if (spec != null) {
148            validateSchema(spec.getName(), "inputSchema", spec.getInputSchema());
149            validateSchema(spec.getName(), "outputSchema", spec.getOutputSchema());
150         }
151      });
152   }
153
154   private static void validateSchema(String toolName, String role, McpSchema schema) {
155      if (schema == null)
156         return;
157      var raw = schema.toJsonMap();
158      JsonValueSafety.check(raw, "Tool '" + toolName + "' " + role + " for MCP revision 2025-06-18");
159      if (neq(raw.get("type"), "object"))
160         throw iaex("Tool ''%s'' %s root type ''%s'' is not object; MCP revision 2025-06-18 requires object schemas.",
161            toolName, role, raw.get("type"));
162      checkKeywords(toolName, role, raw);
163   }
164
165   @SuppressWarnings({
166      "java:S3776" // Cognitive complexity acceptable for an explicit-stack JSON-schema tree walk; splitting the per-shape branches would fragment a single cohesive traversal.
167   })
168   private static void checkKeywords(String toolName, String role, Object root) {
169      var seen = Collections.newSetFromMap(new IdentityHashMap<Object,Boolean>());
170      var stack = new ArrayDeque<SchemaValue>();
171      stack.push(new SchemaValue(root));
172      while (! stack.isEmpty()) {
173         var framed = stack.pop();
174         var value = framed.value();
175         if ((value instanceof Map<?,?> || value instanceof Collection<?> || value != null && value.getClass().isArray())
176            && ! seen.add(value))
177            continue;
178         if (value instanceof Map<?,?> value2) {
179            for (var entry : value2.entrySet()) {
180               var keyword = String.valueOf(entry.getKey());
181               if (! SUPPORTED_SCHEMA_KEYWORDS.contains(keyword))
182                  throw iaex("Tool ''%s'' %s declares JSON Schema keyword ''%s'', which MCP revision 2025-06-18 cannot represent.",
183                     toolName, role, keyword);
184               var child = entry.getValue();
185               if ((eqa(keyword, "properties", "$defs")) && child instanceof Map<?,?> child2) {
186                  for (var schema : child2.values())
187                     stack.push(new SchemaValue(schema));
188               } else {
189                  stack.push(new SchemaValue(child));
190               }
191            }
192         } else if (value instanceof Collection<?> value2)
193            value2.forEach(child -> stack.push(new SchemaValue(child)));
194         else if (value != null && value.getClass().isArray())
195            for (var i = 0; i < Array.getLength(value); i++)
196               stack.push(new SchemaValue(Array.get(value, i)));
197      }
198   }
199
200   private record SchemaValue(Object value) {}
201
202   @Override /* McpRevision */
203   public String protocolVersion() {
204      return McpProtocol.VERSION_2025_06_18;
205   }
206
207   @Override /* McpRevision */
208   public int errorCode(McpErrorKind kind) {
209      return switch (kind) {
210         case INVALID_REQUEST -> CODE_INVALID_REQUEST;
211         case UNKNOWN_METHOD, TOOL_NOT_FOUND, PROMPT_NOT_FOUND -> CODE_METHOD_NOT_FOUND;
212         case RESOURCE_NOT_FOUND -> CODE_RESOURCE_NOT_FOUND;
213         case INVALID_PARAMS -> CODE_INVALID_PARAMS;
214         case INTERNAL_ERROR -> CODE_INTERNAL_ERROR;
215         case PARSE_ERROR -> CODE_PARSE_ERROR;
216      };
217   }
218
219   @Override /* McpRevision */
220   public McpDispatchResult dispatch(McpExchange exchange, McpServerConfig config, BeanStore ctx) {
221      var response = dispatchResponse(exchange, config, ctx);
222      return response == null ? null : new McpResponseResult(response);
223   }
224
225   /**
226    * Does the actual dispatch work. This revision never streams, so every non-notification outcome is a
227    * single {@link JsonRpcResponse}; {@link #dispatch} wraps it in a {@link McpResponseResult}.
228    */
229   private JsonRpcResponse dispatchResponse(McpExchange exchange, McpServerConfig config, BeanStore ctx) {
230      reqnn("exchange", exchange);
231      reqnn("config", config);
232      reqnn("ctx", ctx);
233
234      if (VALIDATED.add(config))
235         validateSchemas(config);
236
237      var req = exchange.request();
238      if (req == null)
239         return JsonRpcResponse.errorResponse(null, errorCode(McpErrorKind.INVALID_REQUEST), "Request envelope is null");
240
241      var id = req.getId();
242      var method = req.getMethod();
243
244      if (isEmpty(method))
245         return JsonRpcResponse.notification(id) ? null
246            : JsonRpcResponse.errorResponse(id, errorCode(McpErrorKind.INVALID_REQUEST), "Missing method");
247
248      try {
249         var result = invoke(method, req.getParams(), config, ctx);
250         return JsonRpcResponse.notification(id) ? null : JsonRpcResponse.ok(id, result);
251      } catch (McpException e) {
252         return JsonRpcResponse.notification(id) ? null : new JsonRpcResponse()
253            .setJsonrpc(McpProtocol.JSON_RPC_2_0)
254            .setId(id)
255            .setError(e.toJsonRpcError());
256      } catch (Exception e) {
257         if (JsonRpcResponse.notification(id))
258            return null;
259         var message = e.getMessage() == null ? cns(e) : e.getMessage();
260         return JsonRpcResponse.errorResponse(id, errorCode(McpErrorKind.INTERNAL_ERROR), message, JsonMap.of("type", cn(e)));
261      }
262   }
263
264   private Object invoke(String method, Object params, McpServerConfig config, BeanStore ctx) {
265      switch (method) {
266         case McpMethods.INITIALIZE:
267            return initialize(config);
268         case McpMethods.PING:
269            return new JsonMap();
270         case McpMethods.TOOLS_LIST:
271            return listTools(config, params, ctx);
272         case McpMethods.TOOLS_CALL:
273            return callTool(config, params, ctx);
274         case McpMethods.PROMPTS_LIST:
275            return listPrompts(config, params, ctx);
276         case McpMethods.PROMPTS_GET:
277            return getPrompt(config, params, ctx);
278         case McpMethods.RESOURCES_LIST:
279            return listResources(config, params, ctx);
280         case McpMethods.RESOURCES_READ:
281            return readResource(config, params, ctx);
282         case McpMethods.RESOURCES_TEMPLATES_LIST:
283            return listResourceTemplates(config, params, ctx);
284         case McpMethods.COMPLETION_COMPLETE:
285            return complete(config, params, ctx);
286         default:
287            throw new McpException(errorCode(McpErrorKind.UNKNOWN_METHOD), "Method not found: " + method);
288      }
289   }
290
291   private InitializeResult initialize(McpServerConfig config) {
292      var caps = this.capabilities;
293      if (caps == null) {
294         caps = new ServerCapabilities();
295         if (! config.getTools().isEmpty())
296            caps.setTools(new ToolCapability());
297         if (! config.getPrompts().isEmpty())
298            caps.setPrompts(new PromptCapability());
299         if (! config.getResources().isEmpty() || ! config.getResourceTemplates().isEmpty())
300            caps.setResources(new ResourceCapability());
301         if (config.hasAnyCompleter())
302            caps.setCompletions(new CompletionCapability());
303      }
304      return new InitializeResult()
305         .setProtocolVersion(protocolVersion())
306         .setCapabilities(caps)
307         .setServerInfo(McpWire.serverInfo(config))
308         .setInstructions(config.getInstructions());
309   }
310
311   private static ListToolsResult listTools(McpServerConfig config, Object params, BeanStore ctx) {
312      var descriptors = config.getTools().stream().map(McpToolHandler::descriptor).toList();
313      var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx);
314      return new ListToolsResult()
315         .setTools(page.items().stream().map(McpWire::toWire).toList())
316         .setNextCursor(page.nextCursor());
317   }
318
319   private CallToolResult callTool(McpServerConfig config, Object params, BeanStore ctx) {
320      var p = McpParamUtils.asMap(params);
321      var name = McpParamUtils.strParam(p, "name");
322      if (name == null)
323         throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing tool name");
324      var handler = config.getTools().stream()
325         .filter(h -> name.equals(h.descriptor().getName()))
326         .findFirst()
327         .orElseThrow(() -> new McpException(errorCode(McpErrorKind.TOOL_NOT_FOUND), "Tool not found: " + name));
328      var args = McpParamUtils.mapParam(p, PARAM_ARGUMENTS);
329      var outcome = handler.call(args, ctx);
330      validateStructuredOutput(outcome);
331      return McpWire.toWire(outcome);
332   }
333
334   private static void validateStructuredOutput(McpToolOutcome outcome) {
335      if (outcome == null || outcome.getStructuredContent() == null)
336         return;
337      if (! (outcome.getStructuredContent() instanceof Map<?,?>))
338         throw new McpException(CODE_INTERNAL_ERROR,
339            "Tool structuredContent must be a JSON object for MCP revision 2025-06-18");
340      try {
341         JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent");
342      } catch (IllegalArgumentException e) {
343         throw new McpException(CODE_INTERNAL_ERROR, e.getMessage());
344      }
345   }
346
347   private static ListPromptsResult listPrompts(McpServerConfig config, Object params, BeanStore ctx) {
348      var descriptors = config.getPrompts().stream().map(McpPromptHandler::descriptor).toList();
349      var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx);
350      return new ListPromptsResult()
351         .setPrompts(page.items().stream().map(McpWire::toWire).toList())
352         .setNextCursor(page.nextCursor());
353   }
354
355   private GetPromptResult getPrompt(McpServerConfig config, Object params, BeanStore ctx) {
356      var p = McpParamUtils.asMap(params);
357      var name = McpParamUtils.strParam(p, "name");
358      if (name == null)
359         throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing prompt name");
360      var handler = config.getPrompts().stream()
361         .filter(h -> name.equals(h.descriptor().getName()))
362         .findFirst()
363         .orElseThrow(() -> new McpException(errorCode(McpErrorKind.PROMPT_NOT_FOUND), "Prompt not found: " + name));
364      var args = McpParamUtils.mapParam(p, PARAM_ARGUMENTS);
365      return McpWire.toWire(handler.get(args, ctx));
366   }
367
368   private static ListResourcesResult listResources(McpServerConfig config, Object params, BeanStore ctx) {
369      var descriptors = config.getResources().stream().map(McpResourceHandler::descriptor).toList();
370      var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx);
371      return new ListResourcesResult()
372         .setResources(page.items().stream().map(McpWire::toWire).toList())
373         .setNextCursor(page.nextCursor());
374   }
375
376   private ReadResourceResult readResource(McpServerConfig config, Object params, BeanStore ctx) {
377      var p = McpParamUtils.asMap(params);
378      var uri = McpParamUtils.strParam(p, "uri");
379      if (uri == null)
380         throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing resource uri");
381      var exact = config.getResources().stream()
382         .filter(h -> uri.equals(h.descriptor().getUri()))
383         .findFirst();
384      if (exact.isPresent())
385         return McpWire.toWire(exact.get().read(uri, ctx));
386      var match = config.resolveResourceTemplate(uri);
387      var outcome = match == null ? null : match.handler().read(uri, match.variables(), ctx);
388      if (outcome == null)
389         throw new McpException(errorCode(McpErrorKind.RESOURCE_NOT_FOUND), "Resource not found: " + uri);
390      return McpWire.toWire(outcome);
391   }
392
393   private static ListResourceTemplatesResult listResourceTemplates(McpServerConfig config, Object params, BeanStore ctx) {
394      var descriptors = config.getResourceTemplates().stream().map(McpResourceTemplateHandler::descriptor).toList();
395      var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx);
396      return new ListResourceTemplatesResult()
397         .setResourceTemplates(page.items().stream().map(McpWire::toWire).toList())
398         .setNextCursor(page.nextCursor());
399   }
400
401   private CompleteResult complete(McpServerConfig config, Object params, BeanStore ctx) {
402      var p = McpParamUtils.asMap(params);
403      var ref = completionRef(McpParamUtils.mapParam(p, "ref"));
404      var argument = McpParamUtils.mapParam(p, "argument");
405      var argumentName = McpParamUtils.strParam(argument, "name");
406      if (isEmpty(argumentName))
407         throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing argument.name");
408      var value = McpParamUtils.strictStrParam(argument, "value");
409      if (value == null)
410         throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing or non-string argument.value");
411      var contextArguments = McpParamUtils.strictStrMapParam(McpParamUtils.mapParam(p, "context"), PARAM_ARGUMENTS);
412
413      var completer = config.completer(ref, argumentName);
414      McpCompletionResult raw;
415      if (completer == null) {
416         raw = McpCompletionResult.empty();
417      } else {
418         var request = new McpCompletionRequest()
419            .setRef(ref)
420            .setArgumentName(argumentName)
421            .setValue(value)
422            .setContextArguments(contextArguments);
423         raw = completer.complete(request, ctx);
424      }
425      return McpWire.toWire(McpCompletionResult.normalize(raw));
426   }
427
428   private McpCompletionRef completionRef(Map<String,Object> refMap) {
429      var type = McpParamUtils.strParam(refMap, "type");
430      if (eq(type, "ref/prompt")) {
431         var name = McpParamUtils.strParam(refMap, "name");
432         if (isEmpty(name))
433            throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing ref.name for ref/prompt");
434         return McpCompletionRef.prompt(name);
435      }
436      if (eq(type, "ref/resource")) {
437         var uri = McpParamUtils.strParam(refMap, "uri");
438         if (isEmpty(uri))
439            throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing ref.uri for ref/resource");
440         return McpCompletionRef.resource(uri);
441      }
442      throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Invalid or missing ref.type: " + type);
443   }
444}