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 ≥ 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 ≥ 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 ≥ 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 ≥ 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 ≥ 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 ≥ 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→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 — 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"));
+ }
+ }
+}