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"
+ +
"¶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");
@@ -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()