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}