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 ≥ 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 ≥ 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 ≥ 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 ≥ 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 ≥ 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 ≥ 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}