This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 4bee9c98c9 First-party runnable MCP example module
(juneau-examples-mcp) + v2 client/server ergonomic conveniences
4bee9c98c9 is described below
commit 4bee9c98c980e119dfcd870de7e9ba32cafbf3db
Author: James Bognar <[email protected]>
AuthorDate: Thu Aug 6 04:49:21 2026 -0700
First-party runnable MCP example module (juneau-examples-mcp) + v2
client/server ergonomic conveniences
Adds juneau-examples-mcp: a runnable 2026-07-28 reference app (a small
"notes" service)
exercising every headline surface — a tool, an elicitation/MRTR
confirm-then-resume tool,
a prompt with argument completion, a resource, a resource-template with
completion, and
subscriptions/listen held-open-SSE change notifications — plus an
embedded-Jetty launcher,
a guided McpClient walkthrough, and a Spring Boot variant
(SpringMcpRestServlet). Ships in
the Apache release via juneau-distrib as a standalone, runnable project zip
alongside
juneau-examples-core.
Also adds four additive v2 conveniences (used by the example so the
teaching code stays clean):
- CallToolResult.firstText() + McpClient.callToolText(...) — null-safe;
scans for the first
text content block instead of assuming index 0.
- ServerDiscoverResult.getServerInfo() — null-safe passthrough;
@BeanIgnore'd so the
server/discover wire format is unchanged.
- McpMrtrResumeContext.continuationAsString() — companion to
continuationAs(Class).
- ElicitationResponses.getBoolean(...) / getString(...) —
decline/missing-safe typed reads.
Co-authored-by: Cursor <[email protected]>
---
.../juneau/bean/mcp/v20260728/CallToolResult.java | 22 ++
.../bean/mcp/v20260728/ServerDiscoverResult.java | 20 ++
.../juneau/bean/mcp/v20260728/McpV2Beans_Test.java | 52 ++++
juneau-distrib/pom.xml | 17 ++
juneau-examples/juneau-examples-mcp/.gitignore | 6 +
juneau-examples/juneau-examples-mcp/README.md | 122 +++++++++
.../juneau-examples-mcp/build-overlay/README.md | 116 ++++++++
.../juneau-examples-mcp/build-overlay/pom.xml | 134 +++++++++
juneau-examples/juneau-examples-mcp/pom.xml | 162 +++++++++++
.../juneau-examples-mcp/src/assembly/bin.xml | 50 ++++
.../apache/juneau/examples/mcp/ExampleClient.java | 177 ++++++++++++
.../juneau/examples/mcp/ExampleMcpServer.java | 301 +++++++++++++++++++++
.../apache/juneau/examples/mcp/ExampleServer.java | 138 ++++++++++
.../org/apache/juneau/examples/mcp/NoteStore.java | 90 ++++++
.../examples/mcp/spring/GreetingService.java | 35 +++
.../mcp/spring/SpringExampleApplication.java | 80 ++++++
.../mcp/spring/SpringExampleMcpServer.java | 71 +++++
.../examples/mcp/ExampleMcpEndToEnd_Test.java | 202 ++++++++++++++
juneau-examples/pom.xml | 1 +
.../rest/client/mcp/v20260728/McpClient.java | 20 ++
.../mcp/v20260728/McpClient_Methods_Test.java | 26 ++
.../server/mcp/v20260728/ElicitationResponses.java | 56 ++++
.../server/mcp/v20260728/McpMrtrResumeContext.java | 22 ++
.../mcp/v20260728/ElicitationResponses_Test.java | 113 ++++++++
.../mcp/v20260728/McpInputRequiredSignal_Test.java | 23 ++
25 files changed, 2056 insertions(+)
diff --git
a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CallToolResult.java
b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CallToolResult.java
index b7c4f03213..e0d52415ba 100644
---
a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CallToolResult.java
+++
b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/CallToolResult.java
@@ -90,6 +90,28 @@ public class CallToolResult extends Result<CallToolResult> {
return this;
}
+ /**
+ * Returns the text of the first {@link TextContent} block anywhere in
the content list.
+ *
+ * <p>
+ * Convenience for the common case of a tool that returns text
(possibly alongside other blocks, e.g. an
+ * image with a caption): scans past any leading non-text blocks
instead of only ever looking at index 0,
+ * and avoids the {@code
((TextContent)result.getContent().get(i)).getText()} cast every such caller
would
+ * otherwise repeat.
+ *
+ * @return The first {@link TextContent} block's text found while
scanning the content list in order, or
+ * {@code null} if the content list is empty or unset, or it
contains no {@link TextContent} block.
+ */
+ public String firstText() {
+ var c = getContent();
+ if (c == null)
+ return null;
+ for (var b : c)
+ if (b instanceof TextContent t)
+ return t.getText();
+ return null;
+ }
+
/**
* When {@code true}, the tool reported an application-level error.
*
diff --git
a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ServerDiscoverResult.java
b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ServerDiscoverResult.java
index 0ea5987f5e..77d0a679f3 100644
---
a/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ServerDiscoverResult.java
+++
b/juneau-bean/juneau-bean-mcp-v20260728/src/main/java/org/apache/juneau/bean/mcp/v20260728/ServerDiscoverResult.java
@@ -21,6 +21,7 @@ import static org.apache.juneau.commons.utils.Shorts.*;
import java.util.*;
+import org.apache.juneau.commons.bean.*;
import org.apache.juneau.marshall.*;
/**
@@ -136,4 +137,23 @@ public class ServerDiscoverResult extends
CacheableResult<ServerDiscoverResult>
instructions = value;
return this;
}
+
+ /**
+ * Returns this result's server identity, if present.
+ *
+ * <p>
+ * Null-safe shortcut for {@code getMeta() == null ? null :
getMeta().getServerInfo()}, so a caller does not
+ * have to null-check the intermediate {@link #getMeta()} just to reach
the server's {@link Implementation}.
+ *
+ * @return The server identity, or {@code null} if {@link #getMeta()}
is unset, or its
+ * {@link ResultMeta#getServerInfo()} is unset.
+ */
+ // @BeanIgnore keeps this convenience getter from being (re)discovered
as its own bean property, which
+ // would add a second, top-level "serverInfo" member to the
server/discover wire format - identity
+ // belongs solely under the inherited _meta envelope (see the class
javadoc).
+ @BeanIgnore
+ public Implementation getServerInfo() {
+ var m = getMeta();
+ return m == null ? null : m.getServerInfo();
+ }
}
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 efd95a40d4..a701f1bcf7 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
@@ -902,4 +902,56 @@ class McpV2Beans_Test {
LoggingCapability.class);
assertBean(logging, "level", "info");
}
+
+ @Test void m01_firstText_returnsFirstTextContentText() {
+ var result = new CallToolResult().setContent(new
TextContent().setText("hi"),
+ new
AudioContent().setData("QUJD").setMimeType("audio/wav"));
+ assertEquals("hi", result.firstText());
+ }
+
+ @Test void m02_firstText_emptyContentList_returnsNull() {
+ assertNull(new
CallToolResult().setContent(List.of()).firstText());
+ }
+
+ @Test void m03_firstText_unsetContent_returnsNull() {
+ assertNull(new CallToolResult().firstText());
+ }
+
+ @Test void m04_firstText_noTextContentAnywhere_returnsNull() {
+ var result = new CallToolResult().setContent(new
AudioContent().setData("QUJD").setMimeType("audio/wav"));
+ assertNull(result.firstText());
+ }
+
+ @Test void m05_firstText_scansPastLeadingNonTextBlocks() {
+ // M-1: firstText() scans the whole list (not just index 0) -
the common image+caption ordering.
+ var result = new CallToolResult().setContent(
+ new
ImageContent().setData("aW1n").setMimeType("image/png"),
+ new TextContent().setText("caption"));
+ assertEquals("caption", result.firstText());
+ }
+
+ @Test void n01_getServerInfo_returnsImplementationFromMeta() {
+ var result = new ServerDiscoverResult()
+ .setMeta(new ResultMeta().setServerInfo(new
Implementation().setName("s").setVersion("1")));
+ assertBean(result.getServerInfo(), "name,version", "s,1");
+ }
+
+ @Test void n02_getServerInfo_unsetMeta_returnsNull() {
+ assertNull(new ServerDiscoverResult().getServerInfo());
+ }
+
+ @Test void n03_getServerInfo_metaWithoutServerInfo_returnsNull() {
+ assertNull(new ServerDiscoverResult().setMeta(new
ResultMeta()).getServerInfo());
+ }
+
+ @Test void
n04_getServerInfo_isBeanIgnored_noTopLevelServerInfoInWireFormat() {
+ // H-4: guards the @BeanIgnore on getServerInfo(). Parse (don't
substring-match) since _meta itself
+ // legitimately nests a "serverInfo" key one level down - only
the top level must never carry one.
+ var result = new ServerDiscoverResult()
+ .setMeta(new ResultMeta().setServerInfo(new
Implementation().setName("s").setVersion("1")));
+ var json = JsonSerializer.DEFAULT.write(result);
+ var m = JsonParser.DEFAULT.read(json, JsonMap.class);
+ assertFalse(m.containsKey("serverInfo"), () -> "must not add a
top-level serverInfo member: " + json);
+
assertTrue(((JsonMap)m.get("_meta")).containsKey(ResultMeta.KEY_SERVER_INFO));
+ }
}
diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index 83b96a07d9..0c6247ca6c 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -300,6 +300,12 @@
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-examples-mcp</artifactId>
+ <version>${project.version}</version>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-petstore-jetty</artifactId>
@@ -1272,6 +1278,17 @@
<destFileName>apache-juneau-examples-core-${project.version}.zip</destFileName>
</artifactItem>
+ <!--
juneau-examples-mcp -->
+ <artifactItem>
+
<outputDirectory>${project.build.directory}/bin/projects</outputDirectory>
+
<groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-examples-mcp</artifactId>
+
<classifier>bin</classifier>
+
<type>zip</type>
+
<version>${project.version}</version>
+
<destFileName>apache-juneau-examples-mcp-${project.version}.zip</destFileName>
+ </artifactItem>
+
<!--
juneau-petstore-jetty -->
<artifactItem>
<outputDirectory>${project.build.directory}/bin/projects</outputDirectory>
diff --git a/juneau-examples/juneau-examples-mcp/.gitignore
b/juneau-examples/juneau-examples-mcp/.gitignore
new file mode 100644
index 0000000000..34acf885cb
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/.gitignore
@@ -0,0 +1,6 @@
+/target/
+**/.DS_Store
+.classpath
+.project
+/.settings/
+/bin/
diff --git a/juneau-examples/juneau-examples-mcp/README.md
b/juneau-examples/juneau-examples-mcp/README.md
new file mode 100644
index 0000000000..31bfd91cc3
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/README.md
@@ -0,0 +1,122 @@
+<!--
+ 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.
+-->
+# Apache Juneau — MCP Example
+
+A **runnable, copy-me reference implementation** of both the server and client
sides of the
+Model Context Protocol (MCP), built on Apache Juneau against the
**`2026-07-28` (v2)** revision.
+
+It is deliberately tiny: one in-memory *notes* service exercised by every
major MCP surface, so
+you can read the whole thing and run it in a few minutes.
+
+## What it demonstrates
+
+A single domain — an in-memory `title → body` note store (`NoteStore`) — is
exposed through:
+
+| MCP surface | Where | What to look at |
+|---|---|---|
+| **tool** | `publishNote(title, body)` | stores a note, then *pushes* a
change to subscribers |
+| **tool + elicitation / MRTR** | `deleteNote(title)` | returns
`input_required` to confirm, then resumes |
+| **prompt + completion** | `summarize(title)` | renders a prompt; `title`
argument auto-completes |
+| **resource** | `note:///index` | lists all note titles |
+| **resource template + completion** | `note:///{title}` | reads one note;
`{title}` auto-completes |
+| **subscription** | `subscriptions/listen` | held-open SSE; `publishNote`
fires `onResourceUpdated` |
+
+## Key files
+
+| File | Role |
+|---|---|
+|
[`ExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleMcpServer.java)
| The MCP server — every surface registered in `createMcpConfig()` |
+|
[`NoteStore.java`](src/main/java/org/apache/juneau/examples/mcp/NoteStore.java)
| The tiny in-memory domain state |
+|
[`ExampleServer.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleServer.java)
| Embedded-Jetty launcher (`main` + `start(port)`) |
+|
[`ExampleClient.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleClient.java)
| Guided `McpClient` walkthrough of every surface |
+|
[`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java)
| Spring Boot variant using `SpringMcpRestServlet` |
+|
[`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java)
| `@SpringBootApplication` launcher |
+|
[`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java)
| In-process end-to-end proof |
+
+## Run it
+
+Build the module (and its dependencies) first:
+
+```bash
+mvn -pl juneau-examples/juneau-examples-mcp -am clean install
+```
+
+> **Note:** these `exec:java` commands use `-f
juneau-examples/juneau-examples-mcp/pom.xml`, not
+> `-pl juneau-examples/juneau-examples-mcp`. Because `.mvn/maven.config` sets
`--also-make`
+> globally, `-pl` resolves `exec:java` against the reactor's root project
(which has no
+> `exec.mainClass` configured) rather than this module. `-f` runs Maven
directly against this
+> module's POM instead.
+
+### 1. The embedded-Jetty server + client walkthrough
+
+Start the server (defaults to port `5000`; pass a port to override):
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \
+ -Dexec.mainClass=org.apache.juneau.examples.mcp.ExampleServer
+```
+
+In another terminal, run the client walkthrough (defaults to
`http://localhost:5000/`):
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \
+ -Dexec.mainClass=org.apache.juneau.examples.mcp.ExampleClient
+```
+
+The client prints a numbered, top-to-bottom transcript: discovery → listing →
tool call →
+templated resource read → completion → prompt → a live subscription
notification → an
+auto-answered `deleteNote` elicitation.
+
+### 2. The Spring Boot variant
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml exec:java \
+
-Dexec.mainClass=org.apache.juneau.examples.mcp.spring.SpringExampleApplication
+```
+
+This serves the MCP endpoint at **`http://localhost:8080/mcp`** on embedded
Tomcat. Its one
+`greet` tool resolves a Spring-managed `GreetingService` through the
per-request `BeanStore` —
+the distinguishing feature of `SpringMcpRestServlet` over the plain
`McpRestServlet`.
+
+The Spring app only registers `greet` — none of the notes surfaces from part 1
— so
+`ExampleClient`'s full walkthrough is not applicable here (it calls
`publishNote`, which this
+server doesn't have, and would abort with a `Tool not found` error). To
exercise it manually,
+call `server/discover` and `tools/call` directly instead:
+
+```bash
+curl -s http://localhost:8080/mcp -H 'Content-Type: application/json' -d \
+
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"greet","arguments":{"name":"world"}}}'
+```
+
+### 3. The test
+
+```bash
+mvn -f juneau-examples/juneau-examples-mcp/pom.xml test
+```
+
+`ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and
drives the real
+client through every surface, asserting each outcome.
+
+## How the wiring works
+
+- **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base).
`createMcpConfig()` lists
+ the tools/prompts/resources; `createMcpOptions()` advertises capabilities.
`SseSerializer` is
+ registered on `@Rest` so the subscription stream can negotiate
`text/event-stream`.
+- **Launcher:** a Jetty `Server` plus the servlet are placed in a
`BasicBeanStore` and handed to
+ a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest`
servlet at `/`.
+- **Client:** `McpClient` (v2) — `connect()` does the mandatory
`server/discover` handshake;
+
`callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation`
do the rest.
diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/README.md
b/juneau-examples/juneau-examples-mcp/build-overlay/README.md
new file mode 100644
index 0000000000..f358b783e9
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/build-overlay/README.md
@@ -0,0 +1,116 @@
+<!--
+ 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.
+-->
+# Apache Juneau — MCP Example
+
+A **runnable, copy-me reference implementation** of both the server and client
sides of the
+Model Context Protocol (MCP), built on Apache Juneau against the
**`2026-07-28` (v2)** revision.
+
+It is deliberately tiny: one in-memory *notes* service exercised by every
major MCP surface, so
+you can read the whole thing and run it in a few minutes.
+
+## What it demonstrates
+
+A single domain — an in-memory `title → body` note store (`NoteStore`) — is
exposed through:
+
+| MCP surface | Where | What to look at |
+|---|---|---|
+| **tool** | `publishNote(title, body)` | stores a note, then *pushes* a
change to subscribers |
+| **tool + elicitation / MRTR** | `deleteNote(title)` | returns
`input_required` to confirm, then resumes |
+| **prompt + completion** | `summarize(title)` | renders a prompt; `title`
argument auto-completes |
+| **resource** | `note:///index` | lists all note titles |
+| **resource template + completion** | `note:///{title}` | reads one note;
`{title}` auto-completes |
+| **subscription** | `subscriptions/listen` | held-open SSE; `publishNote`
fires `onResourceUpdated` |
+
+## Key files
+
+| File | Role |
+|---|---|
+|
[`ExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleMcpServer.java)
| The MCP server — every surface registered in `createMcpConfig()` |
+|
[`NoteStore.java`](src/main/java/org/apache/juneau/examples/mcp/NoteStore.java)
| The tiny in-memory domain state |
+|
[`ExampleServer.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleServer.java)
| Embedded-Jetty launcher (`main` + `start(port)`) |
+|
[`ExampleClient.java`](src/main/java/org/apache/juneau/examples/mcp/ExampleClient.java)
| Guided `McpClient` walkthrough of every surface |
+|
[`spring/SpringExampleMcpServer.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java)
| Spring Boot variant using `SpringMcpRestServlet` |
+|
[`spring/SpringExampleApplication.java`](src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java)
| `@SpringBootApplication` launcher |
+|
[`ExampleMcpEndToEnd_Test.java`](src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java)
| In-process end-to-end proof |
+
+## Run it
+
+This is a standalone Maven project (this `pom.xml` is at the root of the
unzipped archive), so
+every command below is a plain `mvn ...` invocation — no `-f`/`-pl`/reactor
flags needed.
+
+Build it first:
+
+```bash
+mvn clean install
+```
+
+### 1. The embedded-Jetty server + client walkthrough
+
+Start the server (defaults to port `5000`; pass a port to override):
+
+```bash
+mvn exec:java -Dexec.mainClass=org.apache.juneau.examples.mcp.ExampleServer
+```
+
+In another terminal, run the client walkthrough (defaults to
`http://localhost:5000/`):
+
+```bash
+mvn exec:java -Dexec.mainClass=org.apache.juneau.examples.mcp.ExampleClient
+```
+
+The client prints a numbered, top-to-bottom transcript: discovery → listing →
tool call →
+templated resource read → completion → prompt → a live subscription
notification → an
+auto-answered `deleteNote` elicitation.
+
+### 2. The Spring Boot variant
+
+```bash
+mvn exec:java
-Dexec.mainClass=org.apache.juneau.examples.mcp.spring.SpringExampleApplication
+```
+
+This serves the MCP endpoint at **`http://localhost:8080/mcp`** on embedded
Tomcat. Its one
+`greet` tool resolves a Spring-managed `GreetingService` through the
per-request `BeanStore` —
+the distinguishing feature of `SpringMcpRestServlet` over the plain
`McpRestServlet`.
+
+The Spring app only registers `greet` — none of the notes surfaces from part 1
— so
+`ExampleClient`'s full walkthrough is not applicable here (it calls
`publishNote`, which this
+server doesn't have, and would abort with a `Tool not found` error). To
exercise it manually,
+call `server/discover` and `tools/call` directly instead:
+
+```bash
+curl -s http://localhost:8080/mcp -H 'Content-Type: application/json' -d \
+
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"greet","arguments":{"name":"world"}}}'
+```
+
+### 3. The test
+
+```bash
+mvn test
+```
+
+`ExampleMcpEndToEnd_Test` boots the server in-process on an ephemeral port and
drives the real
+client through every surface, asserting each outcome.
+
+## How the wiring works
+
+- **Server:** `ExampleMcpServer extends McpRestServlet` (the v2 base).
`createMcpConfig()` lists
+ the tools/prompts/resources; `createMcpOptions()` advertises capabilities.
`SseSerializer` is
+ registered on `@Rest` so the subscription stream can negotiate
`text/event-stream`.
+- **Launcher:** a Jetty `Server` plus the servlet are placed in a
`BasicBeanStore` and handed to
+ a `Microservice` with `JettyConfiguration`, which auto-mounts the `@Rest`
servlet at `/`.
+- **Client:** `McpClient` (v2) — `connect()` does the mandatory
`server/discover` handshake;
+
`callTool`/`readResource`/`getPrompt`/`complete`/`listen`/`callToolWithElicitation`
do the rest.
diff --git a/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
new file mode 100644
index 0000000000..242e19c0d7
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/build-overlay/pom.xml
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-examples-mcp</artifactId>
+ <version>${project.version}</version>
+ <name>Apache Juneau MCP Examples</name>
+ <description>Runnable first-party example of a Juneau MCP (Model
Context Protocol) server and client (revision 2026-07-28).</description>
+ <packaging>jar</packaging>
+
+ <properties>
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <!-- This standalone project has no <parent>, so it inherits
nothing from the reactor root pom.xml
+ (which sets this same property) - every example source
uses `var`, so without this the
+ maven-compiler-plugin default (1.8) fails the build. -->
+ <maven.compiler.release>17</maven.compiler.release>
+ <!-- Default exec:java target; override with
-Dexec.mainClass=... for ExampleClient or the Spring launcher. -->
+
<exec.mainClass>org.apache.juneau.examples.mcp.ExampleServer</exec.mainClass>
+ </properties>
+
+ <dependencies>
+
+ <!--
+ The v2 (2026-07-28) MCP server adapter. Transitively
brings the revision-neutral MCP server core,
+ the v2 wire beans, JSON-RPC beans, and (importantly)
juneau-rest-server-reactive, which powers the
+ held-open-SSE subscriptions/listen response.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-mcp-v20260728</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- The v2 MCP client facade used by ExampleClient. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-client-mcp-v20260728</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Embedded Jetty launcher (Microservice +
JettyConfiguration). Brings jetty-server / jetty-ee11-servlet. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-microservice-jetty</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!--
+ Backs the Spring Boot variant (SpringMcpRestServlet's
BasicSpringRestServlet base). Declared
+ explicitly because it is marked <optional> in
juneau-rest-server-mcp-v20260728, so it does not
+ transit to consumers of that module.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-springboot</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Spring Boot itself, for the @SpringBootApplication
launcher (embedded Tomcat). -->
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ <version>${spring.version}</version>
+ <exclusions>
+ <exclusion>
+
<groupId>org.springframework.boot</groupId>
+
<artifactId>spring-boot-starter-logging</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <!-- Test: JUnit Jupiter, for the in-process end-to-end test.
-->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test: TestBase, per repo test conventions. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test: assertBean(...), per repo test conventions. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!-- Lets the README's `mvn exec:java
-Dexec.mainClass=...` walkthrough commands run the example's
+ main classes directly. No <configuration> block
here (unlike the in-repo pom.xml): this whole
+ build-overlay/ directory is filtered by the
assembly plugin (see src/assembly/bin.xml), so a
+ literal <mainClass> value here would get
${exec.mainClass} baked in at package time to
+ whichever class happened to be the reactor's
default - and an explicit XML <mainClass> always
+ wins over a same-named -Dexec.mainClass=
command-line property regardless. With no XML
+ configuration at all, exec-maven-plugin reads
exec.mainClass directly from the -D system
+ property the README's commands pass. -->
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <version>3.3.0</version>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git a/juneau-examples/juneau-examples-mcp/pom.xml
b/juneau-examples/juneau-examples-mcp/pom.xml
new file mode 100644
index 0000000000..ec222c9e24
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/pom.xml
@@ -0,0 +1,162 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <artifactId>juneau-examples</artifactId>
+ <groupId>org.apache.juneau</groupId>
+ <version>10.0.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-examples-mcp</artifactId>
+ <name>Apache Juneau MCP Examples</name>
+ <description>Runnable first-party example of a Juneau MCP (Model
Context Protocol) server and client (revision 2026-07-28).</description>
+ <packaging>jar</packaging>
+
+ <properties>
+
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <!-- Default exec:java target; override with
-Dexec.mainClass=... for ExampleClient or the Spring launcher. -->
+
<exec.mainClass>org.apache.juneau.examples.mcp.ExampleServer</exec.mainClass>
+ </properties>
+
+ <dependencies>
+
+ <!--
+ The v2 (2026-07-28) MCP server adapter. Transitively
brings the revision-neutral MCP server core,
+ the v2 wire beans, JSON-RPC beans, and (importantly)
juneau-rest-server-reactive, which powers the
+ held-open-SSE subscriptions/listen response.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-server-mcp-v20260728</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- The v2 MCP client facade used by ExampleClient. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+
<artifactId>juneau-rest-client-mcp-v20260728</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Embedded Jetty launcher (Microservice +
JettyConfiguration). Brings jetty-server / jetty-ee11-servlet. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-microservice-jetty</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!--
+ Backs the Spring Boot variant (SpringMcpRestServlet's
BasicSpringRestServlet base). Declared
+ explicitly because it is marked <optional> in
juneau-rest-server-mcp-v20260728, so it does not
+ transit to consumers of that module.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-springboot</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!-- Spring Boot itself, for the @SpringBootApplication
launcher (embedded Tomcat). -->
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ <version>${spring.version}</version>
+ <exclusions>
+ <exclusion>
+
<groupId>org.springframework.boot</groupId>
+
<artifactId>spring-boot-starter-logging</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+
+ <!-- Test: JUnit Jupiter, for the in-process end-to-end test.
-->
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>${junit.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test: TestBase, per repo test conventions. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test-utils</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ <!-- Test: assertBean(...), per repo test conventions. -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-test</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
+
+ </dependencies>
+
+ <build>
+ <plugins>
+ <!--
+ Lets the README's `mvn ... exec:java`
walkthrough commands run the example's main
+ classes directly. Per .mvn/maven.config's
global also-make flag, invoke with `-f
+ juneau-examples/juneau-examples-mcp/pom.xml`
rather than `-pl ...` — `-pl` still
+ resolves the goal against the reactor's root
project (which has no exec.mainClass),
+ not this module. See
juneau-integration-tests/pom.xml's microbench profile for the
+ same trap.
+ -->
+ <plugin>
+ <groupId>org.codehaus.mojo</groupId>
+ <artifactId>exec-maven-plugin</artifactId>
+ <version>3.3.0</version>
+ <configuration>
+ <!-- ${exec.mainClass} (declared in
<properties> above) so -Dexec.mainClass=... on the
+ command line actually overrides
it; a plugin-level literal value cannot be
+ overridden by a same-named -D
system property. -->
+ <mainClass>${exec.mainClass}</mainClass>
+ </configuration>
+ </plugin>
+ <!--
+ Packages a 'bin' classifier zip (source + a
standalone build-overlay/pom.xml) so this example
+ ships in the Apache release alongside
juneau-examples-core; see juneau-distrib/pom.xml.
+ -->
+ <plugin>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>juneau-assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ <configuration>
+
<finalName>juneau-examples-mcp-${project.version}</finalName>
+ <descriptors>
+
<descriptor>src/assembly/bin.xml</descriptor>
+ </descriptors>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git a/juneau-examples/juneau-examples-mcp/src/assembly/bin.xml
b/juneau-examples/juneau-examples-mcp/src/assembly/bin.xml
new file mode 100644
index 0000000000..2b3df3853f
--- /dev/null
+++ b/juneau-examples/juneau-examples-mcp/src/assembly/bin.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+
+<!--
+ This assembly is used to create the template zip file that developers
use to run standalone with Maven
+ to start their own MCP server/client projects. See
juneau-examples-core's src/assembly/bin.xml for the
+ sibling pattern this mirrors.
+ -->
+<assembly
+
xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+
xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3
https://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>bin</id>
+ <formats>
+ <format>zip</format>
+ </formats>
+ <baseDirectory>/</baseDirectory>
+ <includeBaseDirectory>true</includeBaseDirectory>
+ <fileSets>
+ <fileSet>
+ <useDefaultExcludes>false</useDefaultExcludes>
+ <directory>${basedir}</directory>
+ <includes>
+ <include>src/main/**</include>
+ <include>src/test/**</include>
+ </includes>
+ </fileSet>
+ <fileSet>
+ <directory>${basedir}/build-overlay</directory>
+ <outputDirectory>/</outputDirectory>
+ <filtered>true</filtered>
+ </fileSet>
+ </fileSets>
+
+</assembly>
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleClient.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleClient.java
new file mode 100644
index 0000000000..a74aba55f9
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleClient.java
@@ -0,0 +1,177 @@
+/*
+ * 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.examples.mcp;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.client.mcp.v20260728.*;
+
+/**
+ * A guided, end-to-end walkthrough of every {@link ExampleMcpServer} surface
using the v2
+ * {@link McpClient}.
+ *
+ * <p>
+ * Run {@link #main(String[]) main} (optionally passing the server endpoint;
defaults to
+ * {@code http://localhost:5000/}) after starting {@link ExampleServer}. Each
numbered step prints
+ * what it is doing and what it got back, so the transcript reads
top-to-bottom like a tutorial.
+ *
+ * <p>
+ * The client advertises the {@code elicitation} capability, which the server
requires before it will
+ * pause a call for input; without it the {@code deleteNote} elicitation step
would be rejected.
+ *
+ * <p>
+ * <b>Console output caveat:</b> every printed line below is produced by
re-serializing the already-parsed
+ * response bean with {@code Json.of(...)}, purely for readable console
output. That re-serialization drops
+ * the polymorphic content-block {@code "type"} discriminator (e.g. {@code
"text"}/{@code "audio"}) the real
+ * wire format carries, so what you see here is not byte-for-byte what came
off the wire.
+ */
+public final class ExampleClient {
+
+ private ExampleClient() {}
+
+ /**
+ * Runs the walkthrough against a running {@link ExampleServer}.
+ *
+ * @param args Optional single argument: the server endpoint (defaults
to {@code http://localhost:5000/}).
+ * @throws Exception If any step fails.
+ */
+ public static void main(String[] args) throws Exception {
+ var endpoint = args.length > 0 ? args[0] : "http://localhost:"
+ ExampleServer.DEFAULT_PORT + "/";
+ try (var client = connect(endpoint)) {
+ run(client);
+ }
+ }
+
+ /**
+ * Builds and connects a client that advertises the elicitation
capability.
+ *
+ * @param endpoint The MCP server endpoint URL.
+ * @return A connected client (its mandatory {@code server/discover}
handshake already done).
+ * @throws IOException If the connection or handshake fails.
+ */
+ public static McpClient connect(String endpoint) throws IOException {
+ return McpClient.connect(McpClient.builder()
+ .endpoint(endpoint)
+ .clientInfo(new
Implementation().setName("juneau-notes-example-client").setVersion("1.0.0"))
+ // Required for the deleteNote elicitation step: the
server only pauses for input when the
+ // client says it can answer.
+ .clientCapabilities(new
ClientCapabilities().setElicitation(new ElicitationCapability())));
+ }
+
+ /**
+ * Executes each numbered step of the walkthrough against an
already-connected client.
+ *
+ * @param client The connected MCP client.
+ * @throws Exception If any step fails.
+ */
+ public static void run(McpClient client) throws Exception {
+
+ section("1. server/discover — who are we talking to?");
+ var discovered = client.discoveredServer();
+ System.out.println(" server info: " +
Json.of(discovered.getServerInfo()));
+ System.out.println(" versions: " +
Json.of(discovered.getSupportedVersions()));
+ System.out.println(" capabilities: " +
Json.of(discovered.getCapabilities()));
+ System.out.println(" instructions: " +
discovered.getInstructions());
+
+ section("2. Discovery — list every advertised surface");
+ System.out.println(" tools: " +
Json.of(client.listTools().getTools()));
+ System.out.println(" prompts: " +
Json.of(client.listPrompts().getPrompts()));
+ System.out.println(" resources: " +
Json.of(client.listResources().getResources()));
+ System.out.println(" resourceTemplates: " +
Json.of(client.listResourceTemplates().getResourceTemplates()));
+
+ section("3. tools/call publishNote — store a note (and notify
subscribers)");
+ System.out.println(" -> " +
client.callToolText("publishNote", Map.of("title", "groceries", "body", "Milk,
eggs, bread")));
+
+ section("4. resources/read note:///groceries — read it back via
the template");
+ var read = client.readResource(NoteStore.uriFor("groceries"));
+ System.out.println(" " + Json.of(read.getContents()));
+
+ section("5. resources/read note:///index — the fixed index
resource");
+ System.out.println(" " +
Json.of(client.readResource(NoteStore.SCHEME + "index").getContents()));
+
+ section("6. completion/complete — complete the template's
{title} variable for prefix 'gr'");
+ var ref = new
ResourceTemplateReference().setUri(NoteStore.SCHEME + "{title}");
+ var completion = client.complete(ref, "title", "gr", null);
+ System.out.println(" suggestions: " +
Json.of(completion.getCompletion()));
+
+ section("7. prompts/get summarize — render a prompt from the
stored note");
+ var prompt = client.getPrompt("summarize", Map.of("title",
"groceries"));
+ System.out.println(" " + Json.of(prompt.getMessages()));
+
+ section("8. subscriptions/listen — receive a live change
notification");
+ runSubscriptionDemo(client);
+
+ section("9. tools/call deleteNote — an elicitation (confirm)
round-trip, auto-answered");
+ var deleted = client.callToolWithElicitation("deleteNote",
Map.of("title", "groceries"), requests -> {
+ // The server asked one or more questions; answer each
with ACCEPT + confirm=true. A real client
+ // would present these to a user (the schema in each
request says how to render the control).
+ System.out.println(" server asked: " +
Json.of(requests));
+ var answers = new LinkedHashMap<String,ElicitResult>();
+ requests.keySet().forEach(id -> answers.put(id,
+ new
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
+ return answers;
+ });
+ System.out.println(" -> " + deleted.firstText());
+
+ section("10. resources/read note:///index — confirm the note is
gone");
+ System.out.println(" " +
Json.of(client.readResource(NoteStore.SCHEME + "index").getContents()));
+
+ System.out.println("\nWalkthrough complete.");
+ }
+
+ /**
+ * Subscribes for changes to a note URI, publishes that note, and
prints the change frame that arrives
+ * over the held-open SSE stream.
+ */
+ private static void runSubscriptionDemo(McpClient client) throws
Exception {
+ var noteUri = NoteStore.uriFor("todo");
+ var updates = new LinkedBlockingQueue<String>();
+ var acknowledged = new CountDownLatch(1);
+
+ var handle = client.listen(
+ new
SubscriptionFilter().setResourceSubscriptions(List.of(noteUri)),
+ new McpSubscriptionListener() {
+ @Override public void
onAcknowledged(SubscriptionFilter honoredFilter) { acknowledged.countDown(); }
+ @Override public void onResourceUpdated(String
uri) { updates.add(uri); }
+ @Override public void onError(Throwable t) {
System.out.println(" subscription error: " + t); }
+ });
+ try {
+ if (! acknowledged.await(10, TimeUnit.SECONDS))
+ System.out.println(" (warning: subscription
was not acknowledged in time)");
+ else
+ System.out.println(" subscription
acknowledged; publishing '" + noteUri + "' ...");
+
+ client.callTool("publishNote", Map.of("title", "todo",
"body", "Write MCP example"));
+
+ var updatedUri = updates.poll(10, TimeUnit.SECONDS);
+ if (updatedUri == null)
+ System.out.println(" (warning: no
resources/updated notification arrived in time)");
+ else
+ System.out.println(" -> received
resources/updated for: " + updatedUri);
+ } finally {
+ handle.close();
+ }
+ }
+
+ private static void section(String title) {
+ System.out.println("\n=== " + title + " ===");
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleMcpServer.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleMcpServer.java
new file mode 100644
index 0000000000..74dd6bb636
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleMcpServer.java
@@ -0,0 +1,301 @@
+/*
+ * 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.examples.mcp;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.sse.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+
+/**
+ * A complete, runnable MCP <c>2026-07-28</c> server exposing every major
surface of the protocol
+ * over one tiny, coherent scenario: an in-memory {@link NoteStore notes}
service.
+ *
+ * <p>
+ * This is the reference implementation to copy. Everything the server exposes
is registered in
+ * {@link #createMcpConfig()} below, and every handler is a small anonymous
class that closes over a
+ * single shared {@link NoteStore}. What each surface demonstrates:
+ *
+ * <ul>
+ * <li><b>tool</b> — {@code publishNote(title, body)} stores a note
and then <i>pushes</i> a
+ * {@code resources/updated} change to any subscribers (see the
subscription bullet).
+ * <li><b>tool + elicitation / MRTR</b> — {@code deleteNote(title)}
returns an
+ * {@code input_required} pause asking the caller to confirm, then
resumes to actually delete.
+ * This is one of the framework's two headline features.
+ * <li><b>prompt + completion</b> — {@code summarize} renders a
prompt message from a stored
+ * note; its {@code title} argument offers {@code
completion/complete} suggestions from existing
+ * note titles.
+ * <li><b>resource</b> — the fixed {@code note:///index} resource
lists all note titles.
+ * <li><b>resource template + completion</b> — {@code
note:///{title}} reads one note by title;
+ * its {@code title} variable also offers completions.
+ * <li><b>subscription</b> — {@code subscriptions/listen} (SEP-2575
held-open SSE) delivers the
+ * {@code resources/updated} change {@code publishNote} fires. The
other headline feature.
+ * </ul>
+ *
+ * <p>
+ * The two overridable factory hooks are the whole story:
+ * <ul>
+ * <li>{@link #createMcpConfig()} — <i>what</i> the server exposes
(tools/prompts/resources).
+ * <li>{@link #createMcpOptions()} — <i>how</i> it behaves
(advertised capabilities, etc.).
+ * </ul>
+ *
+ * <p>
+ * The {@link Rest @Rest} annotation must register {@link SseSerializer}
(alongside the JSON
+ * serializer) so a real servlet container can satisfy the {@code Accept:
text/event-stream}
+ * negotiation the subscription stream requires; the base {@link
McpRestServlet} wires only JSON.
+ *
+ * @serial exclude
+ */
+@Rest(serializers = {JsonSerializer.class, SseSerializer.class}, parsers =
JsonParser.class, defaultAccept = "application/json")
+public class ExampleMcpServer extends McpRestServlet {
+
+ private static final long serialVersionUID = 1L;
+
+ // The entire domain state. Handlers below close over this instance, so
there is no DI ceremony to
+ // follow: the server object owns its notes, and every MCP surface is a
thin view over them.
+ private final transient NoteStore notes = new NoteStore();
+
+ /**
+ * Declares everything this server exposes. This one method is the
"table of contents" for the demo.
+ */
+ @Override
+ protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig()
+ .setName("juneau-notes-example")
+ .setVersion("1.0.0")
+ .addTool(publishNoteTool(notes))
+ .addTool(deleteNoteTool(notes))
+ .addPrompt(summarizePrompt(notes))
+ .addResource(noteIndexResource(notes))
+ .addResourceTemplate(noteTemplate(notes));
+ }
+
+ /**
+ * Advertises capabilities and instructions. Explicit here so the
{@code server/discover} handshake
+ * truthfully reports everything this server supports — notably
{@code resources.subscribe},
+ * which pairs with the {@code subscriptions/listen} demo.
+ *
+ * <p>
+ * On revision {@code 2026-07-28}, {@code server/discover}'s {@code
instructions} field is sourced from
+ * this {@link McpOptions}, <b>not</b> {@link
McpServerConfig#setInstructions}, which only feeds the
+ * legacy v1 {@code initialize} handshake.
+ */
+ @Override
+ protected McpOptions createMcpOptions() {
+ return new McpOptions()
+ .setInstructions("A tiny notes service. Use
'publishNote' to add notes, read them via the "
+ + "'note:///{title}' resource template,
subscribe for change notifications, and try "
+ + "'deleteNote' to see an elicitation (confirm)
round-trip.")
+ .setCapabilities(new ServerCapabilities()
+ .setTools(new
ToolCapability().setListChanged(true))
+ .setPrompts(new
PromptCapability().setListChanged(true))
+ .setResources(new
ResourceCapability().setSubscribe(true).setListChanged(true))
+ .setCompletions(new CompletionCapability()));
+ }
+
+ //
=================================================================================================
+ // Tools
+ //
=================================================================================================
+
+ /**
+ * A plain tool that mutates server state and then notifies subscribers.
+ *
+ * <p>
+ * The {@code resourceUpdated(...)} call is the server-push half of the
subscription feature: any client
+ * currently {@code listen}ing for changes to this note's URI receives
an {@code onResourceUpdated} frame.
+ * The {@link McpSubscriptions} SPI is resolved from the per-request
{@link BeanStore}.
+ */
+ private static McpToolHandler publishNoteTool(NoteStore notes) {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() {
+ return new McpToolSpec().setName("publishNote")
+ .setDescription("Stores a note under
the given title and notifies subscribers of the change.");
+ }
+ @Override public McpToolOutcome call(Map<String,Object>
arguments, BeanStore ctx) {
+ var title =
String.valueOf(arguments.getOrDefault("title", ""));
+ var body =
String.valueOf(arguments.getOrDefault("body", ""));
+ notes.put(title, body);
+ ctx.getBean(McpSubscriptions.class).ifPresent(s
-> s.resourceUpdated(NoteStore.uriFor(title)));
+ return McpToolOutcome.text("Stored note '" +
title + "' (" + body.length() + " chars).");
+ }
+ };
+ }
+
+ /**
+ * A tool that pauses for confirmation before acting — the
elicitation / Multi-Round-Trip-Request
+ * (MRTR, SEP-2322) flow.
+ *
+ * <p>
+ * On the first call there is no resume context, so the handler
<i>throws</i> an {@code input_required}
+ * signal built by {@link ElicitationRequests#of(String, ElicitRequest,
Object)}: it carries one question
+ * (id {@code "confirmDelete"}, asking for a boolean {@code confirm}
field) plus a continuation token
+ * ({@code "delete:<title>"}) that the framework seals and echoes back.
The id and the field name are
+ * deliberately distinct (rather than both {@code "confirm"}) so the
two-level {@code (id, field)} lookup
+ * below is legible. When the client answers, the framework re-invokes
this same handler with a populated
+ * {@link McpMrtrResumeContext}; the handler reads the answer and the
continuation and finishes.
+ *
+ * <p>
+ * Note the title is recovered from the continuation on resume rather
than from the arguments: carrying
+ * per-pause state through the continuation is the intended MRTR
pattern.
+ *
+ * <p>
+ * The framework explicitly allows a resume with zero elicitation
answers (a client that never asked the
+ * user, or a transport that dropped the answer), so the continuation
and the {@code confirm} answer are
+ * both treated as possibly missing below — never dereferenced
directly. {@link McpMrtrResumeContext#continuationAsString()}
+ * and {@link ElicitationResponses#getBoolean} are the
decline/omission-safe accessors that make that
+ * possible without hand-rolled null checks.
+ */
+ private static McpToolHandler deleteNoteTool(NoteStore notes) {
+ return new McpToolHandler() {
+ @Override public McpToolSpec descriptor() {
+ return new McpToolSpec().setName("deleteNote")
+ .setDescription("Deletes a note, after
asking the caller to confirm (demonstrates elicitation).");
+ }
+ @Override public McpToolOutcome call(Map<String,Object>
arguments, BeanStore ctx) {
+ var resume =
ctx.getBean(McpMrtrResumeContext.class);
+ if (resume.isEmpty()) {
+ // PAUSE: ask the caller to confirm.
Execution stops here and control returns to the client.
+ var title =
String.valueOf(arguments.getOrDefault("title", ""));
+ var question = new ElicitRequest()
+ .setMessage("Really delete note
'" + title + "'?")
+
.setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm
deletion").build());
+ throw
ElicitationRequests.of("confirmDelete", question, "delete:" + title);
+ }
+ // RESUME: the caller answered (or didn't).
Recover state from the continuation; a missing
+ // continuation or a missing/declined "confirm"
answer both cleanly mean "cancelled".
+ var rc = resume.get();
+ var continuation = rc.continuationAsString();
+ var title = continuation != null &&
continuation.startsWith("delete:")
+ ?
continuation.substring("delete:".length()) : "";
+ if (ElicitationResponses.getBoolean(rc,
"confirmDelete", "confirm")) {
+ var removed = notes.remove(title);
+ return McpToolOutcome.text(removed
+ ? "Deleted note '" + title +
"'."
+ : "There was no note titled '"
+ title + "'.");
+ }
+ return McpToolOutcome.text("Deletion of '" +
title + "' was cancelled.");
+ }
+ };
+ }
+
+ //
=================================================================================================
+ // Prompt
+ //
=================================================================================================
+
+ /**
+ * A prompt whose {@code title} argument declares a completer, so a
client's {@code completion/complete}
+ * request for that argument is answered with matching existing note
titles.
+ */
+ private static McpPromptHandler summarizePrompt(NoteStore notes) {
+ var spec = new McpPromptSpec()
+ .setName("summarize")
+ .setDescription("Builds a prompt asking a model to
summarize a stored note.")
+ .setArguments(List.of(new McpPromptArgument()
+ .setName("title")
+ .setDescription("The title of the note to
summarize.")
+ .setRequired(true)
+ .setCompleter(noteTitleCompleter(notes))));
+ return new McpPromptHandler() {
+ @Override public McpPromptSpec descriptor() { return
spec; }
+ @Override public McpPromptOutcome
get(Map<String,Object> arguments, BeanStore ctx) {
+ var title =
String.valueOf(arguments.getOrDefault("title", ""));
+ var body = notes.get(title);
+ var text = body == null
+ ? "There is no note titled '" + title +
"'."
+ : "Summarize the following note titled
'" + title + "':\n\n" + body;
+ var message = new
McpPromptMessage().setRole(McpRole.USER).setContent(McpContentBlock.text(text));
+ return new McpPromptOutcome()
+ .setDescription("Summary prompt for
note '" + title + "'.")
+ .setMessages(List.of(message));
+ }
+ };
+ }
+
+ //
=================================================================================================
+ // Resources
+ //
=================================================================================================
+
+ /**
+ * A fixed (non-templated) resource that lists all current note titles.
+ */
+ private static McpResourceHandler noteIndexResource(NoteStore notes) {
+ var spec = new McpResourceSpec()
+ .setUri(NoteStore.SCHEME + "index")
+ .setName("note-index")
+ .setDescription("A plain-text list of all note titles.")
+ .setMimeType("text/plain");
+ return new McpResourceHandler() {
+ @Override public McpResourceSpec descriptor() { return
spec; }
+ @Override public McpResourceOutcome read(String uri,
BeanStore ctx) {
+ var titles = notes.titles();
+ var text = titles.isEmpty() ? "(no notes yet)"
: String.join("\n", titles);
+ return new
McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri,
"text/plain", text)));
+ }
+ };
+ }
+
+ /**
+ * A resource template ({@code note:///{title}}) that reads one note by
title, and offers completions
+ * for its {@code title} variable.
+ *
+ * <p>
+ * An exact resource registration (here, {@code note:///index}) always
beats a matching template, so
+ * {@code note:///index} reads the index above while {@code
note:///groceries} routes here.
+ */
+ private static McpResourceTemplateHandler noteTemplate(NoteStore notes)
{
+ var spec = new McpResourceTemplateSpec()
+ .setUriTemplate(NoteStore.SCHEME + "{title}")
+ .setName("note")
+ .setDescription("Reads a single note by title.")
+ .setMimeType("text/plain");
+ var completer = noteTitleCompleter(notes);
+ return new McpResourceTemplateHandler() {
+ @Override public McpResourceTemplateSpec descriptor() {
return spec; }
+ @Override public McpResourceOutcome read(String uri,
Map<String,String> variables, BeanStore ctx) {
+ var title = variables.get("title");
+ var body = notes.get(title);
+ var text = body == null ? "(no note titled '" +
title + "')" : body;
+ return new
McpResourceOutcome().setContents(List.of(McpResourceContents.text(uri,
"text/plain", text)));
+ }
+ @Override public McpCompleter completer(String
variableName) {
+ return "title".equals(variableName) ? completer
: null;
+ }
+ };
+ }
+
+ //
=================================================================================================
+ // Shared completion logic
+ //
=================================================================================================
+
+ /**
+ * A completer that suggests existing note titles beginning with the
caller's partial input. Used by both
+ * the prompt argument and the resource-template variable, so one
implementation drives every
+ * {@code completion/complete} response in this server.
+ */
+ private static McpCompleter noteTitleCompleter(NoteStore notes) {
+ return (request, ctx) -> {
+ var prefix = request.getValue();
+ var matches = notes.titles().stream().filter(t ->
t.startsWith(prefix)).toList();
+ return new McpCompletionResult().setValues(matches);
+ };
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleServer.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleServer.java
new file mode 100644
index 0000000000..7eca0011e5
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/ExampleServer.java
@@ -0,0 +1,138 @@
+/*
+ * 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.examples.mcp;
+
+import java.net.*;
+
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.microservice.*;
+import org.apache.juneau.microservice.jetty.*;
+import org.eclipse.jetty.ee11.servlet.*;
+import org.eclipse.jetty.server.*;
+
+import jakarta.servlet.*;
+
+/**
+ * Boots {@link ExampleMcpServer} on an embedded Jetty server.
+ *
+ * <p>
+ * Run {@link #main(String[]) main} to start the server on a fixed port
(default {@code 5000}, or pass a
+ * port as the first argument) and leave it running so you can point {@link
ExampleClient} (or any MCP
+ * client) at it. The in-process end-to-end test uses {@link #start(int)
start(0)} to boot on an
+ * OS-assigned ephemeral port instead.
+ *
+ * <p>
+ * The wiring mirrors Juneau's own test fixture: a Jetty {@link Server} plus
the {@link ExampleMcpServer}
+ * servlet are placed in a {@link BasicBeanStore}, and {@link
JettyConfiguration} (added via
+ * {@link Microservice.Builder#configurations(Class...)}) auto-mounts the
{@link org.apache.juneau.rest.server.Rest @Rest}
+ * servlet at {@code "/"} and drives the server lifecycle. Because the {@code
Server} is supplied as a bean,
+ * we control the listen port directly — no {@code jetty.xml} required.
+ */
+public final class ExampleServer implements AutoCloseable {
+
+ /** Default listen port used by {@link #main(String[])} when none is
supplied. */
+ public static final int DEFAULT_PORT = 5000;
+
+ private final Microservice microservice;
+ private final URI rootUrl;
+
+ private ExampleServer(Microservice microservice, URI rootUrl) {
+ this.microservice = microservice;
+ this.rootUrl = rootUrl;
+ }
+
+ /**
+ * Starts the example MCP server.
+ *
+ * @param port The TCP port to listen on, or {@code 0} to let the OS
assign an ephemeral port.
+ * @return A running server handle. Close it (or call {@link #close()})
to stop.
+ * @throws Exception If the server fails to start.
+ */
+ @SuppressWarnings("resource") // The bean store is handed to (and
closed by) the Microservice lifecycle.
+ public static ExampleServer start(int port) throws Exception {
+ var jetty = buildServer(port);
+
+ var beanStore = new BasicBeanStore();
+ beanStore.addBean(Server.class, jetty);
+ beanStore.addBean(Servlet.class, new ExampleMcpServer());
+
+ var microservice = Microservice.create()
+ .beanStore(beanStore)
+ .configurations(JettyConfiguration.class)
+ // This example has no interactive commands to offer;
skip the Java console entirely so
+ // startup doesn't print "Could not create console
command" for whatever default console
+ // commands a discovered config might otherwise
register.
+ .consoleEnabled(false)
+ .build();
+ microservice.start();
+
+ return new ExampleServer(microservice,
URI.create("http://localhost:" + boundPort(jetty) + "/"));
+ }
+
+ /**
+ * Returns the root URL the server is listening on (e.g. {@code
http://localhost:5000/}).
+ *
+ * @return The root URL. Never <jk>null</jk>.
+ */
+ public URI getRootUrl() {
+ return rootUrl;
+ }
+
+ @Override
+ public void close() throws Exception {
+ microservice.stop();
+ }
+
+ /**
+ * Runs the server until the process is killed.
+ *
+ * @param args Optional single argument: the port to listen on
(defaults to {@link #DEFAULT_PORT}).
+ * @throws Exception If the server fails to start.
+ */
+ public static void main(String[] args) throws Exception {
+ var port = args.length > 0 ? Integer.parseInt(args[0]) :
DEFAULT_PORT;
+ var server = start(port);
+ System.out.println("Juneau MCP example server is listening at "
+ server.getRootUrl());
+ System.out.println("Drive it with: ExampleClient " +
server.getRootUrl());
+ System.out.println("Press Ctrl-C to stop.");
+ // Microservice.join() defaults to a documented no-op, so it
won't actually block here; join the
+ // main thread directly to keep the process (and the Jetty
daemon threads it started) alive.
+ Thread.currentThread().join();
+ }
+
+ /** Builds a Jetty server with a single connector on {@code port} and a
root servlet context. */
+ private static Server buildServer(int port) {
+ var server = new Server();
+ var connector = new ServerConnector(server);
+ connector.setPort(port);
+ server.addConnector(connector);
+ var handler = new ServletContextHandler();
+ handler.setContextPath("/");
+ // JettyServerComponent discovers the handler via this
attribute (same convention as jetty.xml).
+ server.setAttribute("ServletContextHandler", handler);
+ server.setHandler(handler);
+ return server;
+ }
+
+ /** Reads the actual bound port (meaningful even when the server was
started on port 0). */
+ private static int boundPort(Server server) {
+ for (var connector : server.getConnectors())
+ if (connector instanceof ServerConnector sc)
+ return sc.getLocalPort();
+ throw new IllegalStateException("Could not determine the bound
port of the Jetty server.");
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/NoteStore.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/NoteStore.java
new file mode 100644
index 0000000000..d28dd97a09
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/NoteStore.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.examples.mcp;
+
+import java.util.*;
+
+/**
+ * The example's entire "domain": a tiny in-memory map of note title →
note body.
+ *
+ * <p>
+ * Every MCP surface the {@link ExampleMcpServer} exposes (tools, prompt,
resources, resource
+ * template, completions) is just a thin wrapper over this one object, so a
reader can see the
+ * whole scenario at a glance without wading through business logic. It is
deliberately trivial;
+ * the interesting part is the MCP wiring, not the storage.
+ *
+ * <p>
+ * Access is synchronized because subscription/tool handlers can run on
different threads
+ * (the held-open SSE pump, concurrent HTTP requests). A {@link LinkedHashMap}
keeps listing
+ * order stable so the demo output is deterministic.
+ */
+public final class NoteStore {
+
+ /** URI scheme used to address a single note, e.g. {@code
note:///groceries}. */
+ public static final String SCHEME = "note:///";
+
+ private final Map<String,String> notes = new LinkedHashMap<>();
+
+ /**
+ * Stores (or replaces) a note.
+ *
+ * @param title The note title (its key). Must not be <jk>null</jk>.
+ * @param body The note body. Must not be <jk>null</jk>.
+ */
+ public synchronized void put(String title, String body) {
+ notes.put(title, body);
+ }
+
+ /**
+ * Returns the body of a note.
+ *
+ * @param title The note title. Can be <jk>null</jk>.
+ * @return The body, or <jk>null</jk> if no such note exists.
+ */
+ public synchronized String get(String title) {
+ return notes.get(title);
+ }
+
+ /**
+ * Removes a note.
+ *
+ * @param title The note title. Can be <jk>null</jk>.
+ * @return <jk>true</jk> if a note was actually removed.
+ */
+ public synchronized boolean remove(String title) {
+ return notes.remove(title) != null;
+ }
+
+ /**
+ * Returns a snapshot of all note titles, in insertion order.
+ *
+ * @return An immutable, point-in-time copy of the titles. Never
<jk>null</jk>.
+ */
+ public synchronized List<String> titles() {
+ return List.copyOf(notes.keySet());
+ }
+
+ /**
+ * Builds the canonical resource URI for a note title (e.g. {@code
note:///groceries}).
+ *
+ * @param title The note title.
+ * @return The resource URI.
+ */
+ public static String uriFor(String title) {
+ return SCHEME + title;
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/GreetingService.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/GreetingService.java
new file mode 100644
index 0000000000..17a7505ac9
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/GreetingService.java
@@ -0,0 +1,35 @@
+/*
+ * 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.examples.mcp.spring;
+
+/**
+ * An ordinary Spring-managed service, injected into an MCP tool handler by
{@link SpringExampleMcpServer}
+ * to prove that Spring dependency injection flows into MCP handlers when the
server extends
+ * {@code SpringMcpRestServlet}.
+ */
+public class GreetingService {
+
+ /**
+ * Builds a greeting.
+ *
+ * @param name The name to greet.
+ * @return The greeting text.
+ */
+ public String greet(String name) {
+ return "Hello, " + name + "! (this greeting came from a
Spring-managed bean)";
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java
new file mode 100644
index 0000000000..62a54b1e5c
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleApplication.java
@@ -0,0 +1,80 @@
+/*
+ * 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.examples.mcp.spring;
+
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+
+/**
+ * A minimal Spring Boot application that serves {@link
SpringExampleMcpServer} on embedded Tomcat.
+ *
+ * <p>
+ * Run {@link #main(String[]) main} and the MCP endpoint is available at
{@code http://localhost:8080/mcp}
+ * (default Spring Boot port). Point {@link
org.apache.juneau.examples.mcp.ExampleClient ExampleClient} at
+ * that URL, or drive it with any MCP client. The three {@code @Bean} methods
are the entire integration:
+ *
+ * <ul>
+ * <li>the {@link GreetingService} the MCP tool resolves via Spring DI;
+ * <li>the {@link SpringExampleMcpServer} servlet itself; and
+ * <li>a {@link ServletRegistrationBean} mounting it at {@code /*} (so the
MCP operation lands at {@code /mcp}).
+ * </ul>
+ */
+@SpringBootApplication
+public class SpringExampleApplication {
+
+ /**
+ * The Spring-managed service injected into the MCP {@code greet} tool.
+ *
+ * @return A new {@link GreetingService}.
+ */
+ @Bean
+ public GreetingService greetingService() {
+ return new GreetingService();
+ }
+
+ /**
+ * The MCP servlet bean (constructed by Spring so it participates in
the application context).
+ *
+ * @return A new {@link SpringExampleMcpServer}.
+ */
+ @Bean
+ public SpringExampleMcpServer springExampleMcpServer() {
+ return new SpringExampleMcpServer();
+ }
+
+ /**
+ * Mounts the MCP servlet on the embedded container.
+ *
+ * @param servlet The MCP servlet bean.
+ * @return The registration mapping the servlet at {@code /*}.
+ */
+ @Bean
+ public ServletRegistrationBean<SpringExampleMcpServer>
mcpServletRegistration(SpringExampleMcpServer servlet) {
+ return new ServletRegistrationBean<>(servlet, "/*");
+ }
+
+ /**
+ * Boots the application.
+ *
+ * @param args Standard Spring Boot arguments.
+ */
+ public static void main(String[] args) {
+ SpringApplication.run(SpringExampleApplication.class, args);
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.java
new file mode 100644
index 0000000000..816042a6d9
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/main/java/org/apache/juneau/examples/mcp/spring/SpringExampleMcpServer.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.examples.mcp.spring;
+
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.sse.*;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.mcp.v20260728.*;
+
+/**
+ * The Spring Boot flavor of the MCP server.
+ *
+ * <p>
+ * Extending {@link SpringMcpRestServlet} (rather than the plain {@code
McpRestServlet}) is the whole point:
+ * it bridges the Spring {@code ApplicationContext} into each MCP handler's
per-request {@link org.apache.juneau.commons.inject.BeanStore},
+ * so {@code ctx.getBean(GreetingService.class)} resolves the real
Spring-managed {@link GreetingService}
+ * singleton. The same handler on a plain {@code McpRestServlet} would not see
Spring beans.
+ *
+ * <p>
+ * When registered at servlet mapping {@code /*} (see {@link
SpringExampleApplication}), the MCP endpoint is
+ * served at {@code /mcp} (the Spring convenience servlet mounts its operation
at {@code /mcp} relative to the
+ * servlet root).
+ *
+ * @serial exclude
+ */
+@Rest(serializers = {JsonSerializer.class, SseSerializer.class}, parsers =
JsonParser.class, defaultAccept = "application/json")
+public class SpringExampleMcpServer extends SpringMcpRestServlet {
+
+ private static final long serialVersionUID = 1L;
+
+ @Override
+ public McpServerConfig getMcpConfig() {
+ return new McpServerConfig()
+ .setName("juneau-spring-greeting-example")
+ .setVersion("1.0.0")
+ .addTool(McpToolHandler.of(
+ new
McpToolSpec().setName("greet").setDescription("Greets a name using a
Spring-injected GreetingService."),
+ (arguments, ctx) -> {
+ var name =
String.valueOf(arguments.getOrDefault("name", "world"));
+ return
McpToolOutcome.text(ctx.getBean(GreetingService.class)
+ .map(service ->
service.greet(name))
+ .orElse("(GreetingService was
not resolvable from the BeanStore)"));
+ }));
+ }
+
+ /**
+ * On revision {@code 2026-07-28}, {@code server/discover}'s {@code
instructions} field is sourced from
+ * {@link McpOptions}, <b>not</b> {@link
McpServerConfig#setInstructions}, which only feeds the legacy v1
+ * {@code initialize} handshake.
+ */
+ @Override
+ public McpOptions getMcpOptions() {
+ return new McpOptions()
+ .setInstructions("Call the 'greet' tool; its greeting
is produced by a Spring-managed bean.");
+ }
+}
diff --git
a/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
new file mode 100644
index 0000000000..e98885948c
--- /dev/null
+++
b/juneau-examples/juneau-examples-mcp/src/test/java/org/apache/juneau/examples/mcp/ExampleMcpEndToEnd_Test.java
@@ -0,0 +1,202 @@
+/*
+ * 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.examples.mcp;
+
+import static org.apache.juneau.test.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.rest.client.mcp.v20260728.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Proves the example actually works: boots {@link ExampleServer} in-process
on an ephemeral port and drives
+ * the real {@link McpClient} through every surface the example demonstrates
— discovery, listing, a
+ * tool call, a templated resource read, a completion, a prompt, a
subscription change notification, and an
+ * elicitation (MRTR) round-trip — asserting the observable outcome of
each.
+ *
+ * <p>
+ * It reuses {@link ExampleClient#connect(String)} so the exact client wiring
the walkthrough uses is what is
+ * being tested. Each test publishes its own distinctly-titled note (rather
than relying on notes published by
+ * other tests), so tests remain independent even though the server/client
pair is shared across the class.
+ */
+class ExampleMcpEndToEnd_Test extends TestBase {
+
+ private static ExampleServer server;
+ private static McpClient client;
+
+ @BeforeAll
+ static void setUp() throws Exception {
+ server = ExampleServer.start(0);
+ client = ExampleClient.connect(server.getRootUrl().toString());
+ }
+
+ @AfterAll
+ static void tearDown() throws Exception {
+ if (client != null)
+ client.close();
+ if (server != null)
+ server.close();
+ }
+
+ // -------- server/discover --------
+
+ @Test
+ void a01_discover_reportsServerIdentityAndCapabilities() {
+ var discovered = client.discoveredServer();
+ assertBean(discovered,
"serverInfo{name,version},capabilities{resources{subscribe}}",
+ "{juneau-notes-example,1.0.0},{{true}}");
+ }
+
+ @Test
+ void a02_discover_reportsInstructions() {
+ // HIGH-1 regression guard: instructions are configured via
McpOptions (createMcpOptions()), not
+ // McpServerConfig; on revision 2026-07-28 the latter is
silently ignored for server/discover.
+ var discovered = client.discoveredServer();
+ assertEquals("A tiny notes service. Use 'publishNote' to add
notes, read them via the "
+ + "'note:///{title}' resource template, subscribe for
change notifications, and try "
+ + "'deleteNote' to see an elicitation (confirm)
round-trip.", discovered.getInstructions());
+ }
+
+ // -------- listing --------
+
+ @Test
+ void b01_listTools_exposesRegisteredTools() throws Exception {
+ assertBean(client.listTools(), "tools{#{name}}",
"{[{publishNote},{deleteNote}]}");
+ }
+
+ @Test
+ void b02_listPrompts_exposesRegisteredPrompts() throws Exception {
+ assertBean(client.listPrompts(), "prompts{#{name}}",
"{[{summarize}]}");
+ }
+
+ @Test
+ void b03_listResources_exposesRegisteredResources() throws Exception {
+ assertBean(client.listResources(), "resources{#{name}}",
"{[{note-index}]}");
+ }
+
+ @Test
+ void b04_listResourceTemplates_exposesRegisteredTemplate() throws
Exception {
+ var templates =
client.listResourceTemplates().getResourceTemplates();
+ assertEquals(1, templates.size());
+ assertBean(templates.get(0), "name,uriTemplate",
"note,note:///{title}");
+ }
+
+ // -------- tool call + resource read --------
+
+ @Test
+ void c01_publishThenReadViaTemplate_roundTrips() throws Exception {
+ var stored = client.callTool("publishNote", Map.of("title",
"groceries", "body", "Milk, eggs, bread"));
+ assertEquals("Stored note 'groceries' (17 chars).",
stored.firstText());
+
+ var read = client.readResource(NoteStore.uriFor("groceries"));
+ var contents = read.getContents();
+ assertEquals(1, contents.size());
+ assertEquals("Milk, eggs, bread",
((TextResourceContents)contents.get(0)).getText());
+ }
+
+ @Test
+ void c02_readIndex_listsPublishedNoteTitle() throws Exception {
+ client.callTool("publishNote", Map.of("title", "index-check",
"body", "x"));
+ var contents = client.readResource(NoteStore.SCHEME +
"index").getContents();
+ assertEquals(1, contents.size());
+
assertTrue(((TextResourceContents)contents.get(0)).getText().contains("index-check"));
+ }
+
+ // -------- completion --------
+
+ @Test
+ void d01_completion_suggestsMatchingNoteTitles() throws Exception {
+ client.callTool("publishNote", Map.of("title",
"groceries-for-completion", "body", "x"));
+ var ref = new
ResourceTemplateReference().setUri(NoteStore.SCHEME + "{title}");
+ var completion = client.complete(ref, "title",
"groceries-for-completion", null);
+ assertEquals(List.of("groceries-for-completion"),
completion.getCompletion().getValues());
+ }
+
+ // -------- prompt --------
+
+ @Test
+ void e01_prompt_rendersMessageFromStoredNote() throws Exception {
+ client.callTool("publishNote", Map.of("title", "prompt-note",
"body", "Milk, eggs, bread"));
+ var prompt = client.getPrompt("summarize", Map.of("title",
"prompt-note"));
+ var messages = prompt.getMessages();
+ assertEquals(1, messages.size());
+ assertEquals(Role.USER, messages.get(0).getRole());
+
assertTrue(((TextContent)messages.get(0).getContent()).getText().contains("Milk,
eggs, bread"));
+ }
+
+ // -------- subscription --------
+
+ @Test
+ void f01_subscription_deliversResourceUpdatedOnPublish() throws
Exception {
+ var noteUri = NoteStore.uriFor("subscribed");
+ var updates = new LinkedBlockingQueue<String>();
+ var acknowledged = new CountDownLatch(1);
+
+ var handle = client.listen(
+ new
SubscriptionFilter().setResourceSubscriptions(List.of(noteUri)),
+ new McpSubscriptionListener() {
+ @Override public void
onAcknowledged(SubscriptionFilter honoredFilter) { acknowledged.countDown(); }
+ @Override public void onResourceUpdated(String
uri) { updates.add(uri); }
+ });
+ try {
+ assertTrue(acknowledged.await(15, TimeUnit.SECONDS),
"subscription was not acknowledged");
+ client.callTool("publishNote", Map.of("title",
"subscribed", "body", "hi"));
+ assertEquals(noteUri, updates.poll(15,
TimeUnit.SECONDS));
+ } finally {
+ handle.close();
+ }
+ }
+
+ // -------- deleteNote elicitation (MRTR) --------
+
+ @Test
+ void g01_deleteWithElicitation_confirmsThenDeletes() throws Exception {
+ client.callTool("publishNote", Map.of("title", "trash", "body",
"delete me"));
+
+ var result = client.callToolWithElicitation("deleteNote",
Map.of("title", "trash"), requests -> {
+ var answers = new LinkedHashMap<String,ElicitResult>();
+ requests.keySet().forEach(id -> answers.put(id,
+ new
ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true)));
+ return answers;
+ });
+ assertEquals("Deleted note 'trash'.", result.firstText());
+
+ // And it is really gone from the index.
+ var contents = client.readResource(NoteStore.SCHEME +
"index").getContents();
+
assertFalse(((TextResourceContents)contents.get(0)).getText().contains("trash"));
+ }
+
+ @Test
+ void g02_deleteWithElicitation_declineKeepsNote() throws Exception {
+ client.callTool("publishNote", Map.of("title", "keep", "body",
"keep me"));
+
+ var result = client.callToolWithElicitation("deleteNote",
Map.of("title", "keep"), requests -> {
+ var answers = new LinkedHashMap<String,ElicitResult>();
+ requests.keySet().forEach(id -> answers.put(id, new
ElicitResult().setAction(ElicitAction.DECLINE)));
+ return answers;
+ });
+ assertEquals("Deletion of 'keep' was cancelled.",
result.firstText());
+
+ var contents = client.readResource(NoteStore.SCHEME +
"index").getContents();
+
assertTrue(((TextResourceContents)contents.get(0)).getText().contains("keep"));
+ }
+}
diff --git a/juneau-examples/pom.xml b/juneau-examples/pom.xml
index 44e4b9176e..9d92f4fb01 100644
--- a/juneau-examples/pom.xml
+++ b/juneau-examples/pom.xml
@@ -32,6 +32,7 @@
<modules>
<module>juneau-examples-core</module>
+ <module>juneau-examples-mcp</module>
</modules>
<build>
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 40ba44b98b..9722ac7902 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
@@ -234,6 +234,26 @@ public final class McpClient extends AbstractMcpClient {
return call(McpMethods.TOOLS_CALL, params,
CallToolResult.class);
}
+ /**
+ * Convenience for {@link #callTool} followed by {@link
CallToolResult#firstText()}.
+ *
+ * <p>
+ * Kills the {@code
((TextContent)result.getContent().get(i)).getText()} cast-and-scan that a
caller who
+ * only wants the tool's text result would otherwise repeat at every
call site.
+ *
+ * @param name The tool name to invoke.
+ * @param arguments The tool arguments. Can be <jk>null</jk> (sent as
an empty object).
+ * @return The first {@link TextContent} block's text found while
scanning the result's content list in
+ * order, or <jk>null</jk> if the content list is empty or unset,
it contains no {@link TextContent}
+ * block, or {@link #callTool} itself returned a <jk>null</jk>
result.
+ * @throws IOException If a transport-level or (de)serialization error
occurs.
+ * @throws McpException If the server returned a JSON-RPC error.
+ */
+ public String callToolText(String name, Map<String,Object> arguments)
throws IOException {
+ var r = callTool(name, arguments);
+ return r == null ? null : r.firstText();
+ }
+
/**
* Sends {@value McpMethods#PROMPTS_LIST}.
*
diff --git
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Methods_Test.java
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Methods_Test.java
index 23899fd916..4025b09a34 100644
---
a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Methods_Test.java
+++
b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient_Methods_Test.java
@@ -75,6 +75,32 @@ class McpClient_Methods_Test {
}
}
+ @Test
+ void c02_callToolText_returnsFirstTextContentText() throws Exception {
+ var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"hello\"}]}}";
+ try (var c =
McpClient.builder().endpoint("http://x/mcp").transport(ok(wire)).build()) {
+ assertEquals("hello", c.callToolText("echo",
Map.of("text", "hello")));
+ }
+ }
+
+ @Test
+ void c03_callToolText_nonTextFirstBlock_returnsNull() throws Exception {
+ var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"content\":[{\"type\":\"audio\",\"data\":\"QUJD\",\"mimeType\":\"audio/wav\"}]}}";
+ try (var c =
McpClient.builder().endpoint("http://x/mcp").transport(ok(wire)).build()) {
+ assertNull(c.callToolText("echo", Map.of()));
+ }
+ }
+
+ @Test
+ void c04_callToolText_nullResult_returnsNullNotNpe() throws Exception {
+ // The server returning "result":null is a documented, legal
callTool() outcome; callToolText must
+ // not NPE dereferencing it.
+ var wire = "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":null}";
+ try (var c =
McpClient.builder().endpoint("http://x/mcp").transport(ok(wire)).build()) {
+ assertNull(c.callToolText("echo", Map.of()));
+ }
+ }
+
@Test
void d01_callTool_traceEchoEnabled_readsMetaPath() throws Exception {
var wire =
"{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"_meta\":{\"traceparent\":\"00-parent-01\",\"tracestate\":\"v=x\",\"baggage\":\"u=42\"},\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}";
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
index 6ad90d3659..defbdd6038 100644
---
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
@@ -72,4 +72,60 @@ public final class ElicitationResponses {
ctx.inputResponses().keySet().forEach(id -> out.put(id,
get(ctx, id)));
return out;
}
+
+ /**
+ * Returns a boolean field's value from a single question's answer,
decline-safe.
+ *
+ * <p>
+ * Treats a missing id, a missing field, or a non-{@link
ElicitAction#ACCEPT} action (declined, cancelled)
+ * as <jk>false</jk> rather than requiring the caller to null-check
{@link #get} and its content map by
+ * hand — the safe default for a confirmation question is to
never proceed on ambiguity.
+ *
+ * @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>.
+ * @param field The field name within the answer's content map. Must
not be <jk>null</jk>.
+ * @return <jk>true</jk> only if {@code id} is present, the action is
{@link ElicitAction#ACCEPT}, and
+ * {@code field} is present in the content map with value {@link
Boolean#TRUE}. <jk>false</jk> in every
+ * other case.
+ * @throws IllegalArgumentException If {@code ctx}, {@code id}, or
{@code field} is <jk>null</jk>.
+ * @throws RuntimeException If the answer's decoded shape cannot be
converted to {@link ElicitResult}.
+ */
+ public static boolean getBoolean(McpMrtrResumeContext ctx, String id,
String field) {
+ assertArgNotNull("ctx", ctx);
+ assertArgNotNull("id", id);
+ assertArgNotNull("field", field);
+ var answer = get(ctx, id);
+ if (answer == null || answer.getAction() != ElicitAction.ACCEPT)
+ return false;
+ var content = answer.getContent();
+ return content != null &&
Boolean.TRUE.equals(content.get(field));
+ }
+
+ /**
+ * Returns a string field's value from a single question's answer,
decline-safe.
+ *
+ * <p>
+ * Treats a missing id, a missing field, or a non-{@link
ElicitAction#ACCEPT} action (declined, cancelled)
+ * as <jk>null</jk> rather than requiring the caller to null-check
{@link #get} and its content map by hand.
+ *
+ * @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>.
+ * @param field The field name within the answer's content map. Must
not be <jk>null</jk>.
+ * @return The field's value as a {@link String}, or <jk>null</jk> if
{@code id} is absent, the action is
+ * not {@link ElicitAction#ACCEPT}, {@code field} is absent, or
its value is not a {@link String}.
+ * @throws IllegalArgumentException If {@code ctx}, {@code id}, or
{@code field} is <jk>null</jk>.
+ * @throws RuntimeException If the answer's decoded shape cannot be
converted to {@link ElicitResult}.
+ */
+ public static String getString(McpMrtrResumeContext ctx, String id,
String field) {
+ assertArgNotNull("ctx", ctx);
+ assertArgNotNull("id", id);
+ assertArgNotNull("field", field);
+ var answer = get(ctx, id);
+ if (answer == null || answer.getAction() != ElicitAction.ACCEPT)
+ return null;
+ var content = answer.getContent();
+ if (content == null)
+ return null;
+ return content.get(field) instanceof String s ? s : null;
+ }
}
diff --git
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrResumeContext.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrResumeContext.java
index ab09064110..69b68aeae6 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrResumeContext.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpMrtrResumeContext.java
@@ -90,4 +90,26 @@ public record McpMrtrResumeContext(Object continuation,
Map<String,Object> input
return null;
return Json.to(Json.of(continuation), type);
}
+
+ /**
+ * Returns the continuation as a {@link String}.
+ *
+ * <p>
+ * Convenience for the common case documented on {@link
#continuationAs(Class)}: a handler that pauses with
+ * a plain {@code String} continuation can call this instead of
spelling out {@code continuationAs(String.class)}
+ * at the headline pause/resume call site.
+ *
+ * <p>
+ * <b>Conversion is not lenient.</b> {@code
continuationAs(String.class)} routes through the JSON marshaller,
+ * which parses {@code String} the same as any other target type
— it requires a quoted JSON string
+ * literal, so a continuation that decoded to anything else (a bare
number, a {@code JsonMap}, a
+ * {@code JsonList}, a boolean) throws below rather than being
stringified. Only a continuation whose
+ * decoded shape is already a {@link String} converts successfully.
+ *
+ * @return The continuation as a {@link String}, or <jk>null</jk> if
the continuation is <jk>null</jk>.
+ * @throws RuntimeException If the continuation's decoded shape is not
already a {@link String}.
+ */
+ public String continuationAsString() {
+ return continuationAs(String.class);
+ }
}
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
index e0981f57f7..eb786e2dda 100644
---
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
@@ -92,4 +92,117 @@ class ElicitationResponses_Test {
var ctx = new McpMrtrResumeContext(null, responses);
assertThrows(RuntimeException.class, () ->
ElicitationResponses.get(ctx, "q1"));
}
+
+ @Test void a10_getBoolean_acceptedTrueContent_returnsTrue() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of("confirm", true)));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertTrue(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a11_getBoolean_acceptedFalseContent_returnsFalse() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of("confirm", false)));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertFalse(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a12_getBoolean_declinedAction_returnsFalse() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "decline"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertFalse(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a13_getBoolean_missingId_returnsFalse() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ assertFalse(ElicitationResponses.getBoolean(ctx, "missing",
"confirm"));
+ }
+
+ @Test void a14_getBoolean_missingField_returnsFalse() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of()));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertFalse(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a15_getBoolean_nullFieldThrows() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getBoolean(ctx, "q1", null));
+ assertEquals("Argument 'field' cannot be null.",
e.getMessage());
+ }
+
+ @Test void a16_getString_acceptedContent_returnsValue() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of("name", "al")));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertEquals("al", ElicitationResponses.getString(ctx, "q1",
"name"));
+ }
+
+ @Test void a17_getString_declinedAction_returnsNull() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "decline"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertNull(ElicitationResponses.getString(ctx, "q1", "name"));
+ }
+
+ @Test void a18_getString_missingId_returnsNull() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ assertNull(ElicitationResponses.getString(ctx, "missing",
"name"));
+ }
+
+ @Test void a19_getString_nonStringValue_returnsNull() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of("name", 42)));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertNull(ElicitationResponses.getString(ctx, "q1", "name"));
+ }
+
+ @Test void a20_getString_nullFieldThrows() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getString(ctx, "q1", null));
+ assertEquals("Argument 'field' cannot be null.",
e.getMessage());
+ }
+
+ @Test void a21_getString_acceptedMissingField_returnsNull() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of()));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertNull(ElicitationResponses.getString(ctx, "q1", "name"));
+ }
+
+ @Test void a22_getBoolean_nonBooleanStringValue_returnsFalseSafely() {
+ // L-3: a string "true" is not Boolean.TRUE - getBoolean must
not do a lenient/truthy coercion.
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "accept", "content", JsonMap.of("confirm", "true")));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertFalse(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a23_getBoolean_cancelledAction_returnsFalse() {
+ // L-3: cancel (not just decline) is also a non-ACCEPT action
that must be treated as false.
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "cancel"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertFalse(ElicitationResponses.getBoolean(ctx, "q1",
"confirm"));
+ }
+
+ @Test void a24_getString_cancelledAction_returnsNull() {
+ var responses = Map.<String,Object>of("q1",
JsonMap.of("action", "cancel"));
+ var ctx = new McpMrtrResumeContext(null, responses);
+ assertNull(ElicitationResponses.getString(ctx, "q1", "name"));
+ }
+
+ @Test void a25_getBoolean_nullCtxBlamesCtxBeforeField() {
+ // L-2: ctx/id must be validated before field, so an all-null
call blames "ctx", not "field".
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getBoolean(null, null, null));
+ assertEquals("Argument 'ctx' cannot be null.", e.getMessage());
+ }
+
+ @Test void a26_getBoolean_nullIdBlamesIdBeforeField() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getBoolean(ctx, null, null));
+ assertEquals("Argument 'id' cannot be null.", e.getMessage());
+ }
+
+ @Test void a27_getString_nullCtxBlamesCtxBeforeField() {
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getString(null, null, null));
+ assertEquals("Argument 'ctx' cannot be null.", e.getMessage());
+ }
+
+ @Test void a28_getString_nullIdBlamesIdBeforeField() {
+ var ctx = new McpMrtrResumeContext(null, Map.of());
+ var e = assertThrows(IllegalArgumentException.class, () ->
ElicitationResponses.getString(ctx, null, null));
+ assertEquals("Argument 'id' 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/McpInputRequiredSignal_Test.java
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpInputRequiredSignal_Test.java
index f310ad62a7..9749f14c43 100644
---
a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpInputRequiredSignal_Test.java
+++
b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpInputRequiredSignal_Test.java
@@ -112,6 +112,29 @@ class McpInputRequiredSignal_Test {
assertThrows(RuntimeException.class, () ->
a.continuationAs(B04_Continuation.class));
}
+ @Test void b08_continuationAsStringReturnsStringContinuation() {
+ var a = new McpMrtrResumeContext("hello", Map.of());
+ assertEquals("hello", a.continuationAsString());
+ }
+
+ @Test void b09_continuationAsStringNullContinuationReturnsNull() {
+ var a = new McpMrtrResumeContext(null, Map.of());
+ assertNull(a.continuationAsString());
+ }
+
+ @Test void b10_continuationAsStringNumericContinuationThrows() {
+ // L-4: empirically, Json.to(Json.of(42), String.class) throws
rather than stringifying - String
+ // conversion requires an already-quoted JSON string literal,
so a bare number does not convert.
+ var a = new McpMrtrResumeContext(42, Map.of());
+ assertThrows(RuntimeException.class, a::continuationAsString);
+ }
+
+ @Test void b11_continuationAsStringJsonMapContinuationThrows() {
+ // L-4: a JsonMap continuation is likewise structurally
incompatible with String.
+ var a = new McpMrtrResumeContext(JsonMap.of("step", 1),
Map.of());
+ assertThrows(RuntimeException.class, a::continuationAsString);
+ }
+
// -------- McpMrtrCapabilityContext ---------
@Test void c01_recordAccessorReturnsConstructedValue() {