This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch 3.40.x
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/3.40.x by this push:
new c68e84f53b3 [3.40.x] mcp-server: backport complete test coverage and a
fix for application startup failure
c68e84f53b3 is described below
commit c68e84f53b325e5094128e1c7577fb546ef0777f
Author: Alexandre Gallice <[email protected]>
AuthorDate: Tue Nov 10 08:09:14 2026 +0100
[3.40.x] mcp-server: backport complete test coverage and a fix for
application startup failure
* mcp-server: Do not fail the application startup on an MCP tool name
collision
Backport of the tool side of 564b87d12f0 ("Fixes #9300. Add ai-resource
extension
and serve ai-resource routes as MCP resources"), which fixed this on the
way on main.
An ai-tool route whose name is already taken by a build-time
quarkus-mcp-server
@Tool is refused by the quarkiverse ToolManager, which throws from
ToolDefinition.register(). The McpServerBridge collision check cannot catch
this
case: it only knows about the tools the bridge published itself. For a
route started
with the application the throw was fatal, and the application failed to
boot on a
single duplicated tool name.
Catch the refusal in QuarkusMcpServerEngine.toolAdded() and log it instead,
so the
annotated tool keeps the name and the route still starts. Track the names
the engine
registered, so that toolRemoved() skips a refused tool rather than removing
the
application tool owning that name.
The switch to the two argument removeTool(name, McpServer.DEFAULT) is left
out, as it
belongs to the multi-server support that this branch does not have.
Co-Authored-By: Claude Opus 5 <[email protected]>
* mcp-server: Improve the MCP tool integration test coverage (#9458)
Add the cases the suite left unverified:
* testCamelToolCollidingWithAnnotatedToolIsRefused, with a dedicated
clashing_tool
@Tool and an auto started ai-tool route asking for the same name. Auto
started on
purpose, as that is the case which used to fail the application startup.
Both
implementations answer with a string naming the side that served the
call, so the
assertion reads off which one kept the name. Stopping and restarting the
route
must not hand the name over either. The collision used to reuse
add_numbers,
entangling it with the coexistence test, which now has add_numbers to
itself.
* testCallToolMissingRequiredArgument, pinning the documented contract that
an
argument error returns the validation message, unlike an execution error
which is
replaced by a generic one. It calls its own missing_required_arg_tool
route, so
say_hello stays free for the nominal cases.
* testToolWithoutAnnotationHintsPublishesNone, the negative counterpart of
testToolAnnotationHintsArePublished. The quarkiverse annotations record
uses
primitive booleans, so publishing it for a tool that sets no hint would
advertise
the MCP defaults as if they had been chosen deliberately.
Also assert annotated_tool, unannotated_tool and create_order in the tag
selection
test, and document that the tags only select ai-tool routes, and what
happens when an
ai-tool route asks for a name a tool of the application already owns.
Adapted to this line, which has neither the ai-resource support nor the
multi-server
support: only the tool material is taken. The tests are not disabled here,
as
integration-tests/mcp-server does run on this branch, unlike on main where
#9362
keeps it skipped. They pass in JVM mode.
Co-Authored-By: Claude Opus 5 <[email protected]>
(cherry picked from commit 6a5b0c5cf5e2636237810ffac048f8f0cb78c3bd)
---------
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../pages/reference/extensions/mcp-server.adoc | 14 +++-
.../mcp-server/runtime/src/main/doc/usage.adoc | 14 +++-
.../mcp/server/QuarkusMcpServerEngine.java | 17 +++-
.../component/mcp/server/it/McpServerRoutes.java | 17 ++++
.../mcp/server/it/QuarkusDefinedTools.java | 12 ++-
.../component/mcp/server/it/McpServerTest.java | 95 +++++++++++++++++++---
6 files changed, 150 insertions(+), 19 deletions(-)
diff --git a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
index 9528fb1b9ec..40ef18ea70c 100644
--- a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
@@ -180,9 +180,17 @@ public class CalculatorTools {
}
----
-is exposed alongside the `ai-tool` routes. Annotated tools are registered at
-build time; Camel tools are added and removed dynamically with the route
-lifecycle. Choose distinct tool names — MCP has a flat tool namespace.
+is exposed alongside the `ai-tool` routes. Quarkus AI tools annotated with
@Tool
+are registered at build time; Quarkus AI tools registered via ToolManager are
registered at runtime.
+Camel tools are added and removed dynamically with the route lifecycle.
+`quarkus.camel.mcp-server.tags` only selects `ai-tool` routes:
+The Quarkus AI tools registered by the application are always exposed.
+
+Choose distinct tool names — MCP has a flat tool namespace. When an `ai-tool`
+route asks for a name that a tool of the application already has, the
+application tool is kept and the `ai-tool` route is therefore not exposed via
+MCP. The route still starts and an `ERROR` is logged mentioning the name of the
+ignored `ai-tool`.
[id="extensions-mcp-server-usage-dynamic-tools"]
=== Dynamic tools
diff --git a/extensions/mcp-server/runtime/src/main/doc/usage.adoc
b/extensions/mcp-server/runtime/src/main/doc/usage.adoc
index 27a9de1e12e..f6c537299b0 100644
--- a/extensions/mcp-server/runtime/src/main/doc/usage.adoc
+++ b/extensions/mcp-server/runtime/src/main/doc/usage.adoc
@@ -116,9 +116,17 @@ public class CalculatorTools {
}
----
-is exposed alongside the `ai-tool` routes. Annotated tools are registered at
-build time; Camel tools are added and removed dynamically with the route
-lifecycle. Choose distinct tool names — MCP has a flat tool namespace.
+is exposed alongside the `ai-tool` routes. Quarkus AI tools annotated with
@Tool
+are registered at build time; Quarkus AI tools registered via ToolManager are
registered at runtime.
+Camel tools are added and removed dynamically with the route lifecycle.
+`quarkus.camel.mcp-server.tags` only selects `ai-tool` routes:
+The Quarkus AI tools registered by the application are always exposed.
+
+Choose distinct tool names — MCP has a flat tool namespace. When an `ai-tool`
+route asks for a name that a tool of the application already has, the
+application tool is kept and the `ai-tool` route is therefore not exposed via
+MCP. The route still starts and an `ERROR` is logged mentioning the name of the
+ignored `ai-tool`.
=== Dynamic tools
diff --git
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java
index 8c953f5a2ae..9a9a3cb2785 100644
---
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java
+++
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java
@@ -18,6 +18,8 @@ package org.apache.camel.quarkus.component.mcp.server;
import java.lang.reflect.Type;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
import io.quarkiverse.mcp.server.TextContent;
import io.quarkiverse.mcp.server.ToolManager;
@@ -44,6 +46,8 @@ public class QuarkusMcpServerEngine extends ServiceSupport
implements McpServerE
private static final Logger LOG =
LoggerFactory.getLogger(QuarkusMcpServerEngine.class);
private final ToolManager toolManager;
+ // names of the tools registered by this engine; a refused tool's name
belongs to an application tool
+ private final Set<String> registeredToolNames =
ConcurrentHashMap.newKeySet();
private CamelContext camelContext;
public QuarkusMcpServerEngine(ToolManager toolManager) {
@@ -87,12 +91,23 @@ public class QuarkusMcpServerEngine extends ServiceSupport
implements McpServerE
return result.isError()
? ToolResponse.error(result.text()) :
ToolResponse.success(new TextContent(result.text()));
});
- definition.register();
+ try {
+ definition.register();
+ } catch (IllegalArgumentException e) {
+ // the name is taken by an application tool: refuse the tool, like
the bridge does on a name clash
+ LOG.error("Refusing to expose MCP tool '{}': {}. MCP has a flat
tool namespace.", tool.name(), e.getMessage());
+ return;
+ }
+ registeredToolNames.add(tool.name());
LOG.debug("MCP tool added: {}", tool.name());
}
@Override
public void toolRemoved(String toolName) {
+ if (!registeredToolNames.remove(toolName)) {
+ // refused in toolAdded: removing by name would delete the
application tool with the same name
+ return;
+ }
try {
toolManager.removeTool(toolName);
LOG.debug("MCP tool removed: {}", toolName);
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerRoutes.java
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerRoutes.java
index a350864e75a..d7dc8ce54a5 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerRoutes.java
+++
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerRoutes.java
@@ -25,6 +25,8 @@ import org.apache.camel.builder.RouteBuilder;
@ApplicationScoped
public class McpServerRoutes extends RouteBuilder {
+ public static final String CLASHING_TOOL_ANSWER = "Camel AI tool called
(declared with a from(\"ai-tool:...\") route)";
+
@Override
public void configure() {
from("ai-tool:say_hello?tags=conformance&description=Say hello"
@@ -32,6 +34,11 @@ public class McpServerRoutes extends RouteBuilder {
.routeId("say-hello-route")
.setBody(simple("Hello ${header.name}"));
+
from("ai-tool:missing_required_arg_tool?tags=conformance&description=Description"
+ +
"¶meter.name=string¶meter.name.description=RequiredParam¶meter.name.required=true")
+ .routeId("missing-required-arg-route")
+ .setBody(simple("The required tool argument 'name' was set:
${header.name}"));
+
from("ai-tool:fail_tool?tags=conformance&description=Always fails")
.process(e -> {
throw new IllegalStateException("secret internal detail");
@@ -53,6 +60,16 @@ public class McpServerRoutes extends RouteBuilder {
+ "&idempotentHint=true")
.setBody(constant("annotated"));
+ from("ai-tool:unannotated_tool?tags=conformance&description=Tool
without annotation hints")
+ .setBody(constant("unannotated"));
+
+ // the name is owned by the annotated tool in QuarkusDefinedTools;
started with the application, so it is
+ // refused while the MCP bridge starts, which must not fail the
application startup
+ from("ai-tool:clashing_tool?tags=conformance"
+ + "&description=Name already taken by an annotated tool, must
be refused")
+ .routeId("annotated-clash-route")
+ .setBody(constant(CLASHING_TOOL_ANSWER));
+
from("ai-tool:create_order?tags=conformance&description=Create an
order"
+ "&argSchema=classpath:schema/create-order.json")
.process(e -> {
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
index 88a6cf149fb..85aec120fe4 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
+++
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
@@ -20,14 +20,24 @@ import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
+ * Plain quarkus-mcp-server annotated tools living on the same MCP server as
the Camel ai-tool routes.
*/
public class QuarkusDefinedTools {
+ public static final String CLASHING_TOOL_ANSWER = "Quarkus AI tool called
(declared with @Tool)";
+
@Tool(name = "add_numbers", description = "Add two numbers")
String add(
@ToolArg(description = "First addend") long a,
@ToolArg(description = "Second addend") long b) {
return String.valueOf(a + b);
}
+
+ /**
+ * Owns the name that the annotated-clash-route ai-tool route also asks
for.
+ */
+ @Tool(name = "clashing_tool", description = "Name also asked for by an
ai-tool route")
+ String clashing() {
+ return CLASHING_TOOL_ANSWER;
+ }
}
diff --git
a/integration-tests/mcp-server/src/test/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerTest.java
b/integration-tests/mcp-server/src/test/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerTest.java
index f629eec8949..5cd02cf1709 100644
---
a/integration-tests/mcp-server/src/test/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerTest.java
+++
b/integration-tests/mcp-server/src/test/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerTest.java
@@ -70,7 +70,8 @@ class McpServerTest {
void testListToolsExposesOnlySelectedTags() {
client().when()
.toolsList(page -> assertThat(toolNames(page))
- .contains("say_hello", "fail_tool", "slow_tool")
+ .contains("say_hello", "fail_tool", "slow_tool",
"annotated_tool", "unannotated_tool",
+ "create_order")
.doesNotContain("hidden_tool", "other_tool"))
.thenAssertResults();
}
@@ -88,6 +89,21 @@ class McpServerTest {
.thenAssertResults();
}
+ /**
+ * The negative counterpart of {@link
#testToolAnnotationHintsArePublished()}: a tool setting no hint at all
+ * publishes no annotations and no title, rather than the MCP defaults.
+ */
+ @Test
+ void testToolWithoutAnnotationHintsPublishesNone() {
+ client().when()
+ .toolsList(page -> {
+ ToolInfo tool = toolByName(page, "unannotated_tool");
+ assertThat(tool.title()).isNull();
+ assertThat(tool.annotations()).isEmpty();
+ })
+ .thenAssertResults();
+ }
+
@Test
void testArgSchemaIsPublishedAsInputSchema() {
client().when()
@@ -121,22 +137,79 @@ class McpServerTest {
client().when()
.toolsList(page ->
assertThat(toolNames(page)).contains("add_numbers", "say_hello"))
.toolsCall("add_numbers", Map.of("a", 17, "b", 25), response
-> {
- assertThat(response.isError()).isNotEqualTo(Boolean.TRUE);
+ assertThat(response.isError()).isFalse();
assertThat(textOf(response)).isEqualTo("42");
})
.thenAssertResults();
}
+ /**
+ * MCP has a flat tool namespace: when an ai-tool route asks for a name an
annotated tool already owns, the
+ * annotated tool keeps the name and the route is refused. The route is
started with the application, which
+ * is the case that used to fail the startup.
+ */
+ @Test
+ void testCamelToolCollidingWithAnnotatedToolIsRefused() {
+ assertThat(routeStatus("annotated-clash-route")).isEqualTo("Started");
+ client().when()
+ .toolsCall("clashing_tool", response -> {
+ assertThat(response.isError()).isFalse();
+ // the answer tells which of the two implementations
served the call
+ assertThat(textOf(response))
+
.isEqualTo(QuarkusDefinedTools.CLASHING_TOOL_ANSWER);
+ })
+ .thenAssertResults();
+
+ controlRoute("annotated-clash-route", "stop");
+ assertThat(routeStatus("annotated-clash-route")).isEqualTo("Stopped");
+
+ client().when()
+ .toolsCall("clashing_tool", response -> {
+ assertThat(response.isError()).isFalse();
+ // the answer tells which of the two implementations
served the call
+ assertThat(textOf(response))
+
.isEqualTo(QuarkusDefinedTools.CLASHING_TOOL_ANSWER);
+ })
+ .thenAssertResults();
+
+ controlRoute("annotated-clash-route", "start");
+ assertThat(routeStatus("annotated-clash-route")).isEqualTo("Started");
+
+ client().when()
+ .toolsCall("clashing_tool", response -> {
+ assertThat(response.isError()).isFalse();
+ // the answer tells which of the two implementations
served the call
+ assertThat(textOf(response))
+
.isEqualTo(QuarkusDefinedTools.CLASHING_TOOL_ANSWER);
+ })
+ .thenAssertResults();
+ }
+
@Test
void testCallToolSuccess() {
client().when()
.toolsCall("say_hello", Map.of("name", "World"), response -> {
- assertThat(response.isError()).isNotEqualTo(Boolean.TRUE);
+ assertThat(response.isError()).isFalse();
assertThat(textOf(response)).isEqualTo("Hello World");
})
.thenAssertResults();
}
+ /**
+ * An argument error is not sanitized the way a route failure is: the
client gets the validation message.
+ */
+ @Test
+ void testCallToolMissingRequiredArgument() {
+ client().when()
+ .toolsCall("missing_required_arg_tool", response -> {
+ assertThat(response.isError()).isTrue();
+ // The error message is expected to come from camel when
validating missing required args for an ai-tool route
+ assertThat(textOf(response))
+ .isEqualTo("Missing required argument 'name' for
tool 'missing_required_arg_tool'");
+ })
+ .thenAssertResults();
+ }
+
@Test
void testCallToolExecutionErrorIsSanitized() {
client().when()
@@ -165,31 +238,31 @@ class McpServerTest {
.toolsList(page ->
assertThat(toolNames(page)).contains("say_hello"))
.thenAssertResults();
- controlRoute("stop");
- assertThat(routeStatus()).isEqualTo("Stopped");
+ controlRoute("say-hello-route", "stop");
+ assertThat(routeStatus("say-hello-route")).isEqualTo("Stopped");
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
client().when()
.toolsList(page ->
assertThat(toolNames(page)).doesNotContain("say_hello"))
.thenAssertResults());
- controlRoute("start");
- assertThat(routeStatus()).isEqualTo("Started");
+ controlRoute("say-hello-route", "start");
+ assertThat(routeStatus("say-hello-route")).isEqualTo("Started");
await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
client().when()
.toolsList(page ->
assertThat(toolNames(page)).contains("say_hello"))
.thenAssertResults());
}
- private static void controlRoute(String action) {
+ private static void controlRoute(String routeId, String action) {
RestAssured.given()
.when()
- .post("/mcp-server/route/say-hello-route/" + action)
+ .post("/mcp-server/route/" + routeId + "/" + action)
.then()
.statusCode(204);
}
- private static String routeStatus() {
+ private static String routeStatus(String routeId) {
return RestAssured.given()
.when()
- .get("/mcp-server/route/say-hello-route/status")
+ .get("/mcp-server/route/" + routeId + "/status")
.then()
.statusCode(200)
.extract()