You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/04 03:07:10 UTC
(juneau) branch master updated (995dc12937 -> f823a566f8)
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
from 995dc12937 MCP 2026-07-28 MRTR wire fixtures + end-to-end client integration (TODO-318 phases 4-6)
new 3c67b8a8e0 MCP 2026-07-28 sampling + elicitation (TODO-328)
new f823a566f8 Add missed unit test coverage for MCP sampling + elicitation beans (TODO-328)
The 2 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../{ImageContent.java => AudioContent.java} | 21 +-
.../bean/mcp/v20260728/ClientCapabilities.java | 26 +-
.../apache/juneau/bean/mcp/v20260728/Content.java | 5 +-
.../bean/mcp/v20260728/CreateMessageRequest.java | 292 ++++++++++++++++
...ourceTemplate.java => CreateMessageResult.java} | 92 ++---
.../mcp/v20260728/{Role.java => ElicitAction.java} | 23 +-
.../juneau/bean/mcp/v20260728/ElicitRequest.java | 81 +++++
.../juneau/bean/mcp/v20260728/ElicitResult.java | 95 ++++++
.../juneau/bean/mcp/v20260728/ElicitSchema.java | 373 +++++++++++++++++++++
.../bean/mcp/v20260728/ElicitationCapability.java | 2 +-
.../bean/mcp/v20260728/InputRequiredResult.java | 4 +-
.../juneau/bean/mcp/v20260728/McpMethods.java | 6 +
.../{PromptReference.java => ModelHint.java} | 15 +-
.../bean/mcp/v20260728/ModelPreferences.java | 153 +++++++++
.../{PingResult.java => SamplingCapability.java} | 10 +-
.../{PromptMessage.java => SamplingMessage.java} | 14 +-
.../bean/mcp/v20260728/ElicitSchema_Test.java | 342 +++++++++++++++++++
.../juneau/bean/mcp/v20260728/McpV2Beans_Test.java | 196 ++++++++++-
.../v20260728/McpElicitationIntegration_Test.java | 234 +++++++++++++
.../mcp/v20260728/Characterization_Test.java | 56 ++++
... ELICIT-multi-request-pause.headers.properties} | 2 +-
.../ELICIT-multi-request-pause.request.json | 1 +
.../ELICIT-multi-request-pause.response.json | 1 +
...ICIT-resume-accept-complete.headers.properties} | 2 +-
.../ELICIT-resume-accept-complete.request.json | 1 +
...=> ELICIT-resume-accept-complete.response.json} | 2 +-
...ies => ELICIT-resume-cancel.headers.properties} | 2 +-
.../ELICIT-resume-cancel.request.json | 1 +
...nse.json => ELICIT-resume-cancel.response.json} | 2 +-
...es => ELICIT-resume-decline.headers.properties} | 2 +-
.../ELICIT-resume-decline.request.json | 1 +
...se.json => ELICIT-resume-decline.response.json} | 2 +-
...ELICIT-single-request-pause.headers.properties} | 2 +-
.../ELICIT-single-request-pause.request.json | 1 +
.../ELICIT-single-request-pause.response.json | 1 +
.../client/mcp/v20260728/ElicitationAccess.java | 155 +++++++++
.../rest/client/mcp/v20260728/McpClient.java | 14 +-
.../mcp/v20260728/ElicitationAccess_Test.java | 176 ++++++++++
.../mcp/v20260728/McpClient_Capabilities_Test.java | 2 +-
.../mcp/v20260728/McpDuplexChannel_Test.java | 68 ++++
.../rest/client/mcp/McpDuplexDispatcher.java | 4 +-
.../rest/client/mcp/McpServerRequestHandler.java | 4 +-
.../rest/client/mcp/McpDuplexDispatcher_Test.java | 2 +-
.../server/mcp/v20260728/ElicitationRequests.java | 90 +++++
.../server/mcp/v20260728/ElicitationResponses.java | 75 +++++
.../mcp/v20260728/Characterization_Test.java | 56 ++++
.../mcp/v20260728/ElicitationRequests_Test.java | 88 +++++
.../mcp/v20260728/ElicitationResponses_Test.java | 95 ++++++
.../ELICIT-multi-request-pause.headers.properties | 2 +-
.../ELICIT-multi-request-pause.request.json | 1 +
.../ELICIT-multi-request-pause.response.json | 1 +
...LICIT-resume-accept-complete.headers.properties | 2 +-
.../ELICIT-resume-accept-complete.request.json | 1 +
.../ELICIT-resume-accept-complete.response.json | 2 +-
.../ELICIT-resume-cancel.headers.properties | 2 +-
.../ELICIT-resume-cancel.request.json | 1 +
.../ELICIT-resume-cancel.response.json | 2 +-
.../ELICIT-resume-decline.headers.properties | 2 +-
.../ELICIT-resume-decline.request.json | 1 +
.../ELICIT-resume-decline.response.json | 2 +-
.../ELICIT-single-request-pause.headers.properties | 2 +-
.../ELICIT-single-request-pause.request.json | 1 +
.../ELICIT-single-request-pause.response.json | 1 +
63 files changed, 2771 insertions(+), 144 deletions(-)
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{ImageContent.java => AudioContent.java} (65%)
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageRequest.java
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{ResourceTemplate.java => CreateMessageResult.java} (50%)
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{Role.java => ElicitAction.java} (71%)
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitRequest.java
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitResult.java
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema.java
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{PromptReference.java => ModelHint.java} (74%)
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelPreferences.java
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{PingResult.java => SamplingCapability.java} (71%)
copy juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/{PromptMessage.java => SamplingMessage.java} (75%)
create mode 100644 juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema_Test.java
create mode 100644 juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{ERROR-handler-failure.headers.properties => ELICIT-multi-request-pause.headers.properties} (97%)
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.request.json
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.response.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{ERROR-handler-failure.headers.properties => ELICIT-resume-accept-complete.headers.properties} (97%)
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{COMPLETE-resource-template.response.json => ELICIT-resume-accept-complete.response.json} (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{ERROR-handler-failure.headers.properties => ELICIT-resume-cancel.headers.properties} (97%)
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{COMPLETE-resource-template.response.json => ELICIT-resume-cancel.response.json} (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{ERROR-handler-failure.headers.properties => ELICIT-resume-decline.headers.properties} (97%)
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{COMPLETE-resource-template.response.json => ELICIT-resume-decline.response.json} (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/{ERROR-handler-failure.headers.properties => ELICIT-single-request-pause.headers.properties} (97%)
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.request.json
create mode 100644 juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.response.json
create mode 100644 juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java
create mode 100644 juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses.java
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests_Test.java
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses_Test.java
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ERROR-handler-failure.headers.properties => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.headers.properties (97%)
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.request.json
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.response.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ERROR-handler-failure.headers.properties => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.headers.properties (97%)
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/COMPLETE-resource-template.response.json => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.response.json (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ERROR-handler-failure.headers.properties => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.headers.properties (97%)
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/COMPLETE-resource-template.response.json => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.response.json (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ERROR-handler-failure.headers.properties => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.headers.properties (97%)
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.request.json
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/COMPLETE-resource-template.response.json => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.response.json (60%)
copy juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ERROR-handler-failure.headers.properties => juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.headers.properties (97%)
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.request.json
create mode 100644 juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.response.json
(juneau) 01/02: MCP 2026-07-28 sampling + elicitation (TODO-328)
Posted by ja...@apache.org.
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
commit 3c67b8a8e080142c78acffda70efc8db42706f27
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Aug 3 20:06:20 2026 -0700
MCP 2026-07-28 sampling + elicitation (TODO-328)
Elicitation (SEP-2322): ElicitRequest/ElicitResult/ElicitAction wire beans
plus the ElicitSchema restricted-schema builder, riding the existing MRTR
input_required pause/resume loop with zero mechanism changes. Adds server
helpers ElicitationRequests/ElicitationResponses and the client helper
ElicitationAccess for driving the loop over McpClient#callRaw.
Sampling (SEP-2577, deprecated at the protocol level but fully supported
here): full-fidelity CreateMessageRequest/CreateMessageResult/
SamplingMessage/ModelPreferences/ModelHint beans plus a typed
SamplingCapability (replacing the prior free-form sampling map on
ClientCapabilities). Content gains a new AudioContent member for
text/image/audio/resource fidelity in sampling messages/results.
Fixes McpClient#postClientResult to route posted duplex results through
toWireParams(...) so polymorphic Content fields keep their wire type
discriminator, matching the request-side path.
Removes the Java-level @Deprecated annotations SEP-2577 had prompted on
Sampling/Roots/Logging-adjacent members - the protocol-level deprecation
note stands, but this project has not scheduled removal of the Java types.
Adds ELICIT-* characterization/integration fixtures alongside the existing
MRTR-* set.
Co-authored-by: Cursor <cu...@cursor.com>
---
.../juneau/bean/mcp/v20260728/AudioContent.java | 76 +++++
.../bean/mcp/v20260728/ClientCapabilities.java | 26 +-
.../apache/juneau/bean/mcp/v20260728/Content.java | 5 +-
.../bean/mcp/v20260728/CreateMessageRequest.java | 292 ++++++++++++++++
.../bean/mcp/v20260728/CreateMessageResult.java | 114 +++++++
.../v20260728/{Content.java => ElicitAction.java} | 43 ++-
.../juneau/bean/mcp/v20260728/ElicitRequest.java | 81 +++++
.../juneau/bean/mcp/v20260728/ElicitResult.java | 95 ++++++
.../juneau/bean/mcp/v20260728/ElicitSchema.java | 373 +++++++++++++++++++++
.../bean/mcp/v20260728/ElicitationCapability.java | 2 +-
.../bean/mcp/v20260728/InputRequiredResult.java | 4 +-
.../juneau/bean/mcp/v20260728/McpMethods.java | 6 +
.../{ElicitationCapability.java => ModelHint.java} | 34 +-
.../bean/mcp/v20260728/ModelPreferences.java | 153 +++++++++
.../{Content.java => SamplingCapability.java} | 14 +-
.../juneau/bean/mcp/v20260728/SamplingMessage.java | 71 ++++
.../bean/mcp/v20260728/ElicitSchema_Test.java | 342 +++++++++++++++++++
.../v20260728/McpElicitationIntegration_Test.java | 234 +++++++++++++
.../mcp/v20260728/Characterization_Test.java | 56 ++++
.../ELICIT-multi-request-pause.headers.properties | 17 +
.../ELICIT-multi-request-pause.request.json | 1 +
.../ELICIT-multi-request-pause.response.json | 1 +
...LICIT-resume-accept-complete.headers.properties | 17 +
.../ELICIT-resume-accept-complete.request.json | 1 +
.../ELICIT-resume-accept-complete.response.json | 1 +
.../ELICIT-resume-cancel.headers.properties | 17 +
.../ELICIT-resume-cancel.request.json | 1 +
.../ELICIT-resume-cancel.response.json | 1 +
.../ELICIT-resume-decline.headers.properties | 17 +
.../ELICIT-resume-decline.request.json | 1 +
.../ELICIT-resume-decline.response.json | 1 +
.../ELICIT-single-request-pause.headers.properties | 17 +
.../ELICIT-single-request-pause.request.json | 1 +
.../ELICIT-single-request-pause.response.json | 1 +
.../client/mcp/v20260728/ElicitationAccess.java | 155 +++++++++
.../rest/client/mcp/v20260728/McpClient.java | 14 +-
.../mcp/v20260728/ElicitationAccess_Test.java | 176 ++++++++++
.../mcp/v20260728/McpClient_Capabilities_Test.java | 2 +-
.../mcp/v20260728/McpDuplexChannel_Test.java | 68 ++++
.../rest/client/mcp/McpDuplexDispatcher.java | 4 +-
.../rest/client/mcp/McpServerRequestHandler.java | 4 +-
.../rest/client/mcp/McpDuplexDispatcher_Test.java | 2 +-
.../server/mcp/v20260728/ElicitationRequests.java | 90 +++++
.../server/mcp/v20260728/ElicitationResponses.java | 75 +++++
.../mcp/v20260728/Characterization_Test.java | 56 ++++
.../mcp/v20260728/ElicitationRequests_Test.java | 88 +++++
.../mcp/v20260728/ElicitationResponses_Test.java | 95 ++++++
.../ELICIT-multi-request-pause.headers.properties | 17 +
.../ELICIT-multi-request-pause.request.json | 1 +
.../ELICIT-multi-request-pause.response.json | 1 +
...LICIT-resume-accept-complete.headers.properties | 17 +
.../ELICIT-resume-accept-complete.request.json | 1 +
.../ELICIT-resume-accept-complete.response.json | 1 +
.../ELICIT-resume-cancel.headers.properties | 17 +
.../ELICIT-resume-cancel.request.json | 1 +
.../ELICIT-resume-cancel.response.json | 1 +
.../ELICIT-resume-decline.headers.properties | 17 +
.../ELICIT-resume-decline.request.json | 1 +
.../ELICIT-resume-decline.response.json | 1 +
.../ELICIT-single-request-pause.headers.properties | 17 +
.../ELICIT-single-request-pause.request.json | 1 +
.../ELICIT-single-request-pause.response.json | 1 +
62 files changed, 2980 insertions(+), 60 deletions(-)
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/AudioContent.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/AudioContent.java
new file mode 100644
index 0000000000..af8b1c8294
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/AudioContent.java
@@ -0,0 +1,76 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * MCP {@code audio} content block (base64 data).
+ *
+ * <p>
+ * Joins the general-purpose {@link Content} dictionary alongside {@link TextContent}/{@link ImageContent}/
+ * {@link EmbeddedResourceContent} — usable anywhere a {@link Content} is accepted, not only in sampling contexts.
+ * Added specifically to give {@code SamplingMessage.content}/{@code CreateMessageResult.content} genuine
+ * {@code text}/{@code image}/{@code audio}/{@code resource} fidelity to the real MCP sampling schema; usable
+ * anywhere a {@link Content} is accepted, not only sampling contexts.
+ */
+@Marshalled(typeName = "audio")
+public class AudioContent implements Content {
+
+ private String data;
+ private String mimeType;
+
+ /**
+ * Base64-encoded audio bytes.
+ *
+ * @return The data, or {@code null} if not set.
+ */
+ public String getData() {
+ return data;
+ }
+
+ /**
+ * Sets the base64 payload.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public AudioContent setData(String value) {
+ data = value;
+ return this;
+ }
+
+ /**
+ * Audio MIME type (for example {@code audio/wav}, {@code audio/mpeg}).
+ *
+ * @return The MIME type, or {@code null} if not set.
+ */
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ /**
+ * Sets the MIME type.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public AudioContent setMimeType(String value) {
+ mimeType = value;
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ClientCapabilities.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ClientCapabilities.java
index a4d9c2b733..8d2a5ff386 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ClientCapabilities.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ClientCapabilities.java
@@ -31,7 +31,7 @@ public class ClientCapabilities {
private RootsCapability roots;
private ElicitationCapability elicitation;
- private Map<String,Object> sampling;
+ private SamplingCapability sampling;
private Map<String,Object> experimental;
private Map<String,Object> extensions;
@@ -76,12 +76,12 @@ public class ClientCapabilities {
}
/**
- * Sampling capability (free-form map for forward compatibility).
+ * Sampling capability.
*
- * @return The sampling map, or {@code null} if not set.
+ * @return The capability, or {@code null} if not set.
*/
- public Map<String,Object> getSampling() {
- return u(sampling);
+ public SamplingCapability getSampling() {
+ return sampling;
}
/**
@@ -90,25 +90,11 @@ public class ClientCapabilities {
* @param value The new value. Can be <jk>null</jk> to unset the property.
* @return This object (for method chaining).
*/
- public ClientCapabilities setSampling(Map<String,Object> value) {
+ public ClientCapabilities setSampling(SamplingCapability value) {
sampling = value;
return this;
}
- /**
- * Convenience method to add a single sampling capability entry.
- *
- * @param name The entry name. Can be <jk>null</jk> ({@link LinkedHashMap} tolerates a <jk>null</jk> key).
- * @param value The entry value. Can be <jk>null</jk> (stored as <jk>null</jk>).
- * @return This object (for method chaining).
- */
- public ClientCapabilities putSampling(String name, Object value) {
- if (sampling == null)
- sampling = map();
- sampling.put(name, value);
- return this;
- }
-
/**
* Experimental capability extensions.
*
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
index 0ad0bc11ab..7c45a1e1dd 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
@@ -22,10 +22,11 @@ import org.apache.juneau.marshall.*;
* Polymorphic MCP content block (tool results, prompt messages).
*
* <p>
- * Wire objects carry a {@code type} discriminator with values {@code text}, {@code image}, or {@code resource}.
+ * Wire objects carry a {@code type} discriminator with values {@code text}, {@code image}, {@code audio}, or
+ * {@code resource}.
*/
@Marshalled(
typePropertyName = "type",
- dictionary = { TextContent.class, ImageContent.class, EmbeddedResourceContent.class }
+ dictionary = { TextContent.class, ImageContent.class, AudioContent.class, EmbeddedResourceContent.class }
)
public interface Content {}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageRequest.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageRequest.java
new file mode 100644
index 0000000000..9a172804b8
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageRequest.java
@@ -0,0 +1,292 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * Parameters for {@value McpMethods#SAMPLING_CREATE_MESSAGE}, a server-to-client duplex request (MCP sampling).
+ * Never dispatched through {@code McpRevision} — this is a plain POJO decoded/encoded by
+ * hand (or via the client's typed duplex flow) inside an {@code McpServerRequestHandler} implementation.
+ */
+@Marshalled
+public class CreateMessageRequest {
+
+ private List<SamplingMessage> messages;
+ private ModelPreferences modelPreferences;
+ private String systemPrompt;
+ private String includeContext;
+ private Double temperature;
+ private Integer maxTokens;
+ private List<String> stopSequences;
+ private Map<String,Object> metadata;
+
+ /**
+ * The conversation history to sample from.
+ *
+ * @return The messages list, or {@code null} if not set.
+ */
+ public List<SamplingMessage> getMessages() {
+ return u(messages);
+ }
+
+ /**
+ * Sets the messages.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setMessages(List<SamplingMessage> value) {
+ messages = value;
+ return this;
+ }
+
+ /**
+ * Sets the messages.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setMessages(SamplingMessage...value) {
+ messages = list(value);
+ return this;
+ }
+
+ /**
+ * Appends to the messages.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest addMessages(SamplingMessage...value) {
+ if (messages == null)
+ messages = list();
+ Collections.addAll(messages, value);
+ return this;
+ }
+
+ /**
+ * Appends to the messages.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest addMessages(Collection<SamplingMessage> value) {
+ if (messages == null)
+ messages = list();
+ messages.addAll(value);
+ return this;
+ }
+
+ /**
+ * Model-selection preferences.
+ *
+ * @return The preferences, or {@code null} if not set.
+ */
+ public ModelPreferences getModelPreferences() {
+ return modelPreferences;
+ }
+
+ /**
+ * Sets the model preferences.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setModelPreferences(ModelPreferences value) {
+ modelPreferences = value;
+ return this;
+ }
+
+ /**
+ * An optional system prompt.
+ *
+ * @return The system prompt, or {@code null} if not set.
+ */
+ public String getSystemPrompt() {
+ return systemPrompt;
+ }
+
+ /**
+ * Sets the system prompt.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setSystemPrompt(String value) {
+ systemPrompt = value;
+ return this;
+ }
+
+ /**
+ * Which MCP context to include ({@code none}/{@code thisServer}/{@code allServers} per the pinned schema).
+ * Kept as a plain {@code String}, matching this module's existing precedent of not over-modeling
+ * schema string-enums that do not gate real branching logic in this codebase.
+ *
+ * @return The include-context value, or {@code null} if not set.
+ */
+ public String getIncludeContext() {
+ return includeContext;
+ }
+
+ /**
+ * Sets the include-context value.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setIncludeContext(String value) {
+ includeContext = value;
+ return this;
+ }
+
+ /**
+ * Sampling temperature.
+ *
+ * @return The temperature, or {@code null} if not set.
+ */
+ public Double getTemperature() {
+ return temperature;
+ }
+
+ /**
+ * Sets the temperature.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setTemperature(Double value) {
+ temperature = value;
+ return this;
+ }
+
+ /**
+ * Maximum tokens to sample.
+ *
+ * @return The max tokens, or {@code null} if not set.
+ */
+ public Integer getMaxTokens() {
+ return maxTokens;
+ }
+
+ /**
+ * Sets the max tokens.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setMaxTokens(Integer value) {
+ maxTokens = value;
+ return this;
+ }
+
+ /**
+ * Sequences that stop sampling when generated.
+ *
+ * @return The stop sequences, or {@code null} if not set.
+ */
+ public List<String> getStopSequences() {
+ return u(stopSequences);
+ }
+
+ /**
+ * Sets the stop sequences.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setStopSequences(List<String> value) {
+ stopSequences = value;
+ return this;
+ }
+
+ /**
+ * Sets the stop sequences.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setStopSequences(String...value) {
+ stopSequences = list(value);
+ return this;
+ }
+
+ /**
+ * Appends to the stop sequences.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest addStopSequences(String...value) {
+ if (stopSequences == null)
+ stopSequences = list();
+ Collections.addAll(stopSequences, value);
+ return this;
+ }
+
+ /**
+ * Appends to the stop sequences.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest addStopSequences(Collection<String> value) {
+ if (stopSequences == null)
+ stopSequences = list();
+ stopSequences.addAll(value);
+ return this;
+ }
+
+ /**
+ * Free-form server-defined metadata.
+ *
+ * @return The metadata map, or {@code null} if not set.
+ */
+ public Map<String,Object> getMetadata() {
+ return u(metadata);
+ }
+
+ /**
+ * Sets the metadata.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest setMetadata(Map<String,Object> value) {
+ metadata = value;
+ return this;
+ }
+
+ /**
+ * Convenience method to add a single metadata entry.
+ *
+ * @param name The entry name. Can be <jk>null</jk> ({@link LinkedHashMap} tolerates a <jk>null</jk> key).
+ * @param value The entry value. Can be <jk>null</jk> (stored as <jk>null</jk>).
+ * @return This object (for method chaining).
+ */
+ public CreateMessageRequest putMetadata(String name, Object value) {
+ if (metadata == null)
+ metadata = map();
+ metadata.put(name, value);
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageResult.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageResult.java
new file mode 100644
index 0000000000..ba2bafb56e
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CreateMessageResult.java
@@ -0,0 +1,114 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * Result payload for {@value McpMethods#SAMPLING_CREATE_MESSAGE} (MCP sampling). Never
+ * dispatched through {@code McpRevision}; see {@link CreateMessageRequest}'s class Javadoc for the same
+ * plain-POJO/duplex-payload rationale.
+ */
+@Marshalled
+public class CreateMessageResult {
+
+ private Role role;
+ private Content content;
+ private String model;
+ private String stopReason;
+
+ /**
+ * The generated message's role (real schema restricts this to {@code user}/{@code assistant}).
+ *
+ * @return The role, or {@code null} if not set.
+ */
+ public Role getRole() {
+ return role;
+ }
+
+ /**
+ * Sets the role.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageResult setRole(Role value) {
+ role = value;
+ return this;
+ }
+
+ /**
+ * The generated content — a single block ({@code text}/{@code image}/{@code audio}/{@code resource}).
+ *
+ * @return The content, or {@code null} if not set.
+ */
+ public Content getContent() {
+ return content;
+ }
+
+ /**
+ * Sets the content.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageResult setContent(Content value) {
+ content = value;
+ return this;
+ }
+
+ /**
+ * The model that generated the content.
+ *
+ * @return The model name, or {@code null} if not set.
+ */
+ public String getModel() {
+ return model;
+ }
+
+ /**
+ * Sets the model name.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageResult setModel(String value) {
+ model = value;
+ return this;
+ }
+
+ /**
+ * Why sampling stopped (commonly {@code endTurn}/{@code stopSequence}/{@code maxTokens}, but the schema
+ * also permits arbitrary server-defined strings, so this stays a plain {@code String}).
+ *
+ * @return The stop reason, or {@code null} if not set.
+ */
+ public String getStopReason() {
+ return stopReason;
+ }
+
+ /**
+ * Sets the stop reason.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public CreateMessageResult setStopReason(String value) {
+ stopReason = value;
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitAction.java
similarity index 57%
copy from juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
copy to juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitAction.java
index 0ad0bc11ab..a1f28ae731 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitAction.java
@@ -16,16 +16,37 @@
*/
package org.apache.juneau.bean.mcp.v20260728;
-import org.apache.juneau.marshall.*;
-
/**
- * Polymorphic MCP content block (tool results, prompt messages).
- *
- * <p>
- * Wire objects carry a {@code type} discriminator with values {@code text}, {@code image}, or {@code resource}.
+ * Closed action set for an elicitation answer (MCP {@code 2026-07-28} SEP-2322).
*/
-@Marshalled(
- typePropertyName = "type",
- dictionary = { TextContent.class, ImageContent.class, EmbeddedResourceContent.class }
-)
-public interface Content {}
+public enum ElicitAction {
+
+ /** The end user accepted and supplied {@link ElicitResult#getContent()}. */
+ ACCEPT("accept"),
+
+ /** The end user explicitly declined to answer. */
+ DECLINE("decline"),
+
+ /** The end user cancelled the interaction entirely. */
+ CANCEL("cancel");
+
+ private final String wire;
+
+ ElicitAction(String wire) {
+ this.wire = wire;
+ }
+
+ /**
+ * Wire token for JSON payloads.
+ *
+ * @return Lowercase MCP action string.
+ */
+ public String toWire() {
+ return wire;
+ }
+
+ @Override /* Object */
+ public String toString() {
+ return wire;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitRequest.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitRequest.java
new file mode 100644
index 0000000000..4d3649df83
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitRequest.java
@@ -0,0 +1,81 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * One entry of an {@code input_required} pause's {@code inputRequests} map for an elicitation sub-request (MCP
+ * {@code 2026-07-28} SEP-2322), built by a {@code tools/call}/{@code prompts/get}/{@code resources/read} handler
+ * and placed via {@code org.apache.juneau.rest.server.mcp.v20260728.ElicitationRequests}.
+ *
+ * <p>
+ * {@link #getRequestedSchema()} is a restricted JSON Schema (see
+ * {@code org.apache.juneau.bean.mcp.v20260728.ElicitSchema}) describing the expected shape of the end user's
+ * answer; it permits only primitive-typed, non-nested top-level properties.
+ */
+@Marshalled
+public class ElicitRequest {
+
+ private String message;
+ private Map<String,Object> requestedSchema;
+
+ /**
+ * The prompt shown to the end user.
+ *
+ * @return The message, or {@code null} if not set.
+ */
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * Sets the prompt shown to the end user.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ElicitRequest setMessage(String value) {
+ message = value;
+ return this;
+ }
+
+ /**
+ * The restricted JSON Schema describing the expected answer shape.
+ *
+ * @return An unmodifiable view of the schema, or {@code null} if not set.
+ */
+ public Map<String,Object> getRequestedSchema() {
+ return u(requestedSchema);
+ }
+
+ /**
+ * Sets the restricted JSON Schema describing the expected answer shape.
+ *
+ * @param value The new value, typically built via {@code ElicitSchema.create()....build()}. Can be
+ * <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ElicitRequest setRequestedSchema(Map<String,Object> value) {
+ requestedSchema = value;
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitResult.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitResult.java
new file mode 100644
index 0000000000..6f9349b5ae
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitResult.java
@@ -0,0 +1,95 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * The end user's answer to an {@link ElicitRequest} (MCP {@code 2026-07-28} SEP-2322), echoed back keyed by the
+ * same server-assigned id in a follow-up request's {@code inputResponses} and decoded via
+ * {@code org.apache.juneau.rest.server.mcp.v20260728.ElicitationResponses}.
+ *
+ * <p>
+ * {@link #getContent()} is present only when {@link #getAction()} is {@link ElicitAction#ACCEPT}; its shape is
+ * whatever {@link ElicitRequest#getRequestedSchema()} described, which is per-elicitation and unknowable at the
+ * bean-definition level, so it is deliberately left an untyped, dynamic map.
+ */
+@Marshalled
+public class ElicitResult {
+
+ private ElicitAction action;
+ private Map<String,Object> content;
+
+ /**
+ * The end user's choice.
+ *
+ * @return The action, or {@code null} if not set.
+ */
+ public ElicitAction getAction() {
+ return action;
+ }
+
+ /**
+ * Sets the end user's choice.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ElicitResult setAction(ElicitAction value) {
+ action = value;
+ return this;
+ }
+
+ /**
+ * The schema-shaped answer content. Present only when {@link #getAction()} is {@link ElicitAction#ACCEPT}.
+ *
+ * @return The content map, or {@code null} if not set.
+ */
+ public Map<String,Object> getContent() {
+ return u(content);
+ }
+
+ /**
+ * Sets the schema-shaped answer content.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ElicitResult setContent(Map<String,Object> value) {
+ content = value;
+ return this;
+ }
+
+ /**
+ * Convenience method to add a single content entry.
+ *
+ * @param name The entry name. Can be <jk>null</jk> ({@link LinkedHashMap} tolerates a <jk>null</jk> key).
+ * @param value The entry value. Can be <jk>null</jk> (stored as <jk>null</jk>).
+ * @return This object (for method chaining).
+ */
+ public ElicitResult putContent(String name, Object value) {
+ if (content == null)
+ content = map();
+ content.put(name, value);
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema.java
new file mode 100644
index 0000000000..fbfcb7c864
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema.java
@@ -0,0 +1,373 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.collections.*;
+
+/**
+ * Fluent builder for a restricted elicitation JSON Schema (MCP {@code 2026-07-28} SEP-2322), producing the
+ * {@link JsonMap} consumed by {@link ElicitRequest#setRequestedSchema(java.util.Map)}.
+ *
+ * <p>
+ * The elicitation restricted schema permits only primitive-typed, non-nested top-level properties. There is
+ * deliberately no {@code objectField}/{@code arrayField} method, so a nested/object/array-typed field is a
+ * compile-time impossibility, never a runtime-rejected one. {@link #build()} additionally rejects a handful of
+ * structurally-invalid per-field modifier combinations (for example {@link #format(String)} on a non-string
+ * field) with an {@link IllegalStateException}, so a handler author gets an immediate, in-process failure rather
+ * than a wire-level schema-validation error discovered by the client much later.
+ *
+ * <p>
+ * Each {@code xxxField(name)} call starts a new field and becomes the target of every subsequent modifier call
+ * ({@link #title(String)}, {@link #description(String)}, etc.) until the next {@code xxxField(...)} call.
+ * Re-declaring a field under a {@code name} already used by an earlier {@code xxxField(...)} call replaces that
+ * field's definition entirely, silently discarding any per-field modifiers already applied to it
+ * (last-field-wins). {@link #minLength(int)}/{@link #maxLength(int)} are additional string-only per-field
+ * modifiers, alongside {@link #format(String)}. {@link #required(String...)} is a separate, builder-level (not
+ * per-field) call that marks one or more field names required, emitting a top-level {@code "required":[...]}
+ * array; unlike the per-field modifiers, it is not tied to the most-recently-started field, is order-independent
+ * with respect to {@code xxxField(...)} (a name may be marked required before or after the field it names is
+ * (re)declared, and a redeclaration of that field does not discard the marking), and may be called any time,
+ * with an unknown name validated only at {@link #build()} time.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * JsonMap <jv>schema</jv> = ElicitSchema.<jsm>create</jsm>()
+ * .booleanField(<js>"confirm"</js>).title(<js>"Confirm"</js>)
+ * .build();
+ * </p>
+ */
+public class ElicitSchema {
+
+ private static final String TYPE_STRING = "string";
+ private static final String TYPE_NUMBER = "number";
+ private static final String TYPE_INTEGER = "integer";
+ private static final String TYPE_BOOLEAN = "boolean";
+ private static final String TYPE_OBJECT = "object";
+
+ private static final Set<String> NUMERIC_TYPES = Set.of(TYPE_NUMBER, TYPE_INTEGER);
+
+ private final Map<String,JsonMap> properties = map();
+ private final Set<String> required = new LinkedHashSet<>();
+ private String currentField;
+
+ private ElicitSchema() {}
+
+ /**
+ * Creates a new, empty schema builder.
+ *
+ * @return A new builder. Never <jk>null</jk>.
+ */
+ public static ElicitSchema create() {
+ return new ElicitSchema();
+ }
+
+ /**
+ * Starts a {@code string}-typed field.
+ *
+ * @param name The property name. Must not be <jk>null</jk> or blank.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank.
+ */
+ public ElicitSchema stringField(String name) {
+ return field(name, TYPE_STRING);
+ }
+
+ /**
+ * Starts a {@code number}-typed (floating-point) field.
+ *
+ * @param name The property name. Must not be <jk>null</jk> or blank.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank.
+ */
+ public ElicitSchema numberField(String name) {
+ return field(name, TYPE_NUMBER);
+ }
+
+ /**
+ * Starts an {@code integer}-typed field.
+ *
+ * @param name The property name. Must not be <jk>null</jk> or blank.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank.
+ */
+ public ElicitSchema integerField(String name) {
+ return field(name, TYPE_INTEGER);
+ }
+
+ /**
+ * Starts a {@code boolean}-typed field.
+ *
+ * @param name The property name. Must not be <jk>null</jk> or blank.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank.
+ */
+ public ElicitSchema booleanField(String name) {
+ return field(name, TYPE_BOOLEAN);
+ }
+
+ /**
+ * Starts a closed-choice {@code string}-typed field restricted to {@code values} (wire shape:
+ * {@code type:"string", enum:[...]}). Pair with {@link #enumNames(String...)} for parallel display labels.
+ *
+ * @param name The property name. Must not be <jk>null</jk> or blank.
+ * @param values The allowed values. Must not be <jk>null</jk> or empty, and must not contain a
+ * <jk>null</jk> element.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code name} is <jk>null</jk> or blank, or {@code values} is
+ * <jk>null</jk>, empty, or contains a <jk>null</jk> element.
+ */
+ public ElicitSchema enumField(String name, String...values) {
+ assertArgNotNullOrBlank("name", name);
+ if (values == null || values.length == 0)
+ throw iaex("Field ''%s'': enumField values must not be null or empty", name);
+ for (var value : values)
+ if (value == null)
+ throw iaex("Field ''%s'': enumField values must not contain a null element", name);
+ field(name, TYPE_STRING);
+ properties.get(name).put("enum", List.of(values));
+ return this;
+ }
+
+ private ElicitSchema field(String name, String type) {
+ assertArgNotNullOrBlank("name", name);
+ var p = new JsonMap();
+ p.put("type", type);
+ // Last-field-wins: a redeclared field discards its prior per-field modifiers (see class Javadoc).
+ // required(...) marking is a separate, builder-level set and deliberately survives a redeclaration.
+ properties.put(name, p);
+ currentField = name;
+ return this;
+ }
+
+ /**
+ * Sets the display title of the most-recently-started field.
+ *
+ * @param value The title. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ElicitSchema title(String value) {
+ currentProperty().put("title", assertArgNotNull("value", value));
+ return this;
+ }
+
+ /**
+ * Sets the description of the most-recently-started field.
+ *
+ * @param value The description. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ElicitSchema description(String value) {
+ currentProperty().put("description", assertArgNotNull("value", value));
+ return this;
+ }
+
+ /**
+ * Sets the string-subtype format of the most-recently-started field (for example {@code email}, {@code uri},
+ * {@code date}, {@code date-time}). Applying this to a non-string field is rejected at {@link #build()} time.
+ *
+ * @param value The format token. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ElicitSchema format(String value) {
+ currentProperty().put("format", assertArgNotNull("value", value));
+ return this;
+ }
+
+ /**
+ * Sets the minimum UTF-16 code-unit length of the most-recently-started string field. Applying this to a
+ * non-string field, to an {@link #enumField(String, String...)} (a closed-choice field, where a length
+ * constraint is meaningless), or a value that is negative or exceeds a previously-/subsequently-set
+ * {@link #maxLength(int)}, is rejected at {@link #build()} time.
+ *
+ * @param value The minimum length.
+ * @return This object (for method chaining).
+ */
+ public ElicitSchema minLength(int value) {
+ currentProperty().put("minLength", value);
+ return this;
+ }
+
+ /**
+ * Sets the maximum UTF-16 code-unit length of the most-recently-started string field. Applying this to a
+ * non-string field, to an {@link #enumField(String, String...)} (a closed-choice field, where a length
+ * constraint is meaningless), or a value that is negative or less than a previously-/subsequently-set
+ * {@link #minLength(int)}, is rejected at {@link #build()} time.
+ *
+ * @param value The maximum length.
+ * @return This object (for method chaining).
+ */
+ public ElicitSchema maxLength(int value) {
+ currentProperty().put("maxLength", value);
+ return this;
+ }
+
+ /**
+ * Sets the minimum value of the most-recently-started number/integer field. Applying this to a field of any
+ * other type is rejected at {@link #build()} time.
+ *
+ * @param value The minimum. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ElicitSchema min(Number value) {
+ currentProperty().put("minimum", assertArgNotNull("value", value));
+ return this;
+ }
+
+ /**
+ * Sets the maximum value of the most-recently-started number/integer field. Applying this to a field of any
+ * other type is rejected at {@link #build()} time.
+ *
+ * @param value The maximum. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public ElicitSchema max(Number value) {
+ currentProperty().put("maximum", assertArgNotNull("value", value));
+ return this;
+ }
+
+ /**
+ * Sets the default value of the most-recently-started field.
+ *
+ * <p>
+ * Named {@code defaultValue}, not {@code default} (a reserved Java keyword) — the wire schema property
+ * this writes is still literally {@code "default"}.
+ *
+ * @param value The default value. Can be <jk>null</jk>.
+ * @return This object (for method chaining).
+ */
+ public ElicitSchema defaultValue(Object value) {
+ currentProperty().put("default", value);
+ return this;
+ }
+
+ /**
+ * Sets parallel display labels for the most-recently-started {@link #enumField(String, String...)}'s values.
+ * Applying this to a field with no {@code enum} values is rejected at {@link #build()} time.
+ *
+ * @param values The display labels, parallel in order to the field's {@code enum} values. Must not be
+ * <jk>null</jk> or empty, must not contain a <jk>null</jk> element, and (when the current field already
+ * carries {@code enum} values) must be the same length as those values.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code values} is <jk>null</jk>, empty, or contains a <jk>null</jk>
+ * element, or if its length does not match the current field's existing {@code enum} values' length.
+ */
+ public ElicitSchema enumNames(String...values) {
+ if (values == null || values.length == 0)
+ throw iaex("enumNames values must not be null or empty");
+ for (var value : values)
+ if (value == null)
+ throw iaex("enumNames values must not contain a null element");
+ var p = currentProperty();
+ var enumValues = (List<?>)p.get("enum");
+ if (enumValues != null && enumValues.size() != values.length)
+ throw iaex("enumNames length (%s) must match enum length (%s)", values.length, enumValues.size());
+ p.put("enumNames", List.of(values));
+ return this;
+ }
+
+ /**
+ * Marks one or more already-added field names required, contributing to the built schema's top-level
+ * {@code "required":[...]} array.
+ *
+ * <p>
+ * Unlike the per-field modifiers ({@link #title(String)}, etc.), this is a builder-level, not per-field,
+ * call — it is not tied to the most-recently-started field and may name any field(s) already added via
+ * an earlier {@code xxxField(...)} call, in any order. May be called more than once; each call adds to the
+ * previously-marked names (not replaced). Naming a field not (yet) added is rejected at {@link #build()}
+ * time, not here, since a later {@code xxxField(...)} call may still add it.
+ *
+ * @param names The field names to mark required. Must not be <jk>null</jk> or empty, and must not contain
+ * a <jk>null</jk> element.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code names} is <jk>null</jk>, empty, or contains a <jk>null</jk>
+ * element.
+ */
+ public ElicitSchema required(String...names) {
+ if (names == null || names.length == 0)
+ throw iaex("required field names must not be null or empty");
+ for (var name : names)
+ if (name == null)
+ throw iaex("required field names must not contain a null element");
+ Collections.addAll(required, names);
+ return this;
+ }
+
+ private JsonMap currentProperty() {
+ if (currentField == null)
+ throw isex("No field has been started yet; call stringField(...)/numberField(...)/etc. first");
+ return properties.get(currentField);
+ }
+
+ /**
+ * Validates every field's modifier combination (and the builder-level {@link #required(String...)}
+ * marking) and builds the final restricted-schema {@link JsonMap}
+ * ({@code {"type":"object","properties":{...},"required":[...]}}, the last member present only if
+ * {@link #required(String...)} was called at least once).
+ *
+ * @return The built schema, ready for {@link ElicitRequest#setRequestedSchema(java.util.Map)}.
+ * @throws IllegalStateException If no field was ever added, if any field carries a structurally-invalid
+ * modifier combination, or if {@link #required(String...)} named a field that was never added.
+ */
+ public JsonMap build() {
+ if (properties.isEmpty())
+ throw isex("ElicitSchema requires at least one field (an empty requestedSchema is unanswerable)");
+ properties.forEach((name, p) -> {
+ var type = (String)p.get("type");
+ if (p.containsKey("format") && ! TYPE_STRING.equals(type))
+ throw isex("Field ''%s'': format is only valid on string fields", name);
+ if ((p.containsKey("minimum") || p.containsKey("maximum")) && ! NUMERIC_TYPES.contains(type))
+ throw isex("Field ''%s'': min/max are only valid on number/integer fields", name);
+ if ((p.containsKey("minLength") || p.containsKey("maxLength")) && ! TYPE_STRING.equals(type))
+ throw isex("Field ''%s'': minLength/maxLength are only valid on string fields", name);
+ if ((p.containsKey("minLength") || p.containsKey("maxLength")) && p.containsKey("enum"))
+ throw isex("Field ''%s'': minLength/maxLength are not valid on an enum (closed-choice) field", name);
+ if (p.containsKey("minLength") && (int)p.get("minLength") < 0)
+ throw isex("Field ''%s'': minLength must not be negative", name);
+ if (p.containsKey("maxLength") && (int)p.get("maxLength") < 0)
+ throw isex("Field ''%s'': maxLength must not be negative", name);
+ if (p.containsKey("minLength") && p.containsKey("maxLength") && (int)p.get("minLength") > (int)p.get("maxLength"))
+ throw isex("Field ''%s'': minLength must not exceed maxLength", name);
+ if (p.containsKey("enumNames") && ! p.containsKey("enum"))
+ throw isex("Field ''%s'': enumNames requires enum values (use enumField(...))", name);
+ });
+ required.forEach(name -> {
+ if (! properties.containsKey(name))
+ throw isex("required(...) names field ''%s'', which was never added", name);
+ });
+ var schema = new JsonMap();
+ schema.put("type", TYPE_OBJECT);
+ // Deep-copy each per-field map so a caller who keeps the builder and applies further modifiers after
+ // build() cannot mutate an already-returned schema.
+ var propsCopy = new JsonMap();
+ properties.forEach((name, p) -> propsCopy.put(name, new JsonMap(p)));
+ schema.put("properties", propsCopy);
+ if (! required.isEmpty())
+ schema.put("required", List.copyOf(required));
+ return schema;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java
index eec19eef9c..c6dc42dfdb 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java
@@ -26,7 +26,7 @@ import org.apache.juneau.marshall.*;
* Deliberately empty today — the pinned schema defines no sub-fields for this capability yet. A future
* MCP revision (C6 elicitation territory, out of this TODO's scope) may add fields here; this bean exists now so
* the capability-gate check (see the {@code 2026-07-28} adapter) has a typed, presence-checkable field to test
- * rather than a bare boolean, matching {@link RootsCapability}'s existing shape.
+ * rather than a bare boolean, matching {@link SamplingCapability}'s existing shape.
*/
@Marshalled
public class ElicitationCapability {
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/InputRequiredResult.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/InputRequiredResult.java
index 60f413ac2c..5773f681ee 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/InputRequiredResult.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/InputRequiredResult.java
@@ -39,8 +39,8 @@ import org.apache.juneau.marshall.collections.*;
* models it (a sampling/roots/elicitation sub-request), typed as a raw {@link JsonMap} so a handler-supplied map
* reaches the wire byte-for-byte — there is no synthetic {@code {type, payload}} envelope, and no member is
* relocated or dropped. Concrete sub-request shapes are owned by the eventual consumers of this mechanism (C5
- * sampling, C6 elicitation), both out of this TODO's scope. This mirrors the equally free-form shapes of the
- * request-side {@code inputResponses} and {@link ClientCapabilities#getSampling() sampling} maps.
+ * sampling, C6 elicitation), both out of this TODO's scope. This mirrors the equally free-form shape of the
+ * request-side {@code inputResponses} map.
*/
@Marshalled
public class InputRequiredResult extends Result<InputRequiredResult> {
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/McpMethods.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/McpMethods.java
index 2e21715675..c535c0a188 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/McpMethods.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/McpMethods.java
@@ -55,4 +55,10 @@ public final class McpMethods {
/** Complete a prompt argument or resource-template variable. */
public static final String COMPLETION_COMPLETE = "completion/complete";
+
+ /**
+ * Server-to-client duplex sampling request (MCP sampling). Never dispatched through
+ * {@code McpRevision} — flows over the {@code McpDuplexDispatcher}/{@code McpServerRequestHandler} seam.
+ */
+ public static final String SAMPLING_CREATE_MESSAGE = "sampling/createMessage";
}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelHint.java
similarity index 58%
copy from juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java
copy to juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelHint.java
index eec19eef9c..bed46a1bd8 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ElicitationCapability.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelHint.java
@@ -19,15 +19,31 @@ package org.apache.juneau.bean.mcp.v20260728;
import org.apache.juneau.marshall.*;
/**
- * Client elicitation capability (SEP-2322): presence on {@link ClientCapabilities#getElicitation()} signals the
- * client understands {@code input_required} elicitation-flavored requests.
- *
- * <p>
- * Deliberately empty today — the pinned schema defines no sub-fields for this capability yet. A future
- * MCP revision (C6 elicitation territory, out of this TODO's scope) may add fields here; this bean exists now so
- * the capability-gate check (see the {@code 2026-07-28} adapter) has a typed, presence-checkable field to test
- * rather than a bare boolean, matching {@link RootsCapability}'s existing shape.
+ * A model family/name hint for {@link ModelPreferences#getHints()} (MCP sampling).
*/
@Marshalled
-public class ElicitationCapability {
+public class ModelHint {
+
+ private String name;
+
+ /**
+ * A model family/name substring the server should prefer (fuzzy match; the schema explicitly allows
+ * partial matches, e.g. {@code "claude"}).
+ *
+ * @return The hint name, or {@code null} if not set.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the hint name.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelHint setName(String value) {
+ name = value;
+ return this;
+ }
}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelPreferences.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelPreferences.java
new file mode 100644
index 0000000000..51b9ab880a
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ModelPreferences.java
@@ -0,0 +1,153 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * Model-selection preferences for {@link CreateMessageRequest} (MCP sampling).
+ */
+@Marshalled
+public class ModelPreferences {
+
+ private List<ModelHint> hints;
+ private Double costPriority;
+ private Double speedPriority;
+ private Double intelligencePriority;
+
+ /**
+ * Ordered model-family/name hints, most-preferred first.
+ *
+ * @return The hints list, or {@code null} if not set.
+ */
+ public List<ModelHint> getHints() {
+ return u(hints);
+ }
+
+ /**
+ * Sets the hints.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences setHints(List<ModelHint> value) {
+ hints = value;
+ return this;
+ }
+
+ /**
+ * Sets the hints.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences setHints(ModelHint...value) {
+ hints = list(value);
+ return this;
+ }
+
+ /**
+ * Appends to the hints.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences addHints(ModelHint...value) {
+ if (hints == null)
+ hints = list();
+ Collections.addAll(hints, value);
+ return this;
+ }
+
+ /**
+ * Appends to the hints.
+ *
+ * @param value The values to append.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences addHints(Collection<ModelHint> value) {
+ if (hints == null)
+ hints = list();
+ hints.addAll(value);
+ return this;
+ }
+
+ /**
+ * Relative priority (0.0-1.0) placed on minimizing cost.
+ *
+ * @return The priority, or {@code null} if not set.
+ */
+ public Double getCostPriority() {
+ return costPriority;
+ }
+
+ /**
+ * Sets the cost priority.
+ *
+ * @param value The new value, {@code 0.0}-{@code 1.0}. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences setCostPriority(Double value) {
+ costPriority = value;
+ return this;
+ }
+
+ /**
+ * Relative priority (0.0-1.0) placed on minimizing latency.
+ *
+ * @return The priority, or {@code null} if not set.
+ */
+ public Double getSpeedPriority() {
+ return speedPriority;
+ }
+
+ /**
+ * Sets the speed priority.
+ *
+ * @param value The new value, {@code 0.0}-{@code 1.0}. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences setSpeedPriority(Double value) {
+ speedPriority = value;
+ return this;
+ }
+
+ /**
+ * Relative priority (0.0-1.0) placed on maximizing capability.
+ *
+ * @return The priority, or {@code null} if not set.
+ */
+ public Double getIntelligencePriority() {
+ return intelligencePriority;
+ }
+
+ /**
+ * Sets the intelligence priority.
+ *
+ * @param value The new value, {@code 0.0}-{@code 1.0}. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public ModelPreferences setIntelligencePriority(Double value) {
+ intelligencePriority = value;
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingCapability.java
similarity index 70%
copy from juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
copy to juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingCapability.java
index 0ad0bc11ab..f3b1cabb88 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/Content.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingCapability.java
@@ -19,13 +19,13 @@ package org.apache.juneau.bean.mcp.v20260728;
import org.apache.juneau.marshall.*;
/**
- * Polymorphic MCP content block (tool results, prompt messages).
+ * Client sampling capability marker (MCP sampling): presence on
+ * {@link ClientCapabilities#getSampling()} signals the client understands {@value McpMethods#SAMPLING_CREATE_MESSAGE}.
*
* <p>
- * Wire objects carry a {@code type} discriminator with values {@code text}, {@code image}, or {@code resource}.
+ * Deliberately empty, mirroring {@link ElicitationCapability}'s shape — the pinned schema defines no sub-fields
+ * for this capability.
*/
-@Marshalled(
- typePropertyName = "type",
- dictionary = { TextContent.class, ImageContent.class, EmbeddedResourceContent.class }
-)
-public interface Content {}
+@Marshalled
+public class SamplingCapability {
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingMessage.java b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingMessage.java
new file mode 100644
index 0000000000..e1c214cf3e
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/SamplingMessage.java
@@ -0,0 +1,71 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import org.apache.juneau.marshall.*;
+
+/**
+ * One message in {@link CreateMessageRequest#getMessages()} (MCP sampling).
+ */
+@Marshalled
+public class SamplingMessage {
+
+ private Role role;
+ private Content content;
+
+ /**
+ * The message role. The real schema restricts sampling roles to {@code user}/{@code assistant}, a subset
+ * this reused {@link Role} enum already covers.
+ *
+ * @return The role, or {@code null} if not set.
+ */
+ public Role getRole() {
+ return role;
+ }
+
+ /**
+ * Sets the message role.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public SamplingMessage setRole(Role value) {
+ role = value;
+ return this;
+ }
+
+ /**
+ * The message content — a single block ({@code text}/{@code image}/{@code audio}/{@code resource}), not a
+ * list, matching the real sampling schema.
+ *
+ * @return The content, or {@code null} if not set.
+ */
+ public Content getContent() {
+ return content;
+ }
+
+ /**
+ * Sets the message content.
+ *
+ * @param value The new value. Can be <jk>null</jk> to unset the property.
+ * @return This object (for method chaining).
+ */
+ public SamplingMessage setContent(Content value) {
+ content = value;
+ return this;
+ }
+}
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema_Test.java b/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema_Test.java
new file mode 100644
index 0000000000..b5de2c710d
--- /dev/null
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/ElicitSchema_Test.java
@@ -0,0 +1,342 @@
+/*
+ * 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.bean.mcp.v20260728;
+
+import static org.apache.juneau.BasicTestUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.stream.*;
+
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitSchema}'s fluent builder and build-time restricted-schema validation.
+ */
+class ElicitSchema_Test {
+
+ @Test void a01_stringField_allModifiersRoundTrip() {
+ var schema = ElicitSchema.create()
+ .stringField("name").title("Name").description("Full name").format("email").defaultValue("a@b.com")
+ .build();
+ assertEquals(
+ "{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"title\":\"Name\","
+ + "\"description\":\"Full name\",\"format\":\"email\",\"default\":\"a@b.com\"}}}",
+ schema.toString());
+ }
+
+ @Test void a02_numberAndIntegerField_minMaxDefaultRoundTrip() {
+ var schema = ElicitSchema.create()
+ .numberField("price").min(0.0).max(100.0).defaultValue(9.99)
+ .integerField("qty").min(1).max(10).defaultValue(1)
+ .build();
+ assertEquals(
+ "{\"type\":\"object\",\"properties\":{\"price\":{\"type\":\"number\",\"minimum\":0.0,"
+ + "\"maximum\":100.0,\"default\":9.99},\"qty\":{\"type\":\"integer\",\"minimum\":1,"
+ + "\"maximum\":10,\"default\":1}}}",
+ schema.toString());
+ }
+
+ @Test void a03_booleanField_titleOnly() {
+ var schema = ElicitSchema.create().booleanField("confirm").title("Confirm").build();
+ assertEquals("{\"type\":\"object\",\"properties\":{\"confirm\":{\"type\":\"boolean\",\"title\":\"Confirm\"}}}",
+ schema.toString());
+ }
+
+ @Test void a04_enumField_valuesAndNamesRoundTrip() {
+ var schema = ElicitSchema.create().enumField("color", "r", "g", "b").enumNames("Red", "Green", "Blue").build();
+ var property = schema.getMap("properties").getMap("color");
+ assertEquals(List.of("r", "g", "b"), property.get("enum"));
+ assertEquals(List.of("Red", "Green", "Blue"), property.get("enumNames"));
+ }
+
+ @Test void a05_multipleFields_onOneSchema() {
+ var schema = ElicitSchema.create()
+ .stringField("name").title("Name")
+ .integerField("age").min(0).max(150)
+ .build();
+ var properties = schema.getMap("properties");
+ assertEquals(Map.of("type", "string", "title", "Name"), properties.getMap("name"));
+ assertEquals(Map.of("type", "integer", "minimum", 0, "maximum", 150), properties.getMap("age"));
+ }
+
+ @Test void a06_reDeclaringFieldName_lastFieldWins() {
+ // Re-declaring "name" via a second stringField(...) call must discard the first declaration's
+ // modifiers (title) entirely, leaving only what was applied after the second declaration.
+ var schema = ElicitSchema.create()
+ .stringField("name").title("First").description("first description")
+ .stringField("name").format("email")
+ .build();
+ var property = schema.getMap("properties").getMap("name");
+ assertEquals(Map.of("type", "string", "format", "email"), property);
+ }
+
+ @Test void b01_format_onNonStringField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''age'': format is only valid on string fields",
+ () -> ElicitSchema.create().integerField("age").format("email").build());
+ }
+
+ @Test void b02_minMax_onNonNumericField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''name'': min/max are only valid on number/integer fields",
+ () -> ElicitSchema.create().stringField("name").min(0).build());
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''flag'': min/max are only valid on number/integer fields",
+ () -> ElicitSchema.create().booleanField("flag").max(1).build());
+ }
+
+ @Test void b03_enumNames_withoutEnumValues_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''name'': enumNames requires enum values",
+ () -> ElicitSchema.create().stringField("name").enumNames("A").build());
+ }
+
+ @Test void b04_noFieldStarted_modifierThrowsIllegalStateException() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "No field has been started yet; call stringField(...)/numberField(...)/etc. first",
+ () -> ElicitSchema.create().title("x"));
+ }
+
+ @Test void b05_enumField_nullValues_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class,
+ "Field ''color'': enumField values must not be null or empty",
+ () -> ElicitSchema.create().enumField("color", (String[])null));
+ }
+
+ @Test void b06_enumField_emptyValues_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class,
+ "Field ''color'': enumField values must not be null or empty",
+ () -> ElicitSchema.create().enumField("color"));
+ }
+
+ @Test void b07_title_null_throwsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () -> ElicitSchema.create().stringField("name").title(null));
+ }
+
+ @Test void b08_description_null_throwsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class,
+ () -> ElicitSchema.create().stringField("name").description(null));
+ }
+
+ @Test void b09_format_null_throwsIllegalArgumentException() {
+ assertThrows(IllegalArgumentException.class, () -> ElicitSchema.create().stringField("name").format(null));
+ }
+
+ @Test void b10_enumNames_null_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "enumNames values must not be null or empty",
+ () -> ElicitSchema.create().enumField("color", "r", "g").enumNames((String[])null));
+ }
+
+ @Test void b11_enumNames_empty_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "enumNames values must not be null or empty",
+ () -> ElicitSchema.create().enumField("color", "r", "g").enumNames());
+ }
+
+ @Test void b12_enumNames_lengthMismatch_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "enumNames length (1) must match enum length (2)",
+ () -> ElicitSchema.create().enumField("color", "r", "g").enumNames("Red"));
+ }
+
+ @Test void b13_emptyBuilder_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "ElicitSchema requires at least one field (an empty requestedSchema is unanswerable)",
+ () -> ElicitSchema.create().build());
+ }
+
+ @Test void b14_required_unknownFieldName_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "required(...) names field ''age'', which was never added",
+ () -> ElicitSchema.create().stringField("name").required("age").build());
+ }
+
+ @Test void b15_required_nullOrEmptyNames_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "required field names must not be null or empty",
+ () -> ElicitSchema.create().stringField("name").required((String[])null));
+ assertThrowsWithMessage(IllegalArgumentException.class, "required field names must not be null or empty",
+ () -> ElicitSchema.create().stringField("name").required());
+ }
+
+ @Test void b16_minLength_onNonStringField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''age'': minLength/maxLength are only valid on string fields",
+ () -> ElicitSchema.create().integerField("age").minLength(1).build());
+ }
+
+ @Test void b17_maxLength_onNonStringField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''age'': minLength/maxLength are only valid on string fields",
+ () -> ElicitSchema.create().integerField("age").maxLength(1).build());
+ }
+
+ @Test void b18_minLength_negative_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''name'': minLength must not be negative",
+ () -> ElicitSchema.create().stringField("name").minLength(-1).build());
+ }
+
+ @Test void b19_maxLength_negative_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''name'': maxLength must not be negative",
+ () -> ElicitSchema.create().stringField("name").maxLength(-1).build());
+ }
+
+ @Test void b20_minLength_exceedsMaxLength_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''name'': minLength must not exceed maxLength",
+ () -> ElicitSchema.create().stringField("name").minLength(5).maxLength(2).build());
+ }
+
+ @Test void b21_stringField_nullName_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'name' cannot be null.",
+ () -> ElicitSchema.create().stringField(null));
+ }
+
+ @Test void b22_stringField_emptyName_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'name' cannot be blank.",
+ () -> ElicitSchema.create().stringField(""));
+ }
+
+ @Test void b23_enumField_nullName_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'name' cannot be null.",
+ () -> ElicitSchema.create().enumField(null, "r", "g"));
+ }
+
+ @Test void b24_enumField_emptyName_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'name' cannot be blank.",
+ () -> ElicitSchema.create().enumField("", "r", "g"));
+ }
+
+ @Test void b25_enumField_nullElementValue_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class,
+ "Field ''color'': enumField values must not contain a null element",
+ () -> ElicitSchema.create().enumField("color", "r", null, "g"));
+ }
+
+ @Test void b26_enumNames_nullElementValue_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "enumNames values must not contain a null element",
+ () -> ElicitSchema.create().enumField("color", "r", "g").enumNames("Red", null));
+ }
+
+ @Test void b27_required_nullElementName_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "required field names must not contain a null element",
+ () -> ElicitSchema.create().stringField("a").stringField("b").required("a", (String)null));
+ }
+
+ @Test void b28_min_null_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'value' cannot be null.",
+ () -> ElicitSchema.create().integerField("age").min(null));
+ }
+
+ @Test void b29_max_null_throwsIllegalArgumentException() {
+ assertThrowsWithMessage(IllegalArgumentException.class, "Argument 'value' cannot be null.",
+ () -> ElicitSchema.create().integerField("age").max(null));
+ }
+
+ @Test void b30_minLength_onEnumField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''color'': minLength/maxLength are not valid on an enum (closed-choice) field",
+ () -> ElicitSchema.create().enumField("color", "r", "g").minLength(1).build());
+ }
+
+ @Test void b31_maxLength_onEnumField_throwsAtBuildTime() {
+ assertThrowsWithMessage(IllegalStateException.class,
+ "Field ''color'': minLength/maxLength are not valid on an enum (closed-choice) field",
+ () -> ElicitSchema.create().enumField("color", "r", "g").maxLength(1).build());
+ }
+
+ @Test void c01_noObjectOrArrayFieldMethodExists() {
+ var methodNames = Arrays.stream(ElicitSchema.class.getDeclaredMethods())
+ .map(m -> m.getName().toLowerCase(Locale.ROOT))
+ .collect(Collectors.toSet());
+ for (var forbidden : List.of("objectfield", "arrayfield", "nestedfield"))
+ assertFalse(methodNames.contains(forbidden), () -> "must not declare a " + forbidden + " method");
+ }
+
+ @Test void d01_build_returnsSchemaIndependentOfFurtherBuilderMutation() {
+ // Pins the fix for the shallow-copy aliasing bug: build() must deep-copy each per-field map so that
+ // applying further modifiers to the builder after build() cannot mutate an already-returned schema.
+ var builder = ElicitSchema.create().stringField("name").title("Name");
+ var first = builder.build();
+ builder.description("added after build");
+ builder.integerField("age").min(0);
+ assertEquals("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\",\"title\":\"Name\"}}}",
+ first.toString());
+ }
+
+ @Test void e01_required_singleField_emitsRequiredArray() {
+ var schema = ElicitSchema.create().stringField("name").required("name").build();
+ assertEquals(List.of("name"), schema.get("required"));
+ }
+
+ @Test void e02_required_multipleFields_preservesInsertionOrder() {
+ var schema = ElicitSchema.create()
+ .stringField("name").integerField("age").booleanField("confirm")
+ .required("confirm", "name")
+ .build();
+ assertEquals(List.of("confirm", "name"), schema.get("required"));
+ }
+
+ @Test void e03_required_notCalled_omitsRequiredKey() {
+ var schema = ElicitSchema.create().stringField("name").build();
+ assertFalse(schema.containsKey("required"));
+ }
+
+ @Test void e04_required_multipleCalls_accumulate() {
+ var schema = ElicitSchema.create()
+ .stringField("name").integerField("age")
+ .required("name")
+ .required("age")
+ .build();
+ assertEquals(List.of("name", "age"), schema.get("required"));
+ }
+
+ @Test void e05_required_reDeclaredField_markingSurvives() {
+ // required(...) is a builder-level, order-independent set (see class Javadoc): redeclaring "name" via
+ // a second stringField(...) call discards its per-field modifiers (last-field-wins) but must NOT
+ // discard its earlier required(...) marking.
+ var schema = ElicitSchema.create()
+ .stringField("name").required("name")
+ .stringField("name")
+ .build();
+ assertEquals(List.of("name"), schema.get("required"));
+ }
+
+ @Test void e06_required_beforeFieldDeclared_survivesToBuild() {
+ // The critical ordering: required(...) named before the field it names is even added must still
+ // emit that name in the built "required" array.
+ var schema = ElicitSchema.create()
+ .required("email")
+ .stringField("email")
+ .build();
+ assertEquals("{\"type\":\"object\",\"properties\":{\"email\":{\"type\":\"string\"}},\"required\":[\"email\"]}",
+ schema.toString());
+ }
+
+ @Test void e07_required_mixedBeforeAndAfterFieldDeclaration_bothSurvive() {
+ var schema = ElicitSchema.create()
+ .stringField("a").required("a", "b").stringField("b")
+ .build();
+ assertEquals(List.of("a", "b"), schema.get("required"));
+ }
+
+ @Test void f01_minLengthAndMaxLength_emitOnStringField() {
+ var schema = ElicitSchema.create().stringField("name").minLength(1).maxLength(10).build();
+ assertEquals(Map.of("type", "string", "minLength", 1, "maxLength", 10),
+ schema.getMap("properties").getMap("name"));
+ }
+}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
new file mode 100644
index 0000000000..94543a5c8d
--- /dev/null
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
@@ -0,0 +1,234 @@
+/*
+ * 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.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+// Single-type import needed to disambiguate from org.apache.juneau.rest.server.mcp.McpRevision (the
+// revision-neutral interface), which the wildcard import above collides with.
+import org.apache.juneau.rest.server.mcp.v20260728.McpRevision;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.extension.*;
+
+import jakarta.servlet.*;
+
+/**
+ * End-to-end coverage for MCP {@code 2026-07-28} SEP-2322 elicitation (C6), driven through the real
+ * {@link McpClient} and its {@link ElicitationAccess} typed helper against a live embedded-Jetty server whose
+ * tools pause/resume via {@link ElicitationRequests}/{@link ElicitationResponses} — proving the full typed
+ * path works across a real wire boundary, complementing {@link McpMrtrIntegration_Test}'s generic (untyped)
+ * PAUSE/RESUME coverage and Phase 7's {@code Characterization_Test} in-process replay fixtures.
+ */
+@org.apache.juneau.testing.JettyMicroserviceTest
+class McpElicitationIntegration_Test extends TestBase {
+
+ private static ElicitRequest confirmQuestion() {
+ return new ElicitRequest()
+ .setMessage("Proceed with deletion?")
+ .setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm").build());
+ }
+
+ private static McpToolHandler confirm() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirm").setDescription("Pauses for one confirmation"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw ElicitationRequests.of("confirm", confirmQuestion(), "cont-1");
+ var answer = ElicitationResponses.get(resume.get(), "confirm");
+ // Echoes the answer's content (not just its action) and the continuation this tool paused
+ // with, so the assertions below actually exercise ElicitResult.content marshalling and prove
+ // the continuation round-trips through McpMrtrResumeContext, instead of only the action enum.
+ var confirmValue = answer.getContent() == null ? null : answer.getContent().get("confirm");
+ return McpToolOutcome.text("action:" + answer.getAction() + ",confirm:" + confirmValue
+ + ",cont:" + resume.get().continuation());
+ }
+ };
+ }
+
+ private static McpToolHandler confirmTwo() {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirmTwo").setDescription("Pauses for two confirmations in one round"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty()) {
+ var requests = new LinkedHashMap<String,ElicitRequest>();
+ requests.put("confirm", confirmQuestion());
+ requests.put("reason", new ElicitRequest().setMessage("Why?")
+ .setRequestedSchema(ElicitSchema.create().stringField("reason").build()));
+ throw ElicitationRequests.of(requests, "cont-multi");
+ }
+ var answers = ElicitationResponses.all(resume.get());
+ var confirmAnswer = answers.get("confirm");
+ var reasonAnswer = answers.get("reason");
+ var confirmValue = confirmAnswer.getContent() == null ? null : confirmAnswer.getContent().get("confirm");
+ var reasonValue = reasonAnswer.getContent() == null ? null : reasonAnswer.getContent().get("reason");
+ return McpToolOutcome.text("confirm:" + confirmAnswer.getAction() + "=" + confirmValue
+ + ",reason:" + reasonAnswer.getAction() + "=" + reasonValue
+ + ",cont:" + resume.get().continuation());
+ }
+ };
+ }
+
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class Fixture extends org.apache.juneau.rest.server.mcp.v20260728.McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig().setName("it-elicit-fixture").setVersion("1.0.0")
+ .addTool(confirm()).addTool(confirmTwo());
+ }
+ }
+
+ @Configuration
+ public static class FixtureConfig {
+ @Bean public Servlet mcpServlet() { return new Fixture(); }
+ }
+
+ @RegisterExtension
+ static MicroserviceTestFixture fixture = MicroserviceTestFixture.create()
+ .configurations(FixtureConfig.class);
+
+ private static McpClient.Builder clientBuilder(boolean withElicitation) {
+ var caps = new ClientCapabilities();
+ if (withElicitation)
+ caps.setElicitation(new ElicitationCapability());
+ return McpClient.builder().endpoint(fixture.getRootUrl() + "/").clientCapabilities(caps);
+ }
+
+ /**
+ * Decodes {@code raw} (a {@link McpClient#callRaw} "complete" result) into a {@link CallToolResult} and
+ * returns its first content block's text, replacing the raw {@code List}/{@code Map} cast chains every test
+ * below previously repeated inline.
+ */
+ private static String firstText(Map<String,Object> raw) {
+ var result = Json.to(Json.of(raw), CallToolResult.class);
+ return ((TextContent) result.getContent().get(0)).getText();
+ }
+
+ // =================================================================================================================
+ // A: single-question pause -> resume -> complete, driven entirely through ElicitationAccess.
+ // =================================================================================================================
+
+ @Test void a01_singleQuestion_pauseResumeAccept_fullLoop() throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
+ assertTrue(ElicitationAccess.isInputRequired(paused));
+ var requests = ElicitationAccess.requests(paused);
+ assertEquals("Proceed with deletion?", requests.get("confirm").getMessage());
+ // The requestedSchema (built via ElicitSchema.booleanField("confirm").title("Confirm")) must survive
+ // the wire intact, not just the message - otherwise a real client could not render the right input
+ // control for the end user.
+ var confirmProperty = (Map<?,?>) ((Map<?,?>) requests.get("confirm").getRequestedSchema().get("properties")).get("confirm");
+ assertBean(confirmProperty, "type,title", "boolean,Confirm");
+
+ var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true);
+ var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
+ .setRequestState(ElicitationAccess.requestState(paused))
+ .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
+ assertFalse(ElicitationAccess.isInputRequired(completed));
+ assertEquals("complete", completed.get("resultType"));
+ assertEquals("action:accept,confirm:true,cont:cont-1", firstText(completed));
+ }
+ }
+
+ // =================================================================================================================
+ // B: multi-question pause -> resume with both answers in one round.
+ // =================================================================================================================
+
+ @Test void b01_multiQuestion_pauseResumeBoth_singleRoundTrip() throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirmTwo").setArguments(Map.of()));
+ var requests = ElicitationAccess.requests(paused);
+ assertEquals(2, requests.size());
+ assertTrue(requests.containsKey("confirm"));
+ assertTrue(requests.containsKey("reason"));
+
+ var answers = new LinkedHashMap<String,ElicitResult>();
+ answers.put("confirm", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true));
+ answers.put("reason", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("reason", "cleanup"));
+ var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirmTwo")
+ .setRequestState(ElicitationAccess.requestState(paused))
+ .setInputResponses(ElicitationAccess.toInputResponses(answers)));
+ assertEquals("complete", completed.get("resultType"));
+ assertEquals("confirm:accept=true,reason:accept=cleanup,cont:cont-multi", firstText(completed));
+ }
+ }
+
+ // =================================================================================================================
+ // C: decline and cancel resumes both complete, carrying the correct ElicitAction to the handler.
+ // =================================================================================================================
+
+ @Test void c01_decline_resumeCarriesDeclineAction() throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
+ assertTrue(ElicitationAccess.isInputRequired(paused));
+ assertNotNull(ElicitationAccess.requestState(paused));
+ var answer = new ElicitResult().setAction(ElicitAction.DECLINE);
+ var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
+ .setRequestState(ElicitationAccess.requestState(paused))
+ .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
+ assertEquals("complete", completed.get("resultType"));
+ assertEquals("action:decline,confirm:null,cont:cont-1", firstText(completed));
+ }
+ }
+
+ @Test void c02_cancel_resumeCarriesCancelAction() throws Exception {
+ try (var client = clientBuilder(true).build()) {
+ var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
+ assertTrue(ElicitationAccess.isInputRequired(paused));
+ assertNotNull(ElicitationAccess.requestState(paused));
+ var answer = new ElicitResult().setAction(ElicitAction.CANCEL);
+ var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
+ .setRequestState(ElicitationAccess.requestState(paused))
+ .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
+ assertEquals("complete", completed.get("resultType"));
+ assertEquals("action:cancel,confirm:null,cont:cont-1", firstText(completed));
+ }
+ }
+
+ // =================================================================================================================
+ // D: capability gate still fires unmodified when a typed-helper pause is thrown by an unsupported client.
+ // =================================================================================================================
+
+ /**
+ * Delta vs {@link McpMrtrIntegration_Test#b01_unsupportedCapability_rejectedOverTheWire()}: proves the same
+ * capability gate fires for a signal built via the typed {@code ElicitationRequests.of(...)} helper (not a
+ * hand-built {@code McpInputRequiredSignal}) — i.e. the helper yields a gate-recognized signal.
+ */
+ @Test void d01_unsupportedCapability_typedHelperPauseStillRejectedOverTheWire() throws Exception {
+ try (var client = clientBuilder(false).build()) {
+ var e = assertThrows(McpException.class,
+ () -> client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of())));
+ assertEquals(McpRevision.CODE_MISSING_REQUIRED_CLIENT_CAPABILITY, e.getCode());
+ }
+ }
+}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index 77926b59de..282e32de83 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -312,6 +312,58 @@ class Characterization_Test {
}
}
+ /**
+ * Backs every {@code ELICIT-*} fixture (SEP-2322 elicitation, built on the same PAUSE/RESUME mechanism
+ * {@link F_Mrtr} exercises generically). Unlike {@link F_Mrtr}'s placeholder {@code Map.of("type","elicitation")},
+ * this servlet's tools pause and resume through the real typed path ({@link ElicitationRequests}/
+ * {@link ElicitationResponses}, {@link ElicitRequest}/{@link ElicitResult}/{@link ElicitSchema}), so these
+ * fixtures characterize the actual elicitation wire shape, not just the generic MRTR envelope.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class F_Elicit extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ private static ElicitRequest confirmQuestion() {
+ return new ElicitRequest()
+ .setMessage("Proceed with deletion?")
+ .setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm").build());
+ }
+
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig()
+ .addTool(new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirm").setDescription("desc:confirm"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw ElicitationRequests.of("confirm", confirmQuestion(), "cont-1");
+ var answer = ElicitationResponses.get(resume.get(), "confirm");
+ return McpToolOutcome.text("action:" + answer.getAction());
+ }
+ })
+ .addTool(new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirmTwo").setDescription("desc:confirmTwo"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty()) {
+ var requests = new LinkedHashMap<String,ElicitRequest>();
+ requests.put("confirm", confirmQuestion());
+ requests.put("reason", new ElicitRequest().setMessage("Why?")
+ .setRequestedSchema(ElicitSchema.create().stringField("reason").build()));
+ throw ElicitationRequests.of(requests, "cont-multi");
+ }
+ var answers = ElicitationResponses.all(resume.get());
+ return McpToolOutcome.text("confirm:" + answers.get("confirm").getAction()
+ + ",reason:" + answers.get("reason").getAction());
+ }
+ });
+ }
+
+ @Override protected McpMrtrConfig createMrtrConfig() {
+ return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ }
+ }
+
/**
* Test-only deterministic {@link RequestStateCodec}: AES-256-GCM with a <b>hardcoded</b> key and nonce.
*
@@ -416,6 +468,8 @@ class Characterization_Test {
case "MRTR-input-required-response", "MRTR-resume-complete", "MRTR-resume-input-required-again",
"MRTR-tampered-request-state", "MRTR-expired-request-state", "MRTR-unsupported-capability",
"MRTR-max-rounds-exceeded" -> F_Mrtr.class;
+ case "ELICIT-single-request-pause", "ELICIT-multi-request-pause", "ELICIT-resume-accept-complete",
+ "ELICIT-resume-decline", "ELICIT-resume-cancel" -> F_Elicit.class;
default -> F_Empty.class;
};
}
@@ -459,6 +513,8 @@ class Characterization_Test {
case "MRTR-expired-request-state" -> codec.seal(new McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
case "MRTR-max-rounds-exceeded" -> codec.seal(new McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, FAR_FUTURE_MS), aad("tools/call"));
case "MRTR-tampered-request-state" -> tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS), aad("tools/call")));
+ case "ELICIT-resume-accept-complete", "ELICIT-resume-decline", "ELICIT-resume-cancel" ->
+ codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS), aad("tools/call"));
default -> throw new IllegalArgumentException("No MRTR token mapping for fixture: " + fixture);
};
}
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.headers.properties b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.headers.properties
new file mode 100644
index 0000000000..dffe187ccb
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirmTwo
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.request.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.request.json
new file mode 100644
index 0000000000..cc69ae4dfe
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirmTwo","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.response.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.response.json
new file mode 100644
index 0000000000..808a18bbf9
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-multi-request-pause.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"confirm":{"message":"Proceed with deletion?","requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean","title":"Confirm"}}}},"reason":{"message":"Why?","requestedSchema":{"type":"object","properties":{"reason":{"type":"string"}}}}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.headers.properties b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.request.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.request.json
new file mode 100644
index 0000000000..3fc4aeac40
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.response.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.response.json
new file mode 100644
index 0000000000..17639de560
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-accept-complete.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:accept"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.headers.properties b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.request.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.request.json
new file mode 100644
index 0000000000..d90d60cf57
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"cancel"}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.response.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.response.json
new file mode 100644
index 0000000000..1874cbc3cf
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-cancel.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:cancel"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.headers.properties b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.request.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.request.json
new file mode 100644
index 0000000000..87a1394982
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"decline"}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.response.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.response.json
new file mode 100644
index 0000000000..95ad0952e2
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-resume-decline.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:decline"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.headers.properties b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.request.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.request.json
new file mode 100644
index 0000000000..8b1ebbeac7
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.response.json b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.response.json
new file mode 100644
index 0000000000..3e633eb223
--- /dev/null
+++ b/juneau-integration-tests/src/test/resources/mcp/v20260728/characterization/ELICIT-single-request-pause.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"confirm":{"message":"Proceed with deletion?","requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean","title":"Confirm"}}}}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java
new file mode 100644
index 0000000000..834f941255
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java
@@ -0,0 +1,155 @@
+/*
+ * 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.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Static helper for driving MCP {@code 2026-07-28} SEP-2322 elicitation over {@link McpClient#callRaw}'s raw
+ * {@code Map<String,Object>} result (MRTR, TODO-318's Multi-Round-Trip Requests loop, client side).
+ *
+ * <p>
+ * Works at the raw map/JSON level rather than exposing typed overloads per concrete request bean, because
+ * {@code CallToolRequest}/{@code GetPromptRequest}/{@code ReadResourceRequest} share no common
+ * "has-inputResponses-and-requestState" interface (each independently declares its own pair of fields; their
+ * actual common base, {@code RequestParams<T>}, carries only {@code _meta}). A caller resuming, say, a
+ * {@code CallToolRequest} calls {@link #toInputResponses(Map)} for the raw payload, then makes its own
+ * {@code .setInputResponses(...).setRequestState(...)} call on the concrete bean it already knows it holds.
+ *
+ * @since 10.0.0
+ */
+public final class ElicitationAccess {
+
+ private ElicitationAccess() {}
+
+ /**
+ * Whether a {@link McpClient#callRaw} result represents a paused elicitation (or any other MRTR pause).
+ *
+ * @param raw The raw result from {@link McpClient#callRaw}. Must not be <jk>null</jk>.
+ * @return <jk>true</jk> if {@code raw.get("resultType")} is {@code "input_required"}.
+ * @throws IllegalArgumentException If {@code raw} is <jk>null</jk>.
+ */
+ public static boolean isInputRequired(Map<String,Object> raw) {
+ assertArgNotNull("raw", raw);
+ return "input_required".equals(raw.get("resultType"));
+ }
+
+ /**
+ * Decodes {@code raw}'s {@code inputRequests} into typed {@link ElicitRequest}s.
+ *
+ * <p>
+ * Each raw value is generic JSON (a {@code Map} after wire deserialization, per {@code InputRequiredResult}'s
+ * lossless-map contract), converted via the same {@code Json.to(Json.of(value), type)} technique
+ * {@code McpMrtrResumeContext.continuationAs(Class)} demonstrates server-side. A value that is present but
+ * <jk>null</jk> decodes to a <jk>null</jk> entry (see the return doc); this mirrors
+ * {@code ElicitationResponses.all(McpMrtrResumeContext)}'s null-passthrough on the server side.
+ *
+ * <p>
+ * <b>Every</b> entry is decoded as an {@link ElicitRequest}, unconditionally — per
+ * {@code InputRequiredResult}'s Javadoc, {@code inputRequests} entries may in principle be arbitrary
+ * sub-request kinds (sampling/roots/elicitation), not only elicitation. A non-elicitation entry (one that
+ * shares no field names with {@link ElicitRequest}) simply decodes to an all-<jk>null</jk>-fields
+ * {@link ElicitRequest}, since the underlying {@code Json.to} conversion tolerates unknown/missing bean
+ * properties; it is the caller's responsibility to only call this for elicitation-flavored MRTR pauses.
+ *
+ * @param raw The raw result from {@link McpClient#callRaw}. Must not be <jk>null</jk>.
+ * @return A keyed map of typed requests. Never <jk>null</jk> (empty if {@code raw} carried none). Values
+ * may be <jk>null</jk> when the corresponding raw entry is absent/<jk>null</jk>.
+ * @throws IllegalArgumentException If {@code raw} is <jk>null</jk>.
+ * @throws ClassCastException If {@code raw.get("inputRequests")} is present but is not, at runtime, a
+ * {@code Map} — which can only happen against a malformed/non-conforming remote response, since a
+ * well-formed one always deserializes {@code inputRequests} to a {@code Map} per JSON semantics.
+ * @throws RuntimeException If any entry's decoded shape cannot be converted to {@link ElicitRequest}.
+ */
+ @SuppressWarnings({
+ "unchecked" // raw.get("inputRequests") deserializes to a Map<String,Object> from JSON for any well-formed remote response; a malformed one surfaces as ClassCastException here (see @throws), not a silently-wrong cast.
+ })
+ public static Map<String,ElicitRequest> requests(Map<String,Object> raw) {
+ assertArgNotNull("raw", raw);
+ var rawRequests = (Map<String,Object>) raw.get("inputRequests");
+ Map<String,ElicitRequest> out = new LinkedHashMap<>();
+ if (rawRequests != null)
+ rawRequests.forEach((id, value) -> out.put(id, value == null ? null : Json.to(Json.of(value), ElicitRequest.class)));
+ return out;
+ }
+
+ /**
+ * Returns {@code raw}'s echoed continuation token.
+ *
+ * @param raw The raw result from {@link McpClient#callRaw}. Must not be <jk>null</jk>.
+ * @return The token, or <jk>null</jk> if absent.
+ * @throws IllegalArgumentException If {@code raw} is <jk>null</jk>.
+ * @throws ClassCastException If {@code raw.get("requestState")} is present but is not, at runtime, a
+ * {@code String} — only possible against a malformed/non-conforming remote response.
+ */
+ public static String requestState(Map<String,Object> raw) {
+ assertArgNotNull("raw", raw);
+ return (String) raw.get("requestState");
+ }
+
+ /**
+ * Encodes a single typed {@link ElicitResult} into the raw {@code inputResponses} payload shape.
+ *
+ * @param id The server-assigned id this answer responds to. Must not be <jk>null</jk>.
+ * @param result The typed answer. Must not be <jk>null</jk>.
+ * @return A single-entry map ready to pass to {@link #toInputResponses(Map)}'s multi-answer form, or to hand
+ * directly to a concrete request bean's {@code setInputResponses(...)} after merging with other answers.
+ * @throws IllegalArgumentException If {@code id} or {@code result} is <jk>null</jk>.
+ */
+ public static Map<String,Object> toInputResponse(String id, ElicitResult result) {
+ assertArgNotNull("id", id);
+ assertArgNotNull("result", result);
+ return toInputResponses(Map.of(id, result));
+ }
+
+ /**
+ * Encodes several typed {@link ElicitResult}s into the raw {@code inputResponses} payload shape a resume
+ * call's request bean expects (e.g. {@code CallToolRequest.setInputResponses(...)}).
+ *
+ * <p>
+ * Unlike {@code ElicitationRequests.of(Map,Object)} (server side), an empty {@code results} map is accepted (returning
+ * an empty map), not rejected — deliberately asymmetric, since a caller may legitimately resume with
+ * zero elicitation answers if a round only carried non-elicitation MRTR pauses, whereas building a signal
+ * with zero questions server-side would be a pointless pause.
+ *
+ * @param results Server-assigned-id-keyed typed answers. Must not be <jk>null</jk>, and no value may be
+ * <jk>null</jk>. Output iteration order mirrors this map's order, so a caller wanting deterministic
+ * ordering should pass a {@link LinkedHashMap}.
+ * @return A keyed map of raw, wire-shaped answers. Never <jk>null</jk> (empty if {@code results} was empty).
+ * @throws IllegalArgumentException If {@code results} is <jk>null</jk>, or any value in it is <jk>null</jk>.
+ */
+ public static Map<String,Object> toInputResponses(Map<String,ElicitResult> results) {
+ assertArgNotNull("results", results);
+ Map<String,Object> out = new LinkedHashMap<>();
+ results.forEach((id, result) -> {
+ assertArgNotNull("results[" + id + "]", result);
+ // Pre-marshalled to JsonMap here (rather than left as the typed ElicitResult for McpClient.call's
+ // own toWireParams flattening) so that (a) a null-check on the encoded shape is meaningful even
+ // when this helper is used standalone, outside McpClient.call's flow, and (b) the returned map is
+ // immediately wire-ready for a caller who hands it straight to a concrete request bean's
+ // setInputResponses(...) without ever going through McpClient at all.
+ out.put(id, Json.to(Json.of(result), JsonMap.class));
+ });
+ return out;
+ }
+}
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 5c79a3619c..40c986cbe9 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
@@ -307,9 +307,19 @@ public final class McpClient extends AbstractMcpClient {
// DUPLEX_RETURN_METHOD has no routing-name mapping in McpRoutingNames. It is unclear whether the real
// contract instead wants it to echo the correlated inbound request's tool/prompt/resource name; that
// requires spec clarification and is deliberately left open rather than guessed at here.
+ //
+ // payload must be routed through toWireParams(...) exactly like every other outbound envelope (see #call):
+ // AbstractMcpClient.send(...) posts through restClient, whose serializer is JsonSerializer.DEFAULT
+ // (addBeanTypes=false) - handed the payload's typed beans directly, a polymorphic Content field (e.g.
+ // CreateMessageResult.content holding a TextContent) would be serialized without its "type" discriminator,
+ // since addBeanTypes is what drives whether SerializerSession writes bean-dictionary type names at all.
+ // toWireParams(...) pre-flattens through REQUEST_SERIALIZER (addBeanTypes=true) into a generic Map/List
+ // tree with the discriminator already baked in as literal data, so the final JsonSerializer.DEFAULT pass
+ // re-emits it verbatim.
private void postClientResult(Object id, JsonRpcResponse payload) throws IOException {
- var headers = Map.of("Mcp-Method", DUPLEX_RETURN_METHOD, "Mcp-Name", McpRoutingNames.routingName(DUPLEX_RETURN_METHOD, payload));
- send(new JsonRpcRequest().setJsonrpc("2.0").setId(id).setMethod(DUPLEX_RETURN_METHOD).setParams(payload), headers);
+ var wire = toWireParams(payload);
+ var headers = Map.of("Mcp-Method", DUPLEX_RETURN_METHOD, "Mcp-Name", McpRoutingNames.routingName(DUPLEX_RETURN_METHOD, wire));
+ send(new JsonRpcRequest().setJsonrpc("2.0").setId(id).setMethod(DUPLEX_RETURN_METHOD).setParams(wire), headers);
}
private <T> T call(String method, RequestParams<?> params, Class<T> resultType) throws IOException {
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java
new file mode 100644
index 0000000000..cf0ba044fa
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java
@@ -0,0 +1,176 @@
+/*
+ * 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.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitationAccess}.
+ */
+class ElicitationAccess_Test {
+
+ @Test void a01_isInputRequired_trueOnInputRequiredResultType() {
+ assertTrue(ElicitationAccess.isInputRequired(Map.of("resultType", "input_required")));
+ }
+
+ @Test void a02_isInputRequired_falseOnCompleteResultType() {
+ assertFalse(ElicitationAccess.isInputRequired(Map.of("resultType", "complete")));
+ }
+
+ @Test void a03_isInputRequired_nullRawThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.isInputRequired(null));
+ assertEquals("Argument 'raw' cannot be null.", e.getMessage());
+ }
+
+ @Test void a04_requests_decodesTypedElicitRequestsFromRawMap() {
+ var raw = Map.<String,Object>of(
+ "resultType", "input_required",
+ "inputRequests", JsonMap.of(
+ "q1", JsonMap.of(
+ "message", "Pick",
+ "requestedSchema", JsonMap.of(
+ "type", "object",
+ "properties", JsonMap.of("choice", JsonMap.of("type", "string"))
+ )
+ )
+ )
+ );
+ var requests = ElicitationAccess.requests(raw);
+ var q1 = requests.get("q1");
+ assertEquals("Pick", q1.getMessage());
+ assertNotNull(q1.getRequestedSchema());
+ }
+
+ @Test void a05_requests_missingInputRequestsReturnsEmptyMap() {
+ var requests = ElicitationAccess.requests(Map.of("resultType", "complete"));
+ assertNotNull(requests);
+ assertTrue(requests.isEmpty());
+ }
+
+ @Test void a06_requestState_returnsEchoedToken() {
+ assertEquals("tok-abc", ElicitationAccess.requestState(Map.of("resultType", "input_required", "requestState", "tok-abc")));
+ }
+
+ @Test void a07_requestState_missingReturnsNull() {
+ assertNull(ElicitationAccess.requestState(Map.of("resultType", "complete")));
+ }
+
+ @Test void a08_toInputResponse_singleAnswer_encodesAcceptWithContent() {
+ var result = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
+ var encoded = ElicitationAccess.toInputResponse("q1", result);
+ assertEquals(1, encoded.size());
+ var q1 = (JsonMap) encoded.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ }
+
+ @Test void a09_toInputResponses_multipleAnswers_encodesAll() {
+ var results = new LinkedHashMap<String,ElicitResult>();
+ results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red"));
+ results.put("q2", new ElicitResult().setAction(ElicitAction.DECLINE));
+ var encoded = ElicitationAccess.toInputResponses(results);
+ assertEquals(2, encoded.size());
+ var q1 = (JsonMap) encoded.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ var q2 = (JsonMap) encoded.get("q2");
+ assertEquals("decline", q2.getString("action"));
+ assertNull(q2.get("content"));
+ }
+
+ @Test void a10_toInputResponse_nullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponse(null, new ElicitResult()));
+ assertEquals("Argument 'id' cannot be null.", e.getMessage());
+ }
+
+ @Test void a11_toInputResponse_nullResultThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponse("q1", null));
+ assertEquals("Argument 'result' cannot be null.", e.getMessage());
+ }
+
+ @Test void a12_toInputResponses_nullMapThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponses(null));
+ assertEquals("Argument 'results' cannot be null.", e.getMessage());
+ }
+
+ /**
+ * Documents the wire-shape symmetry between this client module's {@link ElicitationAccess} and the server
+ * module's {@code ElicitationRequests}/{@code ElicitationResponses} (Phase 4) without introducing an illegal
+ * cross-module test dependency: {@code juneau-rest-client-mcp-v20260728} must not depend on
+ * {@code juneau-rest-server-mcp-v20260728}, so the server-side raw shape is hand-built here to match it
+ * exactly (verified by inspection/spec cross-reference against {@code ElicitationRequests.of}/
+ * {@code ElicitationResponses.get}, not a live cross-module call).
+ */
+ @Test void a13_roundTrip_requestsToResponses_matchesServerSideHelpers() {
+ // Mirrors the raw shape ElicitationRequests.of("q1", elicitRequest, ...) would place into
+ // McpInputRequiredSignal.getInputRequests(), which callRaw ultimately surfaces as raw's "inputRequests".
+ var rawInputRequests = JsonMap.of(
+ "q1", JsonMap.of("message", "Pick one", "requestedSchema", JsonMap.of("type", "object"))
+ );
+ var raw = Map.<String,Object>of("resultType", "input_required", "requestState", "tok-abc", "inputRequests", rawInputRequests);
+
+ var decodedRequests = ElicitationAccess.requests(raw);
+ assertEquals("Pick one", decodedRequests.get("q1").getMessage());
+ assertEquals("tok-abc", ElicitationAccess.requestState(raw));
+
+ var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
+ var encodedResponses = ElicitationAccess.toInputResponse("q1", answer);
+
+ // This is exactly the shape ElicitationResponses.get(ctx, "q1") decodes via
+ // Json.to(Json.of(ctx.inputResponses().get("q1")), ElicitResult.class).
+ var q1 = (JsonMap) encodedResponses.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ }
+
+ @Test void a14_requests_malformedEntryThrows() {
+ // A JSON-array entry cannot be converted to a bean target: the marshaller throws (which a caller's own
+ // error handling surfaces). Mirrors ElicitationResponses_Test's a09_get_malformedShapeThrows for the
+ // server-side decode direction.
+ var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", JsonMap.of("q1", JsonList.of(1, 2)));
+ assertThrows(RuntimeException.class, () -> ElicitationAccess.requests(raw));
+ }
+
+ @Test void a15_toInputResponses_nullEntryThrows() {
+ var results = new LinkedHashMap<String,ElicitResult>();
+ results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT));
+ results.put("q2", null);
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponses(results));
+ assertEquals("Argument 'results[q2]' cannot be null.", e.getMessage());
+ }
+
+ @Test void a16_isInputRequired_falseOnMissingResultType() {
+ assertFalse(ElicitationAccess.isInputRequired(Map.of("foo", "bar")));
+ }
+
+ @Test void a17_requests_nullEntryDecodesToNull() {
+ // Mirrors ElicitationResponses.get()'s explicit null handling: a null raw value decodes to a null
+ // typed entry rather than round-tripping through the marshaller.
+ var rawInputRequests = new LinkedHashMap<String,Object>();
+ rawInputRequests.put("q1", null);
+ var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", rawInputRequests);
+ var requests = ElicitationAccess.requests(raw);
+ assertTrue(requests.containsKey("q1"));
+ assertNull(requests.get("q1"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Capabilities_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Capabilities_Test.java
index 278294815c..8c0facdd3c 100644
--- a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Capabilities_Test.java
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Capabilities_Test.java
@@ -47,7 +47,7 @@ class McpClient_Capabilities_Test {
};
var caps = new ClientCapabilities()
.setRoots(new RootsCapability().setListChanged(true))
- .putSampling("supported", true)
+ .setSampling(new SamplingCapability())
.putExperimental("x-flag", "on")
.putExtensions("x-ext", 1);
try (var c = McpClient.builder().endpoint("http://x/mcp").transport(transport).clientCapabilities(caps).build()) {
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpDuplexChannel_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpDuplexChannel_Test.java
index 3538c41c9c..943fff05d6 100644
--- a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpDuplexChannel_Test.java
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpDuplexChannel_Test.java
@@ -23,7 +23,10 @@ import java.nio.charset.*;
import java.util.concurrent.atomic.*;
import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
import org.apache.juneau.rest.client.*;
import org.junit.jupiter.api.*;
@@ -92,4 +95,69 @@ class McpDuplexChannel_Test {
assertTrue(posted.get().contains("\"error\""), posted.get());
assertTrue(posted.get().contains("\"code\":-32603"), posted.get());
}
+
+ @Test
+ void a03_samplingCreateMessage_typedRequestAndResultRoundTripOverDuplexSeam() throws Exception {
+ var request = new CreateMessageRequest()
+ .addMessages(new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("Summarize this")))
+ .setMaxTokens(100);
+ // addBeanTypes() mirrors production McpClient.REQUEST_SERIALIZER's own addBeanTypes() config (and
+ // matches what a real v2 peer's JSON-RPC layer actually emits): a genuine sampling/createMessage
+ // request carries the "type" discriminator on its polymorphic Content field. Without it here, the
+ // handler's Json.to(...) below could not instantiate the abstract Content interface.
+ var wireJson = JsonSerializer.create().addBeanTypes().build().write(request);
+ var inbound = "data: {\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"" + McpMethods.SAMPLING_CREATE_MESSAGE + "\",\"params\":"
+ + wireJson + "}\n\n";
+ var posted = new AtomicReference<String>();
+ var first = new AtomicBoolean(true);
+ HttpTransport transport = tReq -> {
+ if (first.getAndSet(false))
+ return TransportResponse.builder().statusCode(200).header("Content-Type", "text/event-stream")
+ .body(new ByteArrayInputStream(inbound.getBytes(StandardCharsets.UTF_8))).build();
+ try {
+ var out = new ByteArrayOutputStream();
+ tReq.getBody().writeTo(out);
+ posted.set(out.toString(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new TransportException(e.getMessage(), e);
+ }
+ return TransportResponse.builder().statusCode(200).header("Content-Type", "application/json")
+ .body(new ByteArrayInputStream("{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"result\":{\"ok\":true}}".getBytes(StandardCharsets.UTF_8))).build();
+ };
+
+ // Captured rather than asserted inline: McpDuplexDispatcher.dispatch(...) only catches Exception, not
+ // Error, so a JUnit AssertionError thrown here would still surface - but any ordinary decode exception
+ // (e.g. a ParseException/ClassCastException from a real marshalling regression) would be caught, wrapped
+ // into a -32603 McpException, and posted back as an opaque error envelope, leaving the real cause
+ // swallowed. Capturing the decoded values and asserting them after pumpNextServerMessage() returns
+ // guarantees a decode regression fails with a clear assertion instead of a confusing envelope mismatch.
+ var seenText = new AtomicReference<String>();
+ var seenMaxTokens = new AtomicReference<Integer>();
+ var c = McpClient.builder().endpoint("http://x/mcp").transport(transport).build();
+ c.setServerRequestHandler((req, ctx) -> {
+ var typed = Json.to(Json.of(req.getParams()), CreateMessageRequest.class);
+ seenText.set(((TextContent) typed.getMessages().get(0).getContent()).getText());
+ seenMaxTokens.set(typed.getMaxTokens());
+ return new CreateMessageResult().setRole(Role.ASSISTANT).setContent(new TextContent().setText("Summary"))
+ .setModel("test-model").setStopReason("endTurn");
+ });
+
+ c.pumpNextServerMessage();
+
+ assertEquals("Summarize this", seenText.get());
+ assertEquals(100, seenMaxTokens.get());
+ assertTrue(posted.get().contains("\"model\":\"test-model\""), posted.get());
+ assertTrue(posted.get().contains("\"stopReason\":\"endTurn\""), posted.get());
+ assertTrue(posted.get().contains("\"id\":\"99\""), posted.get());
+ // Regression coverage for the polymorphic CreateMessageResult.content field: postClientResult(...) must
+ // route the posted body through the same addBeanTypes-enabled flattening the request path uses (see
+ // McpClient#postClientResult), or the "type" discriminator a real v2 peer needs to decode the abstract
+ // Content field back into a concrete TextContent/ImageContent/etc. is silently dropped.
+ var envelope = Json.to(posted.get(), JsonRpcRequest.class);
+ var response = Json.to(Json.of(envelope.getParams()), JsonRpcResponse.class);
+ var result = Json.to(Json.of(response.getResult()), CreateMessageResult.class);
+ assertTrue(result.getContent() instanceof TextContent, "content did not decode as TextContent (missing wire discriminator?): " + posted.get());
+ assertEquals("Summary", ((TextContent) result.getContent()).getText());
+ assertEquals(Role.ASSISTANT, result.getRole());
+ }
}
diff --git a/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher.java b/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher.java
index 7272191695..e13524c88b 100644
--- a/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher.java
+++ b/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher.java
@@ -28,8 +28,8 @@ import org.apache.juneau.commons.inject.*;
* <p>
* Requests are handed to the registered handler untouched, with their {@code params} left as the generic,
* revision-neutral wire representation - never projected into a typed request bean - since the set of
- * server-initiated methods (for example {@code sampling/createMessage}, {@code elicitation/create}) is
- * revision- and deployment-specific and this dispatcher has no knowledge of it.
+ * server-initiated methods (for example {@code sampling/createMessage}; illustrative only, as sampling is
+ * deprecated per SEP-2577) is revision- and deployment-specific and this dispatcher has no knowledge of it.
*
* @since 10.0.0
*/
diff --git a/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpServerRequestHandler.java b/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpServerRequestHandler.java
index 3196a90841..7b4c214ce0 100644
--- a/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpServerRequestHandler.java
+++ b/juneau-rest/juneau-rest-client-mcp/src/main/java/org/apache/juneau/rest/client/mcp/McpServerRequestHandler.java
@@ -21,8 +21,8 @@ import org.apache.juneau.commons.inject.*;
/**
* Callback invoked by {@link McpDuplexDispatcher} to handle a single server-initiated JSON-RPC request
- * delivered over an MCP client's duplex event-stream channel (for example {@code sampling/createMessage} or
- * {@code elicitation/create}).
+ * delivered over an MCP client's duplex event-stream channel (for example {@code sampling/createMessage};
+ * illustrative only, as sampling is deprecated per SEP-2577).
*
* @since 10.0.0
*/
diff --git a/juneau-rest/juneau-rest-client-mcp/src/test/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher_Test.java b/juneau-rest/juneau-rest-client-mcp/src/test/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher_Test.java
index bf14a66184..9eecbc487a 100644
--- a/juneau-rest/juneau-rest-client-mcp/src/test/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher_Test.java
+++ b/juneau-rest/juneau-rest-client-mcp/src/test/java/org/apache/juneau/rest/client/mcp/McpDuplexDispatcher_Test.java
@@ -60,7 +60,7 @@ class McpDuplexDispatcher_Test {
"nested", JsonMap.of("a", 1, "b", JsonList.of(2, 3)),
"experimental", JsonMap.of("x-key", "x-value"));
var before = params.toString();
- var req = new JsonRpcRequest().setJsonrpc("2.0").setId("44").setMethod("elicitation/create").setParams(params);
+ var req = new JsonRpcRequest().setJsonrpc("2.0").setId("44").setMethod("sampling/createMessage").setParams(params);
d.dispatch(req, BasicBeanStore.INSTANCE);
var after = req.getParams().toString();
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
new file mode 100644
index 0000000000..334fb739f2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
@@ -0,0 +1,90 @@
+/*
+ * 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.server.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Static helper that builds an {@link McpInputRequiredSignal} from one or more typed {@link ElicitRequest}s (MCP
+ * {@code 2026-07-28} SEP-2322 elicitation, riding TODO-318's Multi-Round-Trip Requests loop).
+ *
+ * <p>
+ * {@link McpInputRequiredSignal}'s constructor takes {@code Map<String,Object>} whose values must be {@code Map}
+ * at runtime (see its own Javadoc); this helper serializes each {@link ElicitRequest} to that shape via Juneau's
+ * JSON marshaller (round-tripped through {@link JsonMap}, the same technique
+ * {@link McpMrtrResumeContext#continuationAs(Class)} already demonstrates for the continuation side) so a
+ * handler never hand-builds the raw map itself. Pure marshalling: no dispatcher-visible state, no new capability
+ * gate — the existing elicitation-specific gate in {@code McpRevision#pause} already covers every signal this
+ * helper produces.
+ */
+public final class ElicitationRequests {
+
+ private ElicitationRequests() {}
+
+ /**
+ * Builds a single-question {@link McpInputRequiredSignal}.
+ *
+ * @param id The server-assigned id for this question. Must not be <jk>null</jk>.
+ * @param request The typed elicitation request. Must not be <jk>null</jk>.
+ * @param continuation Handler-opaque continuation value. Can be <jk>null</jk> if the handler needs no
+ * state carried between rounds.
+ * @return A new signal ready to be thrown.
+ * @throws IllegalArgumentException If {@code id} or {@code request} is <jk>null</jk>.
+ */
+ public static McpInputRequiredSignal of(String id, ElicitRequest request, Object continuation) {
+ assertArgNotNull("id", id);
+ assertArgNotNull("request", request);
+ return of(Map.of(id, request), continuation);
+ }
+
+ /**
+ * Builds a multi-question {@link McpInputRequiredSignal} (MRTR's multi-request-per-round case: several
+ * elicitations paused on together, resumed together).
+ *
+ * <p>
+ * Unlike {@code ElicitationAccess.toInputResponses(Map)} (client side), an empty {@code requests} map is
+ * rejected, not accepted — deliberately asymmetric, since a signal built with zero questions would be
+ * a pointless pause, whereas a caller resuming may legitimately have zero elicitation answers on hand if
+ * the round it is resuming only carried non-elicitation MRTR pauses.
+ *
+ * @param requests Server-assigned-id-keyed typed elicitation requests. Must not be <jk>null</jk> or empty,
+ * and no value may be <jk>null</jk>. Output iteration order mirrors this map's order, so a caller
+ * wanting deterministic ordering should pass a {@link LinkedHashMap}.
+ * @param continuation Handler-opaque continuation value. Can be <jk>null</jk>.
+ * @return A new signal ready to be thrown.
+ * @throws IllegalArgumentException If {@code requests} is <jk>null</jk> or empty, or any value in it is
+ * <jk>null</jk>.
+ */
+ public static McpInputRequiredSignal of(Map<String,ElicitRequest> requests, Object continuation) {
+ assertArgNotNull("requests", requests);
+ if (requests.isEmpty())
+ throw iaex("requests must not be empty");
+ Map<String,Object> raw = new LinkedHashMap<>();
+ requests.forEach((id, request) -> {
+ assertArgNotNull("requests[" + id + "]", request);
+ raw.put(id, Json.to(Json.of(request), JsonMap.class));
+ });
+ return new McpInputRequiredSignal(raw, continuation);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses.java
new file mode 100644
index 0000000000..6ad90d3659
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses.java
@@ -0,0 +1,75 @@
+/*
+ * 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.server.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Static helper that parses typed {@link ElicitResult}s out of an {@link McpMrtrResumeContext} (MCP
+ * {@code 2026-07-28} SEP-2322 elicitation, riding TODO-318's Multi-Round-Trip Requests loop).
+ *
+ * <p>
+ * {@link McpMrtrResumeContext#inputResponses()} values are generic JSON (a {@code JsonMap}/{@code JsonList}/
+ * boxed primitive/{@code String} after wire deserialization, never a typed bean automatically); this helper
+ * converts each to {@link ElicitResult} via the same {@code Json.to(Json.of(value), type)} technique
+ * {@link McpMrtrResumeContext#continuationAs(Class)} already uses for the continuation side, just applied
+ * per-key across the map instead of to the single continuation value.
+ */
+public final class ElicitationResponses {
+
+ private ElicitationResponses() {}
+
+ /**
+ * Returns the typed answer for a single question id.
+ *
+ * @param ctx The resume context. Must not be <jk>null</jk>.
+ * @param id The server-assigned id this question was posed under. Must not be <jk>null</jk>.
+ * @return The typed answer, or <jk>null</jk> if {@code id} is absent from {@code ctx}'s responses, or maps
+ * to <jk>null</jk>.
+ * @throws IllegalArgumentException If {@code ctx} or {@code id} is <jk>null</jk>.
+ * @throws RuntimeException If the answer's decoded shape cannot be converted to {@link ElicitResult}.
+ */
+ public static ElicitResult get(McpMrtrResumeContext ctx, String id) {
+ assertArgNotNull("ctx", ctx);
+ assertArgNotNull("id", id);
+ var value = ctx.inputResponses().get(id);
+ if (value == null)
+ return null;
+ return Json.to(Json.of(value), ElicitResult.class);
+ }
+
+ /**
+ * Returns every answer in {@code ctx}, converted to {@link ElicitResult}.
+ *
+ * @param ctx The resume context. Must not be <jk>null</jk>.
+ * @return A keyed map of typed answers. Never <jk>null</jk> (empty if {@code ctx} carried none). Values
+ * may be <jk>null</jk> when the corresponding raw entry is absent/<jk>null</jk> (see {@link #get}).
+ * @throws IllegalArgumentException If {@code ctx} is <jk>null</jk>.
+ * @throws RuntimeException If any answer's decoded shape cannot be converted to {@link ElicitResult}.
+ */
+ public static Map<String,ElicitResult> all(McpMrtrResumeContext ctx) {
+ assertArgNotNull("ctx", ctx);
+ Map<String,ElicitResult> out = new LinkedHashMap<>();
+ ctx.inputResponses().keySet().forEach(id -> out.put(id, get(ctx, id)));
+ return out;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index da3d05cdb6..8452c4ffe5 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -314,6 +314,58 @@ class Characterization_Test {
}
}
+ /**
+ * Backs every {@code ELICIT-*} fixture (SEP-2322 elicitation, built on the same PAUSE/RESUME mechanism
+ * {@link F_Mrtr} exercises generically). Unlike {@link F_Mrtr}'s placeholder {@code Map.of("type","elicitation")},
+ * this servlet's tools pause and resume through the real typed path ({@link ElicitationRequests}/
+ * {@link ElicitationResponses}, {@link ElicitRequest}/{@link ElicitResult}/{@link ElicitSchema}), so these
+ * fixtures characterize the actual elicitation wire shape, not just the generic MRTR envelope.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class F_Elicit extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+
+ private static ElicitRequest confirmQuestion() {
+ return new ElicitRequest()
+ .setMessage("Proceed with deletion?")
+ .setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm").build());
+ }
+
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig()
+ .addTool(new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirm").setDescription("desc:confirm"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty())
+ throw ElicitationRequests.of("confirm", confirmQuestion(), "cont-1");
+ var answer = ElicitationResponses.get(resume.get(), "confirm");
+ return McpToolOutcome.text("action:" + answer.getAction());
+ }
+ })
+ .addTool(new McpToolHandler() {
+ @Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirmTwo").setDescription("desc:confirmTwo"); }
+ @Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
+ var resume = ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty()) {
+ var requests = new LinkedHashMap<String,ElicitRequest>();
+ requests.put("confirm", confirmQuestion());
+ requests.put("reason", new ElicitRequest().setMessage("Why?")
+ .setRequestedSchema(ElicitSchema.create().stringField("reason").build()));
+ throw ElicitationRequests.of(requests, "cont-multi");
+ }
+ var answers = ElicitationResponses.all(resume.get());
+ return McpToolOutcome.text("confirm:" + answers.get("confirm").getAction()
+ + ",reason:" + answers.get("reason").getAction());
+ }
+ });
+ }
+
+ @Override protected McpMrtrConfig createMrtrConfig() {
+ return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ }
+ }
+
/**
* Test-only deterministic {@link RequestStateCodec}: AES-256-GCM with a <b>hardcoded</b> key and nonce.
*
@@ -418,6 +470,8 @@ class Characterization_Test {
case "MRTR-input-required-response", "MRTR-resume-complete", "MRTR-resume-input-required-again",
"MRTR-tampered-request-state", "MRTR-expired-request-state", "MRTR-unsupported-capability",
"MRTR-max-rounds-exceeded" -> F_Mrtr.class;
+ case "ELICIT-single-request-pause", "ELICIT-multi-request-pause", "ELICIT-resume-accept-complete",
+ "ELICIT-resume-decline", "ELICIT-resume-cancel" -> F_Elicit.class;
default -> F_Empty.class;
};
}
@@ -461,6 +515,8 @@ class Characterization_Test {
case "MRTR-expired-request-state" -> codec.seal(new McpRequestState("cont-1", "tools/call", 1, PAST_MS), aad("tools/call"));
case "MRTR-max-rounds-exceeded" -> codec.seal(new McpRequestState("cont-1", "tools/call", McpMrtrConfig.DEFAULT_MAX_ROUNDS, FAR_FUTURE_MS), aad("tools/call"));
case "MRTR-tampered-request-state" -> tamper(codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS), aad("tools/call")));
+ case "ELICIT-resume-accept-complete", "ELICIT-resume-decline", "ELICIT-resume-cancel" ->
+ codec.seal(new McpRequestState("cont-1", "tools/call", 1, FAR_FUTURE_MS), aad("tools/call"));
default -> throw new IllegalArgumentException("No MRTR token mapping for fixture: " + fixture);
};
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests_Test.java
new file mode 100644
index 0000000000..ede29b7d93
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests_Test.java
@@ -0,0 +1,88 @@
+/*
+ * 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.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitationRequests}.
+ */
+class ElicitationRequests_Test {
+
+ @Test void a01_of_singleQuestion_buildsMapShapedValue() {
+ var a = new ElicitRequest().setMessage("Pick one").setRequestedSchema(ElicitSchema.create().stringField("choice").build());
+ var signal = ElicitationRequests.of("q1", a, "cont");
+ var raw = (JsonMap)signal.getInputRequests().get("q1");
+ assertEquals("Pick one", raw.getString("message"));
+ var schema = raw.getMap("requestedSchema");
+ var choice = schema.getMap("properties").getMap("choice");
+ assertEquals("string", choice.getString("type"));
+ assertEquals("cont", signal.getContinuation());
+ }
+
+ @Test void a02_of_multiQuestion_preservesAllKeysAndOrder() {
+ var a1 = new ElicitRequest().setMessage("Question 1");
+ var a2 = new ElicitRequest().setMessage("Question 2");
+ var requests = new LinkedHashMap<String,ElicitRequest>();
+ requests.put("q1", a1);
+ requests.put("q2", a2);
+ var signal = ElicitationRequests.of(requests, "cont");
+ var keys = new ArrayList<>(signal.getInputRequests().keySet());
+ assertEquals(List.of("q1", "q2"), keys);
+ assertEquals("Question 1", ((JsonMap)signal.getInputRequests().get("q1")).getString("message"));
+ assertEquals("Question 2", ((JsonMap)signal.getInputRequests().get("q2")).getString("message"));
+ }
+
+ @Test void a03_of_singleQuestion_nullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.of(null, new ElicitRequest(), "cont"));
+ assertEquals("Argument 'id' cannot be null.", e.getMessage());
+ }
+
+ @Test void a04_of_singleQuestion_nullRequestThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.of("q1", null, "cont"));
+ assertEquals("Argument 'request' cannot be null.", e.getMessage());
+ }
+
+ @Test void a05_of_multiQuestion_nullMapThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.of((Map<String,ElicitRequest>)null, "cont"));
+ assertEquals("Argument 'requests' cannot be null.", e.getMessage());
+ }
+
+ @Test void a06_of_multiQuestion_emptyMapThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.of(Map.of(), "cont"));
+ assertEquals("requests must not be empty", e.getMessage());
+ }
+
+ @Test void a07_of_singleQuestion_nullContinuationAllowed() {
+ var signal = ElicitationRequests.of("q1", new ElicitRequest().setMessage("Pick one"), null);
+ assertNull(signal.getContinuation());
+ }
+
+ @Test void a08_of_multiQuestion_nullValueThrows() {
+ var requests = new LinkedHashMap<String,ElicitRequest>();
+ requests.put("q1", new ElicitRequest().setMessage("Question 1"));
+ requests.put("q2", null);
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.of(requests, "cont"));
+ assertEquals("Argument 'requests[q2]' cannot be null.", e.getMessage());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses_Test.java
new file mode 100644
index 0000000000..e0981f57f7
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationResponses_Test.java
@@ -0,0 +1,95 @@
+/*
+ * 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.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitationResponses}.
+ */
+class ElicitationResponses_Test {
+
+ @Test void a01_get_presentKey_acceptActionWithContent() {
+ var responses = Map.<String,Object>of("q1", JsonMap.of("action", "accept", "content", JsonMap.of("choice", "red")));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ var a = ElicitationResponses.get(ctx, "q1");
+ assertEquals(ElicitAction.ACCEPT, a.getAction());
+ assertEquals("red", a.getContent().get("choice"));
+ }
+
+ @Test void a02_get_presentKey_declineActionNoContent() {
+ var responses = Map.<String,Object>of("q1", JsonMap.of("action", "decline"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ var a = ElicitationResponses.get(ctx, "q1");
+ assertEquals(ElicitAction.DECLINE, a.getAction());
+ assertNull(a.getContent());
+ }
+
+ @Test void a03_get_missingKey_returnsNull() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ assertNull(ElicitationResponses.get(ctx, "missing"));
+ }
+
+ @Test void a04_get_nullCtxThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.get(null, "q1"));
+ assertEquals("Argument 'ctx' cannot be null.", e.getMessage());
+ }
+
+ @Test void a05_get_nullIdThrows() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.get(ctx, null));
+ assertEquals("Argument 'id' cannot be null.", e.getMessage());
+ }
+
+ @Test void a06_all_multipleKeys_returnsAllTyped() {
+ var responses = new LinkedHashMap<String,Object>();
+ responses.put("q1", JsonMap.of("action", "accept", "content", JsonMap.of("choice", "red")));
+ responses.put("q2", JsonMap.of("action", "decline"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ var all = ElicitationResponses.all(ctx);
+ assertEquals(2, all.size());
+ assertEquals(ElicitAction.ACCEPT, all.get("q1").getAction());
+ assertEquals("red", all.get("q1").getContent().get("choice"));
+ assertEquals(ElicitAction.DECLINE, all.get("q2").getAction());
+ }
+
+ @Test void a07_all_emptyResponses_returnsEmptyMap() {
+ var all = ElicitationResponses.all(new McpMrtrResumeContext(null, Map.of()));
+ assertNotNull(all);
+ assertTrue(all.isEmpty());
+ }
+
+ @Test void a08_all_nullCtxThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.all(null));
+ assertEquals("Argument 'ctx' cannot be null.", e.getMessage());
+ }
+
+ @Test void a09_get_malformedShapeThrows() {
+ // A JSON-array answer cannot be converted to a bean target: the marshaller throws (which the
+ // dispatcher's generic branch surfaces as -32603). Mirrors McpInputRequiredSignal_Test's
+ // b07_continuationAsShapeMismatchThrows for the continuation side.
+ var responses = Map.<String,Object>of("q1", JsonList.of(1, 2));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertThrows(RuntimeException.class, () -> ElicitationResponses.get(ctx, "q1"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.headers.properties b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.headers.properties
new file mode 100644
index 0000000000..dffe187ccb
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirmTwo
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.request.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.request.json
new file mode 100644
index 0000000000..cc69ae4dfe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirmTwo","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.response.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.response.json
new file mode 100644
index 0000000000..808a18bbf9
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-multi-request-pause.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"confirm":{"message":"Proceed with deletion?","requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean","title":"Confirm"}}}},"reason":{"message":"Why?","requestedSchema":{"type":"object","properties":{"reason":{"type":"string"}}}}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.headers.properties b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.request.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.request.json
new file mode 100644
index 0000000000..3fc4aeac40
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"accept","content":{"confirm":true}}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.response.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.response.json
new file mode 100644
index 0000000000..17639de560
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-accept-complete.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:accept"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.headers.properties b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.request.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.request.json
new file mode 100644
index 0000000000..d90d60cf57
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"cancel"}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.response.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.response.json
new file mode 100644
index 0000000000..1874cbc3cf
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-cancel.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:cancel"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.headers.properties b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.request.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.request.json
new file mode 100644
index 0000000000..87a1394982
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","requestState":"__REQUEST_STATE__","inputResponses":{"confirm":{"action":"decline"}},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.response.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.response.json
new file mode 100644
index 0000000000..95ad0952e2
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-resume-decline.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"content":[{"type":"text","text":"action:decline"}],"resultType":"complete"}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.headers.properties b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.headers.properties
new file mode 100644
index 0000000000..c03a2fd89f
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.headers.properties
@@ -0,0 +1,17 @@
+# 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.
+
+Mcp-Method=tools/call
+Mcp-Name=confirm
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.request.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.request.json
new file mode 100644
index 0000000000..8b1ebbeac7
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.request.json
@@ -0,0 +1 @@
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"confirm","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"fixture-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
\ No newline at end of file
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.response.json b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.response.json
new file mode 100644
index 0000000000..3e633eb223
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/resources/characterization/ELICIT-single-request-pause.response.json
@@ -0,0 +1 @@
+{"id":1,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"juneau-rest-server-mcp","version":"unknown"}},"inputRequests":{"confirm":{"message":"Proceed with deletion?","requestedSchema":{"type":"object","properties":{"confirm":{"type":"boolean","title":"Confirm"}}}}},"requestState":"<sealed>","resultType":"input_required"}}
\ No newline at end of file
(juneau) 02/02: Add missed unit test coverage for MCP sampling + elicitation beans (TODO-328)
Posted by ja...@apache.org.
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
commit f823a566f85a7508df39f80b5c9a290d54f60548
Author: James Bognar <ja...@apache.org>
AuthorDate: Mon Aug 3 20:06:54 2026 -0700
Add missed unit test coverage for MCP sampling + elicitation beans (TODO-328)
Companion to 3c67b8a8e0: stages the McpV2Beans_Test.java updates
(ElicitAction/ElicitRequest/ElicitResult/AudioContent/ModelHint/
ModelPreferences/CreateMessageRequest/CreateMessageResult/SamplingCapability
round-trip coverage) that were left unstaged in the prior commit.
Co-authored-by: Cursor <cu...@cursor.com>
---
.../juneau/bean/mcp/v20260728/McpV2Beans_Test.java | 196 ++++++++++++++++++++-
1 file changed, 194 insertions(+), 2 deletions(-)
diff --git a/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/McpV2Beans_Test.java b/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/McpV2Beans_Test.java
index 5a9a5b80f6..efd95a40d4 100644
--- a/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/McpV2Beans_Test.java
+++ b/juneau-bean/juneau-bean-mcp-v20260728/src/test/java/org/apache/juneau/bean/mcp/v20260728/McpV2Beans_Test.java
@@ -17,6 +17,7 @@
package org.apache.juneau.bean.mcp.v20260728;
import static org.apache.juneau.BasicTestUtils.*;
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
import static org.apache.juneau.test.bct.BctAssertions.*;
import static org.junit.jupiter.api.Assertions.*;
@@ -697,11 +698,12 @@ class McpV2Beans_Test {
@Test void h01_clientCapabilities_elicitationRoundTrip() {
var caps = new ClientCapabilities()
.setRoots(new RootsCapability().setListChanged(true))
- .setSampling(Map.of("x", 1))
+ .setSampling(new SamplingCapability())
.setElicitation(new ElicitationCapability())
.setExperimental(Map.of("y", 2));
var copy = JsonParser.DEFAULT.read(JsonSerializer.DEFAULT.write(caps), ClientCapabilities.class);
- assertBean(copy, "roots{listChanged},sampling,experimental", "{true},{x=1},{y=2}");
+ assertBean(copy, "roots{listChanged},experimental", "{true},{y=2}");
+ assertNotNull(copy.getSampling(), () -> "must serialize sampling: " + JsonSerializer.DEFAULT.write(copy));
assertNotNull(copy.getElicitation(), () -> "must serialize elicitation: " + JsonSerializer.DEFAULT.write(copy));
}
@@ -710,4 +712,194 @@ class McpV2Beans_Test {
var json = JsonSerializer.DEFAULT.write(new ClientCapabilities());
assertFalse(json.contains("elicitation"), () -> "elicitation must be omitted when unset: " + json);
}
+
+ @Test void i01_elicitAction_wireValuesRoundTrip() {
+ assertEquals("\"accept\"", JsonSerializer.DEFAULT.write(ElicitAction.ACCEPT));
+ assertEquals("\"decline\"", JsonSerializer.DEFAULT.write(ElicitAction.DECLINE));
+ assertEquals("\"cancel\"", JsonSerializer.DEFAULT.write(ElicitAction.CANCEL));
+ assertEquals(ElicitAction.ACCEPT, JsonParser.DEFAULT.read("\"accept\"", ElicitAction.class));
+ assertEquals(ElicitAction.DECLINE, JsonParser.DEFAULT.read("\"decline\"", ElicitAction.class));
+ assertEquals(ElicitAction.CANCEL, JsonParser.DEFAULT.read("\"cancel\"", ElicitAction.class));
+ assertEquals("accept", ElicitAction.ACCEPT.toWire());
+ assertEquals("decline", ElicitAction.DECLINE.toWire());
+ assertEquals("cancel", ElicitAction.CANCEL.toWire());
+ }
+
+ @Test void i02_elicitRequest_roundTrip() {
+ var request = new ElicitRequest().setMessage("Confirm?")
+ .setRequestedSchema(JsonMap.of("type", "object", "properties",
+ JsonMap.of("confirm", JsonMap.of("type", "boolean"))));
+ assertJsonRoundTrip(request, ElicitRequest.class);
+ assertBean(request, "message,requestedSchema{type,properties{confirm{type}}}",
+ "Confirm?,{object,{{boolean}}}");
+ }
+
+ @Test void i02b_elicitRequest_getRequestedSchema_matchesSiblingUnmodifiableViewContract() {
+ // getRequestedSchema()'s contract is pinned to match its siblings ElicitResult.getContent() and
+ // InputRequiredResult.getInputRequests(): a shallow Collections.unmodifiableMap(...) VIEW over the
+ // live backing map (blocks top-level mutation), not a defensive deep copy - so a nested structure
+ // (here "properties") remains mutable through the returned reference, same as those siblings.
+ var schema = JsonMap.of("type", "object", "properties",
+ JsonMap.of("confirm", JsonMap.of("type", "boolean")));
+ var request = new ElicitRequest().setRequestedSchema(schema);
+
+ var view = request.getRequestedSchema();
+ assertThrows(UnsupportedOperationException.class, () -> view.put("type", "array"));
+
+ @SuppressWarnings("unchecked")
+ var properties = (Map<String,Object>) view.get("properties");
+ properties.put("extra", "leaked");
+ @SuppressWarnings("unchecked")
+ var propertiesAgain = (Map<String,Object>) request.getRequestedSchema().get("properties");
+ assertTrue(propertiesAgain.containsKey("extra"),
+ () -> "nested map must remain mutable through the view, matching sibling getters' shallow contract");
+ }
+
+ @Test void i03_elicitResult_acceptRoundTrip() {
+ var result = new ElicitResult().setAction(ElicitAction.ACCEPT)
+ .putContent("confirm", true).putContent("name", "al");
+ var json = JsonSerializer.DEFAULT.write(result);
+ assertTrue(json.contains("\"action\":\"accept\""));
+ assertTrue(json.contains("\"content\":{\"confirm\":true,\"name\":\"al\"}"));
+ var copy = JsonParser.DEFAULT.read(json, ElicitResult.class);
+ assertEquals(json, JsonSerializer.DEFAULT.write(copy));
+ assertEquals(ElicitAction.ACCEPT, copy.getAction());
+ assertEquals(Map.of("confirm", true, "name", "al"), copy.getContent());
+ }
+
+ @Test void i04_elicitResult_declineOmitsContent() {
+ var json = JsonSerializer.DEFAULT.write(new ElicitResult().setAction(ElicitAction.DECLINE));
+ assertEquals("{\"action\":\"decline\"}", json);
+ assertFalse(json.contains("content"), () -> "content must be omitted when unset: " + json);
+ }
+
+ @Test void i05_audioContent_roundTrip() {
+ var audio = new AudioContent().setData("QUJD").setMimeType("audio/wav");
+ assertJsonRoundTrip(audio, AudioContent.class);
+ assertBean(audio, "data,mimeType", "QUJD,audio/wav");
+ }
+
+ @Test void i06_contentDictionary_typeAudioDeserializesToAudioContent() {
+ var json = "{\"type\":\"audio\",\"data\":\"QUJD\",\"mimeType\":\"audio/wav\"}";
+ var copy = MCP_JSON_PARSER.read(json, Content.class);
+ assertTrue(copy instanceof AudioContent, () -> "must deserialize to AudioContent: " + copy);
+ assertBean((AudioContent)copy, "data,mimeType", "QUJD,audio/wav");
+ }
+
+ @Test void j01_modelHint_roundTrip() {
+ var hint = new ModelHint().setName("claude");
+ assertJsonRoundTrip(hint, ModelHint.class);
+ assertBean(hint, "name", "claude");
+ }
+
+ @Test void j02_modelPreferences_roundTripAllFields() {
+ var prefs = new ModelPreferences()
+ .setHints(new ModelHint().setName("claude"), new ModelHint().setName("gpt"))
+ .setCostPriority(0.3).setSpeedPriority(0.5).setIntelligencePriority(0.9);
+ assertJsonRoundTrip(prefs, ModelPreferences.class);
+ assertBean(prefs, "hints{#{name}},costPriority,speedPriority,intelligencePriority",
+ "{[{claude},{gpt}]},0.3,0.5,0.9");
+ }
+
+ @Test void j03_modelPreferences_collectionSettersAndVarargAdders() {
+ var a = new ModelPreferences().setHints(list(new ModelHint().setName("claude"), new ModelHint().setName("gpt")));
+ assertBean(a, "hints{#{name}}", "{[{claude},{gpt}]}");
+ var b = new ModelPreferences().addHints(new ModelHint().setName("claude")).addHints(new ModelHint().setName("gpt"));
+ assertBean(b, "hints{#{name}}", "{[{claude},{gpt}]}");
+ var c = new ModelPreferences().addHints(list(new ModelHint().setName("claude")))
+ .addHints(list(new ModelHint().setName("gpt")));
+ assertBean(c, "hints{#{name}}", "{[{claude},{gpt}]}");
+ }
+
+ @Test void k01_samplingMessage_textContentRoundTrip() {
+ var msg = new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("hi"));
+ assertJsonRoundTrip(msg, SamplingMessage.class);
+ assertBean(msg, "role,content{text}", "USER,{hi}");
+ }
+
+ @Test void k02_samplingMessage_audioContentRoundTrip() {
+ var msg = new SamplingMessage().setRole(Role.ASSISTANT)
+ .setContent(new AudioContent().setData("QUJD").setMimeType("audio/wav"));
+ assertJsonRoundTrip(msg, SamplingMessage.class);
+ assertBean(msg, "role,content{data,mimeType}", "ASSISTANT,{QUJD,audio/wav}");
+ assertTrue(MCP_JSON.write(msg).contains("\"type\":\"audio\""));
+ }
+
+ @Test void k03_createMessageRequest_allFieldsRoundTrip() {
+ var req = new CreateMessageRequest()
+ .setMessages(
+ new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("hi")),
+ new SamplingMessage().setRole(Role.ASSISTANT).setContent(new TextContent().setText("hey")))
+ .setModelPreferences(new ModelPreferences().addHints(new ModelHint().setName("claude")))
+ .setSystemPrompt("be terse")
+ .setIncludeContext("thisServer")
+ .setTemperature(0.7)
+ .setMaxTokens(100)
+ .setStopSequences("STOP1", "STOP2")
+ .putMetadata("k", "v").putMetadata("k2", "v2");
+ assertJsonRoundTrip(req, CreateMessageRequest.class);
+ assertBean(req,
+ "messages{#{role,content{text}}},modelPreferences{hints{#{name}}},systemPrompt,includeContext,"
+ + "temperature,maxTokens,stopSequences,metadata",
+ "{[{USER,{hi}},{ASSISTANT,{hey}}]},{{[{claude}]}},be terse,thisServer,0.7,100,[STOP1,STOP2],{k=v,k2=v2}");
+ }
+
+ @Test void k04_createMessageRequest_collectionSettersAndVarargAdders() {
+ var a = new CreateMessageRequest().setMessages(list(
+ new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("hi")),
+ new SamplingMessage().setRole(Role.ASSISTANT).setContent(new TextContent().setText("hey"))))
+ .setStopSequences(list("STOP1", "STOP2"));
+ assertBean(a, "messages{#{role}},stopSequences", "{[{USER},{ASSISTANT}]},[STOP1,STOP2]");
+ var b = new CreateMessageRequest()
+ .addMessages(new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("hi")))
+ .addMessages(new SamplingMessage().setRole(Role.ASSISTANT).setContent(new TextContent().setText("hey")))
+ .addStopSequences("STOP1").addStopSequences("STOP2");
+ assertBean(b, "messages{#{role}},stopSequences", "{[{USER},{ASSISTANT}]},[STOP1,STOP2]");
+ var c = new CreateMessageRequest()
+ .addMessages(list(new SamplingMessage().setRole(Role.USER).setContent(new TextContent().setText("hi"))))
+ .addMessages(list(new SamplingMessage().setRole(Role.ASSISTANT).setContent(new TextContent().setText("hey"))))
+ .addStopSequences(list("STOP1")).addStopSequences(list("STOP2"));
+ assertBean(c, "messages{#{role}},stopSequences", "{[{USER},{ASSISTANT}]},[STOP1,STOP2]");
+ }
+
+ @Test void k05_createMessageResult_audioContentRoundTrip() {
+ var result = new CreateMessageResult().setRole(Role.ASSISTANT)
+ .setContent(new AudioContent().setData("QUJD").setMimeType("audio/wav"))
+ .setModel("m").setStopReason("endTurn");
+ assertJsonRoundTrip(result, CreateMessageResult.class);
+ assertBean(result, "role,content{data,mimeType},model,stopReason",
+ "ASSISTANT,{QUJD,audio/wav},m,endTurn");
+ assertTrue(MCP_JSON.write(result).contains("\"type\":\"audio\""));
+ }
+
+ @Test void k06_samplingBeans_doNotExtendRequestParamsOrResult() {
+ assertEquals(Object.class, CreateMessageRequest.class.getSuperclass());
+ assertEquals(Object.class, CreateMessageResult.class.getSuperclass());
+ assertEquals(Object.class, SamplingMessage.class.getSuperclass());
+ assertEquals(Object.class, ModelPreferences.class.getSuperclass());
+ assertEquals(Object.class, ModelHint.class.getSuperclass());
+ }
+
+ @Test void l01_samplingCapability_roundTrip() {
+ assertEquals("{}", JsonSerializer.DEFAULT.write(new SamplingCapability()));
+ }
+
+ @Test void l02_samplingCreateMessageMethodConstant() {
+ assertEquals("sampling/createMessage", McpMethods.SAMPLING_CREATE_MESSAGE);
+ }
+
+ @Test void l03_clientCapabilities_samplingIsTypedCapability() {
+ var caps = new ClientCapabilities().setSampling(new SamplingCapability());
+ var copy = JsonParser.DEFAULT.read(JsonSerializer.DEFAULT.write(caps), ClientCapabilities.class);
+ assertTrue(copy.getSampling() instanceof SamplingCapability);
+ }
+
+ @Test void l09_roundTripsStillWorkForRootsAndLoggingCapabilities() {
+ var roots = JsonParser.DEFAULT.read(JsonSerializer.DEFAULT.write(new RootsCapability().setListChanged(true)),
+ RootsCapability.class);
+ assertBean(roots, "listChanged", "true");
+ var logging = JsonParser.DEFAULT.read(JsonSerializer.DEFAULT.write(new LoggingCapability().setLevel("info")),
+ LoggingCapability.class);
+ assertBean(logging, "level", "info");
+ }
}