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> &mdash; 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 &mdash; 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 &mdash; and thus the operation identity ({@link McpRoutingNames#routingName routing name}) &mdash; 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)} &mdash; 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 &mdash; no OpenTelemetry import involved &mdash; 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 &mdash; already
576    * neutral strings, read here with no OpenTelemetry import &mdash; 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 &mdash; no trace keys added &mdash; 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 &mdash; before the handler runs &mdash; 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    * &mdash; any failure raises the mapped {@link McpException} <i>before</i> the handler is re-invoked &mdash;
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} &mdash; 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 &mdash; 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 &mdash;
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) &mdash; 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    * &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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) &mdash; 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} &mdash; 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 &mdash; 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 &mdash; 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}) &mdash; 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 &mdash; 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}