This is an automated email from the ASF dual-hosted git repository.

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 1aa0111c81 MCP 2026-07-28 client-side MRTR auto-resume ergonomics 
(TODO-326)
1aa0111c81 is described below

commit 1aa0111c812b61debd696e57f838c07762785bbb
Author: James Bognar <[email protected]>
AuthorDate: Wed Aug 5 18:05:28 2026 -0700

    MCP 2026-07-28 client-side MRTR auto-resume ergonomics (TODO-326)
    
    Co-authored-by: Cursor <[email protected]>
---
 .../rest/client/mcp/v20260728/McpClient.java       | 192 ++++++++++++
 .../mcp/v20260728/McpElicitationHandler.java       |  68 +++++
 .../v20260728/McpElicitationLimitException.java    |  58 ++++
 .../mcp/v20260728/McpClient_Elicitation_Test.java  | 333 +++++++++++++++++++++
 4 files changed, 651 insertions(+)

diff --git 
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
index 51ff43da35..40ba44b98b 100644
--- 
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
+++ 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
@@ -344,6 +344,198 @@ public final class McpClient extends AbstractMcpClient {
                return call(method, params, Map.class, false);
        }
 
+       /**
+        * Default maximum number of {@code input_required} resume rounds the 
{@code *WithElicitation} helpers will
+        * drive before throwing {@link McpElicitationLimitException}.
+        */
+       public static final int DEFAULT_MAX_ELICITATION_ROUNDS = 8;
+
+       /**
+        * Invokes {@value McpMethods#TOOLS_CALL}, transparently answering any 
MCP {@code 2026-07-28} SEP-2322
+        * {@code input_required} elicitation pauses via {@code handler} until 
a terminal result is reached, using the
+        * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
+        *
+        * <p>
+        * This is the ergonomic counterpart to hand-driving the resume loop 
with {@link #callRaw},
+        * {@link ElicitationRequests}, and {@link ElicitationResponses}: it 
detects each pause, decodes that round's
+        * requests, calls {@code handler} for the answers, echoes them back 
with the carried {@code requestState},
+        * and repeats until the server returns a non-{@code input_required} 
result, which is decoded into a typed
+        * {@link CallToolResult}. {@link #callRaw} remains available as the 
low-level escape hatch.
+        *
+        * @param name The tool name to invoke.
+        * @param arguments The tool arguments. Can be <jk>null</jk> (sent as 
an empty object).
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @return The terminal call-tool result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, or {@code handler} returns a
+        *      <jk>null</jk> result or a result map containing a <jk>null</jk> 
value for a requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
+        */
+       public CallToolResult callToolWithElicitation(String name, 
Map<String,Object> arguments, McpElicitationHandler handler) throws IOException 
{
+               return callToolWithElicitation(name, arguments, handler, 
DEFAULT_MAX_ELICITATION_ROUNDS);
+       }
+
+       /**
+        * Invokes {@value McpMethods#TOOLS_CALL}, transparently answering any 
{@code input_required} elicitation
+        * pauses via {@code handler} until a terminal result is reached, 
bounded by {@code maxRounds}.
+        *
+        * @param name The tool name to invoke.
+        * @param arguments The tool arguments. Can be <jk>null</jk> (sent as 
an empty object).
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @param maxRounds The maximum number of resume rounds before {@link 
McpElicitationLimitException} is thrown. Must be &ge; 1.
+        * @return The terminal call-tool result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, {@code maxRounds} is not &ge; 1, or
+        *      {@code handler} returns a <jk>null</jk> result or a result map 
containing a <jk>null</jk> value for a
+        *      requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@code maxRounds}.
+        */
+       public CallToolResult callToolWithElicitation(String name, 
Map<String,Object> arguments, McpElicitationHandler handler, int maxRounds) 
throws IOException {
+               var params = new 
CallToolRequest().setName(name).setArguments(arguments == null ? JsonMap.of() : 
new JsonMap(arguments));
+               var raw = driveElicitation(McpMethods.TOOLS_CALL, params, 
(responses, state) -> 
params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
+               return decodeResult(raw, CallToolResult.class);
+       }
+
+       /**
+        * Invokes {@value McpMethods#PROMPTS_GET}, transparently answering any 
{@code input_required} elicitation
+        * pauses via {@code handler} until a terminal result is reached, using 
the
+        * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
+        *
+        * @param name The prompt name to fetch.
+        * @param arguments The prompt argument values. Can be <jk>null</jk> 
(sent as an empty object).
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @return The terminal get-prompt result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, or {@code handler} returns a
+        *      <jk>null</jk> result or a result map containing a <jk>null</jk> 
value for a requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
+        */
+       public GetPromptResult getPromptWithElicitation(String name, 
Map<String,Object> arguments, McpElicitationHandler handler) throws IOException 
{
+               return getPromptWithElicitation(name, arguments, handler, 
DEFAULT_MAX_ELICITATION_ROUNDS);
+       }
+
+       /**
+        * Invokes {@value McpMethods#PROMPTS_GET}, transparently answering any 
{@code input_required} elicitation
+        * pauses via {@code handler} until a terminal result is reached, 
bounded by {@code maxRounds}.
+        *
+        * @param name The prompt name to fetch.
+        * @param arguments The prompt argument values. Can be <jk>null</jk> 
(sent as an empty object).
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @param maxRounds The maximum number of resume rounds before {@link 
McpElicitationLimitException} is thrown. Must be &ge; 1.
+        * @return The terminal get-prompt result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, {@code maxRounds} is not &ge; 1, or
+        *      {@code handler} returns a <jk>null</jk> result or a result map 
containing a <jk>null</jk> value for a
+        *      requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@code maxRounds}.
+        */
+       public GetPromptResult getPromptWithElicitation(String name, 
Map<String,Object> arguments, McpElicitationHandler handler, int maxRounds) 
throws IOException {
+               var params = new 
GetPromptRequest().setName(name).setArguments(arguments == null ? JsonMap.of() 
: new JsonMap(arguments));
+               var raw = driveElicitation(McpMethods.PROMPTS_GET, params, 
(responses, state) -> 
params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
+               return decodeResult(raw, GetPromptResult.class);
+       }
+
+       /**
+        * Invokes {@value McpMethods#RESOURCES_READ}, transparently answering 
any {@code input_required} elicitation
+        * pauses via {@code handler} until a terminal result is reached, using 
the
+        * {@link #DEFAULT_MAX_ELICITATION_ROUNDS default} max-rounds bound.
+        *
+        * @param uri The resource URI to read.
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @return The terminal read-resource result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, or {@code handler} returns a
+        *      <jk>null</jk> result or a result map containing a <jk>null</jk> 
value for a requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@link #DEFAULT_MAX_ELICITATION_ROUNDS}.
+        */
+       public ReadResourceResult readResourceWithElicitation(String uri, 
McpElicitationHandler handler) throws IOException {
+               return readResourceWithElicitation(uri, handler, 
DEFAULT_MAX_ELICITATION_ROUNDS);
+       }
+
+       /**
+        * Invokes {@value McpMethods#RESOURCES_READ}, transparently answering 
any {@code input_required} elicitation
+        * pauses via {@code handler} until a terminal result is reached, 
bounded by {@code maxRounds}.
+        *
+        * @param uri The resource URI to read.
+        * @param handler The elicitation answer callback, invoked once per 
pause. Must not be <jk>null</jk>.
+        * @param maxRounds The maximum number of resume rounds before {@link 
McpElicitationLimitException} is thrown. Must be &ge; 1.
+        * @return The terminal read-resource result. Never <jk>null</jk> on 
success unless the server returns a
+        *      <jk>null</jk> result.
+        * @throws IllegalArgumentException If {@code handler} is 
<jk>null</jk>, {@code maxRounds} is not &ge; 1, or
+        *      {@code handler} returns a <jk>null</jk> result or a result map 
containing a <jk>null</jk> value for a
+        *      requested id.
+        * @throws IOException If a transport-level or (de)serialization error 
occurs, or {@code handler} throws it.
+        * @throws McpException If the server returned a JSON-RPC error.
+        * @throws McpElicitationLimitException If the server keeps pausing 
past {@code maxRounds}.
+        */
+       public ReadResourceResult readResourceWithElicitation(String uri, 
McpElicitationHandler handler, int maxRounds) throws IOException {
+               var params = new ReadResourceRequest().setUri(uri);
+               var raw = driveElicitation(McpMethods.RESOURCES_READ, params, 
(responses, state) -> 
params.setInputResponses(responses).setRequestState(state), handler, maxRounds);
+               return decodeResult(raw, ReadResourceResult.class);
+       }
+
+       /**
+        * Shared MRTR (SEP-2322) auto-resume loop backing every {@code 
*WithElicitation} method.
+        *
+        * <p>
+        * Issues the initial {@link #callRaw} for {@code params}, then while 
the raw result is an
+        * {@code input_required} pause: decodes the round's requests, invokes 
{@code handler} for the answers,
+        * applies them plus the pause's echoed {@code requestState} onto 
{@code params} via {@code applyResume} (each
+        * concrete request bean knows its own {@code setInputResponses}/{@code 
setRequestState}, which have no shared
+        * interface — see {@link ElicitationRequests}), and re-issues. A 
decline/cancel answer is not short-circuited
+        * locally: it is echoed back like any other answer, leaving the 
terminal outcome of a refused elicitation to
+        * the server. The loop is bounded by {@code maxRounds} so a server (or 
handler) that never converges surfaces
+        * as a typed {@link McpElicitationLimitException} rather than hanging.
+        */
+       private Map<String,Object> driveElicitation(String method, 
RequestParams<?> params, ResumeApplier applyResume, McpElicitationHandler 
handler, int maxRounds) throws IOException {
+               assertArgNotNull("handler", handler);
+               assertArg(maxRounds >= 1, "maxRounds must be >= 1 (was %s).", 
maxRounds);
+               var raw = callRaw(method, params);
+               var rounds = 0;
+               while (ElicitationRequests.isInputRequired(raw)) {
+                       if (++rounds > maxRounds)
+                               throw new 
McpElicitationLimitException(maxRounds);
+                       var requests = ElicitationRequests.requests(raw);
+                       var requestState = 
ElicitationRequests.requestState(raw);
+                       var answers = assertArgNotNull("handler result", 
handler.elicit(requests));
+                       
applyResume.apply(ElicitationResponses.toInputResponses(answers), requestState);
+                       raw = callRaw(method, params);
+               }
+               return raw;
+       }
+
+       /**
+        * Decodes a terminal raw result {@link Map} into its typed result bean 
the same way {@link #call} decodes a
+        * live wire result, so a polymorphic field (e.g. a {@link 
CallToolResult}'s {@code content} entries) keeps
+        * the {@code type} discriminator it already carries in the raw tree.
+        */
+       private static <T> T decodeResult(Map<String,Object> raw, Class<T> 
resultType) {
+               return JsonMap.of("value", raw).get("value", resultType);
+       }
+
+       /**
+        * Applies one round's collected answers and carried continuation token 
onto the concrete resume request bean.
+        *
+        * <p>
+        * Exists (rather than a {@code BiConsumer}) so each {@code 
*WithElicitation} method can bind its own concrete
+        * bean's {@code setInputResponses}/{@code setRequestState} pair, which 
— unlike {@code RequestParams} — share
+        * no common interface across {@code CallToolRequest}/{@code 
GetPromptRequest}/{@code ReadResourceRequest}.
+        */
+       @FunctionalInterface
+       private interface ResumeApplier {
+               void apply(Map<String,Object> inputResponses, String 
requestState);
+       }
+
        /**
         * Sends {@value McpMethods#SUBSCRIPTIONS_LISTEN}, opening a managed, 
held-open notification stream on a
         * background thread and dispatching decoded frames to {@code listener} 
until the returned handle is closed,
diff --git 
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationHandler.java
 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationHandler.java
new file mode 100644
index 0000000000..c070567f7b
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationHandler.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+import java.io.*;
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+
+/**
+ * Caller-supplied callback that answers one round of server&rarr;client MCP 
{@code 2026-07-28} SEP-2322
+ * elicitation requests during a client-side Multi-Round-Trip-Request (MRTR) 
auto-resume loop.
+ *
+ * <p>
+ * Passed to {@link McpClient#callToolWithElicitation}, {@link 
McpClient#getPromptWithElicitation}, and
+ * {@link McpClient#readResourceWithElicitation}: each time the server pauses 
a call with an {@code input_required}
+ * result, the client decodes that round's pending requests (there may be more 
than one) and invokes
+ * {@link #elicit(Map)} to obtain the matching answers, which it then echoes 
back on the resume call. This turns
+ * the otherwise hand-driven {@link McpClient#callRaw}/{@link 
ElicitationRequests}/{@link ElicitationResponses}
+ * loop into a single call plus one handler.
+ *
+ * <p>
+ * A handler answers by returning, keyed by the same server-assigned ids it 
was given, an {@link ElicitResult}
+ * per request it wishes to answer &mdash; an {@link ElicitAction#ACCEPT} with 
content, or an
+ * {@link ElicitAction#DECLINE}/{@link ElicitAction#CANCEL} to refuse. 
Decline/cancel answers are still sent back
+ * to the server (which decides the terminal outcome of a refused 
elicitation); the client does not short-circuit
+ * them locally.
+ *
+ * @since 10.0.0
+ */
+@FunctionalInterface
+public interface McpElicitationHandler {
+
+       /**
+        * Answers one round of elicitation requests.
+        *
+        * <p>
+        * The returned map may legitimately omit ids the handler chooses not 
to answer (the server may then
+        * re-pause, bounded by the auto-resume loop's max-rounds guard); 
returning <jk>null</jk> outright is illegal
+        * and causes the auto-resume loop to throw {@link 
IllegalArgumentException}; and any extra/unknown ids
+        * present in the returned map are echoed back to the server 
unvalidated.
+        *
+        * @param requests The pending requests for this round, keyed by 
server-assigned id (decoded via
+        *      {@link ElicitationRequests#requests(Map)}). Never 
<jk>null</jk>; may contain more than one entry, and an
+        *      individual value may be <jk>null</jk> if the corresponding raw 
entry was absent/<jk>null</jk>.
+        * @return The answers keyed by the same server-assigned ids. Must not 
be <jk>null</jk>. An
+        *      {@link ElicitResult} value must not be <jk>null</jk>. Use a 
{@link LinkedHashMap} for deterministic
+        *      ordering of the echoed {@code inputResponses}. May legitimately 
omit ids the handler chooses not to
+        *      answer (the server may then re-pause, bounded by the loop's 
max-rounds guard).
+        * @throws IOException If the handler performs its own I/O (e.g. 
prompting a remote user) and it fails; the
+        *      auto-resume loop propagates it to the caller unchanged.
+        */
+       Map<String,ElicitResult> elicit(Map<String,ElicitRequest> requests) 
throws IOException;
+}
diff --git 
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationLimitException.java
 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationLimitException.java
new file mode 100644
index 0000000000..a27d758931
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationLimitException.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+/**
+ * Thrown by the client-side MRTR (SEP-2322) auto-resume helpers
+ * ({@link McpClient#callToolWithElicitation}, {@link 
McpClient#getPromptWithElicitation},
+ * {@link McpClient#readResourceWithElicitation}) when a server keeps 
returning {@code input_required} pauses past
+ * the loop's configured maximum number of resume rounds.
+ *
+ * <p>
+ * This is a guard against an unbounded (or maliciously non-terminating) 
elicitation loop, distinct from a
+ * server-reported JSON-RPC error (which surfaces as {@link 
org.apache.juneau.bean.jsonrpc.McpException}). It is an
+ * unchecked exception so it does not widen the {@code throws} clause of the 
helper methods beyond
+ * {@link java.io.IOException}; reaching it almost always indicates a server 
or handler that never converges rather
+ * than a condition ordinary caller code can recover from.
+ *
+ * @since 10.0.0
+ */
+public class McpElicitationLimitException extends RuntimeException {
+
+       private static final long serialVersionUID = 1L;
+
+       private final int maxRounds;
+
+       /**
+        * Constructor.
+        *
+        * @param maxRounds The configured maximum number of resume rounds that 
was exceeded.
+        */
+       public McpElicitationLimitException(int maxRounds) {
+               super("MCP elicitation auto-resume exceeded the maximum of " + 
maxRounds + " round(s) without reaching a terminal result.");
+               this.maxRounds = maxRounds;
+       }
+
+       /**
+        * The configured maximum number of resume rounds that was exceeded.
+        *
+        * @return The max-rounds bound.
+        */
+       public int getMaxRounds() {
+               return maxRounds;
+       }
+}
diff --git 
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Elicitation_Test.java
 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Elicitation_Test.java
new file mode 100644
index 0000000000..5477b3c059
--- /dev/null
+++ 
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Elicitation_Test.java
@@ -0,0 +1,333 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.io.*;
+import java.nio.charset.*;
+import java.util.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.rest.client.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for the ergonomic client-side MRTR (SEP-2322) auto-resume helpers
+ * {@link McpClient#callToolWithElicitation}, {@link 
McpClient#getPromptWithElicitation}, and
+ * {@link McpClient#readResourceWithElicitation} (TODO-326): the caller 
supplies an
+ * {@link McpElicitationHandler} and the client auto-detects each {@code 
input_required} pause, invokes the
+ * handler for the round's requests, and re-issues the call with the collected 
{@code inputResponses} and carried
+ * {@code requestState} until a terminal result is reached.
+ */
+class McpClient_Elicitation_Test {
+
+       /**
+        * A stub transport that returns a fixed sequence of canned JSON-RPC 
wire responses (the last one repeats for
+        * any extra calls) while recording every request it received so a test 
can assert on the resume payload
+        * (echoed {@code inputResponses}/{@code requestState}).
+        */
+       private static final class Recorder implements HttpTransport {
+               private final List<String> responses;
+               final List<JsonRpcRequest> requests = new ArrayList<>();
+               private int i;
+
+               Recorder(String... responses) {
+                       this.responses = List.of(responses);
+               }
+
+               @Override
+               public TransportResponse execute(TransportRequest request) 
throws TransportException {
+                       try {
+                               var baos = new ByteArrayOutputStream();
+                               request.getBody().writeTo(baos);
+                               
requests.add(JsonParser.DEFAULT.read(baos.toString(StandardCharsets.UTF_8), 
JsonRpcRequest.class));
+                       } catch (IOException e) {
+                               throw new TransportException("Failed reading 
stub request body.", e);
+                       }
+                       var wire = responses.get(Math.min(i, responses.size() - 
1));
+                       i++;
+                       return TransportResponse.builder()
+                               .statusCode(200)
+                               .header("Content-Type", "application/json")
+                               .body(new 
ByteArrayInputStream(wire.getBytes(StandardCharsets.UTF_8)))
+                               .build();
+               }
+       }
+
+       private static McpClient client(HttpTransport transport) {
+               return 
McpClient.builder().endpoint("http://x/mcp";).transport(transport).build();
+       }
+
+       /** The wire params ({@code inputResponses}/{@code requestState}) the 
server saw on the Nth (0-based) request. */
+       @SuppressWarnings("unchecked")
+       private static Map<String,Object> paramsOf(Recorder r, int n) {
+               return (Map<String,Object>) r.requests.get(n).getParams();
+       }
+
+       private static String inputRequired(String requestState, String... 
requestIds) {
+               var sb = new StringBuilder();
+               for (var id : requestIds) {
+                       if (sb.length() > 0)
+                               sb.append(',');
+                       
sb.append('"').append(id).append("\":{\"message\":\"Pick 
").append(id).append("\",\"requestedSchema\":{\"type\":\"object\"}}");
+               }
+               return 
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"inputRequests\":{" + sb + 
"},\"requestState\":\"" + requestState + 
"\",\"resultType\":\"input_required\"}}";
+       }
+
+       private static String completeTool(String text) {
+               return 
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\""
 + text + "\"}],\"resultType\":\"complete\"}}";
+       }
+
+       /** Answers every request in a round with ACCEPT + one content entry 
keyed by the request id. */
+       private static McpElicitationHandler acceptAll() {
+               return requests -> {
+                       var out = new LinkedHashMap<String,ElicitResult>();
+                       requests.forEach((id, req) -> out.put(id, new 
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", id + 
"-answer")));
+                       return out;
+               };
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Happy paths
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void 
a01_callTool_singleRound_handlerAnswers_terminalResultReturned() throws 
Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("done"));
+               var seen = new AtomicReference<Map<String,ElicitRequest>>();
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", 
Map.of("k", "v"), requests -> {
+                               seen.set(requests);
+                               return acceptAll().elicit(requests);
+                       });
+                       assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               // Handler saw the one pending request; the resume call carried 
the answer + echoed the requestState.
+               assertTrue(seen.get().containsKey("q1"));
+               assertEquals("Pick q1", seen.get().get("q1").getMessage());
+               assertEquals(2, t.requests.size());
+               var resume = paramsOf(t, 1);
+               assertEquals("tok1", resume.get("requestState"));
+               assertTrue(((Map<?,?>) 
resume.get("inputResponses")).containsKey("q1"));
+       }
+
+       @Test void a02_callTool_multipleRequestsInOneRound_answeredTogether() 
throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1", "q2"), 
completeTool("done"));
+               var count = new AtomicInteger();
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null, 
requests -> {
+                               count.incrementAndGet();
+                               assertEquals(2, requests.size());
+                               return acceptAll().elicit(requests);
+                       });
+                       assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               assertEquals(1, count.get());
+               var responses = (Map<?,?>) paramsOf(t, 1).get("inputResponses");
+               assertTrue(responses.containsKey("q1"));
+               assertTrue(responses.containsKey("q2"));
+       }
+
+       @Test void 
a03_callTool_multipleSequentialRounds_handlerInvokedPerRound() throws Exception 
{
+               var t = new Recorder(inputRequired("tok1", "q1"), 
inputRequired("tok2", "q2"), completeTool("done"));
+               var rounds = new AtomicInteger();
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null, 
requests -> {
+                               rounds.incrementAndGet();
+                               return acceptAll().elicit(requests);
+                       });
+                       assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               assertEquals(2, rounds.get());
+               assertEquals(3, t.requests.size());
+               // Each resume carried the requestState the 
immediately-preceding pause echoed.
+               assertEquals("tok1", paramsOf(t, 1).get("requestState"));
+               assertEquals("tok2", paramsOf(t, 2).get("requestState"));
+       }
+
+       @Test void a04_getPrompt_singleRound_terminalResultReturned() throws 
Exception {
+               var complete = 
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"description\":\"done\",\"messages\":[],\"resultType\":\"complete\"}}";
+               var t = new Recorder(inputRequired("tok1", "q1"), complete);
+               try (var c = client(t)) {
+                       var result = c.getPromptWithElicitation("greet", null, 
acceptAll());
+                       assertEquals("done", result.getDescription());
+               }
+               assertEquals(2, t.requests.size());
+       }
+
+       @Test void a05_readResource_singleRound_terminalResultReturned() throws 
Exception {
+               var complete = 
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"contents\":[{\"type\":\"resourceText\",\"uri\":\"file:///a\",\"text\":\"body\"}]}}";
+               var t = new Recorder(inputRequired("tok1", "q1"), complete);
+               try (var c = client(t)) {
+                       var result = c.readResourceWithElicitation("file:///a", 
acceptAll());
+                       assertEquals(1, result.getContents().size());
+               }
+               assertEquals(2, t.requests.size());
+       }
+
+       @Test void 
a06_callTool_noPause_terminalResultReturnedWithoutInvokingHandler() throws 
Exception {
+               var t = new Recorder(completeTool("done"));
+               var invoked = new AtomicBoolean();
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null, 
requests -> {
+                               invoked.set(true);
+                               return acceptAll().elicit(requests);
+                       });
+                       assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               assertFalse(invoked.get());
+               assertEquals(1, t.requests.size());
+       }
+
+       @Test void 
a07_callTool_incompleteAnswerInRound_onlyProvidedIdEchoedAndLoopProceeds() 
throws Exception {
+               // Handler answers only q1 of the two requested ids - 
legitimate (see McpElicitationHandler#elicit): the
+               // resume call must carry only the id the handler actually 
answered, not a fabricated answer for q2.
+               var t = new Recorder(inputRequired("tok1", "q1", "q2"), 
completeTool("done"));
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null, 
requests -> {
+                               assertEquals(2, requests.size());
+                               var out = new 
LinkedHashMap<String,ElicitResult>();
+                               out.put("q1", new 
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", 
"q1-answer"));
+                               return out;
+                       });
+                       assertEquals("done", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               assertEquals(2, t.requests.size());
+               var responses = (Map<?,?>) paramsOf(t, 1).get("inputResponses");
+               assertEquals(1, responses.size());
+               assertTrue(responses.containsKey("q1"));
+               assertFalse(responses.containsKey("q2"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Decline / cancel
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void b01_callTool_handlerDeclines_answerSentAndTerminalReturned() 
throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("declined-path"));
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null,
+                               requests -> Map.of("q1", new 
ElicitResult().setAction(ElicitAction.DECLINE)));
+                       assertEquals("declined-path", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               // The decline was echoed back to the server as a real 
inputResponses answer (the server, not the client,
+               // decides the terminal outcome of a declined elicitation).
+               var responses = (Map<?,?>) paramsOf(t, 1).get("inputResponses");
+               var q1 = (Map<?,?>) responses.get("q1");
+               assertEquals("decline", q1.get("action"));
+       }
+
+       @Test void b02_callTool_handlerCancels_answerSentAndTerminalReturned() 
throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("cancelled-path"));
+               try (var c = client(t)) {
+                       var result = c.callToolWithElicitation("ask", null,
+                               requests -> Map.of("q1", new 
ElicitResult().setAction(ElicitAction.CANCEL)));
+                       assertEquals("cancelled-path", ((TextContent) 
result.getContent().get(0)).getText());
+               }
+               var responses = (Map<?,?>) paramsOf(t, 1).get("inputResponses");
+               var q1 = (Map<?,?>) responses.get("q1");
+               assertEquals("cancel", q1.get("action"));
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Bounded loop guard
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void c01_callTool_maxRoundsExceeded_throwsTypedException() throws 
Exception {
+               // Server never terminates: every response is another 
input_required pause.
+               var t = new Recorder(inputRequired("tok", "q1"));
+               try (var c = client(t)) {
+                       var e = assertThrows(McpElicitationLimitException.class,
+                               () -> c.callToolWithElicitation("ask", null, 
acceptAll(), 2));
+                       assertEquals(2, e.getMaxRounds());
+               }
+               // 1 initial call + exactly maxRounds resume attempts, then the 
guard trips before a further re-issue.
+               assertEquals(3, t.requests.size());
+       }
+
+       @Test void c02_callTool_defaultMaxRoundsGuardsRunawayServer() throws 
Exception {
+               var t = new Recorder(inputRequired("tok", "q1"));
+               try (var c = client(t)) {
+                       assertThrows(McpElicitationLimitException.class, () -> 
c.callToolWithElicitation("ask", null, acceptAll()));
+               }
+               // Bounded by the default, not infinite: 1 initial + 
DEFAULT_MAX_ELICITATION_ROUNDS resume attempts.
+               assertEquals(1 + McpClient.DEFAULT_MAX_ELICITATION_ROUNDS, 
t.requests.size());
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Argument guards
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void d01_nullHandlerThrows() throws Exception {
+               try (var c = client(new Recorder(completeTool("done")))) {
+                       var e = assertThrows(IllegalArgumentException.class, () 
-> c.callToolWithElicitation("ask", null, null));
+                       assertEquals("Argument 'handler' cannot be null.", 
e.getMessage());
+               }
+       }
+
+       @Test void d02_nonPositiveMaxRoundsThrows() throws Exception {
+               try (var c = client(new Recorder(completeTool("done")))) {
+                       var eZero = 
assertThrows(IllegalArgumentException.class, () -> 
c.callToolWithElicitation("ask", null, acceptAll(), 0));
+                       assertEquals("maxRounds must be >= 1 (was 0).", 
eZero.getMessage());
+                       var eNegative = 
assertThrows(IllegalArgumentException.class, () -> 
c.callToolWithElicitation("ask", null, acceptAll(), -1));
+                       assertEquals("maxRounds must be >= 1 (was -1).", 
eNegative.getMessage());
+               }
+       }
+
+       @Test void d03_handlerReturnsNullResultThrows() throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("done"));
+               try (var c = client(t)) {
+                       var e = assertThrows(IllegalArgumentException.class,
+                               () -> c.callToolWithElicitation("ask", null, 
requests -> null));
+                       assertEquals("Argument 'handler result' cannot be 
null.", e.getMessage());
+               }
+       }
+
+       @Test void d04_handlerReturnsMapWithNullValueForRequestedIdThrows() 
throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("done"));
+               try (var c = client(t)) {
+                       var e = assertThrows(IllegalArgumentException.class,
+                               () -> c.callToolWithElicitation("ask", null, 
requests -> {
+                                       var out = new 
LinkedHashMap<String,ElicitResult>();
+                                       out.put("q1", null);
+                                       return out;
+                               }));
+                       assertEquals("Argument 'results[q1]' cannot be null.", 
e.getMessage());
+               }
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // Escape hatch: manual callRaw resume still works unchanged alongside 
the ergonomic helper
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test void e01_manualCallRawResume_stillWorks() throws Exception {
+               var t = new Recorder(inputRequired("tok1", "q1"), 
completeTool("done"));
+               try (var c = client(t)) {
+                       var raw = c.callRaw(McpMethods.TOOLS_CALL, new 
CallToolRequest().setName("ask"));
+                       assertTrue(ElicitationRequests.isInputRequired(raw));
+                       var state = ElicitationRequests.requestState(raw);
+                       var answers = ElicitationResponses.toInputResponse("q1",
+                               new 
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "x"));
+                       var resume = c.callRaw(McpMethods.TOOLS_CALL,
+                               new 
CallToolRequest().setName("ask").setInputResponses(answers).setRequestState(state));
+                       assertEquals("complete", resume.get("resultType"));
+               }
+       }
+}

Reply via email to