This is an automated email from the ASF dual-hosted git repository. jamesnetherton pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
commit 1d7d1cc40bfe742d01d5730a0ef83574c3677d23 Author: Federico Mariani <[email protected]> AuthorDate: Fri Aug 7 11:28:02 2026 +0200 CAMEL-24369: mcp-server: publish argSchema input schemas and MCP tool annotations QuarkusMcpServerEngine rebuilt the tool input schema from the flat addArgument calls, so tools defined with a nested argSchema advertised an empty inputSchema to MCP clients (and flat parameters lost their enum constraints). The engine now publishes the bridge's pre-built JSON Schema via ToolDefinition.setInputSchema, which quarkiverse serves verbatim in tools/list. The engine also maps the ai-tool annotation hints (title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint) via setTitle and setAnnotations, matching the Vert.x and Spring AI engines. The quarkiverse annotations record uses primitive booleans, so unset hints are published with the MCP specification's client-side defaults; when no hint is set at all, no annotations are published. Covered by three new conformance scenarios in the integration test (annotations, argSchema in tools/list, argSchema execution with nested Map/List arguments). Co-authored-by: Claude Fable 5 <[email protected]> --- .../mcp/server/QuarkusMcpServerEngine.java | 34 ++++++++++++++++ .../component/mcp/server/it/McpServerRoutes.java | 17 ++++++++ .../src/main/resources/application.properties | 2 + .../src/main/resources/schema/create-order.json | 27 +++++++++++++ .../component/mcp/server/it/McpServerTest.java | 47 ++++++++++++++++++++++ 5 files changed, 127 insertions(+) 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 2f9a19f6be..8c953f5a2a 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 @@ -22,7 +22,9 @@ import java.util.Map; import io.quarkiverse.mcp.server.TextContent; import io.quarkiverse.mcp.server.ToolManager; import io.quarkiverse.mcp.server.ToolResponse; +import io.vertx.core.json.JsonObject; import org.apache.camel.CamelContext; +import org.apache.camel.component.ai.tool.AiToolAnnotations; import org.apache.camel.component.ai.tool.AiToolParameterHelper.ParameterDef; import org.apache.camel.component.mcp.server.McpServerEngine; import org.apache.camel.component.mcp.server.McpServerInfo; @@ -72,6 +74,13 @@ public class QuarkusMcpServerEngine extends ServiceSupport implements McpServerE ParameterDef def = parameter.getValue(); definition.addArgument(parameter.getKey(), def.getDescription(), def.isRequired(), javaType(def)); } + // publish the bridge's pre-built JSON Schema as-is so nested argSchema structures and + // constraints such as enum survive; without this the schema shown to clients would be + // regenerated from the flat arguments above (empty for argSchema-based tools) + if (tool.inputSchemaJson() != null) { + definition.setInputSchema(new JsonObject(tool.inputSchemaJson())); + } + applyAnnotations(definition, tool.annotations()); definition.setHandler(arguments -> { Map<String, Object> args = arguments.args() != null ? arguments.args() : Map.of(); McpToolCallResult result = tool.handler().call(args); @@ -92,6 +101,31 @@ public class QuarkusMcpServerEngine extends ServiceSupport implements McpServerE } } + /** + * Maps the ai-tool annotation hints to the quarkiverse tool definition: {@code title} to the tool's top-level + * title and the boolean hints to {@link ToolManager.ToolAnnotations}. The quarkiverse annotations record uses + * primitive booleans, so hints left unset on the ai-tool endpoint are published with the MCP specification's + * client-side defaults (readOnly=false, destructive=true, idempotent=false, openWorld=true); when no hint is set + * at all, no annotations are published. + */ + private static void applyAnnotations(ToolManager.ToolDefinition definition, AiToolAnnotations annotations) { + if (annotations == null) { + return; + } + if (annotations.title() != null) { + definition.setTitle(annotations.title()); + } + if (annotations.readOnlyHint() != null || annotations.destructiveHint() != null + || annotations.idempotentHint() != null || annotations.openWorldHint() != null) { + definition.setAnnotations(new ToolManager.ToolAnnotations( + null, + annotations.readOnlyHint() != null ? annotations.readOnlyHint() : false, + annotations.destructiveHint() != null ? annotations.destructiveHint() : true, + annotations.idempotentHint() != null ? annotations.idempotentHint() : false, + annotations.openWorldHint() != null ? annotations.openWorldHint() : true)); + } + } + private static Type javaType(ParameterDef def) { String type = def.getType() != null ? def.getType() : "string"; return switch (type) { 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 ab1efba168..a350864e75 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 @@ -16,6 +16,9 @@ */ package org.apache.camel.quarkus.component.mcp.server.it; +import java.util.List; +import java.util.Map; + import jakarta.enterprise.context.ApplicationScoped; import org.apache.camel.builder.RouteBuilder; @@ -43,5 +46,19 @@ public class McpServerRoutes extends RouteBuilder { from("ai-tool:other_tool?tags=untrusted&description=Not a selected tag, must not be exposed") .setBody(constant("other")); + + from("ai-tool:annotated_tool?tags=conformance&description=Tool with annotation hints" + + "&title=Annotated tool" + + "&readOnlyHint=true" + + "&idempotentHint=true") + .setBody(constant("annotated")); + + from("ai-tool:create_order?tags=conformance&description=Create an order" + + "&argSchema=classpath:schema/create-order.json") + .process(e -> { + Map<?, ?> customer = e.getMessage().getHeader("customer", Map.class); + List<?> items = e.getMessage().getHeader("items", List.class); + e.getMessage().setBody("order for customer " + customer.get("id") + " with " + items.size() + " item(s)"); + }); } } diff --git a/integration-tests/mcp-server/src/main/resources/application.properties b/integration-tests/mcp-server/src/main/resources/application.properties index 91728bf643..92abd5f8cb 100644 --- a/integration-tests/mcp-server/src/main/resources/application.properties +++ b/integration-tests/mcp-server/src/main/resources/application.properties @@ -16,3 +16,5 @@ ## --------------------------------------------------------------------------- quarkus.camel.mcp-server.tags=conformance quarkus.camel.mcp-server.tool-timeout=2000 +# the argSchema resource must be reachable in native mode +quarkus.native.resources.includes=schema/create-order.json diff --git a/integration-tests/mcp-server/src/main/resources/schema/create-order.json b/integration-tests/mcp-server/src/main/resources/schema/create-order.json new file mode 100644 index 0000000000..c822f6f31e --- /dev/null +++ b/integration-tests/mcp-server/src/main/resources/schema/create-order.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "properties": { + "customer": { + "type": "object", + "description": "The customer placing the order", + "properties": { + "id": { "type": "string", "description": "Customer id" } + }, + "required": ["id"] + }, + "items": { + "type": "array", + "description": "The items to order", + "items": { + "type": "object", + "properties": { + "sku": { "type": "string" }, + "qty": { "type": "integer" } + }, + "required": ["sku", "qty"] + } + } + }, + "required": ["customer", "items"], + "additionalProperties": false +} 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 efa0e8945a..f629eec894 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 @@ -75,6 +75,46 @@ class McpServerTest { .thenAssertResults(); } + @Test + void testToolAnnotationHintsArePublished() { + client().when() + .toolsList(page -> { + ToolInfo tool = toolByName(page, "annotated_tool"); + assertThat(tool.title()).isEqualTo("Annotated tool"); + assertThat(tool.annotations()).isPresent(); + assertThat(tool.annotations().orElseThrow().readOnlyHint()).isTrue(); + assertThat(tool.annotations().orElseThrow().idempotentHint()).isTrue(); + }) + .thenAssertResults(); + } + + @Test + void testArgSchemaIsPublishedAsInputSchema() { + client().when() + .toolsList(page -> { + ToolInfo tool = toolByName(page, "create_order"); + assertThat(tool.inputSchema().getJsonObject("properties").getJsonObject("customer") + .getString("type")).isEqualTo("object"); + assertThat(tool.inputSchema().getJsonObject("properties").getJsonObject("items") + .getString("type")).isEqualTo("array"); + assertThat(tool.inputSchema().getJsonArray("required")).contains("customer", "items"); + }) + .thenAssertResults(); + } + + @Test + void testArgSchemaToolExecutesWithNestedArguments() { + client().when() + .toolsCall("create_order", + Map.of("customer", Map.of("id", "C-1"), + "items", List.of(Map.of("sku", "BOOK", "qty", 2))), + response -> { + assertThat(response.isError()).isFalse(); + assertThat(textOf(response)).isEqualTo("order for customer C-1 with 1 item(s)"); + }) + .thenAssertResults(); + } + @Test void testQuarkusAnnotatedToolsCoexistWithCamelTools() { // both tool sources are served by the same MCP server @@ -161,6 +201,13 @@ class McpServerTest { return page.tools().stream().map(ToolInfo::name).toList(); } + private static ToolInfo toolByName(ToolsPage page, String name) { + return page.tools().stream() + .filter(t -> name.equals(t.name())) + .findFirst() + .orElseThrow(); + } + private static String textOf(ToolResponse response) { return response.content().stream() .filter(TextContent.class::isInstance)
