You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/08/05 01:27:51 UTC
(juneau) branch master updated: MCP 2026-07-28 v2 config consolidation + Spring Boot integration + SubscriptionPump race fix (TODO-330, TODO-319)
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 8eea1d7b14 MCP 2026-07-28 v2 config consolidation + Spring Boot integration + SubscriptionPump race fix (TODO-330, TODO-319)
8eea1d7b14 is described below
commit 8eea1d7b14729d5c3d07591bc03ed4e6772e57db
Author: James Bognar <ja...@apache.org>
AuthorDate: Tue Aug 4 18:27:33 2026 -0700
MCP 2026-07-28 v2 config consolidation + Spring Boot integration + SubscriptionPump race fix (TODO-330, TODO-319)
- TODO-330: consolidate scattered v2-adapter config into a single McpOptions
aggregate; unify stateful defaults to per-binding on both servlet and mixin
paths (fixes accidental JVM-wide sharing); add identity-keyed
McpEndpointOptionsCache; split ElicitationAccess into ElicitationRequests /
ElicitationResponses; delete SharedMrtrConfig / SharedSubscriptionBroker.
- TODO-319: add McpSpringBootIntegration_Test proving SpringMcpRestServlet
resolves Spring beans in MCP handlers (with negative no-DI contrast).
- Fix happens-before race in SubscriptionPump: set open=false before invoking
the terminal onComplete/onError callback at all three sites so callers that
synchronize on the callback no longer observe a stale isOpen()==true.
Co-authored-by: Cursor <cu...@cursor.com>
---
.../v20260728/McpElicitationIntegration_Test.java | 58 ++--
.../mcp/v20260728/McpMrtrIntegration_Test.java | 4 +-
.../McpSubscriptionsListenIntegration_Test.java | 15 +-
.../mcp/v20260728/Characterization_Test.java | 14 +-
.../v20260728/McpCacheBindingIntegration_Test.java | 16 +-
.../v20260728/McpSpringBootIntegration_Test.java | 262 +++++++++++++++++
.../McpTemplateCompletionIntegration_Test.java | 2 +-
...itationAccess.java => ElicitationRequests.java} | 67 +----
.../client/mcp/v20260728/ElicitationResponses.java | 89 ++++++
.../rest/client/mcp/v20260728/McpClient.java | 23 +-
.../mcp/v20260728/ElicitationAccess_Test.java | 176 ------------
.../mcp/v20260728/ElicitationRequests_Test.java | 99 +++++++
.../mcp/v20260728/ElicitationResponses_Test.java | 108 +++++++
.../McpClientSubscriptionsListen_Test.java | 175 ++++++++++++
.../server/mcp/v20260728/ElicitationRequests.java | 2 +-
.../rest/server/mcp/v20260728/McpEndpoint.java | 159 +++--------
.../mcp/v20260728/McpEndpointOptionsCache.java | 129 +++++++++
.../rest/server/mcp/v20260728/McpOptions.java | 309 +++++++++++++++++++++
.../rest/server/mcp/v20260728/McpRestServlet.java | 280 ++++---------------
.../rest/server/mcp/v20260728/McpRevision.java | 31 ++-
.../mcp/v20260728/McpSubscriptionsConfig.java | 7 +-
.../server/mcp/v20260728/SharedMrtrConfig.java | 52 ----
.../mcp/v20260728/SharedSubscriptionBroker.java | 57 ----
.../server/mcp/v20260728/SpringMcpRestServlet.java | 7 +-
.../mcp/v20260728/Characterization_Test.java | 14 +-
.../server/mcp/v20260728/McpBindings_Test.java | 127 +++++----
.../v20260728/McpEndpointOptionsCache_Test.java | 98 +++++++
.../rest/server/mcp/v20260728/McpOptions_Test.java | 196 +++++++++++++
.../McpSubscriptionBrokerBinding_Test.java | 67 ++---
.../v20260728/McpSubscriptionBrokerMixin_Test.java | 70 +++--
.../v20260728/McpSubscriptionsEndToEnd_Test.java | 3 +
.../SubscriptionsListenDispatch_Test.java | 3 +-
.../juneau/rest/server/mcp/McpEndpointMixin.java | 3 +-
.../mcp/BasicMcpSubscriptionBroker_Test.java | 16 +-
34 files changed, 1835 insertions(+), 903 deletions(-)
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
index 94543a5c8d..f7005ee93b 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpElicitationIntegration_Test.java
@@ -41,10 +41,11 @@ import jakarta.servlet.*;
/**
* End-to-end coverage for MCP {@code 2026-07-28} SEP-2322 elicitation (C6), driven through the real
- * {@link McpClient} and its {@link ElicitationAccess} typed helper against a live embedded-Jetty server whose
- * tools pause/resume via {@link ElicitationRequests}/{@link ElicitationResponses} — proving the full typed
- * path works across a real wire boundary, complementing {@link McpMrtrIntegration_Test}'s generic (untyped)
- * PAUSE/RESUME coverage and Phase 7's {@code Characterization_Test} in-process replay fixtures.
+ * {@link McpClient} and its client-side {@link ElicitationRequests}/{@link ElicitationResponses} typed helpers
+ * against a live embedded-Jetty server whose tools pause/resume via the server-side (same-named)
+ * {@code ElicitationRequests}/{@code ElicitationResponses} — proving the full typed path works across a
+ * real wire boundary, complementing {@link McpMrtrIntegration_Test}'s generic (untyped) PAUSE/RESUME coverage
+ * and Phase 7's {@code Characterization_Test} in-process replay fixtures.
*/
@org.apache.juneau.testing.JettyMicroserviceTest
class McpElicitationIntegration_Test extends TestBase {
@@ -55,14 +56,19 @@ class McpElicitationIntegration_Test extends TestBase {
.setRequestedSchema(ElicitSchema.create().booleanField("confirm").title("Confirm").build());
}
+ // The server-side org.apache.juneau.rest.server.mcp.v20260728.ElicitationRequests/ElicitationResponses
+ // (wildcard-imported above) share their simple names with this test's own client-side package
+ // (org.apache.juneau.rest.client.mcp.v20260728.ElicitationRequests/ElicitationResponses), where same-package
+ // visibility wins over a wildcard import - so every server-side call below is qualified with its full
+ // package to disambiguate, while every client-side call in the test bodies below stays unqualified.
private static McpToolHandler confirm() {
return new McpToolHandler() {
@Override public McpToolSpec descriptor() { return new McpToolSpec().setName("confirm").setDescription("Pauses for one confirmation"); }
@Override public McpToolOutcome call(Map<String,Object> arguments, BeanStore ctx) {
var resume = ctx.getBean(McpMrtrResumeContext.class);
if (resume.isEmpty())
- throw ElicitationRequests.of("confirm", confirmQuestion(), "cont-1");
- var answer = ElicitationResponses.get(resume.get(), "confirm");
+ throw org.apache.juneau.rest.server.mcp.v20260728.ElicitationRequests.of("confirm", confirmQuestion(), "cont-1");
+ var answer = org.apache.juneau.rest.server.mcp.v20260728.ElicitationResponses.get(resume.get(), "confirm");
// Echoes the answer's content (not just its action) and the continuation this tool paused
// with, so the assertions below actually exercise ElicitResult.content marshalling and prove
// the continuation round-trips through McpMrtrResumeContext, instead of only the action enum.
@@ -83,9 +89,9 @@ class McpElicitationIntegration_Test extends TestBase {
requests.put("confirm", confirmQuestion());
requests.put("reason", new ElicitRequest().setMessage("Why?")
.setRequestedSchema(ElicitSchema.create().stringField("reason").build()));
- throw ElicitationRequests.of(requests, "cont-multi");
+ throw org.apache.juneau.rest.server.mcp.v20260728.ElicitationRequests.of(requests, "cont-multi");
}
- var answers = ElicitationResponses.all(resume.get());
+ var answers = org.apache.juneau.rest.server.mcp.v20260728.ElicitationResponses.all(resume.get());
var confirmAnswer = answers.get("confirm");
var reasonAnswer = answers.get("reason");
var confirmValue = confirmAnswer.getContent() == null ? null : confirmAnswer.getContent().get("confirm");
@@ -135,14 +141,14 @@ class McpElicitationIntegration_Test extends TestBase {
}
// =================================================================================================================
- // A: single-question pause -> resume -> complete, driven entirely through ElicitationAccess.
+ // A: single-question pause -> resume -> complete, driven entirely through the client-side ElicitationRequests/ElicitationResponses.
// =================================================================================================================
@Test void a01_singleQuestion_pauseResumeAccept_fullLoop() throws Exception {
try (var client = clientBuilder(true).build()) {
var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
- assertTrue(ElicitationAccess.isInputRequired(paused));
- var requests = ElicitationAccess.requests(paused);
+ assertTrue(ElicitationRequests.isInputRequired(paused));
+ var requests = ElicitationRequests.requests(paused);
assertEquals("Proceed with deletion?", requests.get("confirm").getMessage());
// The requestedSchema (built via ElicitSchema.booleanField("confirm").title("Confirm")) must survive
// the wire intact, not just the message - otherwise a real client could not render the right input
@@ -152,9 +158,9 @@ class McpElicitationIntegration_Test extends TestBase {
var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true);
var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
- .setRequestState(ElicitationAccess.requestState(paused))
- .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
- assertFalse(ElicitationAccess.isInputRequired(completed));
+ .setRequestState(ElicitationRequests.requestState(paused))
+ .setInputResponses(ElicitationResponses.toInputResponse("confirm", answer)));
+ assertFalse(ElicitationRequests.isInputRequired(completed));
assertEquals("complete", completed.get("resultType"));
assertEquals("action:accept,confirm:true,cont:cont-1", firstText(completed));
}
@@ -167,7 +173,7 @@ class McpElicitationIntegration_Test extends TestBase {
@Test void b01_multiQuestion_pauseResumeBoth_singleRoundTrip() throws Exception {
try (var client = clientBuilder(true).build()) {
var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirmTwo").setArguments(Map.of()));
- var requests = ElicitationAccess.requests(paused);
+ var requests = ElicitationRequests.requests(paused);
assertEquals(2, requests.size());
assertTrue(requests.containsKey("confirm"));
assertTrue(requests.containsKey("reason"));
@@ -176,8 +182,8 @@ class McpElicitationIntegration_Test extends TestBase {
answers.put("confirm", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("confirm", true));
answers.put("reason", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("reason", "cleanup"));
var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirmTwo")
- .setRequestState(ElicitationAccess.requestState(paused))
- .setInputResponses(ElicitationAccess.toInputResponses(answers)));
+ .setRequestState(ElicitationRequests.requestState(paused))
+ .setInputResponses(ElicitationResponses.toInputResponses(answers)));
assertEquals("complete", completed.get("resultType"));
assertEquals("confirm:accept=true,reason:accept=cleanup,cont:cont-multi", firstText(completed));
}
@@ -190,12 +196,12 @@ class McpElicitationIntegration_Test extends TestBase {
@Test void c01_decline_resumeCarriesDeclineAction() throws Exception {
try (var client = clientBuilder(true).build()) {
var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
- assertTrue(ElicitationAccess.isInputRequired(paused));
- assertNotNull(ElicitationAccess.requestState(paused));
+ assertTrue(ElicitationRequests.isInputRequired(paused));
+ assertNotNull(ElicitationRequests.requestState(paused));
var answer = new ElicitResult().setAction(ElicitAction.DECLINE);
var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
- .setRequestState(ElicitationAccess.requestState(paused))
- .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
+ .setRequestState(ElicitationRequests.requestState(paused))
+ .setInputResponses(ElicitationResponses.toInputResponse("confirm", answer)));
assertEquals("complete", completed.get("resultType"));
assertEquals("action:decline,confirm:null,cont:cont-1", firstText(completed));
}
@@ -204,12 +210,12 @@ class McpElicitationIntegration_Test extends TestBase {
@Test void c02_cancel_resumeCarriesCancelAction() throws Exception {
try (var client = clientBuilder(true).build()) {
var paused = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm").setArguments(Map.of()));
- assertTrue(ElicitationAccess.isInputRequired(paused));
- assertNotNull(ElicitationAccess.requestState(paused));
+ assertTrue(ElicitationRequests.isInputRequired(paused));
+ assertNotNull(ElicitationRequests.requestState(paused));
var answer = new ElicitResult().setAction(ElicitAction.CANCEL);
var completed = client.callRaw(McpMethods.TOOLS_CALL, new CallToolRequest().setName("confirm")
- .setRequestState(ElicitationAccess.requestState(paused))
- .setInputResponses(ElicitationAccess.toInputResponse("confirm", answer)));
+ .setRequestState(ElicitationRequests.requestState(paused))
+ .setInputResponses(ElicitationResponses.toInputResponse("confirm", answer)));
assertEquals("complete", completed.get("resultType"));
assertEquals("action:cancel,confirm:null,cont:cont-1", firstText(completed));
}
@@ -221,7 +227,7 @@ class McpElicitationIntegration_Test extends TestBase {
/**
* Delta vs {@link McpMrtrIntegration_Test#b01_unsupportedCapability_rejectedOverTheWire()}: proves the same
- * capability gate fires for a signal built via the typed {@code ElicitationRequests.of(...)} helper (not a
+ * capability gate fires for a signal built via the typed server-side {@code ElicitationRequests.of(...)} helper (not a
* hand-built {@code McpInputRequiredSignal}) — i.e. the helper yields a gate-recognized signal.
*/
@Test void d01_unsupportedCapability_typedHelperPauseStillRejectedOverTheWire() throws Exception {
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
index 8192e2d0d9..9436b300d3 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpMrtrIntegration_Test.java
@@ -106,8 +106,8 @@ class McpMrtrIntegration_Test extends TestBase {
}
@Override
- protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig().setCodec(CODEC);
+ protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m -> m.setCodec(CODEC));
}
}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpSubscriptionsListenIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpSubscriptionsListenIntegration_Test.java
index c8bcec4371..8addd12cba 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpSubscriptionsListenIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpSubscriptionsListenIntegration_Test.java
@@ -101,18 +101,15 @@ class McpSubscriptionsListenIntegration_Test extends TestBase {
.addTool(publishTool()).addTool(activeCountTool());
}
- @Override
- protected ServerCapabilities capabilities() {
- return new ServerCapabilities()
- .setResources(new ResourceCapability().setSubscribe(true).setListChanged(true))
- .setTools(new ToolCapability().setListChanged(true));
- }
-
// Short heartbeat so c01 (below) can observe repeated "ping" frames over a real held-open connection
// without waiting through the 15s production default.
@Override
- protected McpSubscriptionsConfig createSubscriptionsConfig() {
- return new McpSubscriptionsConfig().setHeartbeatIntervalMs(HEARTBEAT_INTERVAL_MS);
+ protected McpOptions createMcpOptions() {
+ return new McpOptions()
+ .setCapabilities(new ServerCapabilities()
+ .setResources(new ResourceCapability().setSubscribe(true).setListChanged(true))
+ .setTools(new ToolCapability().setListChanged(true)))
+ .subscriptions(s -> s.setHeartbeatIntervalMs(HEARTBEAT_INTERVAL_MS));
}
}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index 282e32de83..f7042e32a0 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -92,15 +92,15 @@ class Characterization_Test {
public static class F_Cache extends F_Full {
private static final long serialVersionUID = 1L;
- @Override protected McpCacheConfig createCacheConfig() {
- return new McpCacheConfig()
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().cache(c -> c
.setDefaultHint(new McpCacheHint().setTtlMs(30000))
.setToolsList(new McpCacheHint().setTtlMs(5000).setCacheScope(McpCacheScope.PRIVATE))
.setPromptsList(new McpCacheHint().setTtlMs(0).setCacheScope(McpCacheScope.PUBLIC))
.setResourceTemplatesList(new McpCacheHint().setTtlMs(60000).setCacheScope(McpCacheScope.PRIVATE))
.setResourcesRead(new McpCacheHint().setTtlMs(2000))
.addResourceReadOverride("file:///a",
- new McpCacheHint().setTtlMs(1000).setCacheScope(McpCacheScope.PRIVATE));
+ new McpCacheHint().setTtlMs(1000).setCacheScope(McpCacheScope.PRIVATE)));
}
}
@@ -307,8 +307,8 @@ class Characterization_Test {
}
});
}
- @Override protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m -> m.setCodec(new FixedKeyGcmCodec()));
}
}
@@ -359,8 +359,8 @@ class Characterization_Test {
});
}
- @Override protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m -> m.setCodec(new FixedKeyGcmCodec()));
}
}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpCacheBindingIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpCacheBindingIntegration_Test.java
index bfa37e62f3..2c838bbd86 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpCacheBindingIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpCacheBindingIntegration_Test.java
@@ -36,8 +36,8 @@ import org.apache.juneau.rest.server.servlet.*;
import org.junit.jupiter.api.*;
/**
- * Cross-module coverage proving the servlet-subclass and endpoint-mixin cache-config lifecycle hooks
- * are consumable exactly as an external artifact consumer would use them, through the published
+ * Cross-module coverage proving the servlet-subclass and endpoint-mixin {@link McpOptions} cache configuration
+ * seams are consumable exactly as an external artifact consumer would use them, through the published
* public API of {@code juneau-rest-server-mcp-v20260728}.
*/
@SuppressWarnings({
@@ -77,9 +77,9 @@ class McpCacheBindingIntegration_Test {
@Override protected McpServerConfig createMcpConfig() {
return new McpServerConfig().addTool(echo());
}
- @Override protected McpCacheConfig createCacheConfig() {
- return new McpCacheConfig().setToolsList(
- new McpCacheHint().setTtlMs(77).setCacheScope(McpCacheScope.PRIVATE));
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().cache(c -> c.setToolsList(
+ new McpCacheHint().setTtlMs(77).setCacheScope(McpCacheScope.PRIVATE)));
}
}
@@ -91,9 +91,9 @@ class McpCacheBindingIntegration_Test {
@Override public McpServerConfig getMcpConfig() {
return new McpServerConfig().addTool(echo());
}
- @Override public McpCacheConfig cacheConfig() {
- return new McpCacheConfig().setToolsList(
- new McpCacheHint().setTtlMs(77).setCacheScope(McpCacheScope.PRIVATE));
+ @Override public McpOptions getMcpOptions() {
+ return new McpOptions().cache(c -> c.setToolsList(
+ new McpCacheHint().setTtlMs(77).setCacheScope(McpCacheScope.PRIVATE)));
}
}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSpringBootIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSpringBootIntegration_Test.java
new file mode 100644
index 0000000000..59929ff893
--- /dev/null
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSpringBootIntegration_Test.java
@@ -0,0 +1,262 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.*;
+import java.net.http.*;
+import java.net.http.HttpRequest.*;
+import java.net.http.HttpResponse.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.bean.jsonrpc.*;
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.commons.inject.BeanStore;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.marshall.marshaller.Json;
+import org.apache.juneau.rest.server.*;
+import org.apache.juneau.rest.server.mcp.*;
+import org.apache.juneau.rest.server.springboot.*;
+import org.junit.jupiter.api.*;
+import org.springframework.beans.factory.annotation.*;
+import org.springframework.boot.*;
+import org.springframework.boot.autoconfigure.*;
+import org.springframework.boot.test.context.*;
+import org.springframework.boot.test.context.SpringBootTest.*;
+import org.springframework.boot.test.web.server.*;
+import org.springframework.boot.web.servlet.*;
+import org.springframework.context.annotation.*;
+import org.springframework.test.annotation.*;
+
+/**
+ * Real-Spring-Boot proof for the MCP-on-Spring-Boot recipe: that {@link SpringMcpRestServlet} actually
+ * bridges the Spring {@code ApplicationContext} into an MCP tool handler's per-request {@code BeanStore},
+ * versus the plain servlet-subclass path ({@link McpRestServlet}) which does not.
+ *
+ * <p>
+ * Before this test, nothing anywhere exercised MCP + Spring Boot together: the recipe was documented
+ * (see {@code SpringMcpRestServlet}'s javadoc and the {@code juneau-docs} MCP recipes topic) but never
+ * proven against a real embedded-Tomcat Spring Boot context.
+ *
+ * <p>
+ * Boots a single Spring Boot context with embedded Tomcat on a random port (mirroring
+ * {@code BasicApiDocs_Springboot_Test}'s idiom) registering <b>two</b> independent MCP servlets side by
+ * side, both backed by the very same {@code ApplicationContext} and the very same {@code @Bean
+ * MySpringService} singleton:
+ * <ul>
+ * <li><b>Positive case</b> ({@link SpringMcpResource}, {@code /mcp-spring/mcp}): extends
+ * {@link SpringMcpRestServlet}. Its {@code whoAmI} tool handler calls
+ * {@code BeanStore.getBean(MySpringService.class)} and must resolve the real Spring-managed
+ * singleton — proven by echoing back a per-instance random marker that exactly matches the
+ * same instance {@code @Autowired} into this test class.
+ * <li><b>Negative/contrast case</b> ({@link PlainMcpResource}, {@code /mcp-plain/mcp}): extends
+ * {@link McpRestServlet} (the plain servlet-subclass path, no Spring base class). The identical
+ * {@code whoAmI} tool handler's {@code BeanStore.getBean(MySpringService.class)} call must come
+ * back empty, documenting the recipe's known boundary: subclassing {@link McpRestServlet} does not
+ * carry Spring DI, even when deployed inside the same Spring Boot app as a servlet that does.
+ * </ul>
+ *
+ * @since 10.0.0
+ */
+@org.apache.juneau.testing.SpringbootTest
+@SpringBootTest(classes = McpSpringBootIntegration_Test.TestApp.class, webEnvironment = WebEnvironment.RANDOM_PORT)
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class McpSpringBootIntegration_Test {
+
+ /**
+ * A Spring-managed singleton service. Generates a random per-instance marker at construction so any
+ * two distinct instances are trivially distinguishable: a tool handler that echoes back a marker
+ * matching {@link #mySpringService}'s (the instance this test class itself has {@code @Autowired})
+ * can only be doing so because it resolved that exact same Spring-container-managed instance, not a
+ * coincidentally-equal one it constructed itself.
+ */
+ static class MySpringService {
+ private final String marker = "spring-bean-" + UUID.randomUUID();
+ String getMarker() { return marker; }
+ }
+
+ /** Sentinel returned by {@link #whoAmITool()} when {@code BeanStore.getBean(MySpringService.class)} finds nothing. */
+ private static final String NO_BEAN = "NO_SPRING_BEAN_RESOLVED";
+
+ /**
+ * Shared tool logic registered identically on both {@link SpringMcpResource} and
+ * {@link PlainMcpResource} below: resolve {@link MySpringService} through the per-request
+ * {@link BeanStore} and echo back its marker, or {@link #NO_BEAN} if it could not be resolved. Using
+ * one shared handler implementation for both fixtures ensures the only variable between the two test
+ * cases is which servlet base class is doing the resolving, not any difference in the handler itself.
+ */
+ private static McpToolHandler whoAmITool() {
+ return McpToolHandler.of(
+ new McpToolSpec().setName("whoAmI").setDescription("Resolves MySpringService via BeanStore.getBean(...)"),
+ (arguments, ctx) -> McpToolOutcome.text(ctx.getBean(MySpringService.class).map(MySpringService::getMarker).orElse(NO_BEAN)));
+ }
+
+ // -------- positive case: the MCP-on-Spring-Boot recipe ---------
+
+ /**
+ * The recipe under test. {@link SpringMcpRestServlet} extends {@link BasicSpringRestServlet}, whose
+ * {@code SpringRestServlet} ancestor has an {@code @Autowired ApplicationContext} field and a
+ * {@code @Bean createBeanStore(...)} factory method that wraps it in a {@code SpringBeanStore}; that
+ * store becomes this resource's {@code RestContext} bean store, and the MCP endpoint mixin's
+ * {@code handleMcpRequest} wraps a fresh per-request {@code BasicBeanStore} around it before invoking
+ * the tool handler — so {@code BeanStore.getBean(MySpringService.class)} chains all the way
+ * through to {@code ApplicationContext.getBeanProvider(MySpringService.class)}.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class SpringMcpResource extends SpringMcpRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Override public McpServerConfig getMcpConfig() {
+ return new McpServerConfig().addTool(whoAmITool());
+ }
+ }
+
+ // -------- negative/contrast case: the servlet-subclass path does NOT carry Spring DI ---------
+
+ /**
+ * Contrast fixture documenting the recipe's known boundary. {@link McpRestServlet} is a sibling
+ * {@code RestServlet} subclass to {@link BasicSpringRestServlet} (both ultimately extend
+ * {@code RestServlet}), <b>not</b> a subclass of it — so it never gets the
+ * {@code @Autowired ApplicationContext} field {@code SpringRestServlet} injects, and never publishes
+ * a {@code SpringBeanStore}-backed {@code createBeanStore()} bean. Its {@code RestContext} bean store
+ * is therefore a plain, Spring-oblivious store even though this servlet is registered in the very
+ * same Spring Boot context as {@link SpringMcpResource} (same {@code ApplicationContext}, same
+ * {@code @Bean MySpringService}). Its {@code whoAmI} tool handler's
+ * {@code BeanStore.getBean(MySpringService.class)} call therefore cannot see that bean.
+ */
+ @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
+ public static class PlainMcpResource extends McpRestServlet {
+ private static final long serialVersionUID = 1L;
+ @Override protected McpServerConfig createMcpConfig() {
+ return new McpServerConfig().addTool(whoAmITool());
+ }
+ }
+
+ /**
+ * Minimal Spring Boot application registering both fixture servlets side by side at distinct
+ * container URL mappings, plus the single shared {@code @Bean MySpringService} both endpoints (try
+ * to) resolve.
+ */
+ @SpringBootConfiguration
+ @EnableAutoConfiguration
+ public static class TestApp {
+
+ @Bean
+ public MySpringService mySpringService() {
+ return new MySpringService();
+ }
+
+ @Bean
+ public SpringMcpResource springMcpResource() {
+ return new SpringMcpResource();
+ }
+
+ @Bean
+ public ServletRegistrationBean<SpringMcpResource> springMcpRegistration(SpringMcpResource servlet) {
+ return new ServletRegistrationBean<>(servlet, "/mcp-spring/*");
+ }
+
+ @Bean
+ public PlainMcpResource plainMcpResource() {
+ return new PlainMcpResource();
+ }
+
+ @Bean
+ public ServletRegistrationBean<PlainMcpResource> plainMcpRegistration(PlainMcpResource servlet) {
+ return new ServletRegistrationBean<>(servlet, "/mcp-plain/*");
+ }
+ }
+
+ @Autowired
+ MySpringService mySpringService;
+
+ @LocalServerPort
+ int port;
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(5))
+ .followRedirects(HttpClient.Redirect.NEVER)
+ .build();
+
+ private static Object validMeta() {
+ return JsonMap.of(
+ RequestMeta.KEY_PROTOCOL_VERSION, "2026-07-28",
+ RequestMeta.KEY_CLIENT_INFO, JsonMap.of("name", "fixture-client", "version", "1.0"),
+ RequestMeta.KEY_CLIENT_CAPABILITIES, JsonMap.of());
+ }
+
+ private static String toolCallBody(String toolName) {
+ var params = JsonMap.of("name", toolName, "arguments", JsonMap.of(), "_meta", validMeta());
+ return Json.of(new JsonRpcRequest().setJsonrpc(McpProtocol.JSON_RPC_2_0).setId(1).setMethod("tools/call").setParams(params));
+ }
+
+ /**
+ * Drives a real {@code tools/call} for the {@code whoAmI} tool over real HTTP against {@code path},
+ * and returns the text content of the (successful) result.
+ */
+ private String callWhoAmI(String path) throws Exception {
+ var req = HttpRequest.newBuilder()
+ .uri(URI.create("http://localhost:" + port + path))
+ .timeout(Duration.ofSeconds(10))
+ .header("Content-Type", "application/json")
+ .header("Accept", "application/json")
+ .header("Mcp-Method", "tools/call")
+ .header("Mcp-Name", "whoAmI")
+ .POST(BodyPublishers.ofString(toolCallBody("whoAmI")))
+ .build();
+ var resp = HTTP.send(req, BodyHandlers.ofString());
+ assertEquals(200, resp.statusCode(), () -> "Unexpected status; body=" + resp.body());
+ var envelope = Json.to(resp.body(), JsonMap.class);
+ assertNull(envelope.get("error"), () -> "Unexpected JSON-RPC error: " + envelope.get("error"));
+ var result = (Map<?,?>) envelope.get("result");
+ var content = (List<?>) result.get("content");
+ var first = (Map<?,?>) content.get(0);
+ return (String) first.get("text");
+ }
+
+ @Test
+ void a01_springMcpRestServlet_toolHandlerResolvesRealSpringManagedBean() throws Exception {
+ // SpringMcpRestServlet exposes the MCP endpoint via the McpEndpoint mixin's handleMcpRequest,
+ // which is annotated @RestPost(path = "/mcp") -- relative to this resource's own (default,
+ // root) @Rest path -- so the wire URL is basePath + "/mcp".
+ var marker = callWhoAmI("/mcp-spring/mcp");
+
+ assertNotEquals(NO_BEAN, marker, "Positive case: the Spring-DI-aware endpoint must resolve MySpringService");
+ assertEquals(mySpringService.getMarker(), marker,
+ "The MCP tool handler must resolve the SAME Spring-managed singleton this test class has @Autowired -- "
+ + "proving BeanStore.getBean(...) really flowed through to Spring's ApplicationContext, not just "
+ + "some independently-constructed lookalike");
+ }
+
+ @Test
+ void b01_plainServletSubclassPath_doesNotCarrySpringDi() throws Exception {
+ // Contrast/documentation case: same Spring Boot app, same ApplicationContext, same @Bean
+ // MySpringService -- but this endpoint extends McpRestServlet (not SpringMcpRestServlet /
+ // BasicSpringRestServlet), so its RestContext BeanStore was never bridged to Spring. This is a
+ // known, documented boundary of the recipe, not a bug: BeanStore.getBean(MySpringService.class)
+ // must come back empty here, in contrast to a01 above.
+ // McpRestServlet's operation is declared @RestPost(path = "/") -- its servlet root, not "/mcp"
+ // -- unlike the mixin path exercised in a01 above, so the wire URL is just basePath itself.
+ var marker = callWhoAmI("/mcp-plain");
+
+ assertEquals(NO_BEAN, marker,
+ "extends McpRestServlet (the plain servlet-subclass path, no Spring bridge) must NOT resolve the "
+ + "Spring-managed bean -- this is the recipe's documented boundary, not a regression");
+ }
+}
diff --git a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpTemplateCompletionIntegration_Test.java b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpTemplateCompletionIntegration_Test.java
index 80fef0c201..79298a9152 100644
--- a/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpTemplateCompletionIntegration_Test.java
+++ b/juneau-integration-tests/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpTemplateCompletionIntegration_Test.java
@@ -216,7 +216,7 @@ class McpTemplateCompletionIntegration_Test {
public static class C_OverrideWithCompleterRegistered extends McpRestServlet {
private static final long serialVersionUID = 1L;
@Override protected McpServerConfig createMcpConfig() { return new McpServerConfig().addPrompt(promptWithCompleter()); }
- @Override protected ServerCapabilities capabilities() { return explicit(); }
+ @Override protected McpOptions createMcpOptions() { return new McpOptions().setCapabilities(explicit()); }
}
private static String discover(Class<?> servlet) throws Exception {
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests.java
similarity index 61%
rename from juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java
rename to juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests.java
index 834f941255..638690c8f0 100644
--- a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess.java
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests.java
@@ -21,26 +21,33 @@ import static org.apache.juneau.commons.utils.AssertionUtils.*;
import java.util.*;
import org.apache.juneau.bean.mcp.v20260728.*;
-import org.apache.juneau.marshall.collections.*;
import org.apache.juneau.marshall.marshaller.*;
/**
- * Static helper for driving MCP {@code 2026-07-28} SEP-2322 elicitation over {@link McpClient#callRaw}'s raw
- * {@code Map<String,Object>} result (MRTR, TODO-318's Multi-Round-Trip Requests loop, client side).
+ * Static helper for reading server→client MCP {@code 2026-07-28} SEP-2322 elicitation requests out of
+ * {@link McpClient#callRaw}'s raw {@code Map<String,Object>} result (MRTR, TODO-318's Multi-Round-Trip Requests
+ * loop, client side).
+ *
+ * <p>
+ * Paired with {@link ElicitationResponses}, which encodes the client's answers back into the wire shape a resume
+ * call expects — the same {@code ElicitationRequests}/{@code ElicitationResponses} split the server side
+ * already uses (server {@code ElicitationRequests} builds the pause signal; server {@code ElicitationResponses}
+ * reads the resumed answers).
*
* <p>
* Works at the raw map/JSON level rather than exposing typed overloads per concrete request bean, because
* {@code CallToolRequest}/{@code GetPromptRequest}/{@code ReadResourceRequest} share no common
* "has-inputResponses-and-requestState" interface (each independently declares its own pair of fields; their
* actual common base, {@code RequestParams<T>}, carries only {@code _meta}). A caller resuming, say, a
- * {@code CallToolRequest} calls {@link #toInputResponses(Map)} for the raw payload, then makes its own
- * {@code .setInputResponses(...).setRequestState(...)} call on the concrete bean it already knows it holds.
+ * {@code CallToolRequest} calls {@link ElicitationResponses#toInputResponses(Map)} for the raw payload, then
+ * makes its own {@code .setInputResponses(...).setRequestState(...)} call on the concrete bean it already knows
+ * it holds.
*
* @since 10.0.0
*/
-public final class ElicitationAccess {
+public final class ElicitationRequests {
- private ElicitationAccess() {}
+ private ElicitationRequests() {}
/**
* Whether a {@link McpClient#callRaw} result represents a paused elicitation (or any other MRTR pause).
@@ -106,50 +113,4 @@ public final class ElicitationAccess {
assertArgNotNull("raw", raw);
return (String) raw.get("requestState");
}
-
- /**
- * Encodes a single typed {@link ElicitResult} into the raw {@code inputResponses} payload shape.
- *
- * @param id The server-assigned id this answer responds to. Must not be <jk>null</jk>.
- * @param result The typed answer. Must not be <jk>null</jk>.
- * @return A single-entry map ready to pass to {@link #toInputResponses(Map)}'s multi-answer form, or to hand
- * directly to a concrete request bean's {@code setInputResponses(...)} after merging with other answers.
- * @throws IllegalArgumentException If {@code id} or {@code result} is <jk>null</jk>.
- */
- public static Map<String,Object> toInputResponse(String id, ElicitResult result) {
- assertArgNotNull("id", id);
- assertArgNotNull("result", result);
- return toInputResponses(Map.of(id, result));
- }
-
- /**
- * Encodes several typed {@link ElicitResult}s into the raw {@code inputResponses} payload shape a resume
- * call's request bean expects (e.g. {@code CallToolRequest.setInputResponses(...)}).
- *
- * <p>
- * Unlike {@code ElicitationRequests.of(Map,Object)} (server side), an empty {@code results} map is accepted (returning
- * an empty map), not rejected — deliberately asymmetric, since a caller may legitimately resume with
- * zero elicitation answers if a round only carried non-elicitation MRTR pauses, whereas building a signal
- * with zero questions server-side would be a pointless pause.
- *
- * @param results Server-assigned-id-keyed typed answers. Must not be <jk>null</jk>, and no value may be
- * <jk>null</jk>. Output iteration order mirrors this map's order, so a caller wanting deterministic
- * ordering should pass a {@link LinkedHashMap}.
- * @return A keyed map of raw, wire-shaped answers. Never <jk>null</jk> (empty if {@code results} was empty).
- * @throws IllegalArgumentException If {@code results} is <jk>null</jk>, or any value in it is <jk>null</jk>.
- */
- public static Map<String,Object> toInputResponses(Map<String,ElicitResult> results) {
- assertArgNotNull("results", results);
- Map<String,Object> out = new LinkedHashMap<>();
- results.forEach((id, result) -> {
- assertArgNotNull("results[" + id + "]", result);
- // Pre-marshalled to JsonMap here (rather than left as the typed ElicitResult for McpClient.call's
- // own toWireParams flattening) so that (a) a null-check on the encoded shape is meaningful even
- // when this helper is used standalone, outside McpClient.call's flow, and (b) the returned map is
- // immediately wire-ready for a caller who hands it straight to a concrete request bean's
- // setInputResponses(...) without ever going through McpClient at all.
- out.put(id, Json.to(Json.of(result), JsonMap.class));
- });
- return out;
- }
}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses.java
new file mode 100644
index 0000000000..19e1b96f54
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.apache.juneau.marshall.marshaller.*;
+
+/**
+ * Static helper for encoding the client's answers to server→client MCP {@code 2026-07-28} SEP-2322
+ * elicitation requests back into the raw {@code inputResponses} wire shape a resume call expects (MRTR,
+ * TODO-318's Multi-Round-Trip Requests loop, client side).
+ *
+ * <p>
+ * Paired with {@link ElicitationRequests}, which reads the server's questions out of a paused
+ * {@link McpClient#callRaw} result — the same {@code ElicitationRequests}/{@code ElicitationResponses}
+ * split the server side already uses (server {@code ElicitationRequests} builds the pause signal; server
+ * {@code ElicitationResponses} reads the resumed answers).
+ *
+ * @since 10.0.0
+ */
+public final class ElicitationResponses {
+
+ private ElicitationResponses() {}
+
+ /**
+ * Encodes a single typed {@link ElicitResult} into the raw {@code inputResponses} payload shape.
+ *
+ * @param id The server-assigned id this answer responds to. Must not be <jk>null</jk>.
+ * @param result The typed answer. Must not be <jk>null</jk>.
+ * @return A single-entry map ready to pass to {@link #toInputResponses(Map)}'s multi-answer form, or to hand
+ * directly to a concrete request bean's {@code setInputResponses(...)} after merging with other answers.
+ * @throws IllegalArgumentException If {@code id} or {@code result} is <jk>null</jk>.
+ */
+ public static Map<String,Object> toInputResponse(String id, ElicitResult result) {
+ assertArgNotNull("id", id);
+ assertArgNotNull("result", result);
+ return toInputResponses(Map.of(id, result));
+ }
+
+ /**
+ * Encodes several typed {@link ElicitResult}s into the raw {@code inputResponses} payload shape a resume
+ * call's request bean expects (e.g. {@code CallToolRequest.setInputResponses(...)}).
+ *
+ * <p>
+ * Unlike {@code ElicitationRequests.of(Map,Object)} (server side), an empty {@code results} map is accepted
+ * (returning an empty map), not rejected — deliberately asymmetric, since a caller may legitimately
+ * resume with zero elicitation answers if a round only carried non-elicitation MRTR pauses, whereas building
+ * a signal with zero questions server-side would be a pointless pause.
+ *
+ * @param results Server-assigned-id-keyed typed answers. Must not be <jk>null</jk>, and no value may be
+ * <jk>null</jk>. Output iteration order mirrors this map's order, so a caller wanting deterministic
+ * ordering should pass a {@link LinkedHashMap}.
+ * @return A keyed map of raw, wire-shaped answers. Never <jk>null</jk> (empty if {@code results} was empty).
+ * @throws IllegalArgumentException If {@code results} is <jk>null</jk>, or any value in it is <jk>null</jk>.
+ */
+ public static Map<String,Object> toInputResponses(Map<String,ElicitResult> results) {
+ assertArgNotNull("results", results);
+ Map<String,Object> out = new LinkedHashMap<>();
+ results.forEach((id, result) -> {
+ assertArgNotNull("results[" + id + "]", result);
+ // Pre-marshalled to JsonMap here (rather than left as the typed ElicitResult for McpClient.call's
+ // own toWireParams flattening) so that (a) a null-check on the encoded shape is meaningful even
+ // when this helper is used standalone, outside McpClient.call's flow, and (b) the returned map is
+ // immediately wire-ready for a caller who hands it straight to a concrete request bean's
+ // setInputResponses(...) without ever going through McpClient at all.
+ out.put(id, Json.to(Json.of(result), JsonMap.class));
+ });
+ return out;
+ }
+}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/main/java/org/apache/juneau/rest/client/mcp/v20260728/McpClient.java
index 1ea2a92249..51ff43da35 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
@@ -459,13 +459,29 @@ public final class McpClient extends AbstractMcpClient {
// The stream ended (clean EOF, reader.hasNext() == false) without ever reaching a terminal
// frame, and the caller did not initiate this close (open is still true): an abrupt drop the
// listener must be told about, not silent - it is otherwise indistinguishable from a hang.
- if (open && ! reachedTerminal)
+ //
+ // open is flipped to false BEFORE invoking the listener (guarded on the pre-read value so a
+ // concurrent cancel() can't cause a double-fire), not after in the finally block below: the
+ // terminal callback is very often the exact synchronization point (a latch/future countdown,
+ // or a direct isOpen() read from inside the callback itself) a caller relies on to learn the
+ // pump is done, and CountDownLatch/Future's happens-before is one-directional (it orders the
+ // signal before the waiter's wakeup, never the reverse) - so this volatile write must
+ // happen-before that signal, not after it, or a woken caller can transiently observe open
+ // still true.
+ if (open && ! reachedTerminal) {
+ open = false;
invokeListener(() -> listener.onError(new EOFException(
"Subscription stream closed before a terminal frame was received.")));
+ }
} catch (Exception e) {
- if (open)
+ if (open) {
+ open = false;
invokeListener(() -> listener.onError(e));
+ }
} finally {
+ // Idempotent safety net: open is already false on every path above that invoked a terminal
+ // callback, and closeReaderQuietly() must run unconditionally regardless of which path (if
+ // any) was taken - including a plain open cancel()/close() with no callback at all.
open = false;
closeReaderQuietly();
}
@@ -483,6 +499,9 @@ public final class McpClient extends AbstractMcpClient {
return true;
}
var res = JsonParser.DEFAULT.read(data, JsonRpcResponse.class);
+ // open is flipped to false BEFORE invoking either terminal callback below, not after in run()'s
+ // finally block - see the longer rationale on the abrupt-EOF branch in run().
+ open = false;
if (res.getError() != null) {
var mcpException = McpException.fromJsonRpcError(res.getError());
invokeListener(() -> listener.onError(mcpException));
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java
deleted file mode 100644
index cf0ba044fa..0000000000
--- a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationAccess_Test.java
+++ /dev/null
@@ -1,176 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.juneau.rest.client.mcp.v20260728;
-
-import static org.junit.jupiter.api.Assertions.*;
-
-import java.util.*;
-
-import org.apache.juneau.bean.mcp.v20260728.*;
-import org.apache.juneau.marshall.collections.*;
-import org.junit.jupiter.api.*;
-
-/**
- * Coverage for {@link ElicitationAccess}.
- */
-class ElicitationAccess_Test {
-
- @Test void a01_isInputRequired_trueOnInputRequiredResultType() {
- assertTrue(ElicitationAccess.isInputRequired(Map.of("resultType", "input_required")));
- }
-
- @Test void a02_isInputRequired_falseOnCompleteResultType() {
- assertFalse(ElicitationAccess.isInputRequired(Map.of("resultType", "complete")));
- }
-
- @Test void a03_isInputRequired_nullRawThrows() {
- var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.isInputRequired(null));
- assertEquals("Argument 'raw' cannot be null.", e.getMessage());
- }
-
- @Test void a04_requests_decodesTypedElicitRequestsFromRawMap() {
- var raw = Map.<String,Object>of(
- "resultType", "input_required",
- "inputRequests", JsonMap.of(
- "q1", JsonMap.of(
- "message", "Pick",
- "requestedSchema", JsonMap.of(
- "type", "object",
- "properties", JsonMap.of("choice", JsonMap.of("type", "string"))
- )
- )
- )
- );
- var requests = ElicitationAccess.requests(raw);
- var q1 = requests.get("q1");
- assertEquals("Pick", q1.getMessage());
- assertNotNull(q1.getRequestedSchema());
- }
-
- @Test void a05_requests_missingInputRequestsReturnsEmptyMap() {
- var requests = ElicitationAccess.requests(Map.of("resultType", "complete"));
- assertNotNull(requests);
- assertTrue(requests.isEmpty());
- }
-
- @Test void a06_requestState_returnsEchoedToken() {
- assertEquals("tok-abc", ElicitationAccess.requestState(Map.of("resultType", "input_required", "requestState", "tok-abc")));
- }
-
- @Test void a07_requestState_missingReturnsNull() {
- assertNull(ElicitationAccess.requestState(Map.of("resultType", "complete")));
- }
-
- @Test void a08_toInputResponse_singleAnswer_encodesAcceptWithContent() {
- var result = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
- var encoded = ElicitationAccess.toInputResponse("q1", result);
- assertEquals(1, encoded.size());
- var q1 = (JsonMap) encoded.get("q1");
- assertEquals("accept", q1.getString("action"));
- assertEquals("red", q1.getMap("content").getString("choice"));
- }
-
- @Test void a09_toInputResponses_multipleAnswers_encodesAll() {
- var results = new LinkedHashMap<String,ElicitResult>();
- results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red"));
- results.put("q2", new ElicitResult().setAction(ElicitAction.DECLINE));
- var encoded = ElicitationAccess.toInputResponses(results);
- assertEquals(2, encoded.size());
- var q1 = (JsonMap) encoded.get("q1");
- assertEquals("accept", q1.getString("action"));
- assertEquals("red", q1.getMap("content").getString("choice"));
- var q2 = (JsonMap) encoded.get("q2");
- assertEquals("decline", q2.getString("action"));
- assertNull(q2.get("content"));
- }
-
- @Test void a10_toInputResponse_nullIdThrows() {
- var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponse(null, new ElicitResult()));
- assertEquals("Argument 'id' cannot be null.", e.getMessage());
- }
-
- @Test void a11_toInputResponse_nullResultThrows() {
- var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponse("q1", null));
- assertEquals("Argument 'result' cannot be null.", e.getMessage());
- }
-
- @Test void a12_toInputResponses_nullMapThrows() {
- var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponses(null));
- assertEquals("Argument 'results' cannot be null.", e.getMessage());
- }
-
- /**
- * Documents the wire-shape symmetry between this client module's {@link ElicitationAccess} and the server
- * module's {@code ElicitationRequests}/{@code ElicitationResponses} (Phase 4) without introducing an illegal
- * cross-module test dependency: {@code juneau-rest-client-mcp-v20260728} must not depend on
- * {@code juneau-rest-server-mcp-v20260728}, so the server-side raw shape is hand-built here to match it
- * exactly (verified by inspection/spec cross-reference against {@code ElicitationRequests.of}/
- * {@code ElicitationResponses.get}, not a live cross-module call).
- */
- @Test void a13_roundTrip_requestsToResponses_matchesServerSideHelpers() {
- // Mirrors the raw shape ElicitationRequests.of("q1", elicitRequest, ...) would place into
- // McpInputRequiredSignal.getInputRequests(), which callRaw ultimately surfaces as raw's "inputRequests".
- var rawInputRequests = JsonMap.of(
- "q1", JsonMap.of("message", "Pick one", "requestedSchema", JsonMap.of("type", "object"))
- );
- var raw = Map.<String,Object>of("resultType", "input_required", "requestState", "tok-abc", "inputRequests", rawInputRequests);
-
- var decodedRequests = ElicitationAccess.requests(raw);
- assertEquals("Pick one", decodedRequests.get("q1").getMessage());
- assertEquals("tok-abc", ElicitationAccess.requestState(raw));
-
- var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
- var encodedResponses = ElicitationAccess.toInputResponse("q1", answer);
-
- // This is exactly the shape ElicitationResponses.get(ctx, "q1") decodes via
- // Json.to(Json.of(ctx.inputResponses().get("q1")), ElicitResult.class).
- var q1 = (JsonMap) encodedResponses.get("q1");
- assertEquals("accept", q1.getString("action"));
- assertEquals("red", q1.getMap("content").getString("choice"));
- }
-
- @Test void a14_requests_malformedEntryThrows() {
- // A JSON-array entry cannot be converted to a bean target: the marshaller throws (which a caller's own
- // error handling surfaces). Mirrors ElicitationResponses_Test's a09_get_malformedShapeThrows for the
- // server-side decode direction.
- var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", JsonMap.of("q1", JsonList.of(1, 2)));
- assertThrows(RuntimeException.class, () -> ElicitationAccess.requests(raw));
- }
-
- @Test void a15_toInputResponses_nullEntryThrows() {
- var results = new LinkedHashMap<String,ElicitResult>();
- results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT));
- results.put("q2", null);
- var e = assertThrows(IllegalArgumentException.class, () -> ElicitationAccess.toInputResponses(results));
- assertEquals("Argument 'results[q2]' cannot be null.", e.getMessage());
- }
-
- @Test void a16_isInputRequired_falseOnMissingResultType() {
- assertFalse(ElicitationAccess.isInputRequired(Map.of("foo", "bar")));
- }
-
- @Test void a17_requests_nullEntryDecodesToNull() {
- // Mirrors ElicitationResponses.get()'s explicit null handling: a null raw value decodes to a null
- // typed entry rather than round-tripping through the marshaller.
- var rawInputRequests = new LinkedHashMap<String,Object>();
- rawInputRequests.put("q1", null);
- var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", rawInputRequests);
- var requests = ElicitationAccess.requests(raw);
- assertTrue(requests.containsKey("q1"));
- assertNull(requests.get("q1"));
- }
-}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests_Test.java
new file mode 100644
index 0000000000..be3b50602e
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationRequests_Test.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitationRequests}.
+ */
+class ElicitationRequests_Test {
+
+ @Test void a01_isInputRequired_trueOnInputRequiredResultType() {
+ assertTrue(ElicitationRequests.isInputRequired(Map.of("resultType", "input_required")));
+ }
+
+ @Test void a02_isInputRequired_falseOnCompleteResultType() {
+ assertFalse(ElicitationRequests.isInputRequired(Map.of("resultType", "complete")));
+ }
+
+ @Test void a03_isInputRequired_nullRawThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationRequests.isInputRequired(null));
+ assertEquals("Argument 'raw' cannot be null.", e.getMessage());
+ }
+
+ @Test void a04_requests_decodesTypedElicitRequestsFromRawMap() {
+ var raw = Map.<String,Object>of(
+ "resultType", "input_required",
+ "inputRequests", JsonMap.of(
+ "q1", JsonMap.of(
+ "message", "Pick",
+ "requestedSchema", JsonMap.of(
+ "type", "object",
+ "properties", JsonMap.of("choice", JsonMap.of("type", "string"))
+ )
+ )
+ )
+ );
+ var requests = ElicitationRequests.requests(raw);
+ var q1 = requests.get("q1");
+ assertEquals("Pick", q1.getMessage());
+ assertNotNull(q1.getRequestedSchema());
+ }
+
+ @Test void a05_requests_missingInputRequestsReturnsEmptyMap() {
+ var requests = ElicitationRequests.requests(Map.of("resultType", "complete"));
+ assertNotNull(requests);
+ assertTrue(requests.isEmpty());
+ }
+
+ @Test void a06_requestState_returnsEchoedToken() {
+ assertEquals("tok-abc", ElicitationRequests.requestState(Map.of("resultType", "input_required", "requestState", "tok-abc")));
+ }
+
+ @Test void a07_requestState_missingReturnsNull() {
+ assertNull(ElicitationRequests.requestState(Map.of("resultType", "complete")));
+ }
+
+ @Test void a08_requests_malformedEntryThrows() {
+ // A JSON-array entry cannot be converted to a bean target: the marshaller throws (which a caller's own
+ // error handling surfaces). Mirrors ElicitationResponses_Test's a05_toInputResponses_nullEntryThrows-style
+ // server-side decode direction coverage.
+ var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", JsonMap.of("q1", JsonList.of(1, 2)));
+ assertThrows(RuntimeException.class, () -> ElicitationRequests.requests(raw));
+ }
+
+ @Test void a09_isInputRequired_falseOnMissingResultType() {
+ assertFalse(ElicitationRequests.isInputRequired(Map.of("foo", "bar")));
+ }
+
+ @Test void a10_requests_nullEntryDecodesToNull() {
+ // Mirrors ElicitationResponses.get()'s explicit null handling (server side): a null raw value decodes
+ // to a null typed entry rather than round-tripping through the marshaller.
+ var rawInputRequests = new LinkedHashMap<String,Object>();
+ rawInputRequests.put("q1", null);
+ var raw = Map.<String,Object>of("resultType", "input_required", "inputRequests", rawInputRequests);
+ var requests = ElicitationRequests.requests(raw);
+ assertTrue(requests.containsKey("q1"));
+ assertNull(requests.get("q1"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses_Test.java
new file mode 100644
index 0000000000..f2df2e05d9
--- /dev/null
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/ElicitationResponses_Test.java
@@ -0,0 +1,108 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.client.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.bean.mcp.v20260728.*;
+import org.apache.juneau.marshall.collections.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Coverage for {@link ElicitationResponses}.
+ */
+class ElicitationResponses_Test {
+
+ @Test void a01_toInputResponse_singleAnswer_encodesAcceptWithContent() {
+ var result = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
+ var encoded = ElicitationResponses.toInputResponse("q1", result);
+ assertEquals(1, encoded.size());
+ var q1 = (JsonMap) encoded.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ }
+
+ @Test void a02_toInputResponses_multipleAnswers_encodesAll() {
+ var results = new LinkedHashMap<String,ElicitResult>();
+ results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red"));
+ results.put("q2", new ElicitResult().setAction(ElicitAction.DECLINE));
+ var encoded = ElicitationResponses.toInputResponses(results);
+ assertEquals(2, encoded.size());
+ var q1 = (JsonMap) encoded.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ var q2 = (JsonMap) encoded.get("q2");
+ assertEquals("decline", q2.getString("action"));
+ assertNull(q2.get("content"));
+ }
+
+ @Test void a03_toInputResponse_nullIdThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.toInputResponse(null, new ElicitResult()));
+ assertEquals("Argument 'id' cannot be null.", e.getMessage());
+ }
+
+ @Test void a04_toInputResponse_nullResultThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.toInputResponse("q1", null));
+ assertEquals("Argument 'result' cannot be null.", e.getMessage());
+ }
+
+ @Test void a05_toInputResponses_nullMapThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.toInputResponses(null));
+ assertEquals("Argument 'results' cannot be null.", e.getMessage());
+ }
+
+ @Test void a06_toInputResponses_nullEntryThrows() {
+ var results = new LinkedHashMap<String,ElicitResult>();
+ results.put("q1", new ElicitResult().setAction(ElicitAction.ACCEPT));
+ results.put("q2", null);
+ var e = assertThrows(IllegalArgumentException.class, () -> ElicitationResponses.toInputResponses(results));
+ assertEquals("Argument 'results[q2]' cannot be null.", e.getMessage());
+ }
+
+ /**
+ * Documents the wire-shape symmetry between this client module's {@link ElicitationRequests}/
+ * {@link ElicitationResponses} pair and the server module's {@code ElicitationRequests}/
+ * {@code ElicitationResponses} (Phase 4) without introducing an illegal cross-module test dependency:
+ * {@code juneau-rest-client-mcp-v20260728} must not depend on {@code juneau-rest-server-mcp-v20260728}, so
+ * the server-side raw shape is hand-built here to match it exactly (verified by inspection/spec
+ * cross-reference against {@code ElicitationRequests.of}/{@code ElicitationResponses.get}, not a live
+ * cross-module call).
+ */
+ @Test void a07_roundTrip_requestsToResponses_matchesServerSideHelpers() {
+ // Mirrors the raw shape ElicitationRequests.of("q1", elicitRequest, ...) (server side) would place into
+ // McpInputRequiredSignal.getInputRequests(), which callRaw ultimately surfaces as raw's "inputRequests".
+ var rawInputRequests = JsonMap.of(
+ "q1", JsonMap.of("message", "Pick one", "requestedSchema", JsonMap.of("type", "object"))
+ );
+ var raw = Map.<String,Object>of("resultType", "input_required", "requestState", "tok-abc", "inputRequests", rawInputRequests);
+
+ var decodedRequests = ElicitationRequests.requests(raw);
+ assertEquals("Pick one", decodedRequests.get("q1").getMessage());
+ assertEquals("tok-abc", ElicitationRequests.requestState(raw));
+
+ var answer = new ElicitResult().setAction(ElicitAction.ACCEPT).putContent("choice", "red");
+ var encodedResponses = ElicitationResponses.toInputResponse("q1", answer);
+
+ // This is exactly the shape ElicitationResponses.get(ctx, "q1") (server side) decodes via
+ // Json.to(Json.of(ctx.inputResponses().get("q1")), ElicitResult.class).
+ var q1 = (JsonMap) encodedResponses.get("q1");
+ assertEquals("accept", q1.getString("action"));
+ assertEquals("red", q1.getMap("content").getString("choice"));
+ }
+}
diff --git a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClientSubscriptionsListen_Test.java b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClientSubscriptionsListen_Test.java
index 895dec0daf..604a443cfe 100644
--- a/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClientSubscriptionsListen_Test.java
+++ b/juneau-rest/juneau-rest-client-mcp-v20260728/src/test/java/org/apache/juneau/rest/client/mcp/v20260728/McpClientSubscriptionsListen_Test.java
@@ -324,6 +324,181 @@ class McpClientSubscriptionsListen_Test {
}
}
+ /**
+ * Blocks on {@code latch} swallowing {@link InterruptedException} (re-asserting the interrupt flag), so
+ * the {@link McpSubscriptionListener} callbacks below - which override interfaces methods that declare no
+ * checked exception - can wait on it inline.
+ */
+ private static void awaitUninterruptibly(CountDownLatch latch) {
+ try {
+ latch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ /**
+ * Regression coverage for the happens-before race root-caused from a CI flake in {@link #a01_listen_singleStream_deliversAckThenNotificationsThenComplete_inOrder()}:
+ * {@code assertFalse(handle.isOpen())} right after {@code done.await()} intermittently saw {@code true}
+ * because {@code SubscriptionPump} used to flip its {@code open} flag to <jk>false</jk> in the
+ * {@code finally} block - i.e. AFTER invoking the terminal listener callback - so a caller thread woken
+ * from a latch/future the callback itself completes could observe the callback's effects while
+ * {@code open} was still <jk>true</jk> ({@code CountDownLatch}'s happens-before is one-directional: it
+ * only orders {@code countDown()} before the matching {@code await()} return, not the reverse).
+ *
+ * <p>
+ * Rather than reproduce that as a timing-dependent flake, {@code b01}-{@code b04} below observe
+ * {@code isOpen()} from a place where the ordering is no longer a race at all: <i>from inside the
+ * terminal callback itself</i>, which always runs on the pump thread strictly before the pump thread
+ * reaches its {@code finally} block. By the time {@code onComplete()}/{@code onError(Throwable)} starts
+ * running, the pump has therefore already made its "did I flip {@code open} before or after invoking
+ * this callback" decision - reading {@code isOpen()} from inside the callback can only ever observe
+ * whichever the pump did first, deterministically:
+ * <ul>
+ * <li>Buggy ({@code open=false} only in {@code finally}): the callback observes {@code open==true}.
+ * <li>Fixed ({@code open=false} immediately before {@code invokeListener(...)} at each terminal site):
+ * the callback observes {@code open==false}.
+ * </ul>
+ *
+ * <p>
+ * The one wrinkle is that the callback needs a reference to the handle {@link McpClient#listen} returns,
+ * and that return does not happen until after the pump thread has already been started - so the pump
+ * thread could reach the callback before the test thread has published the handle. Each test below closes
+ * that window with a small handoff gate ({@code handleReady}): the callback blocks on it before reading
+ * {@code isOpen()}. That gate only delays <i>when</i> {@code isOpen()} is read, never <i>what</i> it
+ * reads - the buggy-vs-fixed ordering decision the assertion is pinning was already made by the pump
+ * before it ever called into the callback, so blocking briefly inside the callback cannot change it.
+ */
+ @Test
+ void b01_isOpenObservedFromWithinOnComplete_isFalse_notTrueEvenThoughFinallyHasNotRunYet() throws Exception {
+ var ack = new SubscriptionsAcknowledgedNotification().setNotifications(new SubscriptionFilter())
+ .setMeta(new RequestMeta().set(RequestMeta.KEY_SUBSCRIPTION_ID, "sub-b1"));
+ var inbound =
+ "data: {\"jsonrpc\":\"2.0\",\"method\":\"" + McpMethods.NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED + "\",\"params\":" + JsonSerializer.DEFAULT.write(ack) + "}\n\n"
+ + "data: {\"jsonrpc\":\"2.0\",\"id\":\"sub-b1\",\"result\":{\"resultType\":\"complete\"}}\n\n";
+ HttpTransport transport = tReq -> TransportResponse.builder().statusCode(200)
+ .header("Content-Type", "text/event-stream")
+ .body(new ByteArrayInputStream(inbound.getBytes(StandardCharsets.UTF_8))).build();
+
+ var handleRef = new AtomicReference<McpSubscriptionHandle>();
+ var handleReady = new CountDownLatch(1);
+ var capturedOpen = new AtomicBoolean(true);
+ var done = new CountDownLatch(1);
+ var listener = new McpSubscriptionListener() {
+ @Override public void onComplete() {
+ awaitUninterruptibly(handleReady);
+ capturedOpen.set(handleRef.get().isOpen());
+ done.countDown();
+ }
+ };
+
+ try (var client = McpClient.builder().endpoint("http://x/mcp").transport(transport).build()) {
+ var handle = client.listen(new SubscriptionFilter(), listener);
+ handleRef.set(handle);
+ handleReady.countDown();
+ assertTrue(done.await(5, TimeUnit.SECONDS), "onComplete never fired");
+ assertFalse(capturedOpen.get(),
+ "isOpen() read from inside onComplete() itself must already be false - open must be flipped "
+ + "before the terminal callback is invoked, not after, in the finally block");
+ }
+ }
+
+ @Test
+ void b02_isOpenObservedFromWithinOnError_forServerErrorFrame_isFalse_notTrueEvenThoughFinallyHasNotRunYet() throws Exception {
+ var inbound = "data: {\"jsonrpc\":\"2.0\",\"id\":\"sub-b2\",\"error\":{\"code\":-32000,\"message\":\"boom\"}}\n\n";
+ HttpTransport transport = tReq -> TransportResponse.builder().statusCode(200)
+ .header("Content-Type", "text/event-stream")
+ .body(new ByteArrayInputStream(inbound.getBytes(StandardCharsets.UTF_8))).build();
+
+ var handleRef = new AtomicReference<McpSubscriptionHandle>();
+ var handleReady = new CountDownLatch(1);
+ var capturedOpen = new AtomicBoolean(true);
+ var done = new CountDownLatch(1);
+ var listener = new McpSubscriptionListener() {
+ @Override public void onError(Throwable t) {
+ awaitUninterruptibly(handleReady);
+ capturedOpen.set(handleRef.get().isOpen());
+ done.countDown();
+ }
+ };
+
+ try (var client = McpClient.builder().endpoint("http://x/mcp").transport(transport).build()) {
+ var handle = client.listen(new SubscriptionFilter(), listener);
+ handleRef.set(handle);
+ handleReady.countDown();
+ assertTrue(done.await(5, TimeUnit.SECONDS), "onError never fired for a server JSON-RPC error terminal frame");
+ assertFalse(capturedOpen.get(),
+ "isOpen() read from inside onError() itself must already be false for the dispatch(...) "
+ + "error-terminal-frame branch");
+ }
+ }
+
+ @Test
+ void b03_isOpenObservedFromWithinOnError_forAbruptEof_isFalse_notTrueEvenThoughFinallyHasNotRunYet() throws Exception {
+ var ack = new SubscriptionsAcknowledgedNotification().setNotifications(new SubscriptionFilter())
+ .setMeta(new RequestMeta().set(RequestMeta.KEY_SUBSCRIPTION_ID, "sub-b3"));
+ // Deliberately no terminal frame, exactly like a04: the stream just ends (clean EOF).
+ var inbound =
+ "data: {\"jsonrpc\":\"2.0\",\"method\":\"" + McpMethods.NOTIFICATIONS_SUBSCRIPTIONS_ACKNOWLEDGED + "\",\"params\":" + JsonSerializer.DEFAULT.write(ack) + "}\n\n";
+ HttpTransport transport = tReq -> TransportResponse.builder().statusCode(200)
+ .header("Content-Type", "text/event-stream")
+ .body(new ByteArrayInputStream(inbound.getBytes(StandardCharsets.UTF_8))).build();
+
+ var handleRef = new AtomicReference<McpSubscriptionHandle>();
+ var handleReady = new CountDownLatch(1);
+ var capturedOpen = new AtomicBoolean(true);
+ var done = new CountDownLatch(1);
+ var listener = new McpSubscriptionListener() {
+ @Override public void onError(Throwable t) {
+ awaitUninterruptibly(handleReady);
+ capturedOpen.set(handleRef.get().isOpen());
+ done.countDown();
+ }
+ };
+
+ try (var client = McpClient.builder().endpoint("http://x/mcp").transport(transport).build()) {
+ var handle = client.listen(new SubscriptionFilter(), listener);
+ handleRef.set(handle);
+ handleReady.countDown();
+ assertTrue(done.await(5, TimeUnit.SECONDS), "onError never fired for an abrupt EOF with no terminal frame");
+ assertFalse(capturedOpen.get(),
+ "isOpen() read from inside onError() itself must already be false for run()'s abrupt-EOF "
+ + "(reached-terminal==false) branch");
+ }
+ }
+
+ @Test
+ void b04_isOpenObservedFromWithinOnError_forMalformedFrameDecodeException_isFalse_notTrueEvenThoughFinallyHasNotRunYet() throws Exception {
+ // Same malformed payload as a03, but this exercises run()'s catch (Exception e) branch specifically
+ // (the decode failure is thrown out of dispatch(...) itself, not returned as a JSON-RPC error frame).
+ var inbound = "data: {not valid json\n\n";
+ HttpTransport transport = tReq -> TransportResponse.builder().statusCode(200)
+ .header("Content-Type", "text/event-stream")
+ .body(new ByteArrayInputStream(inbound.getBytes(StandardCharsets.UTF_8))).build();
+
+ var handleRef = new AtomicReference<McpSubscriptionHandle>();
+ var handleReady = new CountDownLatch(1);
+ var capturedOpen = new AtomicBoolean(true);
+ var done = new CountDownLatch(1);
+ var listener = new McpSubscriptionListener() {
+ @Override public void onError(Throwable t) {
+ awaitUninterruptibly(handleReady);
+ capturedOpen.set(handleRef.get().isOpen());
+ done.countDown();
+ }
+ };
+
+ try (var client = McpClient.builder().endpoint("http://x/mcp").transport(transport).build()) {
+ var handle = client.listen(new SubscriptionFilter(), listener);
+ handleRef.set(handle);
+ handleReady.countDown();
+ assertTrue(done.await(5, TimeUnit.SECONDS), "onError never fired for a malformed frame");
+ assertFalse(capturedOpen.get(),
+ "isOpen() read from inside onError() itself must already be false for run()'s catch "
+ + "(Exception e) decode-failure branch");
+ }
+ }
+
/**
* A genuinely blocking {@link InputStream}: {@link #read()} parks on a latch (no busy-polling) until
* {@link #release()} is called from another thread, then throws — modeling a real closed-socket read
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
index 334fb739f2..01a3b73672 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/ElicitationRequests.java
@@ -63,7 +63,7 @@ public final class ElicitationRequests {
* elicitations paused on together, resumed together).
*
* <p>
- * Unlike {@code ElicitationAccess.toInputResponses(Map)} (client side), an empty {@code requests} map is
+ * Unlike {@code ElicitationResponses.toInputResponses(Map)} (client side), an empty {@code requests} map is
* rejected, not accepted — deliberately asymmetric, since a signal built with zero questions would be
* a pointless pause, whereas a caller resuming may legitimately have zero elicitation answers on hand if
* the round it is resuming only carried non-elicitation MRTR pauses.
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpoint.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpoint.java
index c26a47a2de..b3b0de127f 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpoint.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpoint.java
@@ -16,9 +16,6 @@
*/
package org.apache.juneau.rest.server.mcp.v20260728;
-import java.util.*;
-
-import org.apache.juneau.bean.mcp.v20260728.*;
import org.apache.juneau.commons.inject.Bean;
import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker;
import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
@@ -28,8 +25,9 @@ import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
* resource.
*
* <p>
- * The mixin path and the servlet-subclass path ({@link McpRestServlet}) are at parity: both bind the
- * revision at compile time and both expose the same {@link #capabilities()} hook.
+ * The mixin path and the servlet-subclass path ({@link McpRestServlet}) are at parity: both bind the revision
+ * at compile time and both read the same {@link McpOptions} shape for behavior configuration
+ * (capabilities/instructions/cache/mrtr/subscriptions/broker) via a single override.
*
* <h5 class='section'>Example:</h5>
* <pre>
@@ -39,6 +37,10 @@ import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
* public McpServerConfig getMcpConfig() {
* return new McpServerConfig().addTool(new MyEchoTool());
* }
+ * @Override // optional
+ * public McpOptions getMcpOptions() {
+ * return new McpOptions().mrtr(m -> m.setTtlMs(600_000));
+ * }
* }
* </pre>
*/
@@ -46,8 +48,8 @@ public interface McpEndpoint extends org.apache.juneau.rest.server.mcp.McpEndpoi
@Override /* McpEndpointMixin */
default org.apache.juneau.rest.server.mcp.McpRevision revision() {
- return new McpRevision(capabilities(), Objects.requireNonNull(cacheConfig(), "cacheConfig"), instructions(),
- Objects.requireNonNull(mrtrConfig(), "mrtrConfig"));
+ var o = McpEndpointOptionsCache.resolve(this);
+ return new McpRevision(o.getCapabilities(), o.getCache(), o.getInstructions(), o.getMrtr());
}
/**
@@ -67,137 +69,60 @@ public interface McpEndpoint extends org.apache.juneau.rest.server.mcp.McpEndpoi
}
/**
- * Optional explicit capabilities advertisement for {@code server/discover}.
- *
- * <p>
- * Returning <jk>null</jk> (the default) leaves capabilities auto-derived from the registered
- * tool / prompt / resource lists; a non-<jk>null</jk> value bypasses auto-derivation.
- *
- * @return The explicit capabilities, or <jk>null</jk> to auto-derive.
- */
- default ServerCapabilities capabilities() {
- return null;
- }
-
- /**
- * Optional free-form {@code server/discover} usage instructions advertised to clients.
- *
- * <p>
- * Returning <jk>null</jk> (the default) omits the {@code instructions} member from the discovery
- * result.
- *
- * @return The instructions, or <jk>null</jk> to omit them.
- */
- default String instructions() {
- return null;
- }
-
- /**
- * Cache configuration for this endpoint's bound revision.
- *
- * <p>
- * The default returns an empty {@link McpCacheConfig} (no cache hints emitted on any result).
- * Override to supply TTL/scope hints. An overriding implementation must return a stable instance
- * that is not mutated after it is returned — this mixin does not lazily cache the result the way
- * the servlet-subclass path does.
- *
- * @return The cache configuration. Must not be <jk>null</jk>.
- */
- default McpCacheConfig cacheConfig() {
- return new McpCacheConfig();
- }
-
- /**
- * MRTR (Multi-Round-Trip Request) configuration for this endpoint's bound revision.
- *
- * <p>
- * The default returns a <b>per-process shared</b> {@link McpMrtrConfig} (AES-GCM ephemeral codec, 5-minute
- * {@code requestState} TTL, 10-round cap) — a single JVM-wide instance holding one ephemeral AES key,
- * lazily created on first use (see {@link SharedMrtrConfig}). Because {@link #revision()} constructs a fresh
- * {@link McpRevision} on every request, returning that same shared instance from every call is what lets a
- * {@code requestState} sealed on a PAUSE request be unsealed on the follow-up RESUME request; a fresh
- * {@link McpMrtrConfig} per call would mint a fresh AES key per request and make every RESUME fail. So the
- * common mixin case is resumable out of the box with no override.
- *
- * <p>
- * Override to supply a custom {@link RequestStateCodec}, TTL, or max-rounds cap. <b>An overriding
- * implementation must return a stable instance</b> (e.g. stored in a field) that is not mutated after it is
- * returned — this mixin does not lazily cache an override the way the servlet-subclass path
- * ({@link McpRestServlet#getMrtrConfig()}) does, and returning a fresh custom instance per call would
- * reintroduce the per-request-key RESUME failure the default avoids.
- *
- * @return The MRTR configuration. Must not be <jk>null</jk>.
- */
- default McpMrtrConfig mrtrConfig() {
- return SharedMrtrConfig.get();
- }
-
- /**
- * Subscriptions configuration for this endpoint's bound revision.
+ * This endpoint's behavior configuration — capabilities, instructions, cache hints, MRTR, subscriptions,
+ * and the subscription broker, all in one place.
*
* <p>
- * The default returns a fresh {@link McpSubscriptionsConfig} (all defaults) on every call — knobs-only, so
- * unlike {@link #subscriptionBroker()} there is no live state a fresh instance could split, matching
- * {@link #cacheConfig()}'s precedent exactly. Override to supply custom
- * concurrency/queue/heartbeat/idle-timeout knobs.
+ * The default returns a per-binding-fresh {@link McpOptions} (all defaults; no live-state sharing across
+ * endpoint instances). Override to customize any of the six concepts.
*
* <p>
- * <b>Warning:</b> overriding {@link McpSubscriptionsConfig#getQueueSize() queueSize} here has <b>no
- * effect</b> on the JVM-wide default shared broker {@link #subscriptionBroker()} returns — that broker is
- * already sized from {@link McpSubscriptionsConfig#DEFAULT_QUEUE_SIZE} the first time it is lazily created
- * (see {@link SharedSubscriptionBroker}), before this method's returned queue size could ever be read. A
- * non-default queue size only takes effect if {@link #subscriptionBroker()} is also overridden to size a
- * broker from it.
- *
- * @return The subscriptions configuration. Must not be <jk>null</jk>.
+ * <b>This method is invoked exactly once per endpoint instance</b> and the result is memoized for that
+ * instance's lifetime by the framework internals that read it ({@link #revision()},
+ * {@link #subscriptionBroker()}, the {@code @Bean}-published accessor) — so an override may freely
+ * construct and configure a fresh instance on every call, exactly as shown above; it never needs to cache the
+ * result itself, and returning a fresh instance per call cannot reintroduce the "must return a stable
+ * instance" footgun the pre-consolidation per-concept hooks warned about.
+ *
+ * @return This endpoint's options. Must not be <jk>null</jk>.
*/
- default McpSubscriptionsConfig subscriptionsConfig() {
- return new McpSubscriptionsConfig();
+ default McpOptions getMcpOptions() {
+ return new McpOptions();
}
/**
- * Publishes {@link #subscriptionsConfig()} into this resource's {@code RestContext} bean store, the
- * same {@code @Bean}-method mechanism already used by {@link #mcpTraceContextExtractor()}.
+ * Publishes this endpoint's memoized {@link McpOptions} into this resource's {@code RestContext} bean store,
+ * the same {@code @Bean}-method mechanism already used by {@link #mcpTraceContextExtractor()}.
*
* <p>
- * This is what lets {@code McpRevision.dispatch(...)}'s {@code subscriptions/listen} branch resolve
- * the real, possibly-overridden config via {@code ctx.getBean(McpSubscriptionsConfig.class)} instead
- * of always falling through to that call's defensive {@code new McpSubscriptionsConfig()} default —
- * the neutral core cannot add this bean itself ({@code McpSubscriptionsConfig} is a v2 type, and the
- * neutral module must not import it), so this v2-side {@code @Bean} method is the wiring's only seam.
+ * This is what lets {@code McpRevision.dispatch(...)}'s {@code subscriptions/listen} branch resolve the
+ * real, possibly-overridden subscriptions configuration via {@code ctx.getBean(McpOptions.class)} instead of
+ * always falling through to that call's defensive {@code new McpSubscriptionsConfig()} default — the
+ * neutral core cannot add this bean itself ({@code McpOptions} is a v2 type, and the neutral module must not
+ * import it), so this v2-side {@code @Bean} method is the wiring's only seam.
*
- * @return This endpoint's subscriptions configuration. Never <jk>null</jk>.
+ * @return This endpoint's memoized options. Never <jk>null</jk>.
*/
@Bean
- default McpSubscriptionsConfig subscriptionsConfigBean() {
- return subscriptionsConfig();
+ default McpOptions mcpOptionsBean() {
+ return McpEndpointOptionsCache.resolve(this);
}
/**
* Subscription broker for this endpoint's bound revision.
*
* <p>
- * The default returns a <b>per-process shared</b> {@link McpSubscriptionBroker} (see
- * {@link SharedSubscriptionBroker}) — a single JVM-wide registry, lazily created on first use — since a
- * fresh broker per call (unlike the knobs-only {@link #subscriptionsConfig()}) would silently split live
- * subscribers and publishers across unrelated registries. Override to supply a custom broker; an
- * overriding implementation must return a stable instance (this mixin does not lazily cache an override
- * the way {@link McpRestServlet#getSubscriptionBroker()} does).
- *
- * <p>
- * Because this registry is shared JVM-wide, every live subscription still shares one namespace across
- * however many mixin resources bind this same broker — but {@code subscriptions/listen}'s dispatch
- * branch registers each stream under a fresh server-minted key (a random {@code UUID}, not the
- * client-supplied JSON-RPC request id), so distinct listens can never collide and evict each other's
- * stream merely because two unrelated clients (or the same client, twice) happened to reuse the same
- * id. The only thing still shared process-wide is capacity: {@code maxConcurrentSubscriptions} is
- * enforced against this one registry's total active count, mirroring the same per-process-sharing
- * trade-off {@link #mrtrConfig()} documents for its shared AES key.
- *
- * @return The subscription broker. Must not be <jk>null</jk>.
+ * Reads {@link #getMcpOptions()}'s memoized {@link McpOptions#getSubscriptionBroker()}: an explicitly
+ * configured broker is returned as-is; otherwise one is derived and memoized per-binding, sized from
+ * {@link McpOptions#getSubscriptions()}'s queue bound (see {@link McpOptions#resolveSubscriptionBroker()}).
+ * Because {@link McpOptions} itself is memoized once per endpoint instance ({@link McpEndpointOptionsCache}),
+ * two distinct endpoint instances resolve to two distinct broker registries by default — there is no
+ * accidental JVM-wide sharing the way the pre-consolidation mixin default had.
+ *
+ * @return The subscription broker. Never <jk>null</jk>.
*/
@Override
default McpSubscriptionBroker subscriptionBroker() {
- return SharedSubscriptionBroker.get();
+ return McpEndpointOptionsCache.resolve(this).resolveSubscriptionBroker();
}
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache.java
new file mode 100644
index 0000000000..d1dec9ba5b
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import java.lang.ref.Reference;
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Per-endpoint-instance memoized {@link McpOptions} resolution for the {@link McpEndpoint} mixin path.
+ *
+ * <p>
+ * The mixin path has no per-instance field to memoize onto (it is an interface, not a class), and
+ * {@link McpEndpoint#getMcpOptions()} is deliberately the raw, unmemoized user override point — its
+ * default returns a fresh {@link McpOptions} on every call, and an overriding implementation is free to do the
+ * same (see that method's Javadoc example). Internal plumbing that needs a <b>stable</b>, per-binding
+ * {@link McpOptions} ({@link McpEndpoint#revision()}, {@link McpEndpoint#subscriptionBroker()}, and the
+ * {@code @Bean}-published accessor) resolves it through this cache instead of calling
+ * {@link McpEndpoint#getMcpOptions()} directly: the first resolution for a given endpoint instance invokes
+ * {@link McpEndpoint#getMcpOptions()} exactly once and publishes the result; every later resolution for that
+ * <i>same</i> instance returns the identical object, while a <i>different</i> endpoint instance resolves to a
+ * distinct {@link McpOptions} — giving per-binding memoization "for free" regardless of whether
+ * {@link McpEndpoint#getMcpOptions()} itself was overridden, and regardless of whether a {@code RestContext} has
+ * been initialized (unlike the {@code RestContext} bean store, this cache works against a bare-constructed
+ * mixin instance too, which is what lets direct-construction unit tests exercise {@link McpEndpoint#revision()}
+ * without standing up a full REST context).
+ *
+ * <p>
+ * Keyed by <b>reference identity</b>, not {@code equals()}/{@code hashCode()}: a resource class that overrides
+ * those (a record, a Lombok {@code @Data} class, or any other value-ish endpoint type) must still get its own
+ * cache entry per distinct instance, or two unrelated bindings would collapse onto one shared {@link McpOptions}
+ * — silently reintroducing the JVM-wide MRTR-key/broker sharing this whole cache exists to eliminate. The
+ * key type is a small {@link WeakReference} subclass whose {@code hashCode()} is the referent's
+ * {@link System#identityHashCode(Object) identity hash} (captured at construction, so it survives the referent
+ * being cleared) and whose {@code equals()} is reference equality (<code>==</code>) on the referent, never
+ * delegating to the endpoint's own {@code equals()}/{@code hashCode()}.
+ *
+ * <p>
+ * Backed by a lock-free {@link ConcurrentHashMap} (no shared monitor serializing mixin traffic across the JVM,
+ * unlike a {@code synchronized}-guarded {@code WeakHashMap}): the read/populate path is a single
+ * {@link ConcurrentHashMap#computeIfAbsent} call. An endpoint instance that becomes otherwise unreachable does
+ * not leak its cache entry: stale keys (whose referent has been garbage-collected) are enqueued on a
+ * {@link ReferenceQueue} and drained — removing themselves from the map — at the start of every
+ * {@link #resolve} call. (As a side effect of using {@code computeIfAbsent}, a {@link McpEndpoint#getMcpOptions()}
+ * override that re-enters {@link #resolve} for the same endpoint instance fails fast with an
+ * {@code IllegalStateException} ("Recursive update"), rather than the unbounded recursion / {@code StackOverflowError}
+ * a {@code WeakHashMap}-backed implementation would suffer.)
+ */
+final class McpEndpointOptionsCache {
+
+ private McpEndpointOptionsCache() {}
+
+ private static final ReferenceQueue<McpEndpoint> QUEUE = new ReferenceQueue<>();
+ private static final Map<IdentityKey,McpOptions> CACHE = new ConcurrentHashMap<>();
+
+ /**
+ * Resolves {@code endpoint}'s memoized {@link McpOptions}, invoking {@link McpEndpoint#getMcpOptions()}
+ * exactly once per endpoint instance.
+ *
+ * @param endpoint The endpoint instance to resolve. Must not be <jk>null</jk>.
+ * @return The memoized options. Never <jk>null</jk>.
+ */
+ static McpOptions resolve(McpEndpoint endpoint) {
+ expungeStaleEntries();
+ return CACHE.computeIfAbsent(new IdentityKey(endpoint), IdentityKey::resolveOptions);
+ }
+
+ /**
+ * Drains {@link #QUEUE} of keys whose referent has been garbage-collected, removing the corresponding
+ * (now-orphaned) cache entry. Called on every {@link #resolve} so the cache never grows unbounded with
+ * entries for endpoint instances nothing references anymore.
+ */
+ private static void expungeStaleEntries() {
+ Reference<? extends McpEndpoint> ref;
+ while ((ref = QUEUE.poll()) != null)
+ CACHE.remove(ref);
+ }
+
+ /**
+ * A weak, identity-keyed {@link McpEndpoint} reference usable as a {@link Map} key: {@code hashCode()} is
+ * the referent's identity hash (captured up front, so it remains stable even after the referent is
+ * cleared), and {@code equals()} is reference equality on the referent — deliberately never
+ * delegating to the endpoint's own {@code equals()}/{@code hashCode()}, since those may be value-based.
+ */
+ private static final class IdentityKey extends WeakReference<McpEndpoint> {
+ private final int hash;
+
+ IdentityKey(McpEndpoint referent) {
+ super(referent, QUEUE);
+ hash = System.identityHashCode(referent);
+ }
+
+ McpOptions resolveOptions() {
+ var endpoint = get();
+ return endpoint == null ? new McpOptions() : endpoint.getMcpOptions();
+ }
+
+ @Override
+ public int hashCode() {
+ return hash;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o)
+ return true;
+ if (!(o instanceof IdentityKey other))
+ return false;
+ var mine = get();
+ return mine != null && mine == other.get();
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions.java
new file mode 100644
index 0000000000..f642003243
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions.java
@@ -0,0 +1,309 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.function.Consumer;
+
+import org.apache.juneau.bean.mcp.v20260728.ServerCapabilities;
+import org.apache.juneau.rest.server.mcp.BasicMcpSubscriptionBroker;
+import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker;
+
+/**
+ * Binding-owned behavior configuration ("how" a {@code 2026-07-28} MCP endpoint behaves) — the single
+ * v2-only aggregate consolidating every per-binding tunable {@link McpEndpoint}/{@link McpRestServlet} previously
+ * exposed as six separate hooks: {@code capabilities}, {@code instructions}, {@code cache}, {@code mrtr},
+ * {@code subscriptions}, and {@code subscriptionBroker}.
+ *
+ * <p>
+ * <b>Mutable during setup, effectively immutable once published.</b> Same builder-less mutable-setup idiom as
+ * {@link McpCacheConfig}/{@link McpMrtrConfig}/{@link McpSubscriptionsConfig} and the neutral
+ * {@code org.apache.juneau.rest.server.mcp.McpServerConfig} it sits next to: configure fully via the single
+ * {@code createMcpOptions()} (servlet-subclass path) / {@code getMcpOptions()} (mixin path) override, then never
+ * mutate it afterward. The framework memoizes exactly one instance per binding (see {@link McpRestServlet#getMcpOptions()}
+ * and {@link McpEndpoint#getMcpOptions()}), so a request-time reader sees a stable, request-independent snapshot.
+ *
+ * <p>
+ * The three nested configs ({@link #getCache() cache}, {@link #getMrtr() mrtr}, {@link #getSubscriptions()
+ * subscriptions}) are framework-owned instances created once, here, at construction time. Use the
+ * {@link #cache(Consumer)}/{@link #mrtr(Consumer)}/{@link #subscriptions(Consumer)} configure-blocks to mutate
+ * them in place — callers never {@code new} a replacement nested config, so the "must return a stable
+ * instance" footgun the pre-consolidation hooks warned about cannot recur.
+ *
+ * <h5 class='section'>The subscription broker default:</h5>
+ * <p>
+ * {@link #getSubscriptionBroker()} defaults to <jk>null</jk>, meaning "framework-derived": the binding lazily
+ * derives a {@link BasicMcpSubscriptionBroker} sized from {@link #getSubscriptions()}'s
+ * {@link McpSubscriptionsConfig#getQueueSize() queueSize} the first time a broker is needed, and memoizes that
+ * one derived instance for the lifetime of the binding (a fresh broker per call would silently split live
+ * subscribers and publishers across unrelated registries). Call {@link #setSubscriptionBroker(McpSubscriptionBroker)}
+ * to supply a custom broker instead (for example one shared across multiple bindings on purpose).
+ *
+ * <h5 class='section'>The idleTimeoutMs / heartbeatIntervalMs caveat:</h5>
+ * <p>
+ * When configuring {@link #subscriptions(Consumer)}, keep
+ * {@link McpSubscriptionsConfig#getHeartbeatIntervalMs() heartbeatIntervalMs} below
+ * {@link McpSubscriptionsConfig#getIdleTimeoutMs() idleTimeoutMs} (see that method's Javadoc) — otherwise
+ * the idle-timeout watchdog can reap a perfectly healthy connection between two heartbeats.
+ *
+ * <h5 class='section'>Example:</h5>
+ * <pre>
+ * McpOptions <jv>options</jv> = <jk>new</jk> McpOptions()
+ * .setInstructions(<js>"Use tool 'echo' to test."</js>)
+ * .cache(<jv>c</jv> -> <jv>c</jv>.setToolsList(<jk>new</jk> McpCacheHint().setTtlMs(60_000)))
+ * .subscriptions(<jv>s</jv> -> <jv>s</jv>.setQueueSize(2048));
+ * </pre>
+ */
+public class McpOptions {
+
+ private ServerCapabilities capabilities;
+ private String instructions;
+ private McpCacheConfig cache = new McpCacheConfig();
+ private McpMrtrConfig mrtr = new McpMrtrConfig();
+ private McpSubscriptionsConfig subscriptions = new McpSubscriptionsConfig();
+ private McpSubscriptionBroker subscriptionBroker;
+
+ @SuppressWarnings({
+ "java:S3077" // Volatile is required for correct double-checked locking in resolveSubscriptionBroker(): unlike a plain lazy read, two BasicMcpSubscriptionBroker instances are NOT equivalent (each holds distinct live per-connection subscription state), so exactly one derived broker must ever be created and published or a subscription registered against one instance would never be visible to a publish call against the other.
+ })
+ private volatile McpSubscriptionBroker derivedBroker;
+
+ private final Object lock = new Object();
+
+ /**
+ * Optional explicit capabilities advertisement for {@code server/discover}.
+ *
+ * @return The explicit capabilities, or <jk>null</jk> to auto-derive from the registered tool / prompt /
+ * resource lists (the default).
+ */
+ public ServerCapabilities getCapabilities() {
+ return capabilities;
+ }
+
+ /**
+ * Sets the explicit {@code server/discover} capabilities advertisement.
+ *
+ * <p>
+ * A non-<jk>null</jk> value bypasses auto-derivation entirely and advertises exactly what is supplied.
+ *
+ * @param value The new value. Can be <jk>null</jk> to auto-derive.
+ * @return This object (for method chaining).
+ */
+ public McpOptions setCapabilities(ServerCapabilities value) {
+ capabilities = value;
+ return this;
+ }
+
+ /**
+ * Optional free-form {@code server/discover} usage instructions advertised to clients.
+ *
+ * @return The instructions, or <jk>null</jk> to omit them from the discovery result (the default).
+ */
+ public String getInstructions() {
+ return instructions;
+ }
+
+ /**
+ * Sets the {@code server/discover} usage instructions.
+ *
+ * @param value The new value. Can be <jk>null</jk> to omit them.
+ * @return This object (for method chaining).
+ */
+ public McpOptions setInstructions(String value) {
+ instructions = value;
+ return this;
+ }
+
+ /**
+ * This binding's cache-hint configuration.
+ *
+ * @return The framework-owned cache configuration. Never <jk>null</jk>.
+ */
+ public McpCacheConfig getCache() {
+ return cache;
+ }
+
+ /**
+ * Replaces this binding's cache configuration outright.
+ *
+ * @param value The new value. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public McpOptions setCache(McpCacheConfig value) {
+ if (value == null)
+ throw iaex("cache must not be null");
+ cache = value;
+ return this;
+ }
+
+ /**
+ * Mutates this binding's cache configuration in place.
+ *
+ * <p>
+ * Preferred over {@link #setCache(McpCacheConfig)} for incremental configuration since it operates on the
+ * framework-owned instance rather than requiring the caller to construct a replacement.
+ *
+ * @param consumer Callback invoked with the framework-owned {@link McpCacheConfig}. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code consumer} is <jk>null</jk>.
+ */
+ public McpOptions cache(Consumer<McpCacheConfig> consumer) {
+ if (consumer == null)
+ throw iaex("consumer must not be null");
+ consumer.accept(cache);
+ return this;
+ }
+
+ /**
+ * This binding's MRTR (Multi-Round-Trip Request) configuration.
+ *
+ * @return The framework-owned MRTR configuration. Never <jk>null</jk>.
+ */
+ public McpMrtrConfig getMrtr() {
+ return mrtr;
+ }
+
+ /**
+ * Replaces this binding's MRTR configuration outright.
+ *
+ * @param value The new value. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public McpOptions setMrtr(McpMrtrConfig value) {
+ if (value == null)
+ throw iaex("mrtr must not be null");
+ mrtr = value;
+ return this;
+ }
+
+ /**
+ * Mutates this binding's MRTR configuration in place.
+ *
+ * @param consumer Callback invoked with the framework-owned {@link McpMrtrConfig}. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code consumer} is <jk>null</jk>.
+ */
+ public McpOptions mrtr(Consumer<McpMrtrConfig> consumer) {
+ if (consumer == null)
+ throw iaex("consumer must not be null");
+ consumer.accept(mrtr);
+ return this;
+ }
+
+ /**
+ * This binding's {@code subscriptions/listen} configuration.
+ *
+ * @return The framework-owned subscriptions configuration. Never <jk>null</jk>.
+ */
+ public McpSubscriptionsConfig getSubscriptions() {
+ return subscriptions;
+ }
+
+ /**
+ * Replaces this binding's subscriptions configuration outright.
+ *
+ * @param value The new value. Must not be <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code value} is <jk>null</jk>.
+ */
+ public McpOptions setSubscriptions(McpSubscriptionsConfig value) {
+ if (value == null)
+ throw iaex("subscriptions must not be null");
+ subscriptions = value;
+ return this;
+ }
+
+ /**
+ * Mutates this binding's subscriptions configuration in place.
+ *
+ * @param consumer Callback invoked with the framework-owned {@link McpSubscriptionsConfig}. Must not be
+ * <jk>null</jk>.
+ * @return This object (for method chaining).
+ * @throws IllegalArgumentException If {@code consumer} is <jk>null</jk>.
+ */
+ public McpOptions subscriptions(Consumer<McpSubscriptionsConfig> consumer) {
+ if (consumer == null)
+ throw iaex("consumer must not be null");
+ consumer.accept(subscriptions);
+ return this;
+ }
+
+ /**
+ * The explicit subscription broker for this binding, if one was supplied.
+ *
+ * @return The explicit broker, or <jk>null</jk> if none was supplied (the default) — in which case the
+ * binding lazily derives and memoizes one (see {@link #resolveSubscriptionBroker()}).
+ */
+ public McpSubscriptionBroker getSubscriptionBroker() {
+ return subscriptionBroker;
+ }
+
+ /**
+ * Sets an explicit subscription broker, bypassing the framework-derived default.
+ *
+ * <p>
+ * Setting this after {@link #resolveSubscriptionBroker()} has already derived and published a default
+ * broker for this instance does not retroactively clear or replace that already-published derived
+ * broker — {@link #resolveSubscriptionBroker()} always prefers an explicit value set here over a
+ * previously-derived one, but under the never-mutate-after-setup contract this method should only be
+ * called during configuration, before the binding is handed to the framework.
+ *
+ * @param value The new value. Can be <jk>null</jk> to revert to the framework-derived default.
+ * @return This object (for method chaining).
+ */
+ public McpOptions setSubscriptionBroker(McpSubscriptionBroker value) {
+ subscriptionBroker = value;
+ return this;
+ }
+
+ /**
+ * Returns {@link #getSubscriptionBroker()} if explicitly set, otherwise lazily derives and memoizes a
+ * {@link BasicMcpSubscriptionBroker} sized from {@link #getSubscriptions()}'s queue bound.
+ *
+ * <p>
+ * This is the <b>effective</b> broker accessor: unlike {@link #getSubscriptionBroker()} (which returns
+ * <jk>null</jk> by default), this method never returns <jk>null</jk>, deriving a default the first time
+ * one is needed. Internal plumbing for {@link McpEndpoint#subscriptionBroker()} /
+ * {@link McpRestServlet#getSubscriptionBroker()}. Since this {@link McpOptions} instance is itself
+ * memoized once per binding, the derived broker this method publishes is likewise stable for the
+ * binding's lifetime — publication uses double-checked locking (not a plain lazy read) because two
+ * {@link BasicMcpSubscriptionBroker} instances are <b>not</b> equivalent: each holds distinct live
+ * per-connection subscription state, so a benign publication race would silently split subscribers and
+ * publishers across two unrelated registries.
+ *
+ * @return The subscription broker to use. Never <jk>null</jk>.
+ */
+ public McpSubscriptionBroker resolveSubscriptionBroker() {
+ var explicit = subscriptionBroker;
+ if (explicit != null)
+ return explicit;
+ var result = derivedBroker;
+ if (result == null) {
+ synchronized (lock) {
+ result = derivedBroker;
+ if (result == null) {
+ result = new BasicMcpSubscriptionBroker(subscriptions.getQueueSize());
+ derivedBroker = result;
+ }
+ }
+ }
+ return result;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRestServlet.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRestServlet.java
index c07d947ff1..24d5def4ac 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRestServlet.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRestServlet.java
@@ -16,10 +16,12 @@
*/
package org.apache.juneau.rest.server.mcp.v20260728;
-import org.apache.juneau.bean.mcp.v20260728.*;
+import static org.apache.juneau.commons.utils.Shorts.*;
+
+import java.util.concurrent.atomic.AtomicReference;
+
import org.apache.juneau.commons.inject.Bean;
import org.apache.juneau.rest.server.*;
-import org.apache.juneau.rest.server.mcp.BasicMcpSubscriptionBroker;
import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker;
import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
@@ -49,6 +51,13 @@ import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
* protected McpServerConfig createMcpConfig() {
* return new McpServerConfig().setName("my-server").setVersion("1.0.0").addTool(new MyEchoTool());
* }
+ * @Override // optional
+ * protected McpOptions createMcpOptions() {
+ * return new McpOptions()
+ * .setInstructions("Use tool 'echo' to test.")
+ * .cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(60_000)))
+ * .subscriptions(s -> s.setQueueSize(2048));
+ * }
* }
* </pre>
*
@@ -61,32 +70,12 @@ import org.apache.juneau.rest.server.tracing.TraceContextExtractor;
public abstract class McpRestServlet extends org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet {
private static final long serialVersionUID = 1L;
- @SuppressWarnings({
- "java:S2226", // Lazily-published, per-servlet-instance cache config; cannot be static (per-instance) or final (assigned after construction).
- "java:S3077" // Volatile + idempotent createCacheConfig() makes concurrent first-access races safe: any racing writer publishes an equivalent value.
- })
- private transient volatile McpCacheConfig cacheConfig;
-
- @SuppressWarnings({
- "java:S2226", // Lazily-published, per-servlet-instance MRTR config; cannot be static (per-instance) or final (assigned after construction).
- "java:S3077" // Volatile is required for the correct double-checked locking in getMrtrConfig(): unlike the cache config, two McpMrtrConfig instances are NOT equivalent (each holds a different random AES key), so exactly one config/key must ever be created and published or a requestState sealed against one key could never be unsealed against another.
- })
- private transient volatile McpMrtrConfig mrtrConfig;
-
- @SuppressWarnings({
- "java:S2226", // Lazily-published, per-servlet-instance subscriptions config; cannot be static (per-instance) or final (assigned after construction).
- })
- private transient volatile McpSubscriptionsConfig subscriptionsConfig;
-
- @SuppressWarnings({
- "java:S2226", // Lazily-published, per-servlet-instance subscription broker; cannot be static (per-instance) or final (assigned after construction).
- "java:S3077" // Volatile is required for correct double-checked locking in getSubscriptionBroker(): unlike the plain-read cache config, two BasicMcpSubscriptionBroker instances are NOT equivalent (each holds distinct live per-connection subscription state), so exactly one broker must ever be created and published or a subscription registered against one instance would never be visible to a publish call against the other.
- })
- private transient volatile McpSubscriptionBroker subscriptionBroker;
+ private final transient AtomicReference<McpOptions> mcpOptions = new AtomicReference<>();
@Override /* AbstractMcpRestServlet */
- protected org.apache.juneau.rest.server.mcp.McpRevision revision() {
- return new McpRevision(capabilities(), getCacheConfig(), instructions(), getMrtrConfig());
+ protected final org.apache.juneau.rest.server.mcp.McpRevision revision() {
+ var o = getMcpOptions();
+ return new McpRevision(o.getCapabilities(), o.getCache(), o.getInstructions(), o.getMrtr());
}
/**
@@ -101,234 +90,71 @@ public abstract class McpRestServlet extends org.apache.juneau.rest.server.mcp.A
* @return This revision's shared {@link TraceContextExtractor}. Never <jk>null</jk>.
*/
@Bean
- public TraceContextExtractor mcpTraceContextExtractor() {
+ public final TraceContextExtractor mcpTraceContextExtractor() {
return McpRevision.TRACE_CONTEXT_EXTRACTOR;
}
/**
- * Optional explicit capabilities advertisement for {@code server/discover}.
+ * Factory method for the behavior configuration published by {@link #getMcpOptions()}.
*
* <p>
- * Returning <jk>null</jk> (the default) leaves capabilities auto-derived from the registered
- * tool / prompt / resource lists. Returning a non-<jk>null</jk> value bypasses auto-derivation
- * entirely and advertises exactly what is returned — the way to advertise capabilities that are
- * not derivable from a handler registry.
+ * Override to customize any of capabilities/instructions/cache/mrtr/subscriptions/broker in one place. The
+ * default returns a plain {@link McpOptions} (all defaults).
*
* <p>
- * This hook is typed against this revision's wire beans on purpose: capabilities diverge between
- * MCP revisions, so a neutral carrier in the core would accept data that is nonsense for the
- * bound revision and fail only at serialization time.
+ * <b>Must be side-effect-free and idempotent</b>, exactly like
+ * {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet#createMcpConfig()}: {@link #getMcpOptions()}
+ * calls this hook at most once per servlet instance and publishes the result via a lock-free
+ * {@link java.util.concurrent.atomic.AtomicReference#compareAndSet}, so under a first-access race it may be
+ * invoked by more than one thread, but only one resulting instance is ever published and returned.
*
- * @return The explicit capabilities, or <jk>null</jk> to auto-derive.
+ * @return A non-<jk>null</jk> options instance.
*/
- protected ServerCapabilities capabilities() {
- return null;
+ protected McpOptions createMcpOptions() {
+ return new McpOptions();
}
/**
- * Optional free-form {@code server/discover} usage instructions advertised to clients.
+ * Returns this servlet's lazily-published, binding-owned {@link McpOptions}.
*
* <p>
- * Returning <jk>null</jk> (the default) omits the {@code instructions} member from the discovery
- * result.
+ * The first successful call publishes the result of {@link #createMcpOptions()}; every later call returns
+ * the same instance, mirroring exactly how
+ * {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet#getMcpConfig() getMcpConfig()} memoizes.
+ * Also published as a {@code @Bean} so downstream plumbing ({@link #getSubscriptionBroker()},
+ * {@code subscriptions/listen} dispatch) can resolve it uniformly through the {@code BeanStore}. This
+ * {@code @Bean} publication is authoritative and takes precedence over any Spring {@code @Bean McpOptions}
+ * in the app context: a Spring user who wants their configuration honored must inject it INTO
+ * {@link #createMcpOptions()}'s override (for example an {@code @Autowired} field read from there), not
+ * publish a bare {@code @Bean McpOptions} in their Spring context and expect it to be picked up instead.
*
- * @return The instructions, or <jk>null</jk> to omit them.
- */
- protected String instructions() {
- return null;
- }
-
- /**
- * Creates the cache configuration published by {@link #getCacheConfig()}.
- *
- * <p>
- * Override to supply TTL/scope hints. The default returns an empty {@link McpCacheConfig} (no
- * cache hints emitted on any result).
- *
- * <p>
- * <b>Must be side-effect-free and idempotent.</b> Concurrent first access under {@link #getCacheConfig()}
- * may invoke this hook more than once before the published value settles; every invocation must return
- * an equivalent, independently valid configuration.
- *
- * @return The cache configuration. Must not be <jk>null</jk>.
- */
- protected McpCacheConfig createCacheConfig() {
- return new McpCacheConfig();
- }
-
- /**
- * Returns this servlet's lazily-published, binding-owned cache configuration.
- *
- * <p>
- * The first successful call publishes the result of {@link #createCacheConfig()}; every later call
- * returns the same instance. The published instance must not be mutated by callers.
- *
- * @return The cache configuration. Never <jk>null</jk>.
- * @throws IllegalStateException If {@link #createCacheConfig()} returns <jk>null</jk>.
- */
- public McpCacheConfig getCacheConfig() {
- var result = cacheConfig;
- if (result == null) {
- result = createCacheConfig();
- if (result == null)
- throw new IllegalStateException("createCacheConfig() returned null");
- cacheConfig = result;
- }
- return result;
- }
-
- /**
- * Creates the MRTR (Multi-Round-Trip Request) configuration published by {@link #getMrtrConfig()}.
- *
- * <p>
- * Override to supply a custom {@link RequestStateCodec}, TTL, or max-rounds cap. The default returns a
- * new {@link McpMrtrConfig} (AES-GCM ephemeral codec, 5-minute {@code requestState} TTL, 10-round cap).
- *
- * <p>
- * <b>Must be side-effect-free.</b> {@link #getMrtrConfig()} calls this hook <b>exactly once</b> per servlet
- * instance under a lock, so — unlike {@link #createCacheConfig()} — the returned instance (and its
- * single random AES key) is the one and only config ever published; it must not depend on being called more
- * than once.
- *
- * @return The MRTR configuration. Must not be <jk>null</jk>.
- */
- protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig();
- }
-
- /**
- * Returns this servlet's lazily-published, binding-owned MRTR configuration.
- *
- * <p>
- * The first call publishes the result of {@link #createMrtrConfig()} under a lock; every later call returns
- * that same instance. Publication is done with double-checked locking (not the plain lazy read
- * {@link #getCacheConfig()} uses) on purpose: two {@link McpMrtrConfig} instances are <b>not</b> equivalent
- * — each {@link AeadRequestStateCodec} generates a distinct random AES key at construction time —
- * so a benign publication race that let two threads each create and publish a config would leave a
- * {@code requestState} sealed against one key impossible to unseal against the other, failing RESUME. The lock
- * guarantees exactly one config/key is ever created and published. The published instance must not be mutated
- * by callers.
- *
- * @return The MRTR configuration. Never <jk>null</jk>.
- * @throws IllegalStateException If {@link #createMrtrConfig()} returns <jk>null</jk>.
- */
- public McpMrtrConfig getMrtrConfig() {
- var result = mrtrConfig;
- if (result == null) {
- synchronized (this) {
- result = mrtrConfig;
- if (result == null) {
- result = createMrtrConfig();
- if (result == null)
- throw new IllegalStateException("createMrtrConfig() returned null");
- mrtrConfig = result;
- }
- }
- }
- return result;
- }
-
- /**
- * Creates the subscriptions configuration published by {@link #getSubscriptionsConfig()}.
- *
- * <p>
- * Override to supply custom concurrency/queue/heartbeat/idle-timeout knobs. The default returns a plain
- * {@link McpSubscriptionsConfig} (all defaults).
- *
- * @return The subscriptions configuration. Must not be <jk>null</jk>.
- */
- protected McpSubscriptionsConfig createSubscriptionsConfig() {
- return new McpSubscriptionsConfig();
- }
-
- /**
- * Returns this servlet's lazily-published, binding-owned subscriptions configuration.
- *
- * <p>
- * Knobs-only (no live/broker state), so — unlike {@link #getSubscriptionBroker()} — this uses the same
- * plain lazy-read strategy as {@link #getCacheConfig()}: a benign publication race is harmless because any
- * two {@link McpSubscriptionsConfig} instances built by an idempotent {@link #createSubscriptionsConfig()}
- * are equivalent.
- *
- * @return The subscriptions configuration. Never <jk>null</jk>.
- * @throws IllegalStateException If {@link #createSubscriptionsConfig()} returns <jk>null</jk>.
- */
- public McpSubscriptionsConfig getSubscriptionsConfig() {
- var result = subscriptionsConfig;
- if (result == null) {
- result = createSubscriptionsConfig();
- if (result == null)
- throw new IllegalStateException("createSubscriptionsConfig() returned null");
- subscriptionsConfig = result;
- }
- return result;
- }
-
- /**
- * Publishes {@link #getSubscriptionsConfig()} into this servlet's {@code RestContext} bean store
- * (the parent store {@link org.apache.juneau.rest.server.mcp.AbstractMcpRestServlet#handleMcp} wraps its
- * per-request {@code BeanStore} with), the same {@code @Bean}-method mechanism already used by
- * {@link #mcpTraceContextExtractor()}.
- *
- * <p>
- * This is what lets {@code McpRevision.dispatch(...)}'s {@code subscriptions/listen} branch resolve
- * the real, possibly-overridden config via {@code ctx.getBean(McpSubscriptionsConfig.class)} instead
- * of always falling through to that call's defensive {@code new McpSubscriptionsConfig()} default —
- * the neutral core cannot add this bean itself ({@code McpSubscriptionsConfig} is a v2 type, and the
- * neutral module must not import it), so this v2-side {@code @Bean} method is the wiring's only seam.
- *
- * @return This servlet's subscriptions configuration. Never <jk>null</jk>.
+ * @return The options. Never <jk>null</jk>.
+ * @throws IllegalStateException If {@link #createMcpOptions()} returns <jk>null</jk>.
*/
@Bean
- public McpSubscriptionsConfig subscriptionsConfigBean() {
- return getSubscriptionsConfig();
- }
-
- /**
- * Creates the subscription broker published by {@link #getSubscriptionBroker()}.
- *
- * <p>
- * Override to supply a custom {@link McpSubscriptionBroker} implementation. The default returns a new
- * {@link BasicMcpSubscriptionBroker} sized from {@link #getSubscriptionsConfig()}'s queue bound.
- *
- * <p>
- * <b>Must be side-effect-free.</b> {@link #getSubscriptionBroker()} calls this hook <b>exactly once</b>
- * per servlet instance under a lock: the returned instance is the one and only broker (and its live
- * subscription registry) ever published.
- *
- * @return The subscription broker. Must not be <jk>null</jk>.
- */
- protected McpSubscriptionBroker createSubscriptionBroker() {
- return new BasicMcpSubscriptionBroker(getSubscriptionsConfig().getQueueSize());
+ public final McpOptions getMcpOptions() {
+ var o = mcpOptions.get();
+ if (o == null) {
+ var no = createMcpOptions();
+ if (no == null)
+ throw isex("createMcpOptions() returned null");
+ o = mcpOptions.compareAndSet(null, no) ? no : mcpOptions.get(); // HTT: the CAS-loses branch requires a genuine concurrent first-access race; untestable deterministically.
+ }
+ return o;
}
/**
* Returns this servlet's lazily-published, binding-owned subscription broker.
*
* <p>
- * The first call publishes the result of {@link #createSubscriptionBroker()} under a lock; every later
- * call returns that same instance. Publication is double-checked locking (MRTR-grade, not the plain lazy
- * read {@link #getSubscriptionsConfig()} uses) on purpose: two {@link BasicMcpSubscriptionBroker}
- * instances are <b>not</b> equivalent — each holds distinct, live per-connection subscription state — so a
- * benign publication race would silently split subscribers and publishers across two unrelated registries.
+ * Reads {@link #getMcpOptions()}'s memoized {@link McpOptions#getSubscriptionBroker()}: an explicitly
+ * configured broker is returned as-is; otherwise one is derived and memoized per-binding, sized from
+ * {@link McpOptions#getSubscriptions()}'s queue bound (see {@link McpOptions#resolveSubscriptionBroker()}).
*
* @return The subscription broker. Never <jk>null</jk>.
- * @throws IllegalStateException If {@link #createSubscriptionBroker()} returns <jk>null</jk>.
*/
@Override
- public McpSubscriptionBroker getSubscriptionBroker() {
- var result = subscriptionBroker;
- if (result == null) {
- synchronized (this) {
- result = subscriptionBroker;
- if (result == null) {
- result = createSubscriptionBroker();
- if (result == null)
- throw new IllegalStateException("createSubscriptionBroker() returned null");
- subscriptionBroker = result;
- }
- }
- }
- return result;
+ public final McpSubscriptionBroker getSubscriptionBroker() {
+ return getMcpOptions().resolveSubscriptionBroker();
}
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
index 56a8b7a783..e424a5fc96 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpRevision.java
@@ -57,7 +57,7 @@ import org.apache.juneau.rest.server.mcp.McpToolOutcome;
*
* <p>
* <b>Constructed per binding, not shared as a singleton.</b> The bound servlet or mixin builds a
- * fresh instance on every {@code revision()} call, passing its own {@code capabilities()} hook
+ * fresh instance on every {@code revision()} call, passing its {@link McpOptions#getCapabilities()}
* result and its published {@link McpCacheConfig} into the constructor — see {@link McpRestServlet}
* and {@link McpEndpoint}. A {@code null} capabilities override auto-derives {@code server/discover}
* capabilities from the registered tool/prompt/resource/resource-template lists; a non-{@code null}
@@ -183,7 +183,7 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
*
* <p>
* Package-visible so tests can confirm the binding-level memoization contract documented on
- * {@link McpRestServlet#getMrtrConfig()} / {@link McpEndpoint#mrtrConfig()}: two {@link McpRevision}
+ * {@link McpRestServlet#getMcpOptions()} / {@link McpEndpoint#getMcpOptions()}: two {@link McpRevision}
* instances built from the same binding must share the same {@link McpMrtrConfig} (and therefore the
* same {@link RequestStateCodec}), even though each dispatched request constructs its own
* {@link McpRevision}.
@@ -265,12 +265,12 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
* @param params The opaque request params. Must decode to a {@code SubscriptionsListenRequest}.
* @param config The neutral handler registry, used to auto-derive capabilities when none were set explicitly.
* @param ctx The per-request bean store, expected to carry a bound {@link McpSubscriptionBroker}
- * (Phase 1-3 wiring) and a bound {@link McpSubscriptionsConfig}. In production the config bean is
- * normally present — {@code McpRestServlet#subscriptionsConfigBean()} / {@code McpEndpoint#subscriptionsConfigBean()}
- * (Task 3.3/3.4) publish it into the {@code RestContext} bean store that this request-scoped {@code ctx}
- * wraps as its parent, since the neutral core cannot add it directly ({@code McpSubscriptionsConfig} is a
- * v2 type). The {@code orElseGet(McpSubscriptionsConfig::new)} fallback below is defense-in-depth only
- * (e.g. a hand-built {@code ctx} in a direct-dispatch test that omits it).
+ * (Phase 1-3 wiring) and a bound {@link McpOptions}. In production the options bean is normally present —
+ * {@code McpRestServlet#getMcpOptions()} / {@code McpEndpoint#mcpOptionsBean()} (TODO-330) publish it into
+ * the {@code RestContext} bean store that this request-scoped {@code ctx} wraps as its parent, since the
+ * neutral core cannot add it directly ({@code McpOptions} is a v2 type). The
+ * {@code orElseGet(McpSubscriptionsConfig::new)} fallback below is defense-in-depth only (e.g. a
+ * hand-built {@code ctx} in a direct-dispatch test that omits it).
* <p>
* <b>SSE negotiation gate (checked first, before any broker registration):</b> every MCP servlet binding
* defaults an unqualified request to {@code Accept: application/json}. A {@code subscriptions/listen}
@@ -288,10 +288,10 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
* <p>
* {@code maxConcurrentSubscriptions} is enforced as a hard cap, not an advisory one: the admission check
* and the registration are one atomic step via {@link McpSubscriptionBroker#registerIfUnder}, so
- * concurrent {@code subscriptions/listen} calls (in particular against the JVM-wide {@code
- * SharedSubscriptionBroker} multiple mixin resources can share) cannot all observe room under the cap
- * and all register, which a separate {@code activeCount()} check followed by a separate {@code
- * register(...)} call would allow.
+ * concurrent {@code subscriptions/listen} calls against the same broker (e.g. a custom broker explicitly
+ * shared across multiple bindings via {@link McpOptions#setSubscriptionBroker}) cannot all observe room
+ * under the cap and all register, which a separate {@code activeCount()} check followed by a separate
+ * {@code register(...)} call would allow.
*
* <p>
* The broker registers under a fresh {@link UUID}, never under the client-supplied JSON-RPC request
@@ -315,11 +315,12 @@ public final class McpRevision implements org.apache.juneau.rest.server.mcp.McpR
var broker = ctx.getBean(McpSubscriptionBroker.class)
.orElseThrow(() -> new McpException(CODE_INTERNAL_ERROR, "McpSubscriptionBroker not bound in BeanStore"));
- var subscriptionsConfig = ctx.getBean(McpSubscriptionsConfig.class).orElseGet(McpSubscriptionsConfig::new);
+ var subscriptionsConfig = ctx.getBean(McpOptions.class).map(McpOptions::getSubscriptions).orElseGet(McpSubscriptionsConfig::new);
// registerIfUnder admission-checks and registers as one atomic step (hard cap, not advisory) — a
// separate activeCount() check followed by a separate register() call would leave a TOCTOU window
- // open for concurrent listen requests racing the same (possibly JVM-wide shared) broker. The
- // registry key is a server-minted UUID, deliberately NOT String.valueOf(id): see the javadoc above.
+ // open for concurrent listen requests racing the same per-binding broker (see
+ // McpOptions#resolveSubscriptionBroker()). The registry key is a server-minted UUID, deliberately
+ // NOT String.valueOf(id): see the javadoc above.
var subscription = broker.registerIfUnder(subscriptionsConfig.getMaxConcurrentSubscriptions(), UUID.randomUUID().toString(), honoredFilter)
.orElseThrow(() -> new McpException(CODE_TOO_MANY_SUBSCRIPTIONS, "Maximum concurrent subscriptions exceeded"));
var honoredWireFilter = SubscriptionCapabilityGate.toWireFilter(honoredFilter);
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsConfig.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsConfig.java
index 8e8de697c2..9836ebc3d1 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsConfig.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsConfig.java
@@ -32,9 +32,10 @@ package org.apache.juneau.rest.server.mcp.v20260728;
* <p>
* Unlike the {@code org.apache.juneau.rest.server.mcp.McpSubscriptionBroker} singleton (which holds live
* per-connection state), this type is knobs-only — every field is a tunable numeric limit with no embedded
- * broker reference — so a servlet binding may memoize it with a plain lazy read (see
- * {@link McpRestServlet#getSubscriptionsConfig()}) rather than the MRTR-grade double-checked lock the broker
- * itself requires (see {@link McpRestServlet#getSubscriptionBroker()}).
+ * broker reference. It is one of {@link McpOptions}'s nested configs (see {@link McpOptions#getSubscriptions()}),
+ * memoized once per binding along with the rest of {@link McpOptions}; the broker itself is derived and
+ * memoized separately, sized from this config's {@link #getQueueSize() queueSize} (see
+ * {@link McpOptions#resolveSubscriptionBroker()}).
*/
public class McpSubscriptionsConfig {
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedMrtrConfig.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedMrtrConfig.java
deleted file mode 100644
index 2398541f65..0000000000
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedMrtrConfig.java
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.juneau.rest.server.mcp.v20260728;
-
-/**
- * Holds the lazily-initialized, per-process shared default {@link McpMrtrConfig} returned by the
- * {@link McpEndpoint#mrtrConfig()} mixin default.
- *
- * <p>
- * The mixin's {@code revision()} constructs a fresh {@link McpRevision} on every request, so a default that
- * returned a fresh {@link McpMrtrConfig} each call would mint a fresh {@link AeadRequestStateCodec} AES key
- * per request — a {@code requestState} sealed on the PAUSE request could never be unsealed on the
- * follow-up RESUME request. Returning this single shared instance instead means the whole JVM shares one
- * ephemeral AES key across every mixin deployment, which is exactly the "per-process ephemeral key" design
- * intent and makes RESUME work out of the box without an override.
- *
- * <p>
- * Uses the initialization-on-demand holder idiom: the instance (and its single per-JVM key) is created the
- * first time {@link #get()} is called — i.e. the first mixin {@code mrtrConfig()} call — and the
- * JVM guarantees that class initialization is thread-safe, so no explicit synchronization is needed.
- */
-final class SharedMrtrConfig {
-
- private SharedMrtrConfig() {}
-
- private static final class Holder {
- static final McpMrtrConfig INSTANCE = new McpMrtrConfig();
- }
-
- /**
- * Returns the per-process shared default MRTR configuration, creating it on first access.
- *
- * @return The shared configuration. Never <jk>null</jk>.
- */
- static McpMrtrConfig get() {
- return Holder.INSTANCE;
- }
-}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedSubscriptionBroker.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedSubscriptionBroker.java
deleted file mode 100644
index 279ff12263..0000000000
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SharedSubscriptionBroker.java
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.juneau.rest.server.mcp.v20260728;
-
-import org.apache.juneau.rest.server.mcp.BasicMcpSubscriptionBroker;
-import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker;
-
-/**
- * Holds the lazily-initialized, per-process shared default {@link McpSubscriptionBroker} returned by the
- * {@link McpEndpoint#subscriptionBroker()} mixin default.
- *
- * <p>
- * The mixin path has no per-endpoint-instance field to memoize a broker onto (it's an interface), and unlike
- * the knobs-only {@link McpSubscriptionsConfig}, a {@link McpSubscriptionBroker} holds live per-connection
- * subscription state that must be shared across every {@code subscriptions/listen} stream a given mixin
- * deployment serves — a fresh broker per call would silently split subscribers and publishers across
- * unrelated registries. Returning this single shared instance instead means the whole JVM shares one
- * subscription registry across every mixin deployment, mirroring {@link SharedMrtrConfig}'s reasoning for why
- * MRTR's per-process default exists.
- *
- * <p>
- * Uses the initialization-on-demand holder idiom: the instance is created the first time {@link #get()} is
- * called — i.e. the first mixin {@code subscriptionBroker()} call — and the JVM guarantees that
- * class initialization is thread-safe, so no explicit synchronization is needed.
- */
-final class SharedSubscriptionBroker {
-
- private SharedSubscriptionBroker() {}
-
- private static final class Holder {
- static final McpSubscriptionBroker INSTANCE =
- new BasicMcpSubscriptionBroker(McpSubscriptionsConfig.DEFAULT_QUEUE_SIZE);
- }
-
- /**
- * Returns the per-process shared default subscription broker, creating it on first access.
- *
- * @return The shared broker. Never <jk>null</jk>.
- */
- static McpSubscriptionBroker get() {
- return Holder.INSTANCE;
- }
-}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SpringMcpRestServlet.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SpringMcpRestServlet.java
index 465029975d..a0567db5f6 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SpringMcpRestServlet.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/main/java/org/apache/juneau/rest/server/mcp/v20260728/SpringMcpRestServlet.java
@@ -34,7 +34,8 @@ import org.apache.juneau.rest.server.springboot.*;
*
* <p>
* Subclasses supply their {@link org.apache.juneau.rest.server.mcp.McpServerConfig} by implementing
- * {@link #getMcpConfig()}.
+ * {@link #getMcpConfig()}, and may optionally override {@link McpEndpoint#getMcpOptions()} to customize
+ * capabilities/instructions/cache/mrtr/subscriptions/broker behavior.
*
* <h5 class='section'>Example:</h5>
* <pre>
@@ -44,6 +45,10 @@ import org.apache.juneau.rest.server.springboot.*;
* public McpServerConfig getMcpConfig() {
* return new McpServerConfig().addTool(new MySpringAwareTool());
* }
+ * @Override // optional
+ * public McpOptions getMcpOptions() {
+ * return new McpOptions().mrtr(m -> m.setTtlMs(600_000));
+ * }
* }
* </pre>
*
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
index 8452c4ffe5..ab785c41ea 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/Characterization_Test.java
@@ -92,15 +92,15 @@ class Characterization_Test {
public static class F_Cache extends F_Full {
private static final long serialVersionUID = 1L;
- @Override protected McpCacheConfig createCacheConfig() {
- return new McpCacheConfig()
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().cache(c -> c
.setDefaultHint(new McpCacheHint().setTtlMs(30000))
.setToolsList(new McpCacheHint().setTtlMs(5000).setCacheScope(McpCacheScope.PRIVATE))
.setPromptsList(new McpCacheHint().setTtlMs(0).setCacheScope(McpCacheScope.PUBLIC))
.setResourceTemplatesList(new McpCacheHint().setTtlMs(60000).setCacheScope(McpCacheScope.PRIVATE))
.setResourcesRead(new McpCacheHint().setTtlMs(2000))
.addResourceReadOverride("file:///a",
- new McpCacheHint().setTtlMs(1000).setCacheScope(McpCacheScope.PRIVATE));
+ new McpCacheHint().setTtlMs(1000).setCacheScope(McpCacheScope.PRIVATE)));
}
}
@@ -309,8 +309,8 @@ class Characterization_Test {
}
});
}
- @Override protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m -> m.setCodec(new FixedKeyGcmCodec()));
}
}
@@ -361,8 +361,8 @@ class Characterization_Test {
});
}
- @Override protected McpMrtrConfig createMrtrConfig() {
- return new McpMrtrConfig().setCodec(new FixedKeyGcmCodec());
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().mrtr(m -> m.setCodec(new FixedKeyGcmCodec()));
}
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
index 3749e7584e..128a56d976 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpBindings_Test.java
@@ -103,7 +103,7 @@ class McpBindings_Test extends TestBase {
assertContains("hello", resp);
}
- // -------- servlet with explicit capability override ---------
+ // -------- servlet with explicit capability override (via McpOptions) ---------
@Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
public static class D extends McpRestServlet {
@@ -113,8 +113,8 @@ class McpBindings_Test extends TestBase {
return new McpServerConfig().setName("override").setVersion("1.0.0").addTool(echo());
}
@Override
- protected ServerCapabilities capabilities() {
- return new ServerCapabilities().setPrompts(new PromptCapability());
+ protected McpOptions createMcpOptions() {
+ return new McpOptions().setCapabilities(new ServerCapabilities().setPrompts(new PromptCapability()));
}
}
@@ -160,17 +160,17 @@ class McpBindings_Test extends TestBase {
assertContains("pong", resp);
}
- // -------- default null capability hooks ---------
+ // -------- default null capability hooks (now read through McpOptions) ---------
@Test void c01_servletCapabilityHook_defaultsToNull() {
- assertNull(new A().capabilities());
+ assertNull(new A().createMcpOptions().getCapabilities());
}
@Test void c02_endpointCapabilityHook_defaultsToNull() {
- assertNull(new B().capabilities());
+ assertNull(new B().getMcpOptions().getCapabilities());
}
- // -------- cache-config lifecycle hooks ---------
+ // -------- cache-config lifecycle, now folded into McpOptions ---------
@Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
public static class E extends McpRestServlet {
@@ -181,9 +181,9 @@ class McpBindings_Test extends TestBase {
return new McpServerConfig().addTool(echo());
}
@Override
- protected McpCacheConfig createCacheConfig() {
+ protected McpOptions createMcpOptions() {
calls.incrementAndGet();
- return new McpCacheConfig().setToolsList(new McpCacheHint().setTtlMs(21));
+ return new McpOptions().cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(21)));
}
}
@@ -195,7 +195,7 @@ class McpBindings_Test extends TestBase {
return new McpServerConfig();
}
@Override
- protected McpCacheConfig createCacheConfig() {
+ protected McpOptions createMcpOptions() {
return null;
}
}
@@ -208,8 +208,8 @@ class McpBindings_Test extends TestBase {
return new McpServerConfig().addTool(echo());
}
@Override
- public McpCacheConfig cacheConfig() {
- return new McpCacheConfig().setToolsList(new McpCacheHint().setCacheScope(McpCacheScope.PRIVATE));
+ public McpOptions getMcpOptions() {
+ return new McpOptions().cache(c -> c.setToolsList(new McpCacheHint().setCacheScope(McpCacheScope.PRIVATE)));
}
}
@@ -221,50 +221,32 @@ class McpBindings_Test extends TestBase {
return MockRestClient.create(G.class).json().contentType("application/json").accept("application/json").build();
}
- @Test void d01_servletCacheConfig_isLazilyCachedAndInjected() throws Exception {
+ @Test void d01_servletOptions_isLazilyCachedAndInjected() throws Exception {
var servlet = new E();
- assertSame(servlet.getCacheConfig(), servlet.getCacheConfig());
+ assertSame(servlet.getMcpOptions(), servlet.getMcpOptions());
assertEquals(1, E.calls.get());
var body = client(E.class).post("/").contentString(body(1, "tools/list", null))
.header("Mcp-Method", "tools/list").header("Mcp-Name", "").run().getContent().asString();
assertContains("\"ttlMs\":21", body);
}
- @Test void d02_servletNullFactoryFailsFast() {
- var e = assertThrows(IllegalStateException.class, () -> new F().getCacheConfig());
- assertEquals("createCacheConfig() returned null", e.getMessage());
+ @Test void d02_servletNullOptionsFactoryFailsFast() {
+ var e = assertThrows(IllegalStateException.class, () -> new F().getMcpOptions());
+ assertEquals("createMcpOptions() returned null", e.getMessage());
}
@Test void d03_endpointDefaultIsEmptyAndOverrideIsInjected() throws Exception {
- assertNotNull(new B().cacheConfig());
+ assertNotNull(new B().getMcpOptions().getCache());
var body = clientBWithCache().post("/mcp").contentString(body(1, "tools/list", null))
.header("Mcp-Method", "tools/list").header("Mcp-Name", "").run().getContent().asString();
assertContains("\"cacheScope\":\"private\"", body);
}
- // -------- mrtr-config lifecycle hooks ---------
+ // -------- MRTR lifecycle, now folded into McpOptions ---------
- @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
- public static class H extends McpRestServlet {
- private static final long serialVersionUID = 1L;
- @Override
- protected McpServerConfig createMcpConfig() {
- return new McpServerConfig();
- }
- @Override
- protected McpMrtrConfig createMrtrConfig() {
- return null;
- }
- }
-
- @Test void e01_servletMrtrConfig_isLazilyCachedAcrossCalls() {
+ @Test void e01_servletOptions_isLazilyCachedAcrossCalls() {
var servlet = new A();
- assertSame(servlet.getMrtrConfig(), servlet.getMrtrConfig());
- }
-
- @Test void e02_servletNullMrtrFactoryFailsFast() {
- var e = assertThrows(IllegalStateException.class, () -> new H().getMrtrConfig());
- assertEquals("createMrtrConfig() returned null", e.getMessage());
+ assertSame(servlet.getMcpOptions(), servlet.getMcpOptions());
}
@Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
@@ -276,40 +258,42 @@ class McpBindings_Test extends TestBase {
return new McpServerConfig();
}
@Override
- protected McpMrtrConfig createMrtrConfig() {
+ protected McpOptions createMcpOptions() {
createCalls.incrementAndGet();
- return new McpMrtrConfig();
+ return new McpOptions();
}
}
/**
- * H2 regression: {@link McpRestServlet#getMrtrConfig()} must publish exactly one {@link McpMrtrConfig} (and
- * thus one AES key) even under a concurrent cold-start race. A plain lazy read would let two racing threads
- * each create and publish a distinct config, so a {@code requestState} sealed against one key could never be
- * unsealed against the other. Every concurrent first-access caller must observe the same instance and
- * {@link #createMrtrConfig()} must run exactly once.
+ * H2 regression: {@link McpRestServlet#getMcpOptions()} must publish exactly one {@link McpOptions} (and
+ * thus one MRTR AES key) even under a concurrent cold-start race, even though (mirroring
+ * {@code AbstractMcpRestServlet#getMcpConfig()}'s lock-free {@link java.util.concurrent.atomic.AtomicReference}
+ * pattern) {@link McpRestServlet#createMcpOptions()} itself MAY run more than once under that race: a
+ * losing thread's locally-computed instance (and its distinct, never-otherwise-used MRTR key) is simply
+ * discarded, never published, and never returned to any caller. Every concurrent first-access caller must
+ * observe the SAME published options instance regardless of how many times the factory itself ran.
*/
- @Test void e06_servletMrtrConfig_concurrentFirstAccessPublishesExactlyOneInstance() throws Exception {
+ @Test void e02_servletOptions_concurrentFirstAccessPublishesExactlyOneInstance() throws Exception {
var servlet = new I();
var threads = 16;
var pool = Executors.newFixedThreadPool(threads);
try {
var start = new CountDownLatch(1);
- var results = new ArrayList<Future<McpMrtrConfig>>();
+ var results = new ArrayList<Future<McpOptions>>();
for (var i = 0; i < threads; i++)
- results.add(pool.submit(() -> { start.await(); return servlet.getMrtrConfig(); }));
+ results.add(pool.submit(() -> { start.await(); return servlet.getMcpOptions(); }));
start.countDown();
var first = results.get(0).get();
for (var f : results)
- assertSame(first, f.get(), "every concurrent first-access caller must observe the same published config");
- assertEquals(1, servlet.createCalls.get(), "createMrtrConfig() must be invoked exactly once under the lock");
+ assertSame(first, f.get(), "every concurrent first-access caller must observe the same published options");
+ assertTrue(servlet.createCalls.get() >= 1, "createMcpOptions() must run at least once");
} finally {
pool.shutdownNow();
}
}
- @Test void e03_endpointMrtrConfigHook_defaultsToNonNull() {
- assertNotNull(new B().mrtrConfig());
+ @Test void e03_endpointOptionsHook_defaultsToNonNull() {
+ assertNotNull(new B().getMcpOptions().getMrtr());
}
/**
@@ -340,22 +324,21 @@ class McpBindings_Test extends TestBase {
/**
* Mixin-path analogue of {@link #e04_mrtrConfigIsStableAcrossSeparateRevisionInstancesFromSameBinding}: two
- * {@link McpRevision}s built through the {@link McpEndpoint} mixin default share the per-process
- * {@link McpMrtrConfig}/{@link RequestStateCodec}, so a {@code requestState} sealed on a PAUSE request unseals
- * on a later RESUME request. The mixin default returns a single JVM-wide shared config (see
- * {@link SharedMrtrConfig}), so the sharing holds even across two distinct endpoint instances — not just
- * repeated {@code revision()} calls on one instance — which is what makes the common mixin case resumable
- * with no override.
+ * {@link McpRevision}s built through the SAME {@link McpEndpoint} instance's mixin default share the
+ * per-binding {@link McpMrtrConfig}/{@link RequestStateCodec} (memoized by {@link McpEndpointOptionsCache}),
+ * so a {@code requestState} sealed on a PAUSE request unseals on a later RESUME request — without ever
+ * standing up a full {@code RestContext} (bare construction).
*/
- @Test void e05_mixinMrtrConfigIsStableAndSharedAcrossEndpointInstances() {
- var rev1 = (McpRevision)new B().revision();
- var rev2 = (McpRevision)new B().revision();
+ @Test void e05_mixinMrtrConfigIsStableAcrossSeparateRevisionInstancesFromSameEndpoint() {
+ var b = new B();
+ var rev1 = (McpRevision)b.revision();
+ var rev2 = (McpRevision)b.revision();
assertNotSame(rev1, rev2, "revision() must construct a fresh McpRevision per call (per-request)");
- assertSame(rev1.mrtrConfig(), rev2.mrtrConfig(), "the mixin default MRTR config must be a per-process shared instance");
+ assertSame(rev1.mrtrConfig(), rev2.mrtrConfig(), "the mixin MRTR config must be memoized at the binding level");
var codec1 = rev1.mrtrConfig().getCodec();
var codec2 = rev2.mrtrConfig().getCodec();
- assertSame(codec1, codec2, "the codec (and its AES key) must be the per-process shared instance");
+ assertSame(codec1, codec2, "the codec (and its AES key) must be memoized at the binding level");
var state = new McpRequestState("resume-here", "tools/call", 1, System.currentTimeMillis() + 60_000L);
var token = codec1.seal(state, "tools/call" + '\u0000' + "2026-07-28");
@@ -363,4 +346,20 @@ class McpBindings_Test extends TestBase {
assertTrue(unsealed.isPresent(), "a requestState sealed on one request must unseal on the next");
assertEquals(state, unsealed.get());
}
+
+ /**
+ * TODO-330 regression: the pre-consolidation mixin default accidentally shared its MRTR key/broker
+ * JVM-wide ({@code SharedMrtrConfig}/{@code SharedSubscriptionBroker}) across every distinct endpoint
+ * instance. Post-consolidation, {@link McpEndpoint#getMcpOptions()}'s default is per-binding: two distinct
+ * endpoint instances must resolve to two distinct {@link McpOptions} (and therefore distinct MRTR keys),
+ * proving the accidental JVM-wide sharing is gone.
+ */
+ @Test void e06_mixinMrtrConfig_distinctAcrossDistinctEndpointInstances() {
+ var rev1 = (McpRevision)new B().revision();
+ var rev2 = (McpRevision)new B().revision();
+ assertNotSame(rev1.mrtrConfig(), rev2.mrtrConfig(),
+ "two distinct endpoint instances must NOT share the same MRTR config (no JVM-wide sharing)");
+ assertNotSame(rev1.mrtrConfig().getCodec(), rev2.mrtrConfig().getCodec(),
+ "two distinct endpoint instances must NOT share the same AES key (no JVM-wide sharing)");
+ }
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache_Test.java
new file mode 100644
index 0000000000..5ad3895b95
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpEndpointOptionsCache_Test.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.rest.server.mcp.McpServerConfig;
+import org.junit.jupiter.api.Test;
+
+/**
+ * TODO-330 code-review regression coverage: {@link McpEndpointOptionsCache} must key by reference
+ * <b>identity</b>, never by an endpoint's own {@code equals()}/{@code hashCode()}.
+ */
+class McpEndpointOptionsCache_Test {
+
+ /**
+ * A resource "fixture" that overrides {@code equals}/{@code hashCode} the way a record, a Lombok
+ * {@code @Data} class, or any other value-ish endpoint type might: every instance of this class
+ * compares equal to every other instance of this class, and every instance hashes identically.
+ *
+ * <p>
+ * A {@code WeakHashMap}-backed cache (keyed by {@code equals()}/{@code hashCode()}) collapses distinct
+ * instances of this class onto ONE cache entry, so a second instance would silently observe the FIRST
+ * instance's memoized {@link McpOptions} (and thus its MRTR AES key and subscription broker) —
+ * exactly the JVM-wide sharing regression TODO-330 set out to eliminate.
+ */
+ public static class EqualsOverridingEndpoint implements McpEndpoint {
+ @Override public McpServerConfig getMcpConfig() { return new McpServerConfig(); }
+ @Override public boolean equals(Object o) { return o instanceof EqualsOverridingEndpoint; }
+ @Override public int hashCode() { return 42; }
+ }
+
+ @Test void a01_fixtureReallyDoesCollapseUnderEqualsHashCode() {
+ var e1 = new EqualsOverridingEndpoint();
+ var e2 = new EqualsOverridingEndpoint();
+ assertEquals(e1, e2, "fixture sanity check: two distinct instances must compare equal");
+ assertEquals(e1.hashCode(), e2.hashCode(), "fixture sanity check: two distinct instances must hash identically");
+ assertNotSame(e1, e2, "fixture sanity check: they must still be distinct object instances");
+ }
+
+ /**
+ * The core regression: two distinct {@link EqualsOverridingEndpoint} instances — which compare
+ * {@code equals()} and hash identically — must still resolve to two distinct memoized
+ * {@link McpOptions}. This is the assertion that FAILS (two instances collapse onto one shared
+ * {@link McpOptions}) against a {@code WeakHashMap}-backed cache keyed by {@code equals()}/
+ * {@code hashCode()}, and PASSES against the identity-keyed {@link McpEndpointOptionsCache}.
+ */
+ @Test void a02_distinctInstances_resolveToDistinctMcpOptions_despiteEqualsCollapsing() {
+ var e1 = new EqualsOverridingEndpoint();
+ var e2 = new EqualsOverridingEndpoint();
+ var options1 = McpEndpointOptionsCache.resolve(e1);
+ var options2 = McpEndpointOptionsCache.resolve(e2);
+ assertNotSame(options1, options2,
+ "two distinct endpoint instances must resolve to distinct McpOptions even though equals()/hashCode() "
+ + "collapse them, or the identity-key contract has regressed back to equals()-keying");
+ }
+
+ /** Same regression, surfaced through the mixin's own {@code subscriptionBroker()} seam. */
+ @Test void a03_distinctInstances_resolveToDistinctBrokers_despiteEqualsCollapsing() {
+ var e1 = new EqualsOverridingEndpoint();
+ var e2 = new EqualsOverridingEndpoint();
+ assertNotSame(e1.subscriptionBroker(), e2.subscriptionBroker(),
+ "two distinct endpoint instances must NOT share a subscription broker even though equals()/hashCode() "
+ + "collapse them");
+ }
+
+ /** Same regression, surfaced through the mixin's own {@code revision()} seam (MRTR codec/AES key). */
+ @Test void a04_distinctInstances_resolveToDistinctMrtrCodecs_despiteEqualsCollapsing() {
+ var e1 = new EqualsOverridingEndpoint();
+ var e2 = new EqualsOverridingEndpoint();
+ var rev1 = (McpRevision)e1.revision();
+ var rev2 = (McpRevision)e2.revision();
+ assertNotSame(rev1.mrtrConfig(), rev2.mrtrConfig(),
+ "two distinct endpoint instances must NOT share an MRTR config even though equals()/hashCode() collapse them");
+ assertNotSame(rev1.mrtrConfig().getCodec(), rev2.mrtrConfig().getCodec(),
+ "two distinct endpoint instances must NOT share an MRTR AES key even though equals()/hashCode() collapse them");
+ }
+
+ @Test void a05_resolve_isStillMemoizedPerInstance() {
+ var e1 = new EqualsOverridingEndpoint();
+ assertSame(McpEndpointOptionsCache.resolve(e1), McpEndpointOptionsCache.resolve(e1),
+ "repeated resolution of the SAME instance must still return the identical memoized McpOptions");
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
new file mode 100644
index 0000000000..5f55bb31e3
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpOptions_Test.java
@@ -0,0 +1,196 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.server.mcp.v20260728;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import org.apache.juneau.bean.mcp.v20260728.PromptCapability;
+import org.apache.juneau.bean.mcp.v20260728.ServerCapabilities;
+import org.apache.juneau.rest.server.mcp.BasicMcpSubscriptionBroker;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Coverage for {@link McpOptions}, the v2 aggregate consolidating capabilities/instructions/cache/mrtr/
+ * subscriptions/subscriptionBroker.
+ */
+class McpOptions_Test {
+
+ // -------- capabilities / instructions: plain getter/setter round-trips ---------
+
+ @Test void a01_capabilities_defaultsToNull() {
+ assertNull(new McpOptions().getCapabilities());
+ }
+
+ @Test void a02_capabilities_setterRoundTrips() {
+ var caps = new ServerCapabilities().setPrompts(new PromptCapability());
+ var o = new McpOptions().setCapabilities(caps);
+ assertSame(caps, o.getCapabilities());
+ }
+
+ @Test void a03_capabilities_setterAcceptsNull() {
+ var o = new McpOptions().setCapabilities(new ServerCapabilities()).setCapabilities(null);
+ assertNull(o.getCapabilities());
+ }
+
+ @Test void a04_instructions_defaultsToNull() {
+ assertNull(new McpOptions().getInstructions());
+ }
+
+ @Test void a05_instructions_setterRoundTrips() {
+ var o = new McpOptions().setInstructions("Use tool 'echo' to test.");
+ assertEquals("Use tool 'echo' to test.", o.getInstructions());
+ }
+
+ @Test void a06_instructions_setterAcceptsNull() {
+ var o = new McpOptions().setInstructions("x").setInstructions(null);
+ assertNull(o.getInstructions());
+ }
+
+ // -------- cache: nested config, replace-outright, and Consumer configure-block ---------
+
+ @Test void b01_cache_defaultsToNonNullFrameworkOwnedInstance() {
+ assertNotNull(new McpOptions().getCache());
+ }
+
+ @Test void b02_setCache_replacesOutrightAndRoundTrips() {
+ var cache = new McpCacheConfig();
+ var o = new McpOptions().setCache(cache);
+ assertSame(cache, o.getCache());
+ }
+
+ @Test void b03_setCache_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().setCache(null));
+ assertEquals("cache must not be null", e.getMessage());
+ }
+
+ @Test void b04_cacheConsumer_mutatesFrameworkOwnedInstanceInPlace() {
+ var o = new McpOptions();
+ var before = o.getCache();
+ var same = o.cache(c -> c.setToolsList(new McpCacheHint().setTtlMs(60_000)));
+ assertSame(o, same, "cache(Consumer) must return this for chaining");
+ assertSame(before, o.getCache(), "the Consumer block must mutate the existing nested instance in place, never replace it");
+ assertEquals(Integer.valueOf(60_000), o.getCache().getToolsList().getTtlMs());
+ }
+
+ @Test void b05_cacheConsumer_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().cache(null));
+ assertEquals("consumer must not be null", e.getMessage());
+ }
+
+ // -------- mrtr: nested config, replace-outright, and Consumer configure-block ---------
+
+ @Test void c01_mrtr_defaultsToNonNullFrameworkOwnedInstance() {
+ assertNotNull(new McpOptions().getMrtr());
+ }
+
+ @Test void c02_setMrtr_replacesOutrightAndRoundTrips() {
+ var mrtr = new McpMrtrConfig();
+ var o = new McpOptions().setMrtr(mrtr);
+ assertSame(mrtr, o.getMrtr());
+ }
+
+ @Test void c03_setMrtr_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().setMrtr(null));
+ assertEquals("mrtr must not be null", e.getMessage());
+ }
+
+ @Test void c04_mrtrConsumer_mutatesFrameworkOwnedInstanceInPlace() {
+ var o = new McpOptions();
+ var before = o.getMrtr();
+ var same = o.mrtr(m -> m.setTtlMs(600_000L));
+ assertSame(o, same, "mrtr(Consumer) must return this for chaining");
+ assertSame(before, o.getMrtr(), "the Consumer block must mutate the existing nested instance in place, never replace it");
+ assertEquals(600_000L, o.getMrtr().getTtlMs());
+ }
+
+ @Test void c05_mrtrConsumer_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().mrtr(null));
+ assertEquals("consumer must not be null", e.getMessage());
+ }
+
+ // -------- subscriptions: nested config, replace-outright, and Consumer configure-block ---------
+
+ @Test void d01_subscriptions_defaultsToNonNullFrameworkOwnedInstance() {
+ assertNotNull(new McpOptions().getSubscriptions());
+ }
+
+ @Test void d02_setSubscriptions_replacesOutrightAndRoundTrips() {
+ var subs = new McpSubscriptionsConfig();
+ var o = new McpOptions().setSubscriptions(subs);
+ assertSame(subs, o.getSubscriptions());
+ }
+
+ @Test void d03_setSubscriptions_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().setSubscriptions(null));
+ assertEquals("subscriptions must not be null", e.getMessage());
+ }
+
+ @Test void d04_subscriptionsConsumer_mutatesFrameworkOwnedInstanceInPlace() {
+ var o = new McpOptions();
+ var before = o.getSubscriptions();
+ var same = o.subscriptions(s -> s.setQueueSize(2048));
+ assertSame(o, same, "subscriptions(Consumer) must return this for chaining");
+ assertSame(before, o.getSubscriptions(), "the Consumer block must mutate the existing nested instance in place, never replace it");
+ assertEquals(2048, o.getSubscriptions().getQueueSize());
+ }
+
+ @Test void d05_subscriptionsConsumer_nullThrows() {
+ var e = assertThrows(IllegalArgumentException.class, () -> new McpOptions().subscriptions(null));
+ assertEquals("consumer must not be null", e.getMessage());
+ }
+
+ // -------- subscriptionBroker: explicit-slot getter/setter vs. resolveSubscriptionBroker() derivation ---------
+
+ @Test void e01_getSubscriptionBroker_defaultsToNull() {
+ assertNull(new McpOptions().getSubscriptionBroker(),
+ "the explicit-slot getter must default to null (meaning framework-derived), unlike resolveSubscriptionBroker()");
+ }
+
+ @Test void e02_resolveSubscriptionBroker_neverNull_derivesADefault() {
+ assertNotNull(new McpOptions().resolveSubscriptionBroker(),
+ "resolveSubscriptionBroker() is the effective accessor and must never return null");
+ }
+
+ @Test void e03_resolveSubscriptionBroker_memoizesTheDerivedInstance() {
+ var o = new McpOptions();
+ assertSame(o.resolveSubscriptionBroker(), o.resolveSubscriptionBroker(),
+ "repeated calls must return the SAME derived broker, or subscribers/publishers would split across registries");
+ }
+
+ @Test void e04_setSubscriptionBroker_explicitValueIsReturnedAsIsByResolve() {
+ var broker = new BasicMcpSubscriptionBroker(16);
+ var o = new McpOptions().setSubscriptionBroker(broker);
+ assertSame(broker, o.getSubscriptionBroker());
+ assertSame(broker, o.resolveSubscriptionBroker(), "an explicitly-set broker must be returned as-is, never overridden by derivation");
+ }
+
+ @Test void e05_setSubscriptionBrokerNull_revertsToDerivedDefault() {
+ var explicit = new BasicMcpSubscriptionBroker(16);
+ var o = new McpOptions().setSubscriptionBroker(explicit);
+ assertSame(explicit, o.resolveSubscriptionBroker());
+
+ o.setSubscriptionBroker(null);
+ assertNull(o.getSubscriptionBroker());
+ assertNotNull(o.resolveSubscriptionBroker(), "after reverting to null, resolveSubscriptionBroker() must derive a default rather than staying null");
+ assertNotSame(explicit, o.resolveSubscriptionBroker(), "the derived default must not be the previously-set explicit broker");
+ }
+
+ @Test void e06_resolveSubscriptionBroker_sizedFromSubscriptionsQueueSize() {
+ var o = new McpOptions().subscriptions(s -> s.setQueueSize(7));
+ assertInstanceOf(BasicMcpSubscriptionBroker.class, o.resolveSubscriptionBroker());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerBinding_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerBinding_Test.java
index af3498ca7e..d4b103c857 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerBinding_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerBinding_Test.java
@@ -20,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import java.util.concurrent.*;
-import java.util.concurrent.atomic.*;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.rest.server.*;
@@ -50,79 +49,53 @@ class McpSubscriptionBrokerBinding_Test {
@Test void a03_servletSubscriptionsConfig_isLazilyCachedAcrossCalls() {
var servlet = new A();
- assertSame(servlet.getSubscriptionsConfig(), servlet.getSubscriptionsConfig());
+ assertSame(servlet.getMcpOptions().getSubscriptions(), servlet.getMcpOptions().getSubscriptions());
}
- @Test void a03b_subscriptionsConfigBean_isAnnotatedAndDelegatesToGetSubscriptionsConfig() throws Exception {
- var servlet = new A();
- var m = McpRestServlet.class.getMethod("subscriptionsConfigBean");
+ @Test void a03b_getMcpOptions_isAnnotatedAsBean() throws Exception {
+ var m = McpRestServlet.class.getMethod("getMcpOptions");
assertTrue(m.isAnnotationPresent(org.apache.juneau.commons.inject.Bean.class),
- "subscriptionsConfigBean() must be @Bean-annotated so the RestContext bean store discovers it, "
+ "getMcpOptions() must be @Bean-annotated so the RestContext bean store discovers it, "
+ "the same mechanism already used by mcpTraceContextExtractor()");
- assertSame(servlet.getSubscriptionsConfig(), servlet.subscriptionsConfigBean());
}
@Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
- public static class B extends McpRestServlet {
+ public static class D extends McpRestServlet {
private static final long serialVersionUID = 1L;
@Override protected org.apache.juneau.rest.server.mcp.McpServerConfig createMcpConfig() {
return new org.apache.juneau.rest.server.mcp.McpServerConfig();
}
- @Override protected McpSubscriptionsConfig createSubscriptionsConfig() { return null; }
- }
-
- @Test void a04_servletNullSubscriptionsConfigFactoryFailsFast() {
- var e = assertThrows(IllegalStateException.class, () -> new B().getSubscriptionsConfig());
- assertEquals("createSubscriptionsConfig() returned null", e.getMessage());
- }
-
- @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
- public static class C extends McpRestServlet {
- private static final long serialVersionUID = 1L;
- @Override protected org.apache.juneau.rest.server.mcp.McpServerConfig createMcpConfig() {
- return new org.apache.juneau.rest.server.mcp.McpServerConfig();
+ @Override protected McpOptions createMcpOptions() {
+ return new McpOptions().setSubscriptionBroker(new BasicMcpSubscriptionBroker(42));
}
- @Override protected McpSubscriptionBroker createSubscriptionBroker() { return null; }
}
- @Test void a05_servletNullBrokerFactoryFailsFast() {
- var e = assertThrows(IllegalStateException.class, () -> new C().getSubscriptionBroker());
- assertEquals("createSubscriptionBroker() returned null", e.getMessage());
- }
-
- @Rest(serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
- public static class D extends McpRestServlet {
- private static final long serialVersionUID = 1L;
- final AtomicInteger createCalls = new AtomicInteger();
- @Override protected org.apache.juneau.rest.server.mcp.McpServerConfig createMcpConfig() {
- return new org.apache.juneau.rest.server.mcp.McpServerConfig();
- }
- @Override protected McpSubscriptionBroker createSubscriptionBroker() {
- createCalls.incrementAndGet();
- return new BasicMcpSubscriptionBroker(getSubscriptionsConfig().getQueueSize());
- }
+ @Test void a04_servletExplicitBroker_isReturnedAsIs() {
+ var servlet = new D();
+ var broker = servlet.getSubscriptionBroker();
+ assertSame(servlet.getMcpOptions().getSubscriptionBroker(), broker);
}
/**
- * Mirrors {@code McpBindings_Test#e06_servletMrtrConfig_concurrentFirstAccessPublishesExactlyOneInstance}:
- * a plain lazy read would let two racing threads each publish a distinct broker, silently splitting live
- * subscription state across two registries. Every concurrent first-access caller must observe the same
- * instance and {@code createSubscriptionBroker()} must run exactly once.
+ * Mirrors {@code McpBindings_Test#e02_servletOptions_concurrentFirstAccessPublishesExactlyOneInstance}, but
+ * exercises {@link McpOptions#resolveSubscriptionBroker()}'s own inner double-checked-locking directly: a
+ * plain lazy read would let two racing threads each publish a distinct derived broker, silently splitting
+ * live subscription state across two registries. Every concurrent first-access caller must observe the same
+ * derived instance.
*/
- @Test void a06_servletBroker_concurrentFirstAccessPublishesExactlyOneInstance() throws Exception {
- var servlet = new D();
+ @Test void a05_derivedBroker_concurrentFirstAccessPublishesExactlyOneInstance() throws Exception {
+ var options = new McpOptions();
var threads = 16;
var pool = Executors.newFixedThreadPool(threads);
try {
var start = new CountDownLatch(1);
var results = new ArrayList<Future<McpSubscriptionBroker>>();
for (var i = 0; i < threads; i++)
- results.add(pool.submit(() -> { start.await(); return servlet.getSubscriptionBroker(); }));
+ results.add(pool.submit(() -> { start.await(); return options.resolveSubscriptionBroker(); }));
start.countDown();
var first = results.get(0).get();
for (var f : results)
- assertSame(first, f.get(), "every concurrent first-access caller must observe the same published broker");
- assertEquals(1, servlet.createCalls.get(), "createSubscriptionBroker() must be invoked exactly once under the lock");
+ assertSame(first, f.get(), "every concurrent first-access caller must observe the same derived broker");
} finally {
pool.shutdownNow();
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerMixin_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerMixin_Test.java
index d39a59b40f..95f7980fe6 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerMixin_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionBrokerMixin_Test.java
@@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import org.apache.juneau.marshall.json.*;
+import org.apache.juneau.rest.mock.classic.MockRestClient;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.mcp.McpServerConfig;
import org.apache.juneau.rest.server.mcp.McpSubscriptionBroker;
@@ -28,6 +29,9 @@ import org.apache.juneau.rest.server.mcp.McpSubscriptionFilter;
import org.apache.juneau.rest.server.servlet.*;
import org.junit.jupiter.api.*;
+@SuppressWarnings({
+ "resource" // MockRestClient is a Closeable test helper; lifetime is bounded by the test method.
+})
class McpSubscriptionBrokerMixin_Test {
@Rest(path = "/api", serializers = JsonSerializer.class, parsers = JsonParser.class, defaultAccept = "application/json")
@@ -40,37 +44,71 @@ class McpSubscriptionBrokerMixin_Test {
assertNotNull(new A().subscriptionBroker());
}
- @Test void a02_mixinDefault_returnsSharedInstanceAcrossSeparateEndpointInstances() {
+ @Test void a02_mixinDefault_isStableAcrossCallsOnSameInstance() {
+ var a = new A();
+ assertSame(a.subscriptionBroker(), a.subscriptionBroker(), "the mixin default broker must be memoized per-binding");
+ }
+
+ /**
+ * TODO-330 regression: the pre-consolidation mixin default accidentally shared its broker JVM-wide
+ * ({@code SharedSubscriptionBroker}) across every distinct endpoint instance. Post-consolidation, the
+ * broker is derived per-binding (memoized via {@link McpEndpointOptionsCache}): two distinct endpoint
+ * instances must resolve to two distinct brokers, proving the accidental JVM-wide sharing is gone.
+ */
+ @Test void a03_mixinDefault_distinctAcrossDistinctEndpointInstances() {
var broker1 = new A().subscriptionBroker();
var broker2 = new A().subscriptionBroker();
- assertSame(broker1, broker2, "the mixin default broker must be a per-process shared instance");
+ assertNotSame(broker1, broker2, "two distinct endpoint instances must NOT share the same broker (no JVM-wide sharing)");
}
- @Test void a03_mixinDefault_subscriptionRegisteredOnOneInstanceVisibleOnAnother() {
+ @Test void a04_mixinDefault_subscriptionRegisteredOnOneInstanceNotVisibleOnAnother() {
McpSubscriptionBroker broker1 = new A().subscriptionBroker();
McpSubscriptionBroker broker2 = new A().subscriptionBroker();
var before = broker2.activeCount();
- var sub = broker1.register("mixin-shared-test-s1", new McpSubscriptionFilter(true, true, true, Set.of()));
- assertEquals(before + 1, broker2.activeCount(), "a subscription registered via one mixin instance's "
- + "broker must be visible via another instance's broker, since both share the JVM-wide default");
+ var sub = broker1.register("mixin-isolated-test-s1", new McpSubscriptionFilter(true, true, true, Set.of()));
+ assertEquals(before, broker2.activeCount(), "a subscription registered via one mixin instance's "
+ + "broker must NOT be visible via another instance's broker, since each binding now derives its own");
sub.close();
}
- @Test void a04_mixinDefault_subscriptionsConfigIsFreshPerCallAndEquivalent() {
+ @Test void a05_mixinDefault_subscriptionsConfigIsStableAcrossCallsOnSameInstance() {
var a = new A();
- var config1 = a.subscriptionsConfig();
- var config2 = a.subscriptionsConfig();
- assertNotSame(config1, config2, "unlike the broker, the knobs-only config has no live state to "
- + "share, so the mixin default (matching cacheConfig()'s precedent) returns a fresh instance");
- assertEquals(config1.getQueueSize(), config2.getQueueSize());
+ var config1 = a.mcpOptionsBean().getSubscriptions();
+ var config2 = a.mcpOptionsBean().getSubscriptions();
+ assertSame(config1, config2, "McpOptions (and its nested subscriptions config) is memoized per-binding via "
+ + "the mcpOptionsBean()/McpEndpointOptionsCache seam, so repeated access on the same endpoint instance "
+ + "returns the identical instance, even though the raw getMcpOptions() override point may return a "
+ + "fresh instance per call");
}
- @Test void a05_subscriptionsConfigBean_isAnnotatedAndDelegatesToSubscriptionsConfig() throws Exception {
+ @Test void a06_mcpOptionsBean_isAnnotatedAndMemoizedPerInstance() throws Exception {
var a = new A();
- var m = McpEndpoint.class.getMethod("subscriptionsConfigBean");
+ var m = McpEndpoint.class.getMethod("mcpOptionsBean");
assertTrue(m.isAnnotationPresent(org.apache.juneau.commons.inject.Bean.class),
- "subscriptionsConfigBean() must be @Bean-annotated so the RestContext bean store discovers it, "
+ "mcpOptionsBean() must be @Bean-annotated so the RestContext bean store discovers it, "
+ "the same mechanism already used by mcpTraceContextExtractor()");
- assertNotNull(a.subscriptionsConfigBean());
+ assertSame(a.mcpOptionsBean(), a.mcpOptionsBean(),
+ "mcpOptionsBean() must return the SAME memoized McpOptions on every call for a given endpoint instance");
+ assertNotSame(a.mcpOptionsBean(), new A().mcpOptionsBean(),
+ "a distinct endpoint instance must resolve to a distinct memoized McpOptions");
+ }
+
+ /**
+ * IMPORTANT-5 code-review regression: {@code a06} above only checks the {@code @Bean} annotation
+ * reflectively, which would NOT catch a future break in interface-default {@code @Bean} discovery (for
+ * example, if the {@code RestContext} bean-collection walk stopped considering default methods on mixin
+ * interfaces). This test proves the wiring end-to-end through a real {@code RestContext}: the resource's
+ * bean store must resolve {@link McpOptions} to the exact SAME instance {@link McpEndpoint#mcpOptionsBean()}
+ * returns, so {@code subscriptions/listen} dispatch (which reads {@code McpOptions} from the bean store,
+ * not by calling {@code mcpOptionsBean()} directly) never silently falls back to defaults.
+ */
+ @Test void a07_mcpOptionsBean_isPublishedInRestContextBeanStore_sameInstanceAsMemoized() {
+ var a = new A();
+ MockRestClient.create(a).json().contentType("application/json").accept("application/json").build();
+ var fromBeanStore = a.getContext().getBeanStore().getBean(McpOptions.class)
+ .orElseThrow(() -> new AssertionError("McpOptions bean not found in RestContext's bean store"));
+ assertSame(a.mcpOptionsBean(), fromBeanStore,
+ "the RestContext bean store's published McpOptions must be the SAME instance as the mixin's memoized "
+ + "mcpOptionsBean(), proving the @Bean interface-default discovery wires the memoized instance end-to-end");
}
}
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsEndToEnd_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsEndToEnd_Test.java
index 2104496bf1..914029374d 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsEndToEnd_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/McpSubscriptionsEndToEnd_Test.java
@@ -64,6 +64,9 @@ class McpSubscriptionsEndToEnd_Test {
return Map.of("Mcp-Method", "tools/call", "Mcp-Name", toolName);
}
+ @SuppressWarnings({
+ "resource" // Each .addBean(...) link returns 'this' (a Closeable) already owned by the enclosing try-with-resources; Eclipse JDT @Owning warning on the intermediate chain links is by design.
+ })
@Test void a01_toolHandler_publishReachesSubscriptionRegisteredThroughTheSameRequestScopedBroker() throws Exception {
var broker = new BasicMcpSubscriptionBroker(4);
var capturedSub = new AtomicReference<McpSubscription>();
diff --git a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/SubscriptionsListenDispatch_Test.java b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/SubscriptionsListenDispatch_Test.java
index 04d0864caf..ed785379a3 100644
--- a/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/SubscriptionsListenDispatch_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp-v20260728/src/test/java/org/apache/juneau/rest/server/mcp/v20260728/SubscriptionsListenDispatch_Test.java
@@ -68,7 +68,8 @@ class SubscriptionsListenDispatch_Test {
"resource" // Returned BeanStore is owned by the caller (a fresh per-test fixture, GC'd with the test); Eclipse JDT @Owning warning is by design.
})
private static BasicBeanStore ctxWith(McpSubscriptionBroker broker, McpSubscriptionsConfig config) {
- return new BasicBeanStore().addBean(McpSubscriptionBroker.class, broker).addBean(McpSubscriptionsConfig.class, config);
+ return new BasicBeanStore().addBean(McpSubscriptionBroker.class, broker)
+ .addBean(McpOptions.class, new McpOptions().setSubscriptions(config));
}
// C1: a subscriptions/listen request that never negotiated SSE (Accept missing, or some other media type)
diff --git a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpEndpointMixin.java b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpEndpointMixin.java
index 0cae40e298..5cefd5c2b1 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpEndpointMixin.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/main/java/org/apache/juneau/rest/server/mcp/McpEndpointMixin.java
@@ -75,7 +75,8 @@ public interface McpEndpointMixin {
* <p>
* A revision binding that implements {@code subscriptions/listen} (for example
* {@code org.apache.juneau.rest.server.mcp.v20260728.McpEndpoint}) overrides this to return a
- * per-process shared broker instance.
+ * binding-owned broker instance (its own per-binding vs. shared-instance semantics are that
+ * revision's own choice — see its Javadoc).
*
* @return The subscription broker, or <jk>null</jk> if this endpoint does not support subscriptions.
*/
diff --git a/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
index 32d5f42ae7..e303acea2e 100644
--- a/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
+++ b/juneau-rest/juneau-rest-server-mcp/src/test/java/org/apache/juneau/rest/server/mcp/BasicMcpSubscriptionBroker_Test.java
@@ -198,12 +198,10 @@ class BasicMcpSubscriptionBroker_Test {
@Test void a11_registerIfUnder_underCap_registersAndReturnsPresent() {
var broker = new BasicMcpSubscriptionBroker(4);
var result = broker.registerIfUnder(2, "s1", new McpSubscriptionFilter(true, true, true, Set.of()));
- try {
- assertTrue(result.isPresent());
+ assertTrue(result.isPresent());
+ try (var sub = result.get()) {
assertEquals(1, broker.activeCount());
- assertFalse(result.get().isClosed());
- } finally {
- result.ifPresent(McpSubscription::close);
+ assertFalse(sub.isClosed());
}
}
@@ -223,13 +221,11 @@ class BasicMcpSubscriptionBroker_Test {
try {
// At cap (1 active, max 1): re-registering the SAME id must still succeed since it replaces, not grows.
var second = broker.registerIfUnder(1, "s1", new McpSubscriptionFilter(false, false, false, Set.of()));
- try {
- assertTrue(second.isPresent());
+ assertTrue(second.isPresent());
+ try (var sub = second.get()) {
assertTrue(first.isClosed());
- assertFalse(second.get().isClosed());
+ assertFalse(sub.isClosed());
assertEquals(1, broker.activeCount());
- } finally {
- second.ifPresent(McpSubscription::close);
}
} finally {
first.close();