This is an automated email from the ASF dual-hosted git repository.

aldettinger pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git


The following commit(s) were added to refs/heads/main by this push:
     new 6a5b0c5cf5e mcp-server: Improve the MCP tool integration test coverage 
(#9458)
6a5b0c5cf5e is described below

commit 6a5b0c5cf5e2636237810ffac048f8f0cb78c3bd
Author: Alexandre Gallice <[email protected]>
AuthorDate: Mon Nov 9 16:15:40 2026 +0100

    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. 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.
    * 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.
    
    The tests of this module do not run yet, see #9362.
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../pages/reference/extensions/mcp-server.adoc     | 16 +++--
 .../mcp-server/runtime/src/main/doc/usage.adoc     | 16 +++--
 .../component/mcp/server/it/McpServerRoutes.java   | 16 ++++-
 .../mcp/server/it/QuarkusDefinedTools.java         | 12 +++-
 .../component/mcp/server/it/McpServerTest.java     | 81 ++++++++++++++++++++--
 5 files changed, 123 insertions(+), 18 deletions(-)

diff --git a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc 
b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
index 985757b78fd..53f7647d016 100644
--- a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
@@ -212,11 +212,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: a
-Camel tool whose name is already taken by a tool of the application is
-refused with an ERROR log, while its route keeps running.
+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 ad2f7ee947c..9feba283c11 100644
--- a/extensions/mcp-server/runtime/src/main/doc/usage.adoc
+++ b/extensions/mcp-server/runtime/src/main/doc/usage.adoc
@@ -145,11 +145,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: a
-Camel tool whose name is already taken by a tool of the application is
-refused with an ERROR log, while its route keeps running.
+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/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 b8c1ab69ee3..3d10441768d 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
@@ -30,6 +30,7 @@ import org.apache.camel.model.dataformat.JsonLibrary;
 public class McpServerRoutes extends RouteBuilder {
 
     public static final String NOTES_DIR = "target/test-files";
+    public static final String CLASHING_TOOL_ANSWER = "Camel AI tool called 
(declared with a from(\"ai-tool:...\") route)";
     // over 57 bytes, so that a MIME Base64 encoder would break the line
     public static final byte[] REPORT = "%PDF-1.7 latest report, long enough 
to span more than one MIME Base64 line"
             .getBytes(StandardCharsets.US_ASCII);
@@ -41,6 +42,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");
@@ -63,6 +69,9 @@ 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"));
+
         from("ai-tool:create_order?tags=conformance&description=Create an 
order"
                 + "&argSchema=classpath:schema/create-order.json")
                 .process(e -> {
@@ -87,11 +96,12 @@ public class McpServerRoutes extends RouteBuilder {
                 .autoStartup(false)
                 .setBody(constant("camel"));
 
-        // the name is taken by the annotated tool in QuarkusDefinedTools; 
started with the application, so it is
+        // 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:add_numbers?tags=conformance"
+        from("ai-tool:clashing_tool?tags=conformance"
                 + "&description=Name already taken by an annotated tool, must 
be refused")
-                .setBody(constant("camel"));
+                .routeId("annotated-clash-route")
+                .setBody(constant(CLASHING_TOOL_ANSWER));
 
         
from("ai-resource:app_config?resourceUri=camel:///config/app.json&tags=conformance"
                 + "&description=Application 
configuration&mimeType=application/json&title=App config")
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 d863773a879..a46daff1b67 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
@@ -25,12 +25,14 @@ import io.quarkus.runtime.StartupEvent;
 import jakarta.enterprise.event.Observes;
 
 /**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as 
the Camel ai-tool routes, and tools
+ * Plain quarkus-mcp-server annotated tools living on the same MCP server as 
the Camel ai-tool routes, and tools
  * registered through the quarkus-mcp-server {@link ToolManager}: one under 
the same name as the clashing-tool-route
  * ai-tool route, and one under the name of the say-hello-route ai-tool route, 
on another MCP server.
  */
 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,
@@ -38,6 +40,14 @@ public class QuarkusDefinedTools {
         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;
+    }
+
     void registerTools(@Observes StartupEvent event) {
         // looked up, not injected, as in QuarkusDefinedResources
         ToolManager toolManager = 
Arc.container().instance(ToolManager.class).get();
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 54eda40360e..0cfbdd1d3fb 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
@@ -100,7 +100,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();
     }
@@ -118,6 +119,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()
@@ -195,27 +211,84 @@ class McpServerTest {
 
     @Test
     void testQuarkusAnnotatedToolsCoexistWithCamelTools() {
-        // both tool sources are served by the same MCP server; the 
add_numbers ai-tool route is refused
+        // both tool sources are served by the same MCP server
         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");
                     assertThat(response.structuredContent()).isNull();
                 })
                 .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()

Reply via email to