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"
+                + 
"&parameter.name=string&parameter.name.description=RequiredParam&parameter.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()

Reply via email to