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.client.mcp.v20260728;
018
019
020import java.io.*;
021import java.util.*;
022import java.util.concurrent.atomic.*;
023import java.util.function.*;
024
025import org.apache.juneau.bean.jsonrpc.*;
026import org.apache.juneau.bean.mcp.v20260728.*;
027import org.apache.juneau.commons.inject.*;
028import org.apache.juneau.marshall.collections.*;
029import org.apache.juneau.marshall.json.*;
030import org.apache.juneau.marshall.marshaller.Json;
031import org.apache.juneau.marshall.sse.*;
032import org.apache.juneau.rest.client.mcp.*;
033import static org.apache.juneau.commons.utils.Shorts.*;
034
035/**
036 * Typed <c>2026-07-28</c> Model Context Protocol (MCP) client facade.
037 *
038 * <p>
039 * Extends {@link AbstractMcpClient}, adding one typed method per <c>2026-07-28</c> JSON-RPC method
040 * (see {@link McpMethods}). Every method stamps the SEP-2243 {@code params._meta} envelope (protocol
041 * version, client capabilities, client info, and any supplied trace-context fields), builds the
042 * corresponding v2 request bean, wraps it in a {@link JsonRpcRequest} with the required
043 * {@code Mcp-Method}/{@code Mcp-Name} headers, delegates to the inherited {@link #send(JsonRpcRequest, Map)},
044 * and then either returns the {@code result} re-marshaled into its typed v2 result bean, or throws an
045 * {@link McpException} built from the response's {@code error}.
046 *
047 * <p>
048 * An optional {@link McpResponseCache} can be supplied via {@link Builder#responseCache(McpResponseCache)}
049 * to serve repeat identical calls from cache instead of the wire, keyed off the method and business params
050 * (never the per-call {@code _meta}, so a per-request trace-context supplier does not defeat the cache).
051 *
052 * <p>
053 * A server-initiated request delivered over the duplex event-stream channel is routed through the
054 * {@link McpDuplexDispatcher} registered via {@link #setServerRequestHandler(McpServerRequestHandler)}.
055 *
056 * @since 10.0.0
057 */
058@SuppressWarnings({
059   "java:S115", // WRAP_KEY_value mirrors the JsonMap key literal it wraps; a conventional UPPER_SNAKE_CASE name would obscure that.
060   "java:S1181", // connect() and closeQuietly() catch Throwable so the transport is closed and any close failure is recorded as suppressed on the original handshake failure, which is then rethrown
061   "java:S1192", // Repeated "value" is the JSON field name read inline from protocol result maps; constants would obscure the wire format.
062   "resource" // connect() returns the built McpClient to the caller, who owns closing it (it is closed here only on handshake failure); the SSE reader is closed by SubscriptionPump.close()
063})
064public final class McpClient extends AbstractMcpClient {
065
066   private static final JsonSerializer REQUEST_SERIALIZER = JsonSerializer.create()
067      .addBeanTypes()
068      .typePropertyName(CompletionReference.class, "type")
069      .build();
070
071   /** JSON-RPC method name used for the client-to-server duplex return-channel POST. */
072   private static final String DUPLEX_RETURN_METHOD = "mcp/clientResult";
073
074   /** SEP-2243 request-routing header carrying the JSON-RPC method name. */
075   private static final String HEADER_MCP_METHOD = "Mcp-Method";
076
077   /** SEP-2243 request-routing header carrying the tool/prompt/resource routing name for the call. */
078   private static final String HEADER_MCP_NAME = "Mcp-Name";
079
080   private final ClientCapabilities clientCapabilities;
081   private final Implementation clientInfo;
082   private final Supplier<Map<String,String>> traceFieldsSupplier;
083   private final McpResponseCache responseCache;
084   private final String privateScopePartitionPrefix;
085   private final McpDuplexDispatcher duplexDispatcher = new McpDuplexDispatcher();
086   private final AtomicReference<ServerDiscoverResult> discoveredServer = new AtomicReference<>();
087
088   private McpClient(Builder builder) {
089      super(builder);
090      this.clientCapabilities = builder.clientCapabilities;
091      this.clientInfo = builder.clientInfo;
092      this.traceFieldsSupplier = builder.traceFieldsSupplier;
093      this.responseCache = builder.responseCache;
094      this.privateScopePartitionPrefix = "private:" + UUID.randomUUID() + ":";
095   }
096
097   /**
098    * Creates a new builder.
099    *
100    * @return A new builder. Never <jk>null</jk>.
101    */
102   public static Builder builder() {
103      return new Builder();
104   }
105
106   /**
107    * Builds a client for {@code endpoint} with default settings and performs its one mandatory handshake call,
108    * returning a ready-to-use client.
109    *
110    * <p>
111    * {@code 2026-07-28} has no {@code initialize} method - {@link #serverDiscover()} is this revision's
112    * handshake - so unlike {@code v20250618}'s {@code McpClient}, forgetting it is a real footgun: nothing in
113    * this class's type system forces a caller to call it before {@link #callTool}, {@link #listTools}, etc. This
114    * is the sanctioned one-expression path for the common case of connecting with default settings; use
115    * {@link #connect(Builder)} to customize the transport, interceptors, or other builder settings first.
116    *
117    * @param endpoint The absolute endpoint URL (e.g. {@code "http://localhost:8080/mcp"}). Must not be <jk>null</jk>.
118    * @return A new client that has already completed {@link #serverDiscover()} exactly once. Never <jk>null</jk>.
119    * @throws IOException If a transport-level or (de)serialization error occurs opening the connection.
120    * @throws McpException If the server returned a JSON-RPC error for {@value McpMethods#SERVER_DISCOVER}.
121    */
122   public static McpClient connect(String endpoint) throws IOException {
123      return connect(builder().endpoint(endpoint));
124   }
125
126   /**
127    * Builds a client from a caller-configured builder and performs its one mandatory handshake call, returning a
128    * ready-to-use client.
129    *
130    * <p>
131    * If {@link #serverDiscover()} fails, the just-built client is closed before the exception propagates - a
132    * caller that only ever sees an exception from {@code connect(...)} never ends up owning a client (and its
133    * underlying transport resources) it has no reference to.
134    *
135    * @param builder The configured builder to build from. Must not be <jk>null</jk>.
136    * @return A new client that has already completed {@link #serverDiscover()} exactly once. Never <jk>null</jk>.
137    * @throws IOException If a transport-level or (de)serialization error occurs opening the connection.
138    * @throws McpException If the server returned a JSON-RPC error for {@value McpMethods#SERVER_DISCOVER}.
139    */
140   public static McpClient connect(Builder builder) throws IOException {
141      reqnn("builder", builder);
142      var client = builder.build();
143      try {
144         client.serverDiscover();
145      } catch (Throwable e) {
146         // Catches Throwable (not just IOException/RuntimeException): a client that only ever surfaces a
147         // thrown Throwable from connect(...) must never end up leaking its transport resources just
148         // because the handshake failed with something other than a checked/runtime exception (e.g. a
149         // StackOverflowError decoding a pathological response). javac's "more precise rethrow" analysis
150         // (JLS 11.2.2) sees that only IOException/RuntimeException/Error can reach this catch from the
151         // try block, so rethrowing this effectively-final `e` still satisfies this method's declared
152         // "throws IOException" without widening to "throws Throwable".
153         closeQuietly(client, e);
154         throw e;
155      }
156      return client;
157   }
158
159   /**
160    * Closes {@code client}, adding any close failure as a suppressed exception on {@code primary} rather than
161    * letting it mask the handshake failure that is the actual reason {@link #connect(Builder)} is failing.
162    */
163   private static void closeQuietly(McpClient client, Throwable primary) {
164      try {
165         client.close();
166      } catch (Throwable e) {
167         // Catches Throwable, mirroring the outer catch in connect(Builder): a caller-supplied
168         // HttpTransport.close() throwing an unchecked failure (RuntimeException/Error) must be recorded
169         // as suppressed, not allowed to propagate from here and replace the handshake failure (primary)
170         // this method exists to preserve.
171         primary.addSuppressed(e);
172      }
173   }
174
175   /**
176    * Sends {@value McpMethods#SERVER_DISCOVER}.
177    *
178    * <p>
179    * Caches its result, so a subsequent {@link #discoveredServer()} call (including one following the mandatory
180    * discovery {@link #connect(Builder)} already performs) does not need a repeat round trip.
181    *
182    * @return The server-discover result. Never <jk>null</jk> on success unless the server returns a
183    *    <jk>null</jk> result.
184    * @throws IOException If a transport-level or (de)serialization error occurs.
185    * @throws McpException If the server returned a JSON-RPC error.
186    */
187   public ServerDiscoverResult serverDiscover() throws IOException {
188      var result = call(McpMethods.SERVER_DISCOVER, new RequestParamsOnly(), ServerDiscoverResult.class);
189      discoveredServer.set(result);
190      return result;
191   }
192
193   /**
194    * Returns the {@link ServerDiscoverResult} cached from the most recent {@link #serverDiscover()} call.
195    *
196    * <p>
197    * A client built via {@link #connect(String)}/{@link #connect(Builder)} already has this populated from the
198    * mandatory handshake call, so a caller that only needs the discovery info (server capabilities, supported
199    * versions, etc.) does not need to invoke {@link #serverDiscover()} again just to read it.
200    *
201    * @return The most recently discovered server info, or <jk>null</jk> if {@link #serverDiscover()} has not
202    *    been called on this client, or the most recent call returned a <jk>null</jk> result.
203    */
204   public ServerDiscoverResult discoveredServer() {
205      return discoveredServer.get();
206   }
207
208   /**
209    * Sends {@value McpMethods#PING} to verify server liveness.
210    *
211    * @return The ping result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
212    * @throws IOException If a transport-level or (de)serialization error occurs.
213    * @throws McpException If the server returned a JSON-RPC error.
214    */
215   public PingResult ping() throws IOException {
216      return call(McpMethods.PING, new RequestParamsOnly(), PingResult.class);
217   }
218
219   /**
220    * Sends {@value McpMethods#TOOLS_LIST}.
221    *
222    * @return The tools-list result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
223    * @throws IOException If a transport-level or (de)serialization error occurs.
224    * @throws McpException If the server returned a JSON-RPC error.
225    */
226   public ListToolsResult listTools() throws IOException {
227      return call(McpMethods.TOOLS_LIST, new RequestParamsOnly(), ListToolsResult.class);
228   }
229
230   /**
231    * Sends {@value McpMethods#TOOLS_CALL}.
232    *
233    * @param name The tool name to invoke.
234    * @param arguments The tool arguments. Can be <jk>null</jk> (sent as an empty object).
235    * @return The call-tool result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
236    * @throws IOException If a transport-level or (de)serialization error occurs.
237    * @throws McpException If the server returned a JSON-RPC error.
238    */
239   public CallToolResult callTool(String name, Map<String,Object> arguments) throws IOException {
240      var params = new CallToolRequest().setName(name).setArguments(arguments == null ? JsonMap.of() : new JsonMap(arguments));
241      return call(McpMethods.TOOLS_CALL, params, CallToolResult.class);
242   }
243
244   /**
245    * Convenience for {@link #callTool} followed by {@link CallToolResult#firstText()}.
246    *
247    * <p>
248    * Kills the {@code ((TextContent)result.getContent().get(i)).getText()} cast-and-scan that a caller who
249    * only wants the tool's text result would otherwise repeat at every call site.
250    *
251    * @param name The tool name to invoke.
252    * @param arguments The tool arguments. Can be <jk>null</jk> (sent as an empty object).
253    * @return The first {@link TextContent} block's text found while scanning the result's content list in
254    *    order, or <jk>null</jk> if the content list is empty or unset, it contains no {@link TextContent}
255    *    block, or {@link #callTool} itself returned a <jk>null</jk> result.
256    * @throws IOException If a transport-level or (de)serialization error occurs.
257    * @throws McpException If the server returned a JSON-RPC error.
258    */
259   public String callToolText(String name, Map<String,Object> arguments) throws IOException {
260      var r = callTool(name, arguments);
261      return r == null ? null : r.firstText();
262   }
263
264   /**
265    * Sends {@value McpMethods#PROMPTS_LIST}.
266    *
267    * @return The prompts-list result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
268    * @throws IOException If a transport-level or (de)serialization error occurs.
269    * @throws McpException If the server returned a JSON-RPC error.
270    */
271   public ListPromptsResult listPrompts() throws IOException {
272      return call(McpMethods.PROMPTS_LIST, new RequestParamsOnly(), ListPromptsResult.class);
273   }
274
275   /**
276    * Sends {@value McpMethods#PROMPTS_GET}.
277    *
278    * @param name The prompt name to fetch.
279    * @param arguments The prompt argument values. Can be <jk>null</jk> (sent as an empty object).
280    * @return The get-prompt result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
281    * @throws IOException If a transport-level or (de)serialization error occurs.
282    * @throws McpException If the server returned a JSON-RPC error.
283    */
284   public GetPromptResult getPrompt(String name, Map<String,Object> arguments) throws IOException {
285      var params = new GetPromptRequest().setName(name).setArguments(arguments == null ? JsonMap.of() : new JsonMap(arguments));
286      return call(McpMethods.PROMPTS_GET, params, GetPromptResult.class);
287   }
288
289   /**
290    * Sends {@value McpMethods#RESOURCES_LIST}.
291    *
292    * @return The resources-list result. Never <jk>null</jk> on success unless the server returns a
293    *    <jk>null</jk> result.
294    * @throws IOException If a transport-level or (de)serialization error occurs.
295    * @throws McpException If the server returned a JSON-RPC error.
296    */
297   public ListResourcesResult listResources() throws IOException {
298      return call(McpMethods.RESOURCES_LIST, new RequestParamsOnly(), ListResourcesResult.class);
299   }
300
301   /**
302    * Sends {@value McpMethods#RESOURCES_READ}.
303    *
304    * @param uri The resource URI to read.
305    * @return The read-resource result. Never <jk>null</jk> on success unless the server returns a
306    *    <jk>null</jk> result.
307    * @throws IOException If a transport-level or (de)serialization error occurs.
308    * @throws McpException If the server returned a JSON-RPC error.
309    */
310   public ReadResourceResult readResource(String uri) throws IOException {
311      return call(McpMethods.RESOURCES_READ, new ReadResourceRequest().setUri(uri), ReadResourceResult.class);
312   }
313
314   /**
315    * Sends {@value McpMethods#RESOURCES_TEMPLATES_LIST}.
316    *
317    * @return The resource-templates-list result. Never <jk>null</jk> on success unless the server returns a
318    *    <jk>null</jk> result.
319    * @throws IOException If a transport-level or (de)serialization error occurs.
320    * @throws McpException If the server returned a JSON-RPC error.
321    */
322   public ListResourceTemplatesResult listResourceTemplates() throws IOException {
323      return call(McpMethods.RESOURCES_TEMPLATES_LIST, new RequestParamsOnly(), ListResourceTemplatesResult.class);
324   }
325
326   /**
327    * Sends {@value McpMethods#COMPLETION_COMPLETE}.
328    *
329    * @param ref The completion target (a declared prompt or resource template reference).
330    * @param argumentName The prompt argument or resource-template variable being completed.
331    * @param argumentValue The current partial value of {@code argumentName}.
332    * @param contextArguments Previously-resolved argument values for context, or <jk>null</jk> to omit the
333    *    {@code context} object entirely.
334    * @return The completion result. Never <jk>null</jk> on success unless the server returns a <jk>null</jk> result.
335    * @throws IOException If a transport-level or (de)serialization error occurs.
336    * @throws McpException If the server returned a JSON-RPC error.
337    */
338   public CompleteResult complete(CompletionReference ref, String argumentName, String argumentValue, Map<String,String> contextArguments) throws IOException {
339      var argument = new CompletionArgument().setName(argumentName).setValue(argumentValue);
340      var params = new CompleteRequest().setRef(ref).setArgument(argument);
341      if (contextArguments != null)
342         params.setContext(new CompletionContext().setArguments(contextArguments));
343      return call(McpMethods.COMPLETION_COMPLETE, params, CompleteResult.class);
344   }
345
346   /**
347    * Performs a JSON-RPC call using a caller-supplied, fully-populated request bean, returning the raw result
348    * payload without deserializing into a specific {@link Result} subtype.
349    *
350    * <p>
351    * Every convenience method on this class ({@link #callTool}, {@link #getPrompt}, {@link #readResource}, etc.)
352    * both builds its own request bean internally and deserializes strictly into one known result type, so none of
353    * them can represent a paused {@code input_required} response (MCP {@code 2026-07-28} SEP-2322 Multi-Round-Trip
354    * Requests) or carry the {@code requestState}/{@code inputResponses} fields a resume call needs to set. This
355    * method exists specifically for MRTR-aware callers that must both populate those fields on the request (e.g.
356    * on a {@link CallToolRequest}) and branch on the response's
357    * {@code resultType} themselves, decoding into a typed result only once {@code resultType} reads
358    * {@code "complete"}.
359    *
360    * @param method The JSON-RPC method (e.g. {@link McpMethods#TOOLS_CALL}).
361    * @param params The fully-populated request bean.
362    * @return The raw result as a generic {@link Map}, exactly as received over the wire, before any
363    *    subclass-specific deserialization. Never <jk>null</jk> on a successful JSON-RPC response.
364    * @throws IOException On a transport failure.
365    * @throws McpException If the server returns a JSON-RPC error.
366    */
367   @SuppressWarnings({
368      "unchecked" // Map.class.cast(...) below always yields a raw Map from JSON deserialization.
369   })
370   public Map<String,Object> callRaw(String method, RequestParams<?> params) throws IOException {
371      return call(method, params, Map.class, false);
372   }
373
374   /**
375    * Default maximum number of {@code input_required} resume rounds the {@code *WithElicitation} helpers will
376    * drive before throwing {@link McpElicitationLimitException}.
377    */
378   public static final int DEFAULT_MAX_ELICITATION_ROUNDS = 8;
379
380   /**
381    * Invokes {@value McpMethods#TOOLS_CALL}, transparently answering any MCP {@code 2026-07-28} SEP-2322
382    * {@code input_required} elicitation pauses via {@code handler} until a terminal result is reached, using the
383    * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
384    *
385    * <p>
386    * This is the ergonomic counterpart to hand-driving the resume loop with {@link #callRaw},
387    * {@link ElicitationRequests}, and {@link ElicitationResponses}: it detects each pause, decodes that round's
388    * requests, calls {@code handler} for the answers, echoes them back with the carried {@code requestState},
389    * and repeats until the server returns a non-{@code input_required} result, which is decoded into a typed
390    * {@link CallToolResult}. {@link #callRaw} remains available as the low-level escape hatch.
391    *
392    * @param name The tool name to invoke.
393    * @param arguments The tool arguments. Can be <jk>null</jk> (sent as an empty object).
394    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
395    * @return The terminal call-tool result. Never <jk>null</jk> on success unless the server returns a
396    *    <jk>null</jk> result.
397    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, or {@code handler} returns a
398    *    <jk>null</jk> result or a result map containing a <jk>null</jk> value for a requested id.
399    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
400    * @throws McpException If the server returned a JSON-RPC error.
401    * @throws McpElicitationLimitException If the server keeps pausing past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
402    */
403   public CallToolResult callToolWithElicitation(String name, Map<String,Object> arguments, McpElicitationHandler handler) throws IOException {
404      return callToolWithElicitation(name, arguments, handler, DEFAULT_MAX_ELICITATION_ROUNDS);
405   }
406
407   /**
408    * Invokes {@value McpMethods#TOOLS_CALL}, transparently answering any {@code input_required} elicitation
409    * pauses via {@code handler} until a terminal result is reached, bounded by {@code maxRounds}.
410    *
411    * @param name The tool name to invoke.
412    * @param arguments The tool arguments. Can be <jk>null</jk> (sent as an empty object).
413    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
414    * @param maxRounds The maximum number of resume rounds before {@link McpElicitationLimitException} is thrown. Must be &ge; 1.
415    * @return The terminal call-tool result. Never <jk>null</jk> on success unless the server returns a
416    *    <jk>null</jk> result.
417    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, {@code maxRounds} is not &ge; 1, or
418    *    {@code handler} returns a <jk>null</jk> result or a result map containing a <jk>null</jk> value for a
419    *    requested id.
420    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
421    * @throws McpException If the server returned a JSON-RPC error.
422    * @throws McpElicitationLimitException If the server keeps pausing past {@code maxRounds}.
423    */
424   public CallToolResult callToolWithElicitation(String name, Map<String,Object> arguments, McpElicitationHandler handler, int maxRounds) throws IOException {
425      var params = new CallToolRequest().setName(name).setArguments(arguments == null ? JsonMap.of() : new JsonMap(arguments));
426      var raw = driveElicitation(McpMethods.TOOLS_CALL, params, (responses, state) -> params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
427      return decodeResult(raw, CallToolResult.class);
428   }
429
430   /**
431    * Invokes {@value McpMethods#PROMPTS_GET}, transparently answering any {@code input_required} elicitation
432    * pauses via {@code handler} until a terminal result is reached, using the
433    * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
434    *
435    * @param name The prompt name to fetch.
436    * @param arguments The prompt argument values. Can be <jk>null</jk> (sent as an empty object).
437    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
438    * @return The terminal get-prompt result. Never <jk>null</jk> on success unless the server returns a
439    *    <jk>null</jk> result.
440    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, or {@code handler} returns a
441    *    <jk>null</jk> result or a result map containing a <jk>null</jk> value for a requested id.
442    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
443    * @throws McpException If the server returned a JSON-RPC error.
444    * @throws McpElicitationLimitException If the server keeps pausing past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
445    */
446   public GetPromptResult getPromptWithElicitation(String name, Map<String,Object> arguments, McpElicitationHandler handler) throws IOException {
447      return getPromptWithElicitation(name, arguments, handler, DEFAULT_MAX_ELICITATION_ROUNDS);
448   }
449
450   /**
451    * Invokes {@value McpMethods#PROMPTS_GET}, transparently answering any {@code input_required} elicitation
452    * pauses via {@code handler} until a terminal result is reached, bounded by {@code maxRounds}.
453    *
454    * @param name The prompt name to fetch.
455    * @param arguments The prompt argument values. Can be <jk>null</jk> (sent as an empty object).
456    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
457    * @param maxRounds The maximum number of resume rounds before {@link McpElicitationLimitException} is thrown. Must be &ge; 1.
458    * @return The terminal get-prompt result. Never <jk>null</jk> on success unless the server returns a
459    *    <jk>null</jk> result.
460    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, {@code maxRounds} is not &ge; 1, or
461    *    {@code handler} returns a <jk>null</jk> result or a result map containing a <jk>null</jk> value for a
462    *    requested id.
463    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
464    * @throws McpException If the server returned a JSON-RPC error.
465    * @throws McpElicitationLimitException If the server keeps pausing past {@code maxRounds}.
466    */
467   public GetPromptResult getPromptWithElicitation(String name, Map<String,Object> arguments, McpElicitationHandler handler, int maxRounds) throws IOException {
468      var params = new GetPromptRequest().setName(name).setArguments(arguments == null ? JsonMap.of() : new JsonMap(arguments));
469      var raw = driveElicitation(McpMethods.PROMPTS_GET, params, (responses, state) -> params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
470      return decodeResult(raw, GetPromptResult.class);
471   }
472
473   /**
474    * Invokes {@value McpMethods#RESOURCES_READ}, transparently answering any {@code input_required} elicitation
475    * pauses via {@code handler} until a terminal result is reached, using the
476    * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
477    *
478    * @param uri The resource URI to read.
479    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
480    * @return The terminal read-resource result. Never <jk>null</jk> on success unless the server returns a
481    *    <jk>null</jk> result.
482    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, or {@code handler} returns a
483    *    <jk>null</jk> result or a result map containing a <jk>null</jk> value for a requested id.
484    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
485    * @throws McpException If the server returned a JSON-RPC error.
486    * @throws McpElicitationLimitException If the server keeps pausing past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
487    */
488   public ReadResourceResult readResourceWithElicitation(String uri, McpElicitationHandler handler) throws IOException {
489      return readResourceWithElicitation(uri, handler, DEFAULT_MAX_ELICITATION_ROUNDS);
490   }
491
492   /**
493    * Invokes {@value McpMethods#RESOURCES_READ}, transparently answering any {@code input_required} elicitation
494    * pauses via {@code handler} until a terminal result is reached, bounded by {@code maxRounds}.
495    *
496    * @param uri The resource URI to read.
497    * @param handler The elicitation answer callback, invoked once per pause. Must not be <jk>null</jk>.
498    * @param maxRounds The maximum number of resume rounds before {@link McpElicitationLimitException} is thrown. Must be &ge; 1.
499    * @return The terminal read-resource result. Never <jk>null</jk> on success unless the server returns a
500    *    <jk>null</jk> result.
501    * @throws IllegalArgumentException If {@code handler} is <jk>null</jk>, {@code maxRounds} is not &ge; 1, or
502    *    {@code handler} returns a <jk>null</jk> result or a result map containing a <jk>null</jk> value for a
503    *    requested id.
504    * @throws IOException If a transport-level or (de)serialization error occurs, or {@code handler} throws it.
505    * @throws McpException If the server returned a JSON-RPC error.
506    * @throws McpElicitationLimitException If the server keeps pausing past {@code maxRounds}.
507    */
508   public ReadResourceResult readResourceWithElicitation(String uri, McpElicitationHandler handler, int maxRounds) throws IOException {
509      var params = new ReadResourceRequest().setUri(uri);
510      var raw = driveElicitation(McpMethods.RESOURCES_READ, params, (responses, state) -> params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
511      return decodeResult(raw, ReadResourceResult.class);
512   }
513
514   /**
515    * Shared MRTR (SEP-2322) auto-resume loop backing every {@code *WithElicitation} method.
516    *
517    * <p>
518    * Issues the initial {@link #callRaw} for {@code params}, then while the raw result is an
519    * {@code input_required} pause: decodes the round's requests, invokes {@code handler} for the answers,
520    * applies them plus the pause's echoed {@code requestState} onto {@code params} via {@code applyResume} (each
521    * concrete request bean knows its own {@code setInputResponses}/{@code setRequestState}, which have no shared
522    * interface — see {@link ElicitationRequests}), and re-issues. A decline/cancel answer is not short-circuited
523    * locally: it is echoed back like any other answer, leaving the terminal outcome of a refused elicitation to
524    * the server. The loop is bounded by {@code maxRounds} so a server (or handler) that never converges surfaces
525    * as a typed {@link McpElicitationLimitException} rather than hanging.
526    */
527   private Map<String,Object> driveElicitation(String method, RequestParams<?> params, ResumeApplier applyResume, McpElicitationHandler handler, int maxRounds) throws IOException {
528      reqnn("handler", handler);
529      req(maxRounds >= 1, "maxRounds must be >= 1 (was %s).", maxRounds);
530      var raw = callRaw(method, params);
531      var rounds = 0;
532      while (ElicitationRequests.isInputRequired(raw)) {
533         if (++rounds > maxRounds)
534            throw new McpElicitationLimitException(maxRounds);
535         var requests = ElicitationRequests.requests(raw);
536         var requestState = ElicitationRequests.requestState(raw);
537         var answers = reqnn("handler result", handler.elicit(requests));
538         applyResume.apply(ElicitationResponses.toInputResponses(answers), requestState);
539         raw = callRaw(method, params);
540      }
541      return raw;
542   }
543
544   /**
545    * Decodes a terminal raw result {@link Map} into its typed result bean the same way {@link #call} decodes a
546    * live wire result, so a polymorphic field (e.g. a {@link CallToolResult}'s {@code content} entries) keeps
547    * the {@code type} discriminator it already carries in the raw tree.
548    */
549   private static <T> T decodeResult(Map<String,Object> raw, Class<T> resultType) {
550      return JsonMap.of("value", raw).get("value", resultType);
551   }
552
553   /**
554    * Applies one round's collected answers and carried continuation token onto the concrete resume request bean.
555    *
556    * <p>
557    * Exists (rather than a {@code BiConsumer}) so each {@code *WithElicitation} method can bind its own concrete
558    * bean's {@code setInputResponses}/{@code setRequestState} pair, which — unlike {@code RequestParams} — share
559    * no common interface across {@code CallToolRequest}/{@code GetPromptRequest}/{@code ReadResourceRequest}.
560    */
561   @FunctionalInterface
562   private interface ResumeApplier {
563      void apply(Map<String,Object> inputResponses, String requestState);
564   }
565
566   /**
567    * Sends {@value McpMethods#SUBSCRIPTIONS_LISTEN}, opening a managed, held-open notification stream on a
568    * background thread and dispatching decoded frames to {@code listener} until the returned handle is closed,
569    * the server completes gracefully, or the connection drops.
570    *
571    * <p>
572    * Only the initial {@value McpMethods#SUBSCRIPTIONS_LISTEN} POST (opening the stream) is synchronous — a
573    * transport-level failure at that point surfaces here as a thrown {@link IOException}. Every frame after that
574    * (the mandatory {@code acknowledged} frame, zero or more change notifications, and the eventual terminal
575    * frame) is decoded on a dedicated background thread and delivered to {@code listener}; frame-processing
576    * failures surface via {@link McpSubscriptionListener#onError(Throwable)}, never as a thrown exception from
577    * this method. This does not reuse {@link #pumpNextServerMessage()} (single-shot, closes its stream after at
578    * most one event) or {@link McpDuplexDispatcher} (request/response-shaped, single registration) — {@code
579    * listen(...)} needs its own long-lived, multi-frame decode loop.
580    *
581    * @param filter The subscription filter (toolsListChanged/promptsListChanged/resourcesListChanged/resourceSubscriptions). Must not be <jk>null</jk>.
582    * @param listener The typed callback sink. Must not be <jk>null</jk>.
583    * @return A handle to cancel/close the subscription. Never <jk>null</jk>.
584    * @throws IOException If the initial listen request/stream-open fails.
585    */
586   public McpSubscriptionHandle listen(SubscriptionFilter filter, McpSubscriptionListener listener) throws IOException {
587      reqnn("filter", filter);
588      reqnn("listener", listener);
589      var params = new SubscriptionsListenRequest().setNotifications(filter);
590      stampMeta(params.setMeta(new RequestMeta()).getMeta());
591      var wireParams = toWireParams(params);
592      var req = new JsonRpcRequest()
593         .setJsonrpc(McpProtocol.JSON_RPC_2_0)
594         .setId(UUID.randomUUID().toString())
595         .setMethod(McpMethods.SUBSCRIPTIONS_LISTEN)
596         .setParams(wireParams);
597      // SEP-2243 Mcp-Method/Mcp-Name headers, stamped exactly like every typed call(...) (see #call) - the v2
598      // server's McpRevision.dispatch() runs validateHeaders(...) unconditionally before branching on the
599      // method, so the opening subscriptions/listen POST is rejected with -32600 without these.
600      var headers = Map.of(HEADER_MCP_METHOD, McpMethods.SUBSCRIPTIONS_LISTEN, HEADER_MCP_NAME, McpRoutingNames.routingName(McpMethods.SUBSCRIPTIONS_LISTEN, wireParams));
601      var reader = openEventStream(req, headers);
602      var pump = new SubscriptionPump(req.getId().toString(), reader, listener);
603      var started = false;
604      try {
605         pump.start();
606         started = true;
607      } finally {
608         // Thread.start() failing must not leak the already-opened stream: nothing else owns it yet.
609         if (! started) pump.closeReaderQuietly(); // HTT: only reachable if the JVM cannot create the native thread (OutOfMemoryError); not reproducible in a unit test
610      }
611      return pump;
612   }
613
614   /**
615    * Background decode loop + closeable handle for one
616    * {@link #listen(SubscriptionFilter, McpSubscriptionListener)} subscription.
617    *
618    * <p>
619    * Every frame is decoded generically first (into a {@link JsonMap}) to tell a notification (has a
620    * {@code "method"} key) apart from the terminal JSON-RPC response (no {@code "method"} key) before committing
621    * to a strongly-typed re-parse — {@link JsonRpcRequest} and {@link JsonRpcResponse} share no common base type
622    * to parse into speculatively.
623    *
624    * <p>
625    * A named {@code "ping"} SSE event (the real transport's heartbeat, carrying no {@code data:} payload — see
626    * {@code SubscriptionsListenPublisher.HEARTBEAT_EVENT_NAME}) is recognized by event name and skipped
627    * <i>before</i> any attempt to inspect its (always <jk>null</jk>) data, so a heartbeat can never be mistaken
628    * for, or misreported as, a malformed JSON-RPC frame.
629    *
630    * <p>
631    * Package-private (rather than {@code private}) solely so tests in this package can observe the background
632    * thread's actual lifecycle (see {@code pumpThread}) — production code only ever sees this through the
633    * {@link McpSubscriptionHandle} interface {@link #listen} returns.
634    */
635   static final class SubscriptionPump implements McpSubscriptionHandle, Runnable {
636      private static final String EVENT_PING = "ping";
637      private static final AtomicLong THREAD_SEQ = new AtomicLong();
638
639      private final String id;
640      private final SseEventReader reader;
641      private final McpSubscriptionListener listener;
642      final Thread pumpThread;
643      private final AtomicBoolean closed = new AtomicBoolean(false);
644      private volatile boolean open = true;
645
646      SubscriptionPump(String id, SseEventReader reader, McpSubscriptionListener listener) {
647         this.id = id;
648         this.reader = reader;
649         this.listener = listener;
650         this.pumpThread = new Thread(this, "mcp-subscriptions-listen-" + THREAD_SEQ.incrementAndGet());
651         this.pumpThread.setDaemon(true);
652      }
653
654      @Override
655      public String id() {
656         return id;
657      }
658
659      void start() {
660         pumpThread.start();
661      }
662
663      @Override
664      @SuppressWarnings({
665         "java:S135" // Multiple break/continue are the clearest expression of this SSE-frame decode loop; splitting them out would obscure the pump's state machine.
666      })
667      public void run() {
668         try {
669            while (open && reader.hasNext()) {
670               var event = reader.next();
671               if (eq(event.getEvent(), EVENT_PING))
672                  continue;
673               if (event.getData() == null || event.getData().isEmpty())
674                  continue;
675               if (! dispatch(event.getData()))
676                  break;
677            }
678            // A terminal frame (see dispatch) has already flipped open to false, so open is still true here only if
679            // the stream ended (clean EOF, reader.hasNext() == false) without ever reaching a terminal
680            // frame, and the caller did not initiate this close: an abrupt drop the
681            // listener must be told about, not silent - it is otherwise indistinguishable from a hang.
682            //
683            // open is flipped to false BEFORE invoking the listener (guarded on the pre-read value so a
684            // concurrent cancel() can't cause a double-fire), not after in the finally block below: the
685            // terminal callback is very often the exact synchronization point (a latch/future countdown,
686            // or a direct isOpen() read from inside the callback itself) a caller relies on to learn the
687            // pump is done, and CountDownLatch/Future's happens-before is one-directional (it orders the
688            // signal before the waiter's wakeup, never the reverse) - so this volatile write must
689            // happen-before that signal, not after it, or a woken caller can transiently observe open
690            // still true.
691            if (open) {
692               open = false;
693               invokeListener(() -> listener.onError(new EOFException(
694                  "Subscription stream closed before a terminal frame was received.")));
695            }
696         } catch (Exception e) {
697            if (open) {
698               open = false;
699               invokeListener(() -> listener.onError(e));
700            }
701         } finally {
702            // Idempotent safety net: open is already false on every path above that invoked a terminal
703            // callback, and closeReaderQuietly() must run unconditionally regardless of which path (if
704            // any) was taken - including a plain open cancel()/close() with no callback at all.
705            open = false;
706            closeReaderQuietly();
707         }
708      }
709
710      /**
711       * Decodes one frame and invokes the matching listener callback.
712       *
713       * @return <jk>false</jk> if this was the terminal frame (the loop must stop); <jk>true</jk> to keep reading.
714       */
715      private boolean dispatch(String data) {
716         var tree = JsonParser.DEFAULT.read(data, JsonMap.class);
717         if (tree.containsKey("method")) {
718            dispatchNotification(JsonParser.DEFAULT.read(data, JsonRpcRequest.class));
719            return true;
720         }
721         var res = JsonParser.DEFAULT.read(data, JsonRpcResponse.class);
722         // open is flipped to false BEFORE invoking either terminal callback below, not after in run()'s
723         // finally block - see the longer rationale on the abrupt-EOF branch in run().
724         open = false;
725         if (res.getError() != null) {
726            var mcpException = McpException.fromJsonRpcError(res.getError());
727            invokeListener(() -> listener.onError(mcpException));
728         } else {
729            invokeListener(listener::onComplete);
730         }
731         return false;
732      }
733
734      private void dispatchNotification(JsonRpcRequest notif) {
735         var method = notif.getMethod();
736         var rawParams = notif.getParams();
737         if (McpMethods.NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED.equals(method)) {
738            var decoded = Json.to(Json.of(rawParams), SubscriptionsAcknowledgedNotification.class);
739            invokeListener(() -> listener.onAcknowledged(decoded.getNotifications()));
740         } else if (McpMethods.NOTIFICATIONS_RESOURCES_UPDATED.equals(method)) {
741            var decoded = Json.to(Json.of(rawParams), ResourceUpdatedNotification.class);
742            invokeListener(() -> listener.onResourceUpdated(decoded.getUri()));
743         } else if (McpMethods.NOTIFICATIONS_RESOURCES_LIST_CHANGED.equals(method)) {
744            invokeListener(() -> listener.onListChanged(McpListChangedKind.RESOURCES));
745         } else if (McpMethods.NOTIFICATIONS_TOOLS_LIST_CHANGED.equals(method)) {
746            invokeListener(() -> listener.onListChanged(McpListChangedKind.TOOLS));
747         } else if (McpMethods.NOTIFICATIONS_PROMPTS_LIST_CHANGED.equals(method)) {
748            invokeListener(() -> listener.onListChanged(McpListChangedKind.PROMPTS));
749         }
750         // Unknown method: forward-compatible no-op, not an error - a future notification kind this
751         // client version doesn't know about must not tear down an otherwise-healthy subscription.
752      }
753
754      /**
755       * Invokes one listener callback in isolation, so a bug in caller-supplied listener code can never be
756       * mistaken for (and re-routed through) a transport/decode failure, and can never itself crash the
757       * pump or escape this method - including a second throw from {@code onError} itself, which must not
758       * re-enter this same path.
759       *
760       * @param callback The single listener callback invocation to contain (e.g. {@code () -> listener.onComplete()}).
761       */
762      private void invokeListener(Runnable callback) {
763         try {
764            callback.run();
765         } catch (@SuppressWarnings("unused") RuntimeException e) {
766            // Contained by design: this is the caller's own listener code, not this pump's transport/decode
767            // path, so its exception must never surface as (or trigger) this pump's onError, and must never
768            // tear down an otherwise-healthy subscription.
769         }
770      }
771
772      @Override
773      public void cancel() {
774         if (closed.compareAndSet(false, true)) {
775            open = false;
776            closeReaderQuietly();
777         }
778      }
779
780      @Override
781      public void close() {
782         cancel();
783      }
784
785      @Override
786      public boolean isOpen() {
787         return open;
788      }
789
790      private void closeReaderQuietly() {
791         try {
792            reader.close();
793         } catch (@SuppressWarnings("unused") IOException e) {
794            // Best-effort close: the stream may already be closed by the peer/EOF.
795         }
796      }
797   }
798
799   /**
800    * Returns this instance's cache-partition prefix used to namespace {@link McpCacheScope#PRIVATE}
801    * entries so two client instances sharing the same {@link McpResponseCache} never see each other's
802    * private-scope results.
803    *
804    * @return The per-instance private-scope partition prefix. Never <jk>null</jk>.
805    */
806   String privateScopePartitionPrefix() {
807      return privateScopePartitionPrefix;
808   }
809
810   /**
811    * Registers the handler invoked for server-initiated requests delivered over the duplex event-stream
812    * channel (see {@link McpDuplexDispatcher}).
813    *
814    * @param value The handler to register. Passing <jk>null</jk> de-registers any previously-registered handler.
815    * @return This object.
816    */
817   public McpClient setServerRequestHandler(McpServerRequestHandler value) {
818      duplexDispatcher.register(value);
819      return this;
820   }
821
822   /**
823    * Opens the duplex event-stream channel, reads at most one server-initiated request, dispatches it to the
824    * registered {@link McpServerRequestHandler}, and posts the result (or a JSON-RPC error) back to the server
825    * via {@value #DUPLEX_RETURN_METHOD}.
826    *
827    * <p>
828    * Package-private test/plumbing entry point: production duplex pumping is driven by the caller repeatedly
829    * invoking this method (or an equivalent loop) for as long as the channel should stay open.
830    *
831    * @throws IOException If a transport-level or (de)serialization error occurs.
832    */
833   void pumpNextServerMessage() throws IOException {
834      try (var r = openEventStream()) {
835         if (! r.hasNext())
836            return;
837         var event = r.next();
838         if (event.getData() == null || event.getData().isEmpty())
839            return;
840         var req = JsonParser.DEFAULT.read(event.getData(), JsonRpcRequest.class);
841         try {
842            var result = duplexDispatcher.dispatch(req, BasicBeanStore.INSTANCE);
843            if (JsonRpcResponse.notification(req.getId()))
844               return;
845            postClientResult(req.getId(), JsonRpcResponse.ok(req.getId(), result));
846         } catch (McpException e) {
847            postClientResult(req.getId(), JsonRpcResponse.errorResponse(req.getId(), e.getCode(), e.getMessage()));
848         }
849      } // HTT: javac try-with-resources null-guard on each early-return path; openEventStream() never returns null (McpClient is final)
850   }
851
852   // C5/C6 duplex return-channel Mcp-Name contract (resolved): Mcp-Name is empty by design on this
853   // channel. Per SEP-2243, Mcp-Name is a body-mirror routing header defined only for the three forward
854   // client->server named methods (tools/call, prompts/get, resources/read); the spec does not define it for
855   // server->client requests or return channels. So McpRoutingNames intentionally has no case for
856   // DUPLEX_RETURN_METHOD (mcp/clientResult) - it falls to the default empty arm - and the response envelope has
857   // no name/uri to mirror anyway. If correlating a server-initiated request back to its originating client
858   // request is ever needed, key it on the JSON-RPC id (SEP-2260 relatedRequestId), not on Mcp-Name.
859   //
860   // payload must be routed through toWireParams(...) exactly like every other outbound envelope (see #call):
861   // AbstractMcpClient.send(...) posts through restClient, whose serializer is JsonSerializer.DEFAULT
862   // (addBeanTypes=false) - handed the payload's typed beans directly, a polymorphic Content field (e.g.
863   // CreateMessageResult.content holding a TextContent) would be serialized without its "type" discriminator,
864   // since addBeanTypes is what drives whether SerializerSession writes bean-dictionary type names at all.
865   // toWireParams(...) pre-flattens through REQUEST_SERIALIZER (addBeanTypes=true) into a generic Map/List
866   // tree with the discriminator already baked in as literal data, so the final JsonSerializer.DEFAULT pass
867   // re-emits it verbatim.
868   private void postClientResult(Object id, JsonRpcResponse payload) throws IOException {
869      var wire = toWireParams(payload);
870      var headers = Map.of(HEADER_MCP_METHOD, DUPLEX_RETURN_METHOD, HEADER_MCP_NAME, McpRoutingNames.routingName(DUPLEX_RETURN_METHOD, wire));
871      send(new JsonRpcRequest().setJsonrpc("2.0").setId(id).setMethod(DUPLEX_RETURN_METHOD).setParams(wire), headers);
872   }
873
874   private <T> T call(String method, RequestParams<?> params, Class<T> resultType) throws IOException {
875      return call(method, params, resultType, true);
876   }
877
878   /**
879    * Shared wire implementation for every typed method and {@link #callRaw}.
880    *
881    * <p>
882    * {@code useCache=false} (used only by {@link #callRaw}) skips the cache READ so a {@code callRaw} call never
883    * retrieves a differently-typed cached result (e.g. a {@link CallToolResult} primed by {@link #callTool}) under
884    * the same cache key, which would otherwise throw a {@link ClassCastException} on the subsequent {@code cast}.
885    * The cache WRITE at the end is unaffected by this flag: it is already a no-op for {@code callRaw} because a
886    * plain {@link Map} result is never a {@link CacheableResult}.
887    */
888   private <T> T call(String method, RequestParams<?> params, Class<T> resultType, boolean useCache) throws IOException {
889      var cacheKey = cacheKey(method, params);
890      if (useCache) {
891         var cached = readCache(cacheKey);
892         if (cached != null)
893            return resultType.cast(cached);
894      }
895      stampMeta(params.getMeta() == null ? params.setMeta(new RequestMeta()).getMeta() : params.getMeta());
896      var wireParams = toWireParams(params);
897      var headers = Map.of(HEADER_MCP_METHOD, method, HEADER_MCP_NAME, McpRoutingNames.routingName(method, wireParams));
898      var req = new JsonRpcRequest()
899         .setJsonrpc(McpProtocol.JSON_RPC_2_0)
900         .setId(UUID.randomUUID().toString())
901         .setMethod(method)
902         .setParams(wireParams);
903      var res = send(req, headers);
904      if (res.getError() != null)
905         throw McpException.fromJsonRpcError(res.getError());
906      var result = JsonMap.of("value", res.getResult()).get("value", resultType);
907      writeCache(cacheKey, result);
908      return result;
909   }
910
911   private static Object toWireParams(Object params) {
912      return JsonParser.DEFAULT.read(REQUEST_SERIALIZER.write(params), Object.class);
913   }
914
915   private void stampMeta(RequestMeta meta) {
916      meta.setProtocolVersion(McpProtocol.VERSION_2026_07_28);
917      meta.setClientCapabilities(clientCapabilities);
918      if (clientInfo != null)
919         meta.setClientInfo(clientInfo);
920      if (traceFieldsSupplier != null) {
921         var fields = traceFieldsSupplier.get();
922         if (fields != null) {
923            var traceparent = fields.get(RequestMeta.KEY_TRACEPARENT);
924            if (traceparent != null)
925               meta.setTraceparent(traceparent);
926            var tracestate = fields.get(RequestMeta.KEY_TRACESTATE);
927            if (tracestate != null)
928               meta.setTracestate(tracestate);
929            var baggage = fields.get(RequestMeta.KEY_BAGGAGE);
930            if (baggage != null)
931               meta.setBaggage(baggage);
932         }
933      }
934   }
935
936   /**
937    * Computes the cache key from {@code method} and the caller-supplied business {@code params} only.
938    *
939    * <p>
940    * Called before {@link #stampMeta(RequestMeta)} ever touches {@code params}, so the key never reflects
941    * per-call {@code _meta} content (protocol version, capabilities, or trace-context fields) — otherwise a
942    * real per-span {@link #traceFieldsSupplier} would make every key unique and defeat caching entirely.
943    */
944   private String cacheKey(String method, Object params) {
945      return method + "|" + JsonMap.of("p", params).toString();
946   }
947
948   private Object readCache(String key) {
949      if (responseCache == null)
950         return null;
951      return responseCache.get(privateScopePartitionPrefix + McpCacheScope.PRIVATE.toWire(), key)
952         .or(() -> responseCache.get(McpCacheScope.PUBLIC.toWire(), key))
953         .orElse(null);
954   }
955
956   private void writeCache(String key, Object result) {
957      if (responseCache == null)
958         return;
959      if (result instanceof CacheableResult<?> c && c.getTtlMs() != null) {
960         var scope = c.getCacheScope() == McpCacheScope.PRIVATE
961            ? privateScopePartitionPrefix + McpCacheScope.PRIVATE.toWire()
962            : McpCacheScope.PUBLIC.toWire();
963         responseCache.put(scope, key, result, c.getTtlMs());
964      }
965   }
966
967   // -----------------------------------------------------------------------------------------------------------------
968   // Builder
969   // -----------------------------------------------------------------------------------------------------------------
970
971   /**
972    * Fluent builder for {@link McpClient}.
973    *
974    * @since 10.0.0
975    */
976   public static final class Builder extends AbstractMcpClient.Builder<Builder> {
977      private ClientCapabilities clientCapabilities = new ClientCapabilities();
978      private Implementation clientInfo;
979      private Supplier<Map<String,String>> traceFieldsSupplier;
980      private McpResponseCache responseCache;
981
982      /**
983       * Sets the client capabilities stamped into every request's {@code params._meta.clientCapabilities}.
984       *
985       * @param value The client capabilities. Must not be <jk>null</jk>.
986       * @return This object.
987       */
988      public Builder clientCapabilities(ClientCapabilities value) {
989         this.clientCapabilities = reqnn("clientCapabilities", value);
990         return this;
991      }
992
993      /**
994       * Sets the client identity stamped into every request's {@code params._meta.clientInfo}.
995       *
996       * @param value The client identity. Can be <jk>null</jk> to omit {@code clientInfo} from every request.
997       * @return This object.
998       */
999      public Builder clientInfo(Implementation value) {
1000         this.clientInfo = value;
1001         return this;
1002      }
1003
1004      /**
1005       * Sets the per-call supplier of W3C trace-context fields ({@code traceparent}/{@code tracestate}/
1006       * {@code baggage}) stamped into every request's {@code params._meta}.
1007       *
1008       * @param value The supplier, invoked once per call. Can be <jk>null</jk> (no trace-context fields are
1009       *    stamped). A supplier that returns <jk>null</jk> for a given call is treated the same as returning an
1010       *    empty map for that call.
1011       * @return This object.
1012       */
1013      public Builder traceFieldsSupplier(Supplier<Map<String,String>> value) {
1014         this.traceFieldsSupplier = value;
1015         return this;
1016      }
1017
1018      /**
1019       * Sets the opt-in response cache used to serve repeat identical calls without a round trip.
1020       *
1021       * <p>
1022       * A cached result instance may be handed back as-is (not re-marshaled) to more than one caller when the
1023       * same cache key hits more than once; callers must not mutate a returned result if they cannot guarantee
1024       * exclusive ownership of it.
1025       *
1026       * @param value The response cache, or <jk>null</jk> (the default) to disable caching entirely.
1027       * @return This object.
1028       */
1029      public Builder responseCache(McpResponseCache value) {
1030         this.responseCache = value;
1031         return this;
1032      }
1033
1034      /**
1035       * Builds the client.
1036       *
1037       * @return A new {@link McpClient}. Never <jk>null</jk>.
1038       */
1039      public McpClient build() {
1040         return new McpClient(this);
1041      }
1042   }
1043}