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.*; 020import static org.apache.juneau.commons.utils.StringUtils.*; 021 022import java.nio.charset.StandardCharsets; 023import java.security.*; 024import java.util.*; 025import java.util.concurrent.Flow; 026import java.util.logging.Level; 027import java.util.logging.Logger; 028 029import org.apache.juneau.bean.jsonrpc.*; 030import org.apache.juneau.bean.mcp.v20260728.*; 031import org.apache.juneau.commons.concurrent.*; 032import org.apache.juneau.commons.inject.*; 033import org.apache.juneau.commons.utils.JsonValueSafety; 034import org.apache.juneau.http.tracing.TraceContextCarrier; 035import org.apache.juneau.marshall.collections.*; 036import org.apache.juneau.marshall.sse.SseEvent; 037import org.apache.juneau.rest.server.RestRequest; 038import org.apache.juneau.rest.server.tracing.*; 039import org.apache.juneau.marshall.jcs.JcsSerializer; 040import org.apache.juneau.marshall.json.JsonParser; 041import org.apache.juneau.marshall.marshaller.Jcs; 042import org.apache.juneau.marshall.marshaller.Json; 043import org.apache.juneau.marshall.serializer.SerializeException; 044import org.apache.juneau.rest.server.mcp.McpCompletionRef; 045import org.apache.juneau.rest.server.mcp.McpCompletionRequest; 046import org.apache.juneau.rest.server.mcp.McpCompletionResult; 047import org.apache.juneau.rest.server.mcp.McpCursor; 048import org.apache.juneau.rest.server.mcp.McpDispatchResult; 049import org.apache.juneau.rest.server.mcp.McpErrorKind; 050import org.apache.juneau.rest.server.mcp.McpExchange; 051import org.apache.juneau.rest.server.mcp.McpParamUtils; 052import org.apache.juneau.rest.server.mcp.McpPromptHandler; 053import org.apache.juneau.rest.server.mcp.McpResourceHandler; 054import org.apache.juneau.rest.server.mcp.McpResourceTemplateHandler; 055import org.apache.juneau.rest.server.mcp.McpResponseResult; 056import org.apache.juneau.rest.server.mcp.McpServerConfig; 057import org.apache.juneau.rest.server.mcp.McpStreamResult; 058import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker; 059import org.apache.juneau.rest.server.mcp.McpToolHandler; 060import org.apache.juneau.rest.server.mcp.McpToolOutcome; 061 062/** 063 * {@link org.apache.juneau.rest.server.mcp.McpRevision} implementation for MCP revision {@code 2026-07-28}. 064 * 065 * <p> 066 * Owns this revision's stateless JSON-RPC method table, per-request {@code _meta} negotiation, 067 * SEP-2243 {@code Mcp-Method}/{@code Mcp-Name} header agreement checks, {@code server/discover} 068 * dispatch, and error-code table. Every request is independently negotiated from its own 069 * {@code _meta}; no handshake, session, or prior-request state exists. 070 * 071 * <p> 072 * <b>Constructed per binding, not shared as a singleton.</b> The bound servlet or mixin builds a 073 * fresh instance on every {@code revision()} call, passing its {@link McpOptions#getCapabilities()} 074 * result and its published {@link McpCacheConfig} into the constructor — see {@link McpRestServlet} 075 * and {@link McpEndpoint}. A {@code null} capabilities override auto-derives {@code server/discover} 076 * capabilities from the registered tool/prompt/resource/resource-template lists; a non-{@code null} 077 * override is advertised as-is. The cache config is binding-owned: it is supplied once at 078 * construction, is never {@code null}, and is treated as static and request-independent for the 079 * lifetime of this instance — every page of a given list method resolves to identical cache values. 080 * The instance retains no request-derived state. 081 */ 082@SuppressWarnings({ 083 "java:S2176", // Settled, intentional name shadow against the neutral org.apache.juneau.rest.server.mcp.McpRevision SPI 084 // (renaming this to e.g. AbstractMcpRevision was evaluated and rejected): every dated adapter binding 085 // class is deliberately de-versioned to plain "McpRevision" and differentiated from its siblings only by 086 // package, matching the revision-string-free naming already used throughout each vNNNNNNNN module. 087 "resource" // The McpSubscription (AutoCloseable) returned by registerIfUnder is handed to the streaming publisher, which closes it 088}) 089public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpRevision { 090 091 private final ServerCapabilities capabilities; 092 private final McpCacheConfig cacheConfig; 093 private final String instructions; 094 private final McpMrtrConfig mrtrConfig; 095 096 /** 097 * Constructor. Uses an empty {@link McpCacheConfig} (no cache hints emitted on any result) and no 098 * discovery instructions. 099 * 100 * @param capabilities Explicit capabilities to advertise on {@code server/discover}, or <jk>null</jk> 101 * to auto-derive from the registered tool/prompt/resource lists. 102 */ 103 public McpRevision(ServerCapabilities capabilities) { 104 this(capabilities, new McpCacheConfig()); 105 } 106 107 /** 108 * Constructor. Advertises no discovery instructions. 109 * 110 * @param capabilities Explicit capabilities to advertise on {@code server/discover}, or <jk>null</jk> 111 * to auto-derive from the registered tool/prompt/resource lists. 112 * @param cacheConfig Binding-owned cache configuration. Must not be <jk>null</jk>. 113 */ 114 public McpRevision(ServerCapabilities capabilities, McpCacheConfig cacheConfig) { 115 this(capabilities, cacheConfig, null); 116 } 117 118 /** 119 * Constructor. Uses a default {@link McpMrtrConfig} (AES-GCM ephemeral codec, 5-minute 120 * {@code requestState} TTL, 10-round cap). 121 * 122 * @param capabilities Explicit capabilities to advertise on {@code server/discover}, or <jk>null</jk> 123 * to auto-derive from the registered tool/prompt/resource lists. 124 * @param cacheConfig Binding-owned cache configuration. Must not be <jk>null</jk>. 125 * @param instructions Optional free-form {@code server/discover} usage instructions, or <jk>null</jk> 126 * to omit them. 127 */ 128 public McpRevision(ServerCapabilities capabilities, McpCacheConfig cacheConfig, String instructions) { 129 this(capabilities, cacheConfig, instructions, new McpMrtrConfig()); 130 } 131 132 /** 133 * Constructor. 134 * 135 * @param capabilities Explicit capabilities to advertise on {@code server/discover}, or <jk>null</jk> 136 * to auto-derive from the registered tool/prompt/resource lists. 137 * @param cacheConfig Binding-owned cache configuration. Must not be <jk>null</jk>. 138 * @param instructions Optional free-form {@code server/discover} usage instructions, or <jk>null</jk> 139 * to omit them. 140 * @param mrtrConfig Binding-owned MRTR (Multi-Round-Trip Request) configuration. Must not be <jk>null</jk>. 141 */ 142 public McpRevision(ServerCapabilities capabilities, McpCacheConfig cacheConfig, String instructions, McpMrtrConfig mrtrConfig) { 143 this.capabilities = capabilities; 144 this.cacheConfig = Objects.requireNonNull(cacheConfig, "cacheConfig"); 145 this.instructions = instructions; 146 this.mrtrConfig = Objects.requireNonNull(mrtrConfig, "mrtrConfig"); 147 } 148 149 /** JSON-RPC error code: parse error. Never reported by this revision (see {@link McpErrorKind#PARSE_ERROR}). */ 150 public static final int CODE_PARSE_ERROR = -32700; 151 152 /** JSON-RPC error code: invalid request. */ 153 public static final int CODE_INVALID_REQUEST = -32600; 154 155 /** JSON-RPC error code: method not found. */ 156 public static final int CODE_METHOD_NOT_FOUND = -32601; 157 158 /** JSON-RPC error code: invalid params. */ 159 public static final int CODE_INVALID_PARAMS = -32602; 160 161 /** JSON-RPC error code: internal error. */ 162 public static final int CODE_INTERNAL_ERROR = -32603; 163 164 /** 165 * SEP-2322 error code: the client did not advertise a capability ({@code elicitation}) required to receive an 166 * {@code input_required} result. Pinned directly by the SEP-2322 schema (spec Open item #3). 167 */ 168 public static final int CODE_MISSING_REQUIRED_CLIENT_CAPABILITY = -32021; 169 170 /** 171 * MRTR error code: the echoed {@code requestState} has expired (past its sealed {@code expiresAtMs}). This 172 * plan's own pin, chosen from the JSON-RPC-reserved {@code -32000}..{@code -32099} server-error range adjacent 173 * to the schema-pinned {@code -32021}; not a SEP-2322-sourced value. 174 */ 175 public static final int CODE_REQUEST_STATE_EXPIRED = -32022; 176 177 /** 178 * MRTR error code: the echoed {@code requestState}'s embedded round counter is at or above the configured 179 * max-rounds cap. This plan's own pin (same range/rationale as {@link #CODE_REQUEST_STATE_EXPIRED}). 180 */ 181 public static final int CODE_MAX_ROUNDS_EXCEEDED = -32023; 182 183 /** 184 * C8 error code: the server already has {@code maxConcurrentSubscriptions} live {@code 185 * subscriptions/listen} streams. This plan's own pin (same {@code -32000}..{@code -32099} 186 * server-error range and rationale as {@link #CODE_REQUEST_STATE_EXPIRED} / {@link #CODE_MAX_ROUNDS_EXCEEDED}); 187 * not sourced from SEP-2575. 188 */ 189 public static final int CODE_TOO_MANY_SUBSCRIPTIONS = -32024; 190 191 /** 192 * MRTR error code: an operator-configured {@link ReplayCache} reports that the echoed {@code requestState} 193 * has already been consumed once before. Only ever thrown when {@link McpMrtrConfig#getReplayCache()} is 194 * non-<jk>null</jk> — replay rejection is opt-in (same range/rationale as 195 * {@link #CODE_REQUEST_STATE_EXPIRED}). 196 */ 197 public static final int CODE_REQUEST_STATE_REPLAYED = -32025; 198 199 /** 200 * MRTR error code: the current request's {@code arguments} do not hash to the same value as the arguments 201 * sealed at PAUSE time — the client has resumed with a different argument set than the one the paused 202 * operation originally authorized. Always checked, unlike {@link #CODE_REQUEST_STATE_REPLAYED} (same 203 * range/rationale as {@link #CODE_REQUEST_STATE_EXPIRED}). 204 */ 205 public static final int CODE_REQUEST_STATE_ARGUMENTS_MISMATCH = -32026; 206 207 /** Default server name reported by {@code server/discover} when the config supplies no server identity. */ 208 public static final String DEFAULT_SERVER_NAME = "juneau-rest-server-mcp"; 209 210 private static final String META_KEY = "_meta"; 211 private static final String PARAM_ARGUMENTS = "arguments"; 212 213 private static final Logger LOG = Logger.getLogger(McpRevision.class.getName()); 214 215 // 16 random bytes -> 22 base64url chars (unpadded): matches EphemeralKeyProvider's keyId minting pattern, 216 // sized generously since a jti only ever travels inside the AEAD-sealed plaintext, never on its own. 217 private static final int JTI_BYTES = 16; 218 private static final SecureRandom JTI_RANDOM = new SecureRandom(); 219 private static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding(); 220 private static final Base64.Decoder B64URL_DECODER = Base64.getUrlDecoder(); 221 222 // RFC 8785 canonicalizer for the argument hash, deliberately configured for UNBOUNDED depth with recursion 223 // detection: JcsSerializer's inherited depth-limit default of 100 SILENTLY truncates over-depth nodes to 224 // null, which would let two argument sets differing only below the limit hash identically while the handler 225 // still received the divergent subtree. Configuring an unbounded max-depth here makes the hash cover the 226 // full subtree, and enabling recursion detection fails fast, rather than looping, on a pathological cyclic 227 // structure. The complementary JSON value-safety check performed as part of the arguments-hash step is what 228 // actually bounds attacker-controlled depth, size, and CPU: this canonicalizer only guarantees that whatever 229 // passes that guard is hashed in full. 230 private static final Jcs JCS = new Jcs( 231 JcsSerializer.create().maxDepth(Integer.MAX_VALUE).detectRecursions().build(), JsonParser.DEFAULT); 232 233 /** 234 * This revision instance's binding-owned MRTR (Multi-Round-Trip Request) configuration, as supplied at 235 * construction time. 236 * 237 * <p> 238 * Package-visible so tests can confirm the binding-level memoization contract documented on 239 * {@link McpRestServlet#getMcpOptions()} / {@link McpEndpoint#getMcpOptions()}: two {@link McpRevision} 240 * instances built from the same binding must share the same {@link McpMrtrConfig} (and therefore the 241 * same {@link RequestStateCodec}), even though each dispatched request constructs its own 242 * {@link McpRevision}. 243 * 244 * @return The MRTR configuration. Never <jk>null</jk>. 245 */ 246 McpMrtrConfig mrtrConfig() { 247 return mrtrConfig; 248 } 249 250 @Override /* McpRevision */ 251 public String protocolVersion() { 252 return McpProtocol.VERSION_2026_07_28; 253 } 254 255 @Override /* McpRevision */ 256 public int errorCode(McpErrorKind kind) { 257 return switch (kind) { 258 case INVALID_REQUEST -> CODE_INVALID_REQUEST; 259 case UNKNOWN_METHOD -> CODE_METHOD_NOT_FOUND; 260 case TOOL_NOT_FOUND, PROMPT_NOT_FOUND, RESOURCE_NOT_FOUND, INVALID_PARAMS -> CODE_INVALID_PARAMS; 261 case INTERNAL_ERROR -> CODE_INTERNAL_ERROR; 262 case PARSE_ERROR -> CODE_PARSE_ERROR; 263 }; 264 } 265 266 @Override /* McpRevision */ 267 @SuppressWarnings({ 268 "java:S3776" // Cognitive complexity acceptable for the single JSON-RPC dispatch entry point; splitting the notification/response/stream branching and the McpException/Exception recovery paths would fragment one cohesive request-to-result routine without real clarity gain. 269 }) 270 public McpDispatchResult dispatch(McpExchange exchange, McpServerConfig config, BeanStore ctx) { 271 reqnn("exchange", exchange); 272 reqnn("config", config); 273 reqnn("ctx", ctx); 274 275 var req = exchange.request(); 276 if (req == null) 277 return new McpResponseResult(reportError(ctx, null, errorCode(McpErrorKind.INVALID_REQUEST), "Request envelope is null")); 278 279 var id = req.getId(); 280 var method = req.getMethod(); 281 282 if (isEmpty(method)) 283 return JsonRpcResponse.notification(id) ? null 284 : new McpResponseResult(reportError(ctx, id, errorCode(McpErrorKind.INVALID_REQUEST), "Missing method")); 285 286 // SEP-2350 per-operation step-up enforcement at the POST-parse point (method now known). Deliberately OUTSIDE 287 // the try below: an insufficient-scope 403 (with its WWW-Authenticate step-up challenge) must propagate as a real 288 // HTTP 403 through the response processors, not be caught and folded into a JSON-RPC error over HTTP 200. 289 enforceStepUpScopes(method, req.getParams(), ctx); 290 291 try { 292 validateHeaders(exchange, req); 293 validateMeta(req.getParams()); 294 if (McpMethods.SUBSCRIPTIONS_LISTEN.equals(method)) 295 return JsonRpcResponse.notification(id) ? null 296 : new McpStreamResult(dispatchSubscriptionsListen(exchange, id, req.getParams(), config, ctx)); 297 var result = finalizeResult(invoke(method, req.getParams(), config, ctx), config, ctx); 298 return JsonRpcResponse.notification(id) ? null : new McpResponseResult(JsonRpcResponse.ok(id, result)); 299 } catch (McpException e) { 300 if (JsonRpcResponse.notification(id)) 301 return null; 302 recordRpcError(ctx, e.getCode(), e.getMessage()); 303 return new McpResponseResult(new JsonRpcResponse() 304 .setJsonrpc(McpProtocol.JSON_RPC_2_0) 305 .setId(id) 306 .setError(e.toJsonRpcError())); 307 } catch (Exception e) { 308 if (JsonRpcResponse.notification(id)) 309 return null; 310 var message = e.getMessage() == null ? cns(e) : e.getMessage(); 311 var code = errorCode(McpErrorKind.INTERNAL_ERROR); 312 recordRpcError(ctx, code, message); 313 return new McpResponseResult(JsonRpcResponse.errorResponse(id, code, message, JsonMap.of("type", cn(e)))); 314 } 315 } 316 317 /** 318 * Enforces SEP-2350 per-operation step-up scopes for a dispatched request (the POST-parse enforcement point). 319 * 320 * <p> 321 * A no-op unless an {@link McpOptions} bean is bound and its {@link McpResourceServerConfig} is 322 * {@link McpResourceServerConfig#isEnabled() enabled} with a per-operation scope configured for this operation. The 323 * MCP method — and thus the operation identity ({@link McpRoutingNames#routingName routing name}) — is 324 * known here (unlike the pre-parse {@code @RestStartCall} bearer gate), so a scoped {@code 403 insufficient_scope} 325 * step-up challenge naming exactly this operation's required scopes can be emitted. Delegates the actual 326 * granted-vs-required comparison and challenge construction to {@link McpResourceServerSupport#enforceOperationScopes}. 327 * 328 * @param method The resolved JSON-RPC method. Never <jk>null</jk>/empty at the call site. 329 * @param params The raw JSON-RPC request params (used to derive the operation name and passed through verbatim as 330 * the {@link McpOperationContext#params() resolver seam}). 331 * @param ctx The per-request bean store (carries the bound {@link McpOptions} and {@link RestRequest}). 332 */ 333 private static void enforceStepUpScopes(String method, Object params, BeanStore ctx) { 334 var opt = ctx.getBean(McpOptions.class).orElse(null); 335 if (opt == null) 336 return; 337 var cfg = opt.getResourceServer(); 338 if (!cfg.isEnabled()) 339 return; 340 var granted = McpResourceServerSupport.grantedScopes( 341 ctx.getBean(RestRequest.class).map(RestRequest::getHttpServletRequest).orElse(null)); 342 var rn = McpRoutingNames.routingName(method, params); 343 var name = isEmpty(rn) ? null : rn; 344 var rawParams = params instanceof Map<?,?> m ? castParams(m) : Map.<String,Object>of(); 345 McpResourceServerSupport.enforceOperationScopes(cfg, granted, new McpOperationContext(method, name, rawParams)); 346 } 347 348 @SuppressWarnings({ 349 "unchecked" // castParams narrows the wildcard JSON-RPC params map to Map<String,Object>; JSON object keys are always strings 350 }) 351 private static Map<String,Object> castParams(Map<?,?> m) { 352 return (Map<String,Object>)m; 353 } 354 355 /** 356 * Capability-gates, limit-checks, registers, and constructs the streaming publisher for {@code 357 * subscriptions/listen}. Bypasses {@link #finalizeResult} / {@link JsonRpcResponse#ok}: the successful 358 * result is a {@code Flow.Publisher<SseEvent>}, not a JSON-RPC envelope. A thrown {@link McpException} 359 * (for example the over-limit case) is caught by {@link #dispatch}'s existing {@code McpException} 360 * handler, which builds the normal JSON-RPC error envelope. 361 * 362 * @param exchange The transport-neutral request/header accessor, used to enforce the SSE {@code Accept} 363 * gate below before any broker registration happens. 364 * @param id The JSON-RPC request id. Never <jk>null</jk> (this method is never called for a notification). 365 * @param params The opaque request params. Must decode to a {@code SubscriptionsListenRequest}. 366 * @param config The neutral handler registry, used to auto-derive capabilities when none were set explicitly. 367 * @param ctx The per-request bean store, expected to carry a bound {@link McpSubscriptionBroker} 368 * and a bound {@link McpOptions}. In production the options bean is normally present — 369 * {@code McpRestServlet#getMcpOptions()} / {@code McpEndpoint#mcpOptionsBean()} publish it into 370 * the {@code RestContext} bean store that this request-scoped {@code ctx} wraps as its parent, since the 371 * neutral core cannot add it directly ({@code McpOptions} is a v2 type). The 372 * {@code orElseGet(McpSubscriptionsConfig::new)} fallback below is defense-in-depth only (e.g. a 373 * hand-built {@code ctx} in a direct-dispatch test that omits it). 374 * <p> 375 * <b>SSE negotiation gate (checked first, before any broker registration):</b> every MCP servlet binding 376 * defaults an unqualified request to {@code Accept: application/json}. A {@code subscriptions/listen} 377 * call that never negotiated {@code text/event-stream} would still register with the broker and hand 378 * back a real {@code Flow.Publisher}, but {@code ReactiveResponseProcessor} would then render it in its 379 * BUFFER shape (unbounded {@code request(Long.MAX_VALUE)} demand, collected into a list that is only 380 * flushed once the publisher completes — which, for a live subscription, is never, until the connection 381 * itself drops). Because this dead-client cleanup is write-failure-driven (see the class javadoc), a 382 * BUFFER-shape stream never even attempts a write, so the broker slot, the pump thread, and the 383 * heartbeat executor all leak permanently, and any caller can trigger this for free. Checking {@code 384 * exchange.header("Accept")} here, before {@link McpSubscriptionBroker#registerIfUnder} is ever called, 385 * closes that hole at zero cost to a well-behaved caller (see {@code McpClient#listen} / 386 * {@code AbstractMcpClient#openEventStream}, which already always negotiate SSE on this call). 387 * 388 * <p> 389 * {@code maxConcurrentSubscriptions} is enforced as a hard cap, not an advisory one: the admission check 390 * and the registration are one atomic step via {@link McpSubscriptionBroker#registerIfUnder}, so 391 * concurrent {@code subscriptions/listen} calls against the same broker (e.g. a custom broker explicitly 392 * shared across multiple bindings via {@link McpOptions#setSubscriptionBroker}) cannot all observe room 393 * under the cap and all register, which a separate {@code activeCount()} check followed by a separate 394 * {@code register(...)} call would allow. 395 * 396 * <p> 397 * The broker registers under a fresh {@link UUID}, never under the client-supplied JSON-RPC request 398 * {@code id}: the broker's registry is process-wide (see {@link McpEndpoint#subscriptionBroker()}), so 399 * two unrelated concurrent listens that happen to share the same client id would otherwise evict each 400 * other's stream (the second {@code register(...)} call under a shared key closes whichever 401 * subscription is already registered there). The client's original {@code id} is passed to 402 * {@link SubscriptionsListenPublisher} unchanged and is still what every ack/notification frame on that 403 * client's own stream carries as its {@code _meta.subscriptionId} — this only decouples the *internal* 404 * registry key from the *wire* id, which stays exactly as the spec expects. 405 * 406 * @return The {@code Flow.Publisher<SseEvent>} for the newly registered subscription. 407 * @throws McpException If the caller never negotiated {@code text/event-stream}, the broker bean is 408 * missing, or {@code maxConcurrentSubscriptions} is reached. 409 */ 410 private Flow.Publisher<SseEvent> dispatchSubscriptionsListen(McpExchange exchange, Object id, Object params, McpServerConfig config, BeanStore ctx) { 411 requireEventStreamAccept(exchange); 412 var request = Json.to(Json.of(params), SubscriptionsListenRequest.class); 413 var caps = discoverCapabilities(config); 414 var honoredFilter = SubscriptionCapabilityGate.honor(request.getNotifications(), caps); 415 416 var broker = ctx.getBean(McpSubscriptionBroker.class) 417 .orElseThrow(() -> new McpException(CODE_INTERNAL_ERROR, "McpSubscriptionBroker not bound in BeanStore")); 418 var subscriptionsConfig = ctx.getBean(McpOptions.class).map(McpOptions::getSubscriptions).orElseGet(McpSubscriptionsConfig::new); 419 // registerIfUnder admission-checks and registers as one atomic step (hard cap, not advisory) — a 420 // separate activeCount() check followed by a separate register() call would leave a TOCTOU window 421 // open for concurrent listen requests racing the same per-binding broker (see 422 // McpOptions#resolveSubscriptionBroker()). The registry key is a server-minted UUID, deliberately 423 // NOT String.valueOf(id): see the javadoc above. 424 var subscription = broker.registerIfUnder(subscriptionsConfig.getMaxConcurrentSubscriptions(), UUID.randomUUID().toString(), honoredFilter) 425 .orElseThrow(() -> new McpException(CODE_TOO_MANY_SUBSCRIPTIONS, "Maximum concurrent subscriptions exceeded")); 426 var honoredWireFilter = SubscriptionCapabilityGate.toWireFilter(honoredFilter); 427 return new SubscriptionsListenPublisher(id, honoredWireFilter, subscription, subscriptionsConfig.getHeartbeatIntervalMs(), subscriptionsConfig.getIdleTimeoutMs()); 428 } 429 430 /** 431 * C1: rejects a {@code subscriptions/listen} call that never negotiated {@code text/event-stream}, 432 * before any broker registration happens (see {@link #dispatchSubscriptionsListen}'s javadoc). 433 * 434 * <p> 435 * A bare {@code Accept: * / *} (every media type acceptable) is rejected exactly like an absent 436 * {@code Accept} header: this endpoint requires the caller to have explicitly negotiated 437 * {@code text/event-stream}, not merely to be willing to accept it among everything else. Without this, 438 * a wildcard-accepting request would fall into {@code ReactiveResponseProcessor}'s non-streaming BUFFER 439 * shape (the same permanent-leak hole C1 closes for a missing header), rather than the STREAM shape 440 * this method exists to require. A well-behaved caller is unaffected: both {@code McpClient#listen} and 441 * the underlying {@code AbstractMcpClient#openEventStream} already always send the explicit 442 * {@code text/event-stream} value on this call. 443 * 444 * @param exchange The transport-neutral request/header accessor. Must not be <jk>null</jk>. 445 * @throws McpException If the {@code Accept} header is absent or does not contain {@code text/event-stream} 446 * (case-insensitive, substring match — a real client's {@code Accept} header is commonly a 447 * comma-separated list of media-type ranges, not the bare value alone). 448 */ 449 private static void requireEventStreamAccept(McpExchange exchange) { 450 var accept = exchange.header("Accept"); 451 if (accept == null || ! accept.toLowerCase(Locale.ROOT).contains("text/event-stream")) 452 throw new McpException(CODE_INVALID_REQUEST, "subscriptions/listen requires Accept: text/event-stream"); 453 } 454 455 /** 456 * Builds a JSON-RPC error response, first giving an active request-scoped tracing observation a 457 * chance to record the outcome via {@link #recordRpcError(BeanStore, int, String)} — every 458 * dated dispatch returns JSON-RPC errors over HTTP {@code 200}, so this is the only place such an 459 * outcome reaches an active span. 460 * 461 * @param ctx The request-scoped bean store. Must not be <jk>null</jk>. 462 * @param id The JSON-RPC request id, or <jk>null</jk> when the envelope itself could not be read. 463 * @param code The JSON-RPC error code. 464 * @param message The JSON-RPC error message. 465 * @return The error response. Never <jk>null</jk>. 466 */ 467 private static JsonRpcResponse reportError(BeanStore ctx, Object id, int code, String message) { 468 recordRpcError(ctx, code, message); 469 return JsonRpcResponse.errorResponse(id, code, message); 470 } 471 472 /** 473 * Records a JSON-RPC error outcome on the active request-scoped {@link Scope}, if any. 474 * 475 * <p> 476 * A neutral no-op — no OpenTelemetry import involved — when no tracer is active for 477 * this request (no {@link Scope} was stashed under {@link TraceContextResponseProcessor#ATTR_SCOPE} 478 * by {@code RestOpInvoker}) or when the request-scoped {@link RestRequest} itself is unavailable 479 * (for example, a unit test dispatching directly against a bare {@link BeanStore}). 480 * 481 * @param ctx The request-scoped bean store. Must not be <jk>null</jk>. 482 * @param code The JSON-RPC error code. 483 * @param message The JSON-RPC error message. 484 */ 485 private static void recordRpcError(BeanStore ctx, int code, String message) { 486 ctx.getBean(RestRequest.class).ifPresent(request -> { 487 var scope = request.getAttribute(TraceContextResponseProcessor.ATTR_SCOPE).as(Scope.class).orElse(null); 488 if (scope != null) 489 scope.recordRpcError(code, message); 490 }); 491 } 492 493 // --- SEP-2243 header/body agreement ----------------------------------------------------- 494 495 private static void validateHeaders(McpExchange exchange, JsonRpcRequest req) { 496 var headerMethod = exchange.header("Mcp-Method"); 497 if (headerMethod == null) 498 throw new McpException(CODE_INVALID_REQUEST, "Missing required header: Mcp-Method"); 499 var headerName = exchange.header("Mcp-Name"); 500 if (headerName == null) 501 throw new McpException(CODE_INVALID_REQUEST, "Missing required header: Mcp-Name"); 502 var method = req.getMethod(); 503 if (! headerMethod.equals(method)) 504 throw new McpException(CODE_INVALID_REQUEST, "Mcp-Method header '" + headerMethod + "' does not match request method '" + method + "'"); 505 var name = McpRoutingNames.routingName(method, req.getParams()); 506 if (! headerName.equals(name)) 507 throw new McpException(CODE_INVALID_REQUEST, "Mcp-Name header '" + headerName + "' does not match request name '" + name + "'"); 508 } 509 510 // --- per-request params._meta negotiation ----------------------------------------------- 511 512 /** 513 * Validates the opaque {@code params._meta} shape directly. 514 * 515 * <p> 516 * The parsed {@link RequestMeta} bean cannot distinguish a malformed {@code clientCapabilities} (a 517 * scalar/array) from an absent one — parsing either into {@code ClientCapabilities} raises a 518 * generic parse error rather than a validation failure — so metadata shape is checked against 519 * the opaque map to produce the exact per-field {@code -32600} messages naming the exact wire path. 520 * 521 * @param params The opaque request params (the JSON-RPC {@code params} member). Can be <jk>null</jk>. 522 */ 523 private static void validateMeta(Object params) { 524 if (! (params instanceof Map<?,?> p)) 525 throw new McpException(CODE_INVALID_REQUEST, "Request params must be an object"); 526 var meta = p.get(META_KEY); 527 if (! (meta instanceof Map<?,?> m)) 528 throw new McpException(CODE_INVALID_REQUEST, "Request params._meta must be an object"); 529 var protocolVersion = str(m.get(RequestMeta.KEY_PROTOCOL_VERSION)); 530 if (isEmpty(protocolVersion)) 531 throw new McpException(CODE_INVALID_REQUEST, "Missing required params._meta." + RequestMeta.KEY_PROTOCOL_VERSION); 532 if (! McpProtocol.VERSION_2026_07_28.equals(protocolVersion)) 533 throw new McpException(CODE_INVALID_REQUEST, "Unsupported protocol version: " + protocolVersion); 534 var clientInfo = m.get(RequestMeta.KEY_CLIENT_INFO); 535 if (clientInfo != null && (! (clientInfo instanceof Map<?,?> ci) || isEmpty(str(ci.get("name"))) || isEmpty(str(ci.get("version"))))) 536 throw new McpException(CODE_INVALID_REQUEST, "Malformed params._meta." + RequestMeta.KEY_CLIENT_INFO); 537 if (! m.containsKey(RequestMeta.KEY_CLIENT_CAPABILITIES) || m.get(RequestMeta.KEY_CLIENT_CAPABILITIES) == null) 538 throw new McpException(CODE_INVALID_REQUEST, "Missing required params._meta." + RequestMeta.KEY_CLIENT_CAPABILITIES); 539 if (! (m.get(RequestMeta.KEY_CLIENT_CAPABILITIES) instanceof Map<?,?>)) 540 throw new McpException(CODE_INVALID_REQUEST, "params._meta." + RequestMeta.KEY_CLIENT_CAPABILITIES + " must be an object"); 541 } 542 543 private static String str(Object value) { 544 return value == null ? null : value.toString(); 545 } 546 547 /** 548 * Attaches the common success-result finalization to every dispatched result: a {@code "complete"} 549 * {@code resultType} (already the {@link Result} default) and {@link ResultMeta#getServerInfo() server 550 * identity} under {@code _meta}, without disturbing cache hints or payload fields a method already set. 551 * 552 * @param result The neutral-to-wire result returned by {@link #invoke}. Can be any wire result type. 553 * @param config The server configuration supplying server identity. 554 * @return The same {@code result} instance, finalized in place. 555 */ 556 private static Object finalizeResult(Object result, McpServerConfig config, BeanStore ctx) { 557 if (result instanceof Result<?> r) { 558 var meta = r.getMeta(); 559 if (meta == null) 560 meta = new ResultMeta(); 561 meta.setServerInfo(McpWire.serverInfo(config)); 562 echoTraceContext(meta, ctx); 563 r.setMeta(meta); 564 } 565 return result; 566 } 567 568 /** 569 * Echoes the request-scoped W3C trace context into a successful result's {@code _meta}. 570 * 571 * <p> 572 * At span-start time, an active {@code TracerHook} bridge (for example {@code OtelTracerHook}) 573 * stashes the rendered {@code traceparent} / {@code tracestate} / {@code baggage} values as 574 * {@link RestRequest} attributes under {@link TraceContextResponseProcessor#ATTR_TRACEPARENT} / 575 * {@code ATTR_TRACESTATE} / {@code ATTR_BAGGAGE}. This copies those same values — already 576 * neutral strings, read here with no OpenTelemetry import — into <c>meta</c> so a caller can 577 * read the server-started trace identifiers off the JSON-RPC result body, in addition to the HTTP 578 * response headers {@link TraceContextResponseProcessor} emits from the same captured values. 579 * 580 * <p> 581 * A no-op — no trace keys added — when no tracer is active (no {@code ATTR_TRACEPARENT} 582 * was stashed) or when the request-scoped {@link RestRequest} itself is unavailable. 583 * 584 * @param meta The result metadata to enrich in place. Must not be <jk>null</jk>. 585 * @param ctx The request-scoped bean store. Must not be <jk>null</jk>. 586 */ 587 private static void echoTraceContext(ResultMeta meta, BeanStore ctx) { 588 ctx.getBean(RestRequest.class).ifPresent(request -> { 589 var traceparent = request.getAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT).as(String.class).orElse(null); 590 if (isEmpty(traceparent)) 591 return; 592 meta.setTraceparent(traceparent); 593 var tracestate = request.getAttribute(TraceContextResponseProcessor.ATTR_TRACESTATE).as(String.class).orElse(null); 594 if (! isEmpty(tracestate)) 595 meta.setTracestate(tracestate); 596 var baggage = request.getAttribute(TraceContextResponseProcessor.ATTR_BAGGAGE).as(String.class).orElse(null); 597 if (! isEmpty(baggage)) 598 meta.setBaggage(baggage); 599 }); 600 } 601 602 // --- method table ----------------------------------------------------------------------- 603 604 private Object invoke(String method, Object params, McpServerConfig config, BeanStore ctx) { 605 return switch (method) { 606 case McpMethods.SERVER_DISCOVER -> discover(config); 607 case McpMethods.PING -> new PingResult(); 608 case McpMethods.TOOLS_LIST -> listTools(config, params, ctx); 609 case McpMethods.TOOLS_CALL -> callTool(config, params, ctx); 610 case McpMethods.PROMPTS_LIST -> listPrompts(config, params, ctx); 611 case McpMethods.PROMPTS_GET -> getPrompt(config, params, ctx); 612 case McpMethods.RESOURCES_LIST -> listResources(config, params, ctx); 613 case McpMethods.RESOURCES_READ -> readResource(config, params, ctx); 614 case McpMethods.RESOURCES_TEMPLATES_LIST -> listResourceTemplates(config, params, ctx); 615 case McpMethods.COMPLETION_COMPLETE -> complete(config, params, ctx); 616 default -> throw new McpException(errorCode(McpErrorKind.UNKNOWN_METHOD), "Method not found: " + method); 617 }; 618 } 619 620 // --- cache-hint precedence --------------------------------------------------------------- 621 622 private static McpCacheHint first(McpCacheHint... values) { 623 for (var value : values) 624 if (value != null) 625 return value; 626 return null; 627 } 628 629 private static <T extends CacheableResult<T>> T applyCache(T result, McpCacheHint hint) { 630 if (hint != null) { 631 result.setTtlMs(hint.getTtlMs()); 632 result.setCacheScope(hint.getCacheScope()); 633 } 634 return result; 635 } 636 637 private McpCacheHint readHint(String uri) { 638 return first(cacheConfig.getResourceReadOverrides().get(uri), 639 cacheConfig.getResourcesRead(), cacheConfig.getDefaultHint()); 640 } 641 642 private ServerDiscoverResult discover(McpServerConfig config) { 643 var result = McpWire.discover(discoverCapabilities(config), protocolVersion(), instructions); 644 return applyCache(result, first(cacheConfig.getServerDiscover(), cacheConfig.getDefaultHint())); 645 } 646 647 private ServerCapabilities discoverCapabilities(McpServerConfig config) { 648 var caps = this.capabilities; 649 if (caps == null) { 650 caps = new ServerCapabilities(); 651 if (! config.getTools().isEmpty()) 652 caps.setTools(new ToolCapability()); 653 if (! config.getPrompts().isEmpty()) 654 caps.setPrompts(new PromptCapability()); 655 if (! config.getResources().isEmpty() || ! config.getResourceTemplates().isEmpty()) 656 caps.setResources(new ResourceCapability()); 657 if (config.hasAnyCompleter()) 658 caps.setCompletions(new CompletionCapability()); 659 } 660 return caps; 661 } 662 663 private ListToolsResult listTools(McpServerConfig config, Object params, BeanStore ctx) { 664 var descriptors = config.getTools().stream().map(McpToolHandler::descriptor).toList(); 665 var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx); 666 return applyCache(new ListToolsResult() 667 .setTools(page.items().stream().map(McpWire::toWire).toList()) 668 .setNextCursor(page.nextCursor()), 669 first(cacheConfig.getToolsList(), cacheConfig.getDefaultHint())); 670 } 671 672 private Object callTool(McpServerConfig config, Object params, BeanStore ctx) { 673 var p = McpParamUtils.asMap(params); 674 var name = McpParamUtils.strParam(p, "name"); 675 if (name == null) 676 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing tool name"); 677 var handler = config.getTools().stream() 678 .filter(h -> name.equals(h.descriptor().getName())) 679 .findFirst() 680 .orElseThrow(() -> new McpException(errorCode(McpErrorKind.TOOL_NOT_FOUND), "Tool not found: " + name)); 681 var args = McpParamUtils.mapParam(p, PARAM_ARGUMENTS); 682 McpSchemaSafety.validateInput(handler.descriptor().getInputSchema(), args); 683 var mrtr = resolveMrtrContext(McpMethods.TOOLS_CALL, p, ctx); 684 try (var store = mrtr.store()) { 685 var outcome = handler.call(args, store); 686 validateStructuredOutput(outcome); 687 return McpWire.toWire(outcome); 688 } catch (McpInputRequiredSignal signal) { 689 return pause(signal, McpMethods.TOOLS_CALL, p, mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx); 690 } 691 } 692 693 private static void validateStructuredOutput(McpToolOutcome outcome) { 694 if (outcome == null || outcome.getStructuredContent() == null) 695 return; 696 try { 697 JsonValueSafety.check(outcome.getStructuredContent(), "Tool structuredContent"); 698 } catch (IllegalArgumentException e) { 699 throw new McpException(CODE_INTERNAL_ERROR, e.getMessage()); 700 } 701 } 702 703 private ListPromptsResult listPrompts(McpServerConfig config, Object params, BeanStore ctx) { 704 var descriptors = config.getPrompts().stream().map(McpPromptHandler::descriptor).toList(); 705 var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx); 706 return applyCache(new ListPromptsResult() 707 .setPrompts(page.items().stream().map(McpWire::toWire).toList()) 708 .setNextCursor(page.nextCursor()), 709 first(cacheConfig.getPromptsList(), cacheConfig.getDefaultHint())); 710 } 711 712 private Object getPrompt(McpServerConfig config, Object params, BeanStore ctx) { 713 var p = McpParamUtils.asMap(params); 714 var name = McpParamUtils.strParam(p, "name"); 715 if (name == null) 716 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing prompt name"); 717 var handler = config.getPrompts().stream() 718 .filter(h -> name.equals(h.descriptor().getName())) 719 .findFirst() 720 .orElseThrow(() -> new McpException(errorCode(McpErrorKind.PROMPT_NOT_FOUND), "Prompt not found: " + name)); 721 var args = McpParamUtils.mapParam(p, PARAM_ARGUMENTS); 722 var mrtr = resolveMrtrContext(McpMethods.PROMPTS_GET, p, ctx); 723 try (var store = mrtr.store()) { 724 return McpWire.toWire(handler.get(args, store)); 725 } catch (McpInputRequiredSignal signal) { 726 return pause(signal, McpMethods.PROMPTS_GET, p, mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx); 727 } 728 } 729 730 private ListResourcesResult listResources(McpServerConfig config, Object params, BeanStore ctx) { 731 var descriptors = config.getResources().stream().map(McpResourceHandler::descriptor).toList(); 732 var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx); 733 return applyCache(new ListResourcesResult() 734 .setResources(page.items().stream().map(McpWire::toWire).toList()) 735 .setNextCursor(page.nextCursor()), 736 first(cacheConfig.getResourcesList(), cacheConfig.getDefaultHint())); 737 } 738 739 private Object readResource(McpServerConfig config, Object params, BeanStore ctx) { 740 var p = McpParamUtils.asMap(params); 741 var uri = McpParamUtils.strParam(p, "uri"); 742 if (uri == null) 743 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing resource uri"); 744 var exact = config.getResources().stream() 745 .filter(h -> uri.equals(h.descriptor().getUri())) 746 .findFirst(); 747 if (exact.isPresent()) { 748 // MRTR PAUSE/RESUME applies only to the exact-resource path; C4's resource-template branch below is 749 // untouched (spec rule 5). A PAUSE returns before applyCache(...), so a pending result never gets a 750 // cache hint. 751 var mrtr = resolveMrtrContext(McpMethods.RESOURCES_READ, p, ctx); 752 try (var store = mrtr.store()) { 753 return applyCache(McpWire.toWire(exact.get().read(uri, store)), readHint(uri)); 754 } catch (McpInputRequiredSignal signal) { 755 return pause(signal, McpMethods.RESOURCES_READ, p, mrtr.currentRound(), mrtr.argumentsHash(), mrtr.target(), ctx); 756 } 757 } 758 var match = config.resolveResourceTemplate(uri); 759 var outcome = match == null ? null : match.handler().read(uri, match.variables(), ctx); 760 if (outcome == null) 761 throw new McpException(errorCode(McpErrorKind.RESOURCE_NOT_FOUND), "Resource not found: " + uri); 762 return applyCache(McpWire.toWire(outcome), readHint(uri)); 763 } 764 765 private ListResourceTemplatesResult listResourceTemplates(McpServerConfig config, Object params, BeanStore ctx) { 766 var descriptors = config.getResourceTemplates().stream().map(McpResourceTemplateHandler::descriptor).toList(); 767 var page = config.getCursor().page(descriptors, McpCursor.cursorOf(params), ctx); 768 return applyCache(new ListResourceTemplatesResult() 769 .setResourceTemplates(page.items().stream().map(McpWire::toWire).toList()) 770 .setNextCursor(page.nextCursor()), 771 first(cacheConfig.getResourceTemplatesList(), cacheConfig.getDefaultHint())); 772 } 773 774 private CompleteResult complete(McpServerConfig config, Object params, BeanStore ctx) { 775 var p = McpParamUtils.asMap(params); 776 var ref = completionRef(McpParamUtils.mapParam(p, "ref")); 777 var argument = McpParamUtils.mapParam(p, "argument"); 778 var argumentName = McpParamUtils.strParam(argument, "name"); 779 if (isEmpty(argumentName)) 780 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing argument.name"); 781 var value = McpParamUtils.strictStrParam(argument, "value"); 782 if (value == null) 783 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing or non-string argument.value"); 784 var contextArguments = McpParamUtils.strictStrMapParam(McpParamUtils.mapParam(p, "context"), PARAM_ARGUMENTS); 785 786 var completer = config.completer(ref, argumentName); 787 McpCompletionResult raw; 788 if (completer == null) { 789 raw = McpCompletionResult.empty(); 790 } else { 791 var request = new McpCompletionRequest() 792 .setRef(ref) 793 .setArgumentName(argumentName) 794 .setValue(value) 795 .setContextArguments(contextArguments); 796 raw = completer.complete(request, ctx); 797 } 798 return McpWire.toWire(McpCompletionResult.normalize(raw)); 799 } 800 801 private McpCompletionRef completionRef(Map<String,Object> refMap) { 802 var type = McpParamUtils.strParam(refMap, "type"); 803 if (eq(type, "ref/prompt")) { 804 var name = McpParamUtils.strParam(refMap, "name"); 805 if (isEmpty(name)) 806 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing ref.name for ref/prompt"); 807 return McpCompletionRef.prompt(name); 808 } 809 if (eq(type, "ref/resource")) { 810 var uri = McpParamUtils.strParam(refMap, "uri"); 811 if (isEmpty(uri)) 812 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Missing ref.uri for ref/resource"); 813 return McpCompletionRef.resource(uri); 814 } 815 throw new McpException(errorCode(McpErrorKind.INVALID_PARAMS), "Invalid or missing ref.type: " + type); 816 } 817 818 // --- MRTR (Multi-Round-Trip Request) pause/resume --------------------------------------- 819 820 /** 821 * The per-request MRTR resolution result: the {@link WritableBeanStore} a handler is invoked with (always 822 * carrying {@link McpMrtrCapabilityContext}, and on a RESUME also {@link McpMrtrResumeContext}) plus the 823 * current round decoded from the echoed {@code requestState} (0 on a first-round call), threaded to 824 * {@link #pause} so it can increment without a second unseal. 825 * 826 * <p> 827 * {@code store} is typed {@link WritableBeanStore} (rather than the narrower {@link BeanStore}) specifically 828 * so call sites can close it in a try-with-resources block: any bean a handler resolves through it is tracked 829 * for {@code @PreDestroy} cleanup on <i>this</i> store, not the caller-owned {@code ctx} it wraps (see 830 * {@link BasicBeanStore#close()}), so it must be closed once the handler invocation completes. 831 * 832 * <p> 833 * {@code argumentsHash} is the canonical hash of the current request's {@code arguments} (see 834 * {@link #argumentsHash(Map)}), computed <i>once</i> here — before the handler runs — and threaded 835 * to {@link #pause} so the next token seals the hash of what the client actually sent, not a hash recomputed 836 * from the live {@code arguments} map after a handler may have mutated it in place. 837 * 838 * <p> 839 * {@code target} is the operation target resolved by {@link #mrtrTarget(String, Map)} (the tool/prompt 840 * {@code name} or resource {@code uri}), captured at the same pre-handler point as {@code argumentsHash} and 841 * for the same reason: {@link #pause} seals it into the next token's {@link #aad(String, String) AAD} 842 * unchanged, so a handler that mutates the live {@code params} map in place cannot shift which 843 * tool/prompt/resource the next token is bound to. 844 */ 845 private record MrtrContext(WritableBeanStore store, int currentRound, String argumentsHash, String target) {} 846 847 /** 848 * Builds the {@link BeanStore} an in-scope handler receives and, on a RESUME, validates the echoed 849 * {@code requestState} before the handler runs. 850 * 851 * <p> 852 * Always wraps {@code ctx} with a {@link McpMrtrCapabilityContext} so a handler can pre-check the client's 853 * advertised {@code elicitation} capability. When the request carries a {@code requestState} 854 * (a RESUME), it is unsealed under the canonical AAD and validated for integrity, method agreement, expiry, 855 * the max-rounds cap, argument-hash agreement (always; see {@link #argumentsHash(Map)}), and replay (only when 856 * {@link McpMrtrConfig#getReplayCache()} is configured; see {@link #checkReplay(ReplayCache, String, long)}) 857 * — any failure raises the mapped {@link McpException} <i>before</i> the handler is re-invoked — 858 * then the decoded continuation and the client's {@code inputResponses} are exposed via 859 * {@link McpMrtrResumeContext}. 860 * 861 * <p> 862 * <b>Check ordering is deliberate.</b> The stateless argument-hash comparison runs <i>before</i> the 863 * (stateful) replay {@code checkAndRecord}, so a submission carrying the wrong {@code arguments} is rejected 864 * without ever consuming the token's {@code jti} — an attacker holding only a leaked token (but not the 865 * arguments) cannot burn a victim's in-flight resume, and an honest client that sends slightly-wrong arguments 866 * does not destroy its own token. The argument hash is also computed <i>once</i> here, before the handler 867 * runs, and threaded to {@link #pause} (see {@link MrtrContext}). 868 * 869 * @param method The in-scope JSON-RPC method. Must not be <jk>null</jk>. 870 * @param params The request params map. Must not be <jk>null</jk>. 871 * @param ctx The request-scoped bean store to wrap. Must not be <jk>null</jk>. 872 * @return The resolved MRTR context. Never <jk>null</jk>. 873 */ 874 private MrtrContext resolveMrtrContext(String method, Map<String,Object> params, BeanStore ctx) { 875 // Computed once, up front, from the CURRENT request's arguments: reused both for the RESUME-time 876 // comparison below and (threaded through MrtrContext) for the hash pause(...) seals into the next token, 877 // so a handler that mutates the live arguments map in place cannot make the next token seal a hash the 878 // client can no longer reproduce. Also applies the bounded-traversal + canonicalization guards (see 879 // argumentsHash), so structurally-hostile arguments become a -32602 here on both first-round and RESUME. 880 var argumentsHash = argumentsHash(McpParamUtils.mapParam(params, PARAM_ARGUMENTS)); 881 // Captured once, up front, alongside argumentsHash and for the same reason: threaded through MrtrContext 882 // to pause(...) so the AAD sealed into the next token binds the target actually resolved for THIS 883 // request, immune to a handler mutating params in place before pause(...) runs (see MrtrContext's 884 // javadoc above). 885 var target = mrtrTarget(method, params); 886 var requestState = McpParamUtils.strParam(params, "requestState"); 887 if (requestState == null) { 888 var wrapped = new BasicBeanStore(ctx) 889 .addBean(McpMrtrCapabilityContext.class, new McpMrtrCapabilityContext(clientElicitationSupported(params))); 890 return new MrtrContext(wrapped, 0, argumentsHash, target); 891 } 892 // Validate the echoed requestState before constructing the BasicBeanStore below: every failure here 893 // throws, and a BasicBeanStore built earlier would never reach a caller's try-with-resources to be 894 // closed (java:S2095). Deferring construction until validation succeeds means no path leaks it. 895 var sealed = mrtrConfig.getCodec().unseal(requestState, aad(method, target), principal(ctx)) 896 .orElseThrow(() -> new McpException(CODE_INVALID_PARAMS, "Invalid or tampered requestState")); 897 if (! method.equals(sealed.method())) 898 throw new McpException(CODE_INVALID_PARAMS, "requestState method mismatch"); 899 if (sealed.expiresAtMs() <= mrtrConfig.getClock().millis()) 900 throw new McpException(CODE_REQUEST_STATE_EXPIRED, "requestState has expired"); 901 if (sealed.round() >= mrtrConfig.getMaxRounds()) 902 // Client-facing message deliberately omits the configured cap value so server config is not leaked. 903 throw new McpException(CODE_MAX_ROUNDS_EXCEEDED, "Max MRTR rounds exceeded"); 904 // Stateless argument-hash agreement BEFORE the stateful replay record (see the ordering note above). A 905 // null sealed hash (e.g. a custom codec that dropped the field) fails closed as an ordinary mismatch 906 // here; a malformed (non-base64url) sealed hash is a codec-contract violation and argumentsMatch itself 907 // throws CODE_INVALID_PARAMS for it (mirroring the null-jti fail-closed check below), rather than 908 // reaching this mismatch branch. 909 if (! argumentsMatch(argumentsHash, sealed.argumentsHash())) 910 throw new McpException(CODE_REQUEST_STATE_ARGUMENTS_MISMATCH, "resumed arguments do not match the original request"); 911 var replayCache = mrtrConfig.getReplayCache(); 912 if (replayCache != null) { 913 // A null/empty jti reaching the cache is a codec/contract violation (a custom RequestStateCodec that 914 // dropped the field, or a pre-jti in-flight token during a rolling deploy), NOT a store outage: fail 915 // CLOSED here rather than letting a downstream NPE be swallowed by checkReplay's fail-OPEN catch, 916 // which would silently disable replay protection. 917 if (isEmpty(sealed.jti())) 918 throw new McpException(CODE_INVALID_PARAMS, "Invalid or tampered requestState"); 919 if (! checkReplay(replayCache, sealed.jti(), sealed.expiresAtMs())) 920 // Client-facing message deliberately omits any cache/backend detail, mirroring the max-rounds 921 // message's "don't leak server config" caution above. 922 throw new McpException(CODE_REQUEST_STATE_REPLAYED, "requestState has already been used"); 923 } 924 var inputResponses = McpParamUtils.mapParam(params, "inputResponses"); 925 var wrapped = new BasicBeanStore(ctx) 926 .addBean(McpMrtrCapabilityContext.class, new McpMrtrCapabilityContext(clientElicitationSupported(params))) 927 .addBean(McpMrtrResumeContext.class, new McpMrtrResumeContext(sealed.continuation(), inputResponses)); 928 return new MrtrContext(wrapped, sealed.round(), argumentsHash, target); 929 } 930 931 /** 932 * Turns a caught {@link McpInputRequiredSignal} into a wire {@link InputRequiredResult}: capability-gates the 933 * client, seals a fresh {@code requestState} carrying the incremented round counter, a fresh {@code jti} (see 934 * {@link #mintJti()}), and the {@code argumentsHash} of the current request's {@code arguments} (see 935 * {@link #argumentsHash(Map)}), and assembles the requested-inputs map. 936 * 937 * <p> 938 * The capability gate runs <i>before</i> any token is minted — a client that never advertised 939 * {@code elicitation} causes no seal at all, only a {@link #CODE_MISSING_REQUIRED_CLIENT_CAPABILITY} error. 940 * 941 * @param signal The signal thrown by the handler. Must not be <jk>null</jk>. 942 * @param method The in-scope JSON-RPC method this pause is for. Must not be <jk>null</jk>. 943 * @param params The request params map, used only for {@link #clientElicitationSupported(Map)} here — 944 * the AAD's operation target is the caller-supplied {@code target}, below, not re-derived from this map. 945 * @param currentRound The round decoded on RESUME (0 on a first-round pause), incremented into the new token. 946 * @param argumentsHash The hash of the current request's {@code arguments}, computed by 947 * {@link #resolveMrtrContext} <i>before</i> the handler ran and threaded here (see {@link MrtrContext}), so a 948 * handler that mutated the live {@code arguments} map cannot make this token seal an unreproducible hash. 949 * @param target The operation target sealed into the next token's {@link #aad(String, String) AAD}, resolved 950 * by {@link #resolveMrtrContext} <i>before</i> the handler ran and threaded here (see {@link MrtrContext}), 951 * so a handler that mutated the live {@code params} map cannot shift which tool/prompt/resource the next 952 * token is bound to. 953 * @param ctx The per-request bean store, used to resolve the authenticated {@link #principal(BeanStore)} threaded 954 * into the codec's {@link RequestStateCodec#seal seal}. Must not be <jk>null</jk>. 955 * @return The assembled, validated {@code input_required} result. Never <jk>null</jk>. 956 */ 957 private InputRequiredResult pause(McpInputRequiredSignal signal, String method, Map<String,Object> params, int currentRound, String argumentsHash, String target, BeanStore ctx) { 958 if (! clientElicitationSupported(params)) 959 throw new McpException(CODE_MISSING_REQUIRED_CLIENT_CAPABILITY, 960 "Client does not advertise the elicitation capability required for input_required"); 961 var state = new McpRequestState(signal.getContinuation(), method, currentRound + 1, mrtrConfig.getClock().millis() + mrtrConfig.getTtlMs(), 962 mintJti(), argumentsHash); 963 var result = new InputRequiredResult().setRequestState(mrtrConfig.getCodec().seal(state, aad(method, target), principal(ctx))); 964 // Each inputRequests value is carried to the wire byte-for-byte as a raw sub-request object (see 965 // McpInputRequiredSignal). The pinned schema models every value as an object; a non-map handler value is a 966 // programming error that surfaces (as a ClassCastException here) via dispatch's -32603 fail-safe. 967 signal.getInputRequests().forEach((id, raw) -> result.putInputRequest(id, new JsonMap((Map<?,?>) raw))); 968 result.validate(); 969 return result; 970 } 971 972 /** 973 * The canonical MRTR AAD binding a sealed {@code requestState} to the request that produced it: 974 * {@code method + '\u0000' + protocolVersion + '\u0000' + target} (NUL-separated; see 975 * {@link RequestStateCodec}). 976 * 977 * <p> 978 * <b>The operation {@code target} is folded in so a token is bound to the specific tool/prompt/resource it 979 * paused against, not merely the method.</b> Without it, a token paused for tool A could be resumed against 980 * tool B (or resource {@code uri} A against {@code uri} B) — both are {@code tools/call} (or 981 * {@code resources/read}), so the method-only AAD verified identically. The {@code target} is the tool 982 * {@code name} for {@code tools/call}, the prompt {@code name} for {@code prompts/get}, and the {@code uri} 983 * for {@code resources/read} (see {@link #mrtrTarget(String, Map)}); a mismatch now fails the GCM tag check, 984 * surfacing as the existing "invalid or tampered requestState" {@link #CODE_INVALID_PARAMS}. Neither 985 * {@code method} nor {@code protocolVersion} can contain a NUL, and {@code target} is the terminal field, so 986 * the three-field NUL join is unambiguous (distinct {@code (method, version, target)} tuples never collide) 987 * — the same injectivity property the two-field form relied on, extended by one trailing field. The 988 * codec treats this whole string as one opaque AAD field, framing it against {@code keyId}/principal with its 989 * own length-prefixed join (see {@link AeadRequestStateCodec#lengthPrefixJoin}). 990 * 991 * @param method The in-scope JSON-RPC method. Must not be <jk>null</jk>. 992 * @param target The operation target (tool/prompt {@code name} or resource {@code uri}); coalesced to empty 993 * when <jk>null</jk> (only the three MRTR-wired methods reach here, and each has already validated its 994 * target as non-null at the call site). 995 * @return The AAD string. Never <jk>null</jk>. 996 */ 997 private String aad(String method, String target) { 998 return method + '\u0000' + protocolVersion() + '\u0000' + ein(target); 999 } 1000 1001 /** 1002 * Derives the operation target folded into the {@link #aad(String, String) AAD}: the tool {@code name} for 1003 * {@code tools/call}, the prompt {@code name} for {@code prompts/get}, and the resource {@code uri} for 1004 * {@code resources/read}. Any other method (none of which reach the MRTR pause/resume path) yields 1005 * <jk>null</jk>. 1006 * 1007 * @param method The in-scope JSON-RPC method. Must not be <jk>null</jk>. 1008 * @param params The request params map. Must not be <jk>null</jk>. 1009 * @return The operation target, or <jk>null</jk> for a non-MRTR method. 1010 */ 1011 private static String mrtrTarget(String method, Map<String,Object> params) { 1012 return switch (method) { 1013 case McpMethods.TOOLS_CALL, McpMethods.PROMPTS_GET -> McpParamUtils.strParam(params, "name"); 1014 case McpMethods.RESOURCES_READ -> McpParamUtils.strParam(params, "uri"); 1015 default -> null; 1016 }; 1017 } 1018 1019 /** 1020 * Mints a fresh, per-token random {@code jti} for a newly-sealed {@code requestState}, used only to detect 1021 * replay of this exact token (see {@link #checkReplay(ReplayCache, String, long)}). 1022 * 1023 * <p> 1024 * 16 random bytes, base64url-encoded — the same minting pattern {@link EphemeralKeyProvider} already 1025 * uses for its own {@code keyId}. Collision probability is negligible (128 bits of entropy) and, in any 1026 * case, is not a security property this identifier depends on: it rides inside the AEAD-sealed plaintext, so 1027 * a client can neither forge nor read it without first breaking the GCM tag. 1028 * 1029 * @return A fresh {@code jti}. Never <jk>null</jk>. 1030 */ 1031 private static String mintJti() { 1032 var bytes = new byte[JTI_BYTES]; 1033 JTI_RANDOM.nextBytes(bytes); 1034 return B64URL.encodeToString(bytes); 1035 } 1036 1037 /** 1038 * Computes the canonical hash of a request's {@code arguments}, sealed at PAUSE time and re-verified at 1039 * RESUME time (see {@code McpRequestState#argumentsHash()}). 1040 * 1041 * <p> 1042 * First runs the module's shared bounded-traversal guard ({@link JsonValueSafety#check(Object, String)}: 1043 * {@code MAX_DEPTH=64}, {@code MAX_NODES}, a traversal-time deadline) so structurally-hostile {@code arguments} 1044 * (excessive depth or node count — reachable via {@code prompts/get}, which has no other input-shape 1045 * guard, and schemaless {@code tools/call}) become a deterministic {@link #CODE_INVALID_PARAMS} rejection on 1046 * BOTH PAUSE and RESUME, rather than a silent hash collision (the JCS canonicalizer's inherited depth limit 1047 * would otherwise truncate over-depth nodes to {@code null}). It then canonicalizes {@code arguments} per RFC 1048 * 8785 (JSON Canonicalization Scheme) using an <b>unbounded-depth</b> serializer (see {@link #JCS}), SHA-256s 1049 * the canonical UTF-8 bytes, and base64url-encodes the digest (mirroring {@link EphemeralKeyProvider}'s 1050 * existing {@code keyId} encoding convention). An absent or empty {@code arguments} map canonicalizes to the 1051 * empty JSON object {@code "{}"}, giving a single, consistent sentinel hash for calls that take no arguments 1052 * (for example {@code resources/read}'s exact-path branch), so PAUSE and RESUME agree even when there is 1053 * nothing to hash. 1054 * 1055 * <p> 1056 * By design, RFC 8785 canonical equivalences are treated as equal — for example {@code 1} and 1057 * {@code 1.0} canonicalize identically, so they hash the same and a resume switching between them is not a 1058 * mismatch. A canonicalization failure on a hostile-but-syntactically-legal value (a non-finite number such 1059 * as {@code 1e999}, a lone UTF-16 surrogate, or a {@code BigDecimal}/{@code BigInteger} outside IEEE-754 1060 * range) is mapped to {@link #CODE_INVALID_PARAMS} rather than surfacing as a generic internal error. 1061 * 1062 * <p> 1063 * Package-visible so tests can construct {@link McpRequestState} fixtures whose {@code argumentsHash} agrees 1064 * with a given (or absent) {@code arguments} map without duplicating this derivation. 1065 * 1066 * @param arguments The request's {@code arguments} map, as returned by 1067 * {@code McpParamUtils.mapParam(params, "arguments")}. Never <jk>null</jk> (empty when absent). 1068 * @return The canonical hash. Never <jk>null</jk>. 1069 * @throws McpException ({@link #CODE_INVALID_PARAMS}) if {@code arguments} violates the structural safety 1070 * limits or cannot be canonicalized. 1071 */ 1072 static String argumentsHash(Map<String,Object> arguments) { 1073 try { 1074 JsonValueSafety.check(arguments, PARAM_ARGUMENTS); 1075 } catch (IllegalArgumentException e) { 1076 throw new McpException(CODE_INVALID_PARAMS, e.getMessage()); 1077 } 1078 String canonical; 1079 try { 1080 canonical = JCS.write(arguments); 1081 } catch (SerializeException e) { 1082 // Message deliberately generic (no echo of the offending value), mirroring the other MRTR "don't leak" 1083 // messages: a hostile-but-legal input (non-finite number, lone surrogate, out-of-IEEE BigDecimal) must 1084 // not surface as a -32603 internal error. 1085 throw new McpException(CODE_INVALID_PARAMS, "Invalid arguments: not canonicalizable"); 1086 } 1087 return B64URL.encodeToString(sha256(canonical.getBytes(StandardCharsets.UTF_8))); 1088 } 1089 1090 /** 1091 * Constant-time comparison of the current request's {@code argumentsHash} against the one sealed into the 1092 * echoed token. Both operands are base64url of a SHA-256 digest; they are decoded to their raw digest bytes 1093 * and compared with {@link MessageDigest#isEqual(byte[], byte[])}. A <jk>null</jk> sealed hash (for example a 1094 * custom codec that dropped the field) fails closed (returns <jk>false</jk>, surfacing as the ordinary 1095 * {@link #CODE_REQUEST_STATE_ARGUMENTS_MISMATCH} at the call site). 1096 * 1097 * <p> 1098 * A non-<jk>null</jk> sealed hash that is not valid base64url (for example a custom codec that computed 1099 * {@code argumentsHash} some other way) is a codec-contract violation rather than an ordinary mismatch, and 1100 * fails closed the same way the null-{@code jti} codec-contract violation does at the call site: a thrown 1101 * {@link McpException} ({@link #CODE_INVALID_PARAMS}), not a propagated {@link IllegalArgumentException} 1102 * from {@link Base64.Decoder#decode(String)} surfacing as a generic {@link #CODE_INTERNAL_ERROR}. 1103 * 1104 * @param currentHash The hash recomputed from the current request's {@code arguments}. Never <jk>null</jk>. 1105 * @param sealedHash The hash sealed into the echoed token, or <jk>null</jk>. 1106 * @return <jk>true</jk> if the two hashes match. 1107 * @throws McpException ({@link #CODE_INVALID_PARAMS}) if {@code sealedHash} is non-<jk>null</jk> but not 1108 * valid base64url. 1109 */ 1110 private static boolean argumentsMatch(String currentHash, String sealedHash) { 1111 if (sealedHash == null) 1112 return false; 1113 byte[] sealedBytes; 1114 try { 1115 sealedBytes = B64URL_DECODER.decode(sealedHash); 1116 } catch (IllegalArgumentException e) { 1117 // Same fail-closed contract as the null-jti check below: an untrusted or misbehaving codec must 1118 // never turn into a raw exception bubbling up as -32603 (see resolveMrtrContext's ordering note 1119 // on why this stateless check runs before the stateful replay record). 1120 throw new McpException(CODE_INVALID_PARAMS, "Invalid or tampered requestState"); 1121 } 1122 return MessageDigest.isEqual(B64URL_DECODER.decode(currentHash), sealedBytes); 1123 } 1124 1125 private static byte[] sha256(byte[] input) { 1126 try { 1127 return MessageDigest.getInstance("SHA-256").digest(input); 1128 } catch (NoSuchAlgorithmException e) { // HTT every JDK guarantees SHA-256 via the standard JCE provider 1129 throw rex(e, "SHA-256 not available"); 1130 } 1131 } 1132 1133 /** 1134 * Invokes an operator-configured {@link ReplayCache}, applying the fail-open policy documented on that SPI: 1135 * a thrown exception is logged and treated as first-seen, so a store outage degrades to today's 1136 * already-documented multi-use-tolerant behavior rather than rejecting all MRTR resume traffic. 1137 * 1138 * <p> 1139 * An operator who wants fail-closed-on-store-outage instead must implement that themselves by returning 1140 * <jk>false</jk> from their own {@link ReplayCache#checkAndRecord(String, long)} rather than throwing (see 1141 * the SPI's own Javadoc) — this method applies no policy beyond "don't let an unexpected exception take 1142 * down resume traffic". 1143 * 1144 * @param replayCache The configured replay cache. Must not be <jk>null</jk> (callers only invoke this when 1145 * {@link McpMrtrConfig#getReplayCache()} is non-<jk>null</jk>). 1146 * @param jti The token identifier to check. Must not be <jk>null</jk>. 1147 * @param expiresAtMs The sealed token's own absolute expiry, passed through unchanged. 1148 * @return <jk>true</jk> if the cache reports first-seen (or itself failed, fail-open); <jk>false</jk> only 1149 * when the cache affirmatively reports a replay. 1150 */ 1151 private static boolean checkReplay(ReplayCache replayCache, String jti, long expiresAtMs) { 1152 // MCP deliberately opts into fail-open: a thrown failure is logged and treated as first-seen, degrading a 1153 // transient store outage to the same multi-use-tolerant behavior seen when no ReplayCache is wired at all. 1154 return replayCache.checkAndRecord(jti, expiresAtMs, ReplayCache.FailMode.FAIL_OPEN, 1155 e -> LOG.log(Level.WARNING, e, () -> "ReplayCache.checkAndRecord failed; treating requestState as first-seen (fail-open).")); 1156 } 1157 1158 /** 1159 * Resolves the authenticated caller {@link Principal} for the current request, threaded into the 1160 * {@link RequestStateCodec} at seal and unseal so a hardened codec can bind the {@code requestState} to who 1161 * requested it (enables principal-bound AAD). 1162 * 1163 * <p> 1164 * Reads the resource-server principal via {@link McpResourceServerSupport#principal} — the same 1165 * {@code RestRequest -> HttpServletRequest} lookup {@link #enforceStepUpScopes} uses for granted scopes, so the 1166 * two stay in lock-step. Returns <jk>null</jk> when there is no bound {@link RestRequest} (a direct-dispatch unit 1167 * test), when RS auth is disabled, or when the caller is anonymous — the codec must seal/unseal cleanly in 1168 * that case. 1169 * 1170 * @param ctx The per-request bean store. Must not be <jk>null</jk>. 1171 * @return The authenticated principal, or <jk>null</jk> when none is available. 1172 */ 1173 private static Principal principal(BeanStore ctx) { 1174 return ctx.getBean(RestRequest.class) 1175 .map(RestRequest::getHttpServletRequest) 1176 .map(McpResourceServerSupport::principal) 1177 .orElse(null); 1178 } 1179 1180 /** 1181 * Reads whether the request advertised the client {@code elicitation} capability, from the opaque 1182 * {@code _meta.clientCapabilities} map directly (mirroring {@link #validateMeta}'s opaque-map style rather than 1183 * re-parsing a full {@code RequestMeta}/{@code ClientCapabilities} bean for one key). 1184 * 1185 * @param params The request params map. Must not be <jk>null</jk>. 1186 * <p> 1187 * {@link #validateMeta} has already run for every dispatched request and rejects a missing or non-{@code Map} 1188 * {@code clientCapabilities} with {@link #CODE_INVALID_REQUEST}, so {@code caps} is always a non-<jk>null</jk> 1189 * {@code Map} here — no defensive type guard is needed. 1190 * 1191 * @return <jk>true</jk> if {@code _meta.clientCapabilities.elicitation} is present and non-<jk>null</jk>. 1192 */ 1193 private static boolean clientElicitationSupported(Map<String,Object> params) { 1194 var caps = (Map<?,?>) McpWire.metaMapOrEmpty(params).get(RequestMeta.KEY_CLIENT_CAPABILITIES); 1195 return caps.get("elicitation") != null; 1196 } 1197 1198 // --- trace-context extraction (Part B / trace-context propagation) ---------------------- 1199 1200 /** 1201 * This revision's stable, stateless {@link TraceContextExtractor}, published as a {@code @Bean} by 1202 * both {@link McpRestServlet} and {@link McpEndpoint} so an active {@code TracerHook} can recognize 1203 * this revision's resolved {@link JsonRpcRequest} argument and its {@code params._meta} carrier 1204 * before span creation. 1205 * 1206 * <p> 1207 * Imports only the neutral {@code juneau-rest-server} tracing seam ({@link TraceContextExtractor}, 1208 * {@link TraceContextCarrier}, {@link TraceOperation}) — never OpenTelemetry. 1209 */ 1210 static final TraceContextExtractor TRACE_CONTEXT_EXTRACTOR = new V2TraceContextExtractor(); 1211 1212 /** 1213 * {@link TraceContextExtractor} implementation for revision {@code 2026-07-28}. 1214 * 1215 * <p> 1216 * Recognizes the resolved {@link JsonRpcRequest} among {@code @RestOp}-resolved arguments, reads 1217 * its opaque {@code params._meta} (via {@link McpWire#metaMapOrEmpty(Object)}, since extraction 1218 * runs before {@link #validateMeta(Object)} enforces the per-request contract on a request that 1219 * has not yet been validated), and derives a composite carrier plus a low-cardinality 1220 * {@link TraceOperation}. 1221 */ 1222 private static final class V2TraceContextExtractor implements TraceContextExtractor { 1223 1224 @Override /* TraceContextExtractor */ 1225 public Optional<TraceContextCarrier> extract(RestRequest request, Object[] resolvedArguments) { 1226 var req = findRequest(resolvedArguments); 1227 if (req == null) 1228 return Optional.empty(); 1229 var meta = new LinkedHashMap<>(McpWire.metaMapOrEmpty(req.getParams())); 1230 return Optional.of(new MetaCarrier(meta, request)); 1231 } 1232 1233 @Override /* TraceContextExtractor */ 1234 public TraceOperation operation(RestRequest request, Object[] resolvedArguments) { 1235 var req = findRequest(resolvedArguments); 1236 return req == null ? TraceOperation.DEFAULT : buildOperation(req); 1237 } 1238 1239 private static JsonRpcRequest findRequest(Object[] resolvedArguments) { 1240 for (var arg : resolvedArguments) 1241 if (arg instanceof JsonRpcRequest r) 1242 return r; 1243 return null; 1244 } 1245 1246 /** 1247 * Derives method, protocol version, request id, and tool/prompt/resource target into a 1248 * {@link TraceOperation}, following the exact pinned MCP/GenAI attribute and span-name mapping: 1249 * {@code "tools/call "+name}, {@code "prompts/get "+name}, bare {@code "resources/read"} (the 1250 * URI stays an attribute, never a span-name suffix), or the exact method name otherwise. 1251 */ 1252 private static TraceOperation buildOperation(JsonRpcRequest req) { 1253 var method = req.getMethod(); 1254 if (isEmpty(method)) 1255 return TraceOperation.DEFAULT; 1256 var meta = McpWire.metaMapOrEmpty(req.getParams()); 1257 var attrs = new LinkedHashMap<String,String>(); 1258 attrs.put(TraceOperation.ATTR_MCP_METHOD_NAME, method); 1259 var protocolVersion = str(meta.get(RequestMeta.KEY_PROTOCOL_VERSION)); 1260 if (! isEmpty(protocolVersion)) 1261 attrs.put(TraceOperation.ATTR_MCP_PROTOCOL_VERSION, protocolVersion); 1262 if (nn(req.getId())) 1263 attrs.put(TraceOperation.ATTR_JSONRPC_REQUEST_ID, String.valueOf(req.getId())); 1264 1265 var spanName = method; 1266 switch (method) { 1267 case McpMethods.TOOLS_CALL -> { 1268 var name = McpRoutingNames.routingName(method, req.getParams()); 1269 if (! isEmpty(name)) { 1270 attrs.put(TraceOperation.ATTR_GEN_AI_TOOL_NAME, name); 1271 spanName = method + " " + name; 1272 } 1273 attrs.put(TraceOperation.ATTR_GEN_AI_OPERATION_NAME, "execute_tool"); 1274 } 1275 case McpMethods.PROMPTS_GET -> { 1276 var name = McpRoutingNames.routingName(method, req.getParams()); 1277 if (! isEmpty(name)) { 1278 attrs.put(TraceOperation.ATTR_GEN_AI_PROMPT_NAME, name); 1279 spanName = method + " " + name; 1280 } 1281 } 1282 case McpMethods.RESOURCES_READ -> { 1283 var uri = McpRoutingNames.routingName(method, req.getParams()); 1284 if (! isEmpty(uri)) 1285 attrs.put(TraceOperation.ATTR_MCP_RESOURCE_URI, uri); 1286 } 1287 default -> { 1288 // Exact method name; no method-specific attributes. 1289 } 1290 } 1291 return TraceOperation.of(spanName, attrs); 1292 } 1293 } 1294 1295 /** 1296 * Composite, per-request {@link TraceContextCarrier} giving an explicit {@code params._meta} value 1297 * precedence over the equivalent HTTP header, for any key — in practice the bare W3C 1298 * {@code traceparent} / {@code tracestate} / {@code baggage} keys, since those are the only names 1299 * both sides share: {@code lookup(key) = params._meta[key] if present, otherwise the HTTP header of 1300 * the same name}. 1301 */ 1302 private static final class MetaCarrier implements TraceContextCarrier { 1303 private final Map<String,Object> meta; 1304 private final RestRequest request; 1305 1306 MetaCarrier(Map<String,Object> meta, RestRequest request) { 1307 this.meta = meta; 1308 this.request = request; 1309 } 1310 1311 @Override /* TraceContextCarrier */ 1312 public String get(String key) { 1313 var value = meta.get(key); 1314 if (nn(value)) 1315 return value.toString(); 1316 return request.getHeaderParam(key).orElse(null); 1317 } 1318 1319 @Override /* TraceContextCarrier */ 1320 public Iterable<String> keys() { 1321 var result = new LinkedHashSet<String>(meta.keySet()); 1322 result.addAll(request.getHeaders().getNames()); 1323 return result; 1324 } 1325 1326 @Override /* TraceContextCarrier */ 1327 public void set(String key, String value) { 1328 meta.put(key, value); 1329 } 1330 } 1331}