This is an automated email from the ASF dual-hosted git repository.
jamesnetherton pushed a commit to branch camel-main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/camel-main by this push:
new 6a394090de CAMEL-24369: mcp-server: publish argSchema input schemas
and MCP tool annotations
6a394090de is described below
commit 6a394090de1c55685f0533db4cb043a09d8def18
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)