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
The following commit(s) were added to refs/heads/main by this push:
new 564b87d12f Fixes #9300. Add ai-resource extension and serve
ai-resource routes as MCP resources
564b87d12f is described below
commit 564b87d12f0cbf6917a56504dd875a8dfc23df1d
Author: Jiří Ondrušek <[email protected]>
AuthorDate: Sat Oct 24 10:19:18 2026 +0200
Fixes #9300. Add ai-resource extension and serve ai-resource routes as MCP
resources
* Fixes #9300. Expose ai-resource routes as MCP resources
- Add the camel-quarkus-ai-resource extension (preview)
- Publish tagged ai-resource routes via the quarkus-mcp-server
ResourceManager, with the new mcp-server resource-timeout option
- Refuse clashing names/uris; remove only resources the engine added
- Inject the tool and resource managers so listChanged is advertised
- Add ITs, including route-backed resources
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* Fixes #9300. Address review round 1
Squash of the 8 commits answering the first review of #9368:
- Remove MCP resources from the default server only
- Refuse MCP tools clashing with application tools like resources
- Make the MCP server timeouts runtime configuration
- Widen the timing margins of the MCP server timeout tests
- Read a binary MCP resource longer than a MIME Base64 line
- Test the MCP tool clash refusal at application startup
- Test that MCP tools are removed from the default server only
- Mention the MCP tool clashes in the configuration docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
---------
Co-authored-by: jiriOndrusek-agent <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
catalog/pom.xml | 13 +
.../ROOT/examples/components/ai-resource.yml | 13 +
docs/modules/ROOT/nav.adoc | 1 +
.../pages/reference/extensions/ai-resource.adoc | 50 ++++
.../pages/reference/extensions/mcp-server.adoc | 55 ++++-
extensions/ai-resource/deployment/pom.xml | 63 +++++
.../resource/deployment/AiResourceProcessor.java | 21 +-
extensions/ai-resource/pom.xml | 39 +++
extensions/ai-resource/runtime/pom.xml | 102 ++++++++
.../ai-resource/runtime/src/main/doc/usage.adoc | 1 +
.../main/resources/META-INF/quarkus-extension.yaml | 36 +++
.../mcp/server/deployment/McpServerProcessor.java | 10 +-
.../mcp-server/runtime/src/main/doc/intro.adoc | 7 +-
.../mcp-server/runtime/src/main/doc/usage.adoc | 30 ++-
.../component/mcp/server/CamelMcpServerConfig.java | 17 +-
.../mcp/server/CamelMcpServerRecorder.java | 16 +-
...onfig.java => CamelMcpServerRunTimeConfig.java} | 30 +--
.../mcp/server/QuarkusMcpServerEngine.java | 96 +++++++-
.../mcp/server/QuarkusMcpServerEngineFactory.java | 28 ++-
extensions/pom.xml | 1 +
.../{mcp-server => ai-resource}/pom.xml | 60 +++--
.../ai/resource/it/AiResourceResource.java | 121 ++++++++++
.../component/ai/resource/it/AiResourceRoutes.java | 57 +++++
.../quarkus/component/ai/resource/it/Order.java} | 18 +-
.../component/ai/resource/it/AiResourceIT.java} | 17 +-
.../component/ai/resource/it/AiResourceTest.java | 147 ++++++++++++
integration-tests/mcp-server/pom.xml | 34 +++
.../component/mcp/server/it/McpServerResource.java | 14 ++
.../component/mcp/server/it/McpServerRoutes.java | 83 ++++++-
.../it/{QuarkusDefinedTools.java => Order.java} | 16 +-
.../mcp/server/it/QuarkusDefinedResources.java | 55 +++++
.../mcp/server/it/QuarkusDefinedTools.java | 23 +-
.../src/main/resources/application.properties | 4 +
.../component/mcp/server/it/McpServerTest.java | 265 ++++++++++++++++++++-
integration-tests/pom.xml | 1 +
poms/bom/pom.xml | 10 +
poms/bom/src/main/generated/flattened-full-pom.xml | 10 +
.../src/main/generated/flattened-reduced-pom.xml | 10 +
.../generated/flattened-reduced-verbose-pom.xml | 10 +
tooling/scripts/test-categories.yaml | 1 +
40 files changed, 1415 insertions(+), 170 deletions(-)
diff --git a/catalog/pom.xml b/catalog/pom.xml
index eedcb6d9d7..594af814bd 100644
--- a/catalog/pom.xml
+++ b/catalog/pom.xml
@@ -123,6 +123,19 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
diff --git a/docs/modules/ROOT/examples/components/ai-resource.yml
b/docs/modules/ROOT/examples/components/ai-resource.yml
new file mode 100644
index 0000000000..06baae00a1
--- /dev/null
+++ b/docs/modules/ROOT/examples/components/ai-resource.yml
@@ -0,0 +1,13 @@
+# Do not edit directly!
+# This file was generated by
camel-quarkus-maven-plugin:update-extension-doc-page
+cqArtifactId: camel-quarkus-ai-resource
+cqArtifactIdBase: ai-resource
+cqNativeSupported: true
+cqStatus: Stable
+cqDeprecated: false
+cqJvmSince: 4.0.0
+cqNativeSince: 4.0.0
+cqCamelPartName: ai-resource
+cqCamelPartTitle: AI Resource
+cqCamelPartDescription: Framework-agnostic consumer endpoint that registers a
Camel route as a read-only AI resource in the shared AiResourceRegistry.
+cqExtensionPageTitle: AI Resource
diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc
index 6cfbb1bae3..0208871b2d 100644
--- a/docs/modules/ROOT/nav.adoc
+++ b/docs/modules/ROOT/nav.adoc
@@ -37,6 +37,7 @@
** xref:reference/index.adoc[Extensions]
// extensions: START
*** xref:reference/extensions/a2a.adoc[A2A]
+*** xref:reference/extensions/ai-resource.adoc[AI Resource]
*** xref:reference/extensions/ai-tool.adoc[AI Tool]
*** xref:reference/extensions/amqp.adoc[AMQP]
*** xref:reference/extensions/as2.adoc[AS2]
diff --git a/docs/modules/ROOT/pages/reference/extensions/ai-resource.adoc
b/docs/modules/ROOT/pages/reference/extensions/ai-resource.adoc
new file mode 100644
index 0000000000..ac3a748efd
--- /dev/null
+++ b/docs/modules/ROOT/pages/reference/extensions/ai-resource.adoc
@@ -0,0 +1,50 @@
+// Do not edit directly!
+// This file was generated by
camel-quarkus-maven-plugin:update-extension-doc-page
+[id="extensions-ai-resource"]
+= AI Resource
+:linkattrs:
+:cq-artifact-id: camel-quarkus-ai-resource
+:cq-native-supported: true
+:cq-status: Preview
+:cq-status-deprecation: Preview
+:cq-description: Framework-agnostic consumer endpoint that registers a Camel
route as a read-only AI resource in the shared AiResourceRegistry.
+:cq-deprecated: false
+:cq-jvm-since: 4.0.0
+:cq-native-since: 4.0.0
+
+ifeval::[{doc-show-badges} == true]
+[.badges]
+[.badge-key]##JVM since##[.badge-supported]##4.0.0## [.badge-key]##Native
since##[.badge-supported]##4.0.0##
+endif::[]
+
+Framework-agnostic consumer endpoint that registers a Camel route as a
read-only AI resource in the shared AiResourceRegistry.
+
+[id="extensions-ai-resource-whats-inside"]
+== What's inside
+
+* xref:{cq-camel-components}::ai-resource-component.adoc[AI Resource
component], URI syntax: `ai-resource:resourceName`
+
+Please refer to the above link for usage and configuration details.
+
+[id="extensions-ai-resource-maven-coordinates"]
+== Maven coordinates
+
+https://{link-quarkus-code-generator}/?extension-search=camel-quarkus-ai-resource[Create
a new project with this extension on {link-quarkus-code-generator},
window="_blank"]
+
+Or add the coordinates to your existing project:
+
+[source,xml]
+----
+<dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+</dependency>
+----
+ifeval::[{doc-show-user-guide-link} == true]
+Check the xref:user-guide/index.adoc[User guide] for more information about
writing Camel Quarkus applications.
+endif::[]
+
+[id="extensions-ai-resource-usage"]
+== Usage
+The `ai-resource` component registers Camel routes as read-only resources in a
shared `AiResourceRegistry`. To serve the tagged routes as MCP resources, add
the `camel-quarkus-mcp-server` extension, see
xref:reference/extensions/mcp-server.adoc#extensions-mcp-server-usage-resources[MCP
Server resources].
+
diff --git a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
index 6790c70ad1..ecc0a279e7 100644
--- a/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
+++ b/docs/modules/ROOT/pages/reference/extensions/mcp-server.adoc
@@ -18,11 +18,12 @@ ifeval::[{doc-show-badges} == true]
endif::[]
The MCP Server extension exposes Camel routes registered via the `ai-tool`
-component as tools of a Model Context Protocol (MCP) server, served through
+component as tools, and `ai-resource` routes as resources, of a Model Context
+Protocol (MCP) server, served through
the quarkiverse
https://docs.quarkiverse.io/quarkus-mcp-server/dev/index.html[quarkus-mcp-server]
extension. No route is needed for the server itself: add the extension, tag
-the `ai-tool` routes to expose, and any MCP client (another Camel
-application, an IDE, a coding agent) can discover and call them.
+the `ai-tool` and `ai-resource` routes to expose, and any MCP client (another
+Camel application, an IDE, a coding agent) can discover and use them.
Tool semantics — tag-based opt-in (the untagged default pool is never
exposed), flat-namespace collision refusal, per-call timeout and error
@@ -107,19 +108,38 @@ in the AI Tool component documentation.
In native mode, an `outputSchema` loaded from `classpath:` must be
xref:user-guide/native-mode.adoc#embedding-resource-in-native-executable[embedded
in the native executable].
+[id="extensions-mcp-server-usage-resources"]
+=== Resources
+
+Add the `camel-quarkus-ai-resource` extension to define
+xref:{cq-camel-components}::ai-resource-component.adoc[`ai-resource`] routes:
+the tagged ones are served as MCP resources, selected by the same
+`quarkus.camel.mcp-server.tags`. Like tools, they follow the route lifecycle
+and clients are notified via `notifications/resources/list_changed`. A failed
+read returns a JSON-RPC error with the generic message `Resource read failed`;
+a read exceeding `quarkus.camel.mcp-server.resource-timeout` returns
+`Resource read timed out`. Resource names must be unique, as
+quarkus-mcp-server requires: a resource whose name is already taken, or
+whose uri is already used by a resource the application registers with
+quarkus-mcp-server, is refused with an ERROR log, while its route keeps
+running.
+
[id="extensions-mcp-server-usage-combining-the-camel-and-quarkus-mcp-server-configuration"]
=== Combining the Camel and quarkus-mcp-server configuration
The configuration is split by ownership: `quarkus.camel.mcp-server.*`
-decides *which tools exist and how they execute* (identical semantics on
-every Camel runtime), while `quarkus.mcp.server.*` decides *how the server
-is exposed*. Both are set side by side in `application.properties`:
+decides *which tools and resources exist and how they execute* (identical
+semantics on every Camel runtime, except for the tool and resource clashes
+refused by quarkus-mcp-server), while `quarkus.mcp.server.*` decides
+*how the server is exposed*. Both are set side by side in
+`application.properties`:
[source,properties]
----
-# Camel-owned: tool selection and execution
+# Camel-owned: tool and resource selection and execution
quarkus.camel.mcp-server.tags = crm,notify
quarkus.camel.mcp-server.tool-timeout = 10000
+quarkus.camel.mcp-server.resource-timeout = 5000
# quarkus-mcp-server-owned: serving and server identity
quarkus.mcp.server.server-info.name = my-integration-app
@@ -248,7 +268,9 @@ 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.
+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.
[id="extensions-mcp-server-usage-dynamic-tools"]
=== Dynamic tools
@@ -290,23 +312,32 @@ Boot it is the `camel-mcp-server-starter`.
a|icon:lock[title=Fixed at build time]
[[quarkus-camel-mcp-server-enabled]]`link:#quarkus-camel-mcp-server-enabled[quarkus.camel.mcp-server.enabled]`
-Whether to expose ai-tool routes as MCP tools through the quarkus-mcp-server
extension.
+Whether to expose ai-tool routes as MCP tools and ai-resource routes as MCP
resources through the
+quarkus-mcp-server extension.
| `boolean`
| `true`
a|icon:lock[title=Fixed at build time]
[[quarkus-camel-mcp-server-tags]]`link:#quarkus-camel-mcp-server-tags[quarkus.camel.mcp-server.tags]`
-Comma-separated list of ai-tool tags to expose as MCP tools. Only tools
registered under one of these tags are
-exposed; the untagged default pool is never exposed. When not set, no tools
are exposed.
+Comma-separated list of tags selecting the ai-tool routes to expose as MCP
tools and the ai-resource routes to
+expose as MCP resources. Only routes registered under one of these tags are
exposed; the untagged default pools
+are never exposed. When not set, nothing is exposed.
| `string`
|
-a|icon:lock[title=Fixed at build time]
[[quarkus-camel-mcp-server-tool-timeout]]`link:#quarkus-camel-mcp-server-tool-timeout[quarkus.camel.mcp-server.tool-timeout]`
+a|
[[quarkus-camel-mcp-server-tool-timeout]]`link:#quarkus-camel-mcp-server-tool-timeout[quarkus.camel.mcp-server.tool-timeout]`
Per-call tool execution timeout in milliseconds. A call exceeding the timeout
returns an error result to the
MCP client; the underlying route keeps running until it completes on its own.
| `long`
| `20000`
+
+a|
[[quarkus-camel-mcp-server-resource-timeout]]`link:#quarkus-camel-mcp-server-resource-timeout[quarkus.camel.mcp-server.resource-timeout]`
+
+Per-read resource execution timeout in milliseconds. A read exceeding the
timeout returns an error to the MCP
+client; the underlying route keeps running until it completes on its own.
+| `long`
+| `20000`
|===
[.configuration-legend]
diff --git a/extensions/ai-resource/deployment/pom.xml
b/extensions/ai-resource/deployment/pom.xml
new file mode 100644
index 0000000000..f45d78d1ac
--- /dev/null
+++ b/extensions/ai-resource/deployment/pom.xml
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-parent</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <name>Camel Quarkus :: AI Resource :: Deployment</name>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-core-deployment</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <annotationProcessorPaths>
+ <path>
+ <groupId>io.quarkus</groupId>
+
<artifactId>quarkus-extension-processor</artifactId>
+ <version>${quarkus.version}</version>
+ </path>
+ </annotationProcessorPaths>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
b/extensions/ai-resource/deployment/src/main/java/org/apache/camel/quarkus/component/ai/resource/deployment/AiResourceProcessor.java
similarity index 60%
copy from
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
copy to
extensions/ai-resource/deployment/src/main/java/org/apache/camel/quarkus/component/ai/resource/deployment/AiResourceProcessor.java
index 88a6cf149f..a55173832f 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
+++
b/extensions/ai-resource/deployment/src/main/java/org/apache/camel/quarkus/component/ai/resource/deployment/AiResourceProcessor.java
@@ -14,20 +14,17 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.mcp.server.it;
+package org.apache.camel.quarkus.component.ai.resource.deployment;
-import io.quarkiverse.mcp.server.Tool;
-import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkus.deployment.annotations.BuildStep;
+import io.quarkus.deployment.builditem.FeatureBuildItem;
-/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
- */
-public class QuarkusDefinedTools {
+class AiResourceProcessor {
+
+ private static final String FEATURE = "camel-ai-resource";
- @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);
+ @BuildStep
+ FeatureBuildItem feature() {
+ return new FeatureBuildItem(FEATURE);
}
}
diff --git a/extensions/ai-resource/pom.xml b/extensions/ai-resource/pom.xml
new file mode 100644
index 0000000000..4265cb4afd
--- /dev/null
+++ b/extensions/ai-resource/pom.xml
@@ -0,0 +1,39 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-extensions</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>camel-quarkus-ai-resource-parent</artifactId>
+ <name>Camel Quarkus :: AI Resource</name>
+ <packaging>pom</packaging>
+
+ <modules>
+ <module>deployment</module>
+ <module>runtime</module>
+ </modules>
+</project>
diff --git a/extensions/ai-resource/runtime/pom.xml
b/extensions/ai-resource/runtime/pom.xml
new file mode 100644
index 0000000000..4a606a1169
--- /dev/null
+++ b/extensions/ai-resource/runtime/pom.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-parent</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ <relativePath>../pom.xml</relativePath>
+ </parent>
+
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <name>Camel Quarkus :: AI Resource :: Runtime</name>
+ <description>Framework-agnostic consumer endpoint that registers a Camel
route as a read-only AI resource in the shared AiResourceRegistry.</description>
+
+ <properties>
+ <quarkus.metadata.status>preview</quarkus.metadata.status>
+ <camel.quarkus.jvmSince>4.0.0</camel.quarkus.jvmSince>
+ <camel.quarkus.nativeSince>4.0.0</camel.quarkus.nativeSince>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel</groupId>
+ <artifactId>camel-ai-resource</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>io.quarkus</groupId>
+ <artifactId>quarkus-extension-maven-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <configuration>
+ <annotationProcessorPaths>
+ <path>
+ <groupId>io.quarkus</groupId>
+
<artifactId>quarkus-extension-processor</artifactId>
+ <version>${quarkus.version}</version>
+ </path>
+ </annotationProcessorPaths>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+
+ <profiles>
+ <profile>
+ <id>full</id>
+ <activation>
+ <property>
+ <name>!quickly</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>update-extension-doc-page</id>
+ <goals>
+ <goal>update-extension-doc-page</goal>
+ </goals>
+ <phase>process-classes</phase>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+</project>
diff --git a/extensions/ai-resource/runtime/src/main/doc/usage.adoc
b/extensions/ai-resource/runtime/src/main/doc/usage.adoc
new file mode 100644
index 0000000000..84f3753331
--- /dev/null
+++ b/extensions/ai-resource/runtime/src/main/doc/usage.adoc
@@ -0,0 +1 @@
+The `ai-resource` component registers Camel routes as read-only resources in a
shared `AiResourceRegistry`. To serve the tagged routes as MCP resources, add
the `camel-quarkus-mcp-server` extension, see
xref:reference/extensions/mcp-server.adoc#extensions-mcp-server-usage-resources[MCP
Server resources].
diff --git
a/extensions/ai-resource/runtime/src/main/resources/META-INF/quarkus-extension.yaml
b/extensions/ai-resource/runtime/src/main/resources/META-INF/quarkus-extension.yaml
new file mode 100644
index 0000000000..601113e01f
--- /dev/null
+++
b/extensions/ai-resource/runtime/src/main/resources/META-INF/quarkus-extension.yaml
@@ -0,0 +1,36 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+# This is a generated file. Do not edit directly!
+# To re-generate, run the following command from the top level directory:
+#
+# mvn -N cq:update-quarkus-metadata
+#
+---
+name: "Camel AI Resource"
+description: "Framework-agnostic consumer endpoint that registers a Camel
route as a read-only AI resource in the shared AiResourceRegistry"
+metadata:
+ icon-url:
"https://raw.githubusercontent.com/apache/camel-website/main/antora-ui-camel/src/img/logo-d.svg"
+ sponsor: "Apache Software Foundation"
+ guide:
"https://camel.apache.org/camel-quarkus/latest/reference/extensions/ai-resource.html"
+ categories:
+ - "integration"
+ status: "preview"
+ integrates:
+ - name: "Camel"
+ artifact: "org.apache.camel:camel-base"
+ version: "${camel.version}"
\ No newline at end of file
diff --git
a/extensions/mcp-server/deployment/src/main/java/org/apache/camel/quarkus/component/mcp/server/deployment/McpServerProcessor.java
b/extensions/mcp-server/deployment/src/main/java/org/apache/camel/quarkus/component/mcp/server/deployment/McpServerProcessor.java
index f43febbb35..e30e0aad63 100644
---
a/extensions/mcp-server/deployment/src/main/java/org/apache/camel/quarkus/component/mcp/server/deployment/McpServerProcessor.java
+++
b/extensions/mcp-server/deployment/src/main/java/org/apache/camel/quarkus/component/mcp/server/deployment/McpServerProcessor.java
@@ -18,12 +18,14 @@ package
org.apache.camel.quarkus.component.mcp.server.deployment;
import java.util.function.BooleanSupplier;
+import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.deployment.annotations.BuildStep;
import io.quarkus.deployment.annotations.ExecutionTime;
import io.quarkus.deployment.annotations.Record;
import io.quarkus.deployment.builditem.FeatureBuildItem;
import org.apache.camel.quarkus.component.mcp.server.CamelMcpServerConfig;
import org.apache.camel.quarkus.component.mcp.server.CamelMcpServerRecorder;
+import
org.apache.camel.quarkus.component.mcp.server.QuarkusMcpServerEngineFactory;
import
org.apache.camel.quarkus.core.deployment.spi.RuntimeCamelContextCustomizerBuildItem;
class McpServerProcessor {
@@ -35,11 +37,15 @@ class McpServerProcessor {
return new FeatureBuildItem(FEATURE);
}
+ @BuildStep(onlyIf = McpServerEnabled.class)
+ AdditionalBeanBuildItem mcpServerEngineFactory() {
+ return
AdditionalBeanBuildItem.unremovableOf(QuarkusMcpServerEngineFactory.class);
+ }
+
@BuildStep(onlyIf = McpServerEnabled.class)
@Record(ExecutionTime.RUNTIME_INIT)
RuntimeCamelContextCustomizerBuildItem
mcpServerBridge(CamelMcpServerRecorder recorder, CamelMcpServerConfig config) {
- return new RuntimeCamelContextCustomizerBuildItem(
- recorder.createContextCustomizer(config.tags().orElse(null),
config.toolTimeout()));
+ return new
RuntimeCamelContextCustomizerBuildItem(recorder.createContextCustomizer(config.tags().orElse(null)));
}
static final class McpServerEnabled implements BooleanSupplier {
diff --git a/extensions/mcp-server/runtime/src/main/doc/intro.adoc
b/extensions/mcp-server/runtime/src/main/doc/intro.adoc
index 7c60ee6f2d..f6faac8d46 100644
--- a/extensions/mcp-server/runtime/src/main/doc/intro.adoc
+++ b/extensions/mcp-server/runtime/src/main/doc/intro.adoc
@@ -1,9 +1,10 @@
The MCP Server extension exposes Camel routes registered via the `ai-tool`
-component as tools of a Model Context Protocol (MCP) server, served through
+component as tools, and `ai-resource` routes as resources, of a Model Context
+Protocol (MCP) server, served through
the quarkiverse
https://docs.quarkiverse.io/quarkus-mcp-server/dev/index.html[quarkus-mcp-server]
extension. No route is needed for the server itself: add the extension, tag
-the `ai-tool` routes to expose, and any MCP client (another Camel
-application, an IDE, a coding agent) can discover and call them.
+the `ai-tool` and `ai-resource` routes to expose, and any MCP client (another
+Camel application, an IDE, a coding agent) can discover and use them.
Tool semantics — tag-based opt-in (the untagged default pool is never
exposed), flat-namespace collision refusal, per-call timeout and error
diff --git a/extensions/mcp-server/runtime/src/main/doc/usage.adoc
b/extensions/mcp-server/runtime/src/main/doc/usage.adoc
index 64b4c5b902..5473295c53 100644
--- a/extensions/mcp-server/runtime/src/main/doc/usage.adoc
+++ b/extensions/mcp-server/runtime/src/main/doc/usage.adoc
@@ -45,18 +45,36 @@ in the AI Tool component documentation.
In native mode, an `outputSchema` loaded from `classpath:` must be
xref:user-guide/native-mode.adoc#embedding-resource-in-native-executable[embedded
in the native executable].
+=== Resources
+
+Add the `camel-quarkus-ai-resource` extension to define
+xref:{cq-camel-components}::ai-resource-component.adoc[`ai-resource`] routes:
+the tagged ones are served as MCP resources, selected by the same
+`quarkus.camel.mcp-server.tags`. Like tools, they follow the route lifecycle
+and clients are notified via `notifications/resources/list_changed`. A failed
+read returns a JSON-RPC error with the generic message `Resource read failed`;
+a read exceeding `quarkus.camel.mcp-server.resource-timeout` returns
+`Resource read timed out`. Resource names must be unique, as
+quarkus-mcp-server requires: a resource whose name is already taken, or
+whose uri is already used by a resource the application registers with
+quarkus-mcp-server, is refused with an ERROR log, while its route keeps
+running.
+
=== Combining the Camel and quarkus-mcp-server configuration
The configuration is split by ownership: `quarkus.camel.mcp-server.*`
-decides *which tools exist and how they execute* (identical semantics on
-every Camel runtime), while `quarkus.mcp.server.*` decides *how the server
-is exposed*. Both are set side by side in `application.properties`:
+decides *which tools and resources exist and how they execute* (identical
+semantics on every Camel runtime, except for the tool and resource clashes
+refused by quarkus-mcp-server), while `quarkus.mcp.server.*` decides
+*how the server is exposed*. Both are set side by side in
+`application.properties`:
[source,properties]
----
-# Camel-owned: tool selection and execution
+# Camel-owned: tool and resource selection and execution
quarkus.camel.mcp-server.tags = crm,notify
quarkus.camel.mcp-server.tool-timeout = 10000
+quarkus.camel.mcp-server.resource-timeout = 5000
# quarkus-mcp-server-owned: serving and server identity
quarkus.mcp.server.server-info.name = my-integration-app
@@ -182,7 +200,9 @@ 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.
+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.
=== Dynamic tools
diff --git
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
index 5975fc5d86..45b50b1568 100644
---
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
+++
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
@@ -32,7 +32,8 @@ import io.smallrye.config.WithDefault;
public interface CamelMcpServerConfig {
/**
- * Whether to expose ai-tool routes as MCP tools through the
quarkus-mcp-server extension.
+ * Whether to expose ai-tool routes as MCP tools and ai-resource routes as
MCP resources through the
+ * quarkus-mcp-server extension.
*
* @asciidoclet
*/
@@ -40,19 +41,11 @@ public interface CamelMcpServerConfig {
boolean enabled();
/**
- * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools
registered under one of these tags are
- * exposed; the untagged default pool is never exposed. When not set, no
tools are exposed.
+ * Comma-separated list of tags selecting the ai-tool routes to expose as
MCP tools and the ai-resource routes to
+ * expose as MCP resources. Only routes registered under one of these tags
are exposed; the untagged default pools
+ * are never exposed. When not set, nothing is exposed.
*
* @asciidoclet
*/
Optional<String> tags();
-
- /**
- * Per-call tool execution timeout in milliseconds. A call exceeding the
timeout returns an error result to the
- * MCP client; the underlying route keeps running until it completes on
its own.
- *
- * @asciidoclet
- */
- @WithDefault("20000")
- long toolTimeout();
}
diff --git
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRecorder.java
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRecorder.java
index f7d5ef2157..238d193c0b 100644
---
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRecorder.java
+++
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRecorder.java
@@ -16,7 +16,6 @@
*/
package org.apache.camel.quarkus.component.mcp.server;
-import io.quarkiverse.mcp.server.ToolManager;
import io.quarkus.arc.Arc;
import io.quarkus.runtime.RuntimeValue;
import io.quarkus.runtime.annotations.Recorder;
@@ -30,16 +29,23 @@ import org.apache.camel.spi.CamelContextCustomizer;
@Recorder
public class CamelMcpServerRecorder {
- public RuntimeValue<CamelContextCustomizer> createContextCustomizer(String
tags, long toolTimeout) {
+ private final RuntimeValue<CamelMcpServerRunTimeConfig> runtimeConfig;
+
+ public CamelMcpServerRecorder(RuntimeValue<CamelMcpServerRunTimeConfig>
runtimeConfig) {
+ this.runtimeConfig = runtimeConfig;
+ }
+
+ public RuntimeValue<CamelContextCustomizer> createContextCustomizer(String
tags) {
return new RuntimeValue<>(new CamelContextCustomizer() {
@Override
public void configure(CamelContext camelContext) {
- ToolManager toolManager =
Arc.container().instance(ToolManager.class).get();
- QuarkusMcpServerEngine engine = new
QuarkusMcpServerEngine(toolManager);
+ QuarkusMcpServerEngine engine =
Arc.container().instance(QuarkusMcpServerEngineFactory.class).get()
+ .createEngine();
McpServerConfiguration configuration = new
McpServerConfiguration();
configuration.setTags(tags);
- configuration.setToolTimeout(toolTimeout);
+
configuration.setToolTimeout(runtimeConfig.getValue().toolTimeout());
+
configuration.setResourceTimeout(runtimeConfig.getValue().resourceTimeout());
McpServerBridge bridge = new McpServerBridge(configuration);
try {
// the bridge resolves the engine registry-first
diff --git
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRunTimeConfig.java
similarity index 64%
copy from
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
copy to
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRunTimeConfig.java
index 5975fc5d86..ec0ad50638 100644
---
a/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java
+++
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerRunTimeConfig.java
@@ -16,43 +16,33 @@
*/
package org.apache.camel.quarkus.component.mcp.server;
-import java.util.Optional;
-
import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.smallrye.config.ConfigMapping;
import io.smallrye.config.WithDefault;
/**
- * Bridge-owned configuration of the Camel MCP server. Serving concerns
(endpoint path, transports, authentication)
- * are owned by the quarkiverse quarkus-mcp-server extension and configured
via {@code quarkus.mcp.server.*}.
+ * Bridge-owned runtime configuration of the Camel MCP server, see {@link
CamelMcpServerConfig}.
*/
-@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED)
+@ConfigRoot(phase = ConfigPhase.RUN_TIME)
@ConfigMapping(prefix = "quarkus.camel.mcp-server")
-public interface CamelMcpServerConfig {
+public interface CamelMcpServerRunTimeConfig {
/**
- * Whether to expose ai-tool routes as MCP tools through the
quarkus-mcp-server extension.
- *
- * @asciidoclet
- */
- @WithDefault("true")
- boolean enabled();
-
- /**
- * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools
registered under one of these tags are
- * exposed; the untagged default pool is never exposed. When not set, no
tools are exposed.
+ * Per-call tool execution timeout in milliseconds. A call exceeding the
timeout returns an error result to the
+ * MCP client; the underlying route keeps running until it completes on
its own.
*
* @asciidoclet
*/
- Optional<String> tags();
+ @WithDefault("20000")
+ long toolTimeout();
/**
- * Per-call tool execution timeout in milliseconds. A call exceeding the
timeout returns an error result to the
- * MCP client; the underlying route keeps running until it completes on
its own.
+ * Per-read resource execution timeout in milliseconds. A read exceeding
the timeout returns an error to the MCP
+ * client; the underlying route keeps running until it completes on its
own.
*
* @asciidoclet
*/
@WithDefault("20000")
- long toolTimeout();
+ long resourceTimeout();
}
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 9b09642325..e6d02a540c 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
@@ -17,18 +17,31 @@
package org.apache.camel.quarkus.component.mcp.server;
import java.lang.reflect.Type;
+import java.util.Base64;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import io.quarkiverse.mcp.server.BlobResourceContents;
+import io.quarkiverse.mcp.server.JsonRpcErrorCodes;
+import io.quarkiverse.mcp.server.McpException;
+import io.quarkiverse.mcp.server.McpServer;
+import io.quarkiverse.mcp.server.ResourceContents;
+import io.quarkiverse.mcp.server.ResourceManager;
+import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkiverse.mcp.server.TextContent;
+import io.quarkiverse.mcp.server.TextResourceContents;
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.McpResourceReadResult;
import org.apache.camel.component.mcp.server.McpServerEngine;
import org.apache.camel.component.mcp.server.McpServerInfo;
+import org.apache.camel.component.mcp.server.McpServerResource;
import org.apache.camel.component.mcp.server.McpServerTool;
import org.apache.camel.component.mcp.server.McpToolCallResult;
import org.apache.camel.support.service.ServiceSupport;
@@ -36,19 +49,25 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
- * {@link McpServerEngine} publishing tools into the quarkiverse
quarkus-mcp-server through its programmatic
- * {@link ToolManager} API. Serving concerns (endpoint path, transports,
authentication) are owned by
- * quarkus-mcp-server and configured via {@code quarkus.mcp.server.*}.
+ * {@link McpServerEngine} publishing tools and resources into the quarkiverse
quarkus-mcp-server through its
+ * programmatic {@link ToolManager} and {@link ResourceManager} APIs. Serving
concerns (endpoint path, transports,
+ * authentication) are owned by quarkus-mcp-server and configured via {@code
quarkus.mcp.server.*}.
*/
public class QuarkusMcpServerEngine extends ServiceSupport implements
McpServerEngine {
private static final Logger LOG =
LoggerFactory.getLogger(QuarkusMcpServerEngine.class);
private final ToolManager toolManager;
+ private final ResourceManager resourceManager;
+ // 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();
+ // uris of the resources registered by this engine; a refused resource's
uri may belong to an application resource
+ private final Set<String> registeredResourceUris =
ConcurrentHashMap.newKeySet();
private CamelContext camelContext;
- public QuarkusMcpServerEngine(ToolManager toolManager) {
+ public QuarkusMcpServerEngine(ToolManager toolManager, ResourceManager
resourceManager) {
this.toolManager = toolManager;
+ this.resourceManager = resourceManager;
}
@Override
@@ -93,20 +112,85 @@ public class QuarkusMcpServerEngine extends ServiceSupport
implements McpServerE
? ToolResponse.error(result.text())
: new ToolResponse(false, List.of(new
TextContent(result.text())), result.structuredContent(), null);
});
- 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);
+ toolManager.removeTool(toolName, McpServer.DEFAULT);
LOG.debug("MCP tool removed: {}", toolName);
} catch (Exception e) {
LOG.debug("Failed to remove MCP tool {}: {}", toolName,
e.getMessage());
}
}
+ @Override
+ public void resourceAdded(McpServerResource resource) {
+ ResourceManager.ResourceDefinition definition =
resourceManager.newResource(resource.name())
+ .setUri(resource.uri())
+ .setMimeType(resource.mimeType())
+ .setDescription(resource.description());
+ if (resource.title() != null && !resource.title().isBlank()) {
+ definition.setTitle(resource.title());
+ }
+ definition.setHandler(arguments -> {
+ McpResourceReadResult result = resource.handler().read();
+ if (result.isError()) {
+ // resources/read has no in-band error flag; the message is
pre-sanitized by the bridge
+ throw new McpException(result.errorMessage(),
JsonRpcErrorCodes.INTERNAL_ERROR);
+ }
+ // plain Base64 as in the Vert.x engine:
BlobResourceContents.create(String, byte[]) adds MIME line breaks
+ ResourceContents contents = result.blob() != null
+ ? new BlobResourceContents(resource.uri(),
Base64.getEncoder().encodeToString(result.blob()),
+ resource.mimeType())
+ : new TextResourceContents(resource.uri(), result.text(),
resource.mimeType());
+ return new ResourceResponse(List.of(contents));
+ });
+ try {
+ definition.register();
+ } catch (IllegalArgumentException e) {
+ // quarkus-mcp-server also requires unique names: refuse the
resource, like the bridge does on a uri clash
+ LOG.error("Refusing to expose MCP resource '{}': {}. Resource
names and uris must be unique.",
+ resource.uri(), e.getMessage());
+ return;
+ }
+ registeredResourceUris.add(resource.uri());
+ LOG.debug("MCP resource added: {}", resource.uri());
+ }
+
+ @Override
+ public void resourceRemoved(String resourceUri) {
+ if (!registeredResourceUris.remove(resourceUri)) {
+ // refused in resourceAdded: removing by uri would delete another
resource with the same uri
+ return;
+ }
+ try {
+ // only from the default server, where resourceAdded registered it
+ resourceManager.removeResource(resourceUri, McpServer.DEFAULT);
+ LOG.debug("MCP resource removed: {}", resourceUri);
+ } catch (Exception e) {
+ LOG.debug("Failed to remove MCP resource {}: {}", resourceUri,
e.getMessage());
+ }
+ }
+
+ @Override
+ public boolean supportsResources() {
+ return true;
+ }
+
/**
* 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
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngineFactory.java
similarity index 50%
copy from
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
copy to
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngineFactory.java
index 88a6cf149f..ccc3d76a05 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
+++
b/extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngineFactory.java
@@ -14,20 +14,28 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.mcp.server.it;
+package org.apache.camel.quarkus.component.mcp.server;
-import io.quarkiverse.mcp.server.Tool;
-import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ResourceManager;
+import io.quarkiverse.mcp.server.ToolManager;
+import jakarta.inject.Inject;
+import jakarta.inject.Singleton;
/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
+ * Creates the {@link QuarkusMcpServerEngine}. The managers are injected
rather than looked up programmatically:
+ * quarkus-mcp-server advertises the {@code listChanged} capability of tools
and resources only when their manager has
+ * an injection point, and the Camel tools and resources change with the route
lifecycle.
*/
-public class QuarkusDefinedTools {
+@Singleton
+public class QuarkusMcpServerEngineFactory {
- @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);
+ @Inject
+ ToolManager toolManager;
+
+ @Inject
+ ResourceManager resourceManager;
+
+ QuarkusMcpServerEngine createEngine() {
+ return new QuarkusMcpServerEngine(toolManager, resourceManager);
}
}
diff --git a/extensions/pom.xml b/extensions/pom.xml
index 1b0d97dd64..381984e59e 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -37,6 +37,7 @@
<module>a2a</module>
<module>activemq</module>
<module>activemq6</module>
+ <module>ai-resource</module>
<module>ai-tool</module>
<module>amqp</module>
<module>arangodb</module>
diff --git a/integration-tests/mcp-server/pom.xml
b/integration-tests/ai-resource/pom.xml
similarity index 73%
copy from integration-tests/mcp-server/pom.xml
copy to integration-tests/ai-resource/pom.xml
index fead80ad89..2f6ec263a6 100644
--- a/integration-tests/mcp-server/pom.xml
+++ b/integration-tests/ai-resource/pom.xml
@@ -17,7 +17,9 @@
limitations under the License.
-->
-<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.apache.camel.quarkus</groupId>
@@ -26,24 +28,22 @@
<relativePath>../../poms/build-parent-it/pom.xml</relativePath>
</parent>
- <artifactId>camel-quarkus-integration-test-mcp-server</artifactId>
- <name>Camel Quarkus :: Integration Tests :: MCP Server</name>
- <description>Integration tests for Camel Quarkus MCP Server
extension</description>
-
- <properties>
- <!-- TODO: Re-enable when quarkus-mcp-server is compatible with
Quarkus 4 - https://github.com/apache/camel-quarkus/issues/9362 -->
- <skipTests>true</skipTests>
- <quarkus.build.skip>true</quarkus.build.skip>
- </properties>
+ <artifactId>camel-quarkus-integration-test-ai-resource</artifactId>
+ <name>Camel Quarkus :: Integration Tests :: AI Resource</name>
+ <description>Integration tests for Camel Quarkus AI Resource
extension</description>
<dependencies>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
- <artifactId>camel-quarkus-mcp-server</artifactId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-file</artifactId>
</dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
- <artifactId>camel-quarkus-ai-tool</artifactId>
+ <artifactId>camel-quarkus-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
@@ -51,21 +51,6 @@
</dependency>
<!-- test dependencies -->
- <dependency>
- <groupId>io.quarkiverse.mcp</groupId>
- <artifactId>quarkus-mcp-server-test</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.awaitility</groupId>
- <artifactId>awaitility</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.assertj</groupId>
- <artifactId>assertj-core</artifactId>
- <scope>test</scope>
- </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>
@@ -100,9 +85,6 @@
<goal>integration-test</goal>
<goal>verify</goal>
</goals>
- <configuration>
-
<rerunFailingTestsCount>${rerun.failing.test.count}</rerunFailingTestsCount>
- </configuration>
</execution>
</executions>
</plugin>
@@ -120,7 +102,7 @@
<!-- The following dependencies guarantee that this module is
built after them. You can update them by running `mvn process-resources
-Pformat -N` from the source tree root directory -->
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
- <artifactId>camel-quarkus-ai-tool-deployment</artifactId>
+
<artifactId>camel-quarkus-ai-resource-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
@@ -133,7 +115,20 @@
</dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
-
<artifactId>camel-quarkus-mcp-server-deployment</artifactId>
+ <artifactId>camel-quarkus-file-deployment</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-jackson-deployment</artifactId>
<version>${project.version}</version>
<type>pom</type>
<scope>test</scope>
@@ -147,4 +142,5 @@
</dependencies>
</profile>
</profiles>
+
</project>
diff --git
a/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceResource.java
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceResource.java
new file mode 100644
index 0000000000..4f10c53b4d
--- /dev/null
+++
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceResource.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.ai.resource.it;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
+import org.apache.camel.component.ai.resource.AiResourceExecutor;
+import org.apache.camel.component.ai.resource.AiResourceRegistry;
+import org.apache.camel.component.ai.resource.AiResourceResult;
+import org.apache.camel.component.ai.resource.AiResourceSpec;
+import org.apache.camel.support.DefaultExchange;
+
+@Path("/ai-resource")
+@ApplicationScoped
+public class AiResourceResource {
+
+ @Inject
+ CamelContext camelContext;
+
+ @Path("/resources/tag/{tag}")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getResourcesByTag(@PathParam("tag") String tag) {
+ AiResourceRegistry registry =
AiResourceRegistry.getOrCreate(camelContext);
+ Set<AiResourceSpec> resources = registry.getResourcesByTag(tag);
+ return resources.stream()
+ .map(AiResourceSpec::getName)
+ .sorted()
+ .collect(Collectors.joining(","));
+ }
+
+ @Path("/resources/all")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getAllResources() {
+ AiResourceRegistry registry =
AiResourceRegistry.getOrCreate(camelContext);
+ Set<AiResourceSpec> resources = registry.getAllResources();
+ return resources.stream()
+ .map(AiResourceSpec::getName)
+ .sorted()
+ .collect(Collectors.joining(","));
+ }
+
+ @Path("/resources/{resourceName}/description")
+ @GET
+ @Produces(MediaType.TEXT_PLAIN)
+ public String getResourceDescription(@PathParam("resourceName") String
resourceName) {
+ AiResourceSpec spec = findResource(resourceName);
+ if (spec == null) {
+ return "NOT_FOUND";
+ }
+ return spec.getDescription();
+ }
+
+ @Path("/read/{resourceName}")
+ @GET
+ public Response readResource(@PathParam("resourceName") String
resourceName) {
+ AiResourceSpec spec = findResource(resourceName);
+ if (spec == null) {
+ return Response.status(404).entity("Resource not found: " +
resourceName).build();
+ }
+
+ Exchange exchange = new DefaultExchange(camelContext);
+ AiResourceResult result = AiResourceExecutor.execute(spec, exchange);
+
+ if (result instanceof AiResourceResult.Text t) {
+ return Response.ok(t.value(), spec.getMimeType()).build();
+ } else if (result instanceof AiResourceResult.Binary b) {
+ return Response.ok(b.value(), spec.getMimeType()).build();
+ } else if (result instanceof AiResourceResult.ExecutionError e) {
+ return Response.status(500).entity(e.message()).build();
+ }
+ return Response.status(500).entity("Unknown result type").build();
+ }
+
+ @Path("/notes")
+ @PUT
+ @Consumes(MediaType.TEXT_PLAIN)
+ public void writeNotes(String content) throws IOException {
+ Files.createDirectories(Paths.get(AiResourceRoutes.NOTES_DIR));
+ Files.writeString(Paths.get(AiResourceRoutes.NOTES_DIR, "notes.txt"),
content);
+ }
+
+ private AiResourceSpec findResource(String resourceName) {
+ AiResourceRegistry registry =
AiResourceRegistry.getOrCreate(camelContext);
+ return registry.getAllResources().stream()
+ .filter(s -> s.getName().equals(resourceName))
+ .findFirst()
+ .orElse(null);
+ }
+}
diff --git
a/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceRoutes.java
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceRoutes.java
new file mode 100644
index 0000000000..514940b4ab
--- /dev/null
+++
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceRoutes.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.ai.resource.it;
+
+import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.dataformat.JsonLibrary;
+
+public class AiResourceRoutes extends RouteBuilder {
+ public static final String NOTES_DIR = "target/test-files";
+
+ @Override
+ public void configure() {
+
from("ai-resource:appConfig?resourceUri=camel:///config/app.json&tags=config"
+ + "&description=Current application configuration"
+ + "&mimeType=application/json")
+ .setBody(constant("{\"env\":\"test\"}"));
+
+
from("ai-resource:latestReport?resourceUri=camel:///reports/latest.pdf&tags=reports"
+ + "&description=Latest sales report"
+ + "&mimeType=application/pdf")
+ .setBody(constant(new byte[] { 0x25, 0x50, 0x44, 0x46 }));
+
+ from("ai-resource:readme?resourceUri=camel:///readme.txt"
+ + "&description=Project readme")
+ .setBody(constant("Read me"));
+
+ from("ai-resource:failingResource?resourceUri=camel:///fail&tags=test"
+ + "&description=A resource that always fails")
+ .throwException(new IllegalStateException("Intentional
failure"));
+
+ // the file is polled on every read; idempotent=false keeps it
readable more than once
+ from("ai-resource:notes?resourceUri=camel:///notes.txt&tags=files"
+ + "&description=Notes read from a file"
+ + "&mimeType=text/plain")
+ .pollEnrich("file:" + NOTES_DIR +
"?fileName=notes.txt&noop=true&idempotent=false", 1000);
+
+
from("ai-resource:latestOrder?resourceUri=camel:///orders/latest.json&tags=orders"
+ + "&description=Latest order"
+ + "&mimeType=application/json")
+ .setBody(constant(new Order("A-1", 2)))
+ .marshal().json(JsonLibrary.Jackson);
+ }
+}
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/Order.java
similarity index 60%
copy from
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
copy to
integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/Order.java
index 88a6cf149f..e536419756 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
+++
b/integration-tests/ai-resource/src/main/java/org/apache/camel/quarkus/component/ai/resource/it/Order.java
@@ -14,20 +14,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.mcp.server.it;
+package org.apache.camel.quarkus.component.ai.resource.it;
-import io.quarkiverse.mcp.server.Tool;
-import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkus.runtime.annotations.RegisterForReflection;
-/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
- */
-public class QuarkusDefinedTools {
-
- @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);
- }
+@RegisterForReflection
+public record Order(String id, int quantity) {
}
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
b/integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceIT.java
similarity index 60%
copy from
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
copy to
integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceIT.java
index 88a6cf149f..28e9a7034f 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
+++
b/integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceIT.java
@@ -14,20 +14,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-package org.apache.camel.quarkus.component.mcp.server.it;
+package org.apache.camel.quarkus.component.ai.resource.it;
-import io.quarkiverse.mcp.server.Tool;
-import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkus.test.junit.QuarkusIntegrationTest;
-/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
- */
-public class QuarkusDefinedTools {
+@QuarkusIntegrationTest
+class AiResourceIT extends AiResourceTest {
- @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);
- }
}
diff --git
a/integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceTest.java
b/integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceTest.java
new file mode 100644
index 0000000000..feb9d0e622
--- /dev/null
+++
b/integration-tests/ai-resource/src/test/java/org/apache/camel/quarkus/component/ai/resource/it/AiResourceTest.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.ai.resource.it;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@QuarkusTest
+class AiResourceTest {
+
+ @Test
+ void resourceRegistrationByTag() {
+ RestAssured.get("/ai-resource/resources/tag/config")
+ .then()
+ .statusCode(200)
+ .body(containsString("appConfig"));
+
+ RestAssured.get("/ai-resource/resources/tag/reports")
+ .then()
+ .statusCode(200)
+ .body(containsString("latestReport"));
+ }
+
+ @Test
+ void taglessResourceInDefaultPool() {
+ RestAssured.get("/ai-resource/resources/all")
+ .then()
+ .statusCode(200)
+ .body(containsString("readme"));
+ }
+
+ @Test
+ void defaultPoolMergedWithTaggedResources() {
+ RestAssured.get("/ai-resource/resources/tag/config")
+ .then()
+ .statusCode(200)
+ .body(containsString("readme"));
+ }
+
+ @Test
+ void allResourcesReturnsEveryRegisteredResource() {
+ String resources = RestAssured.get("/ai-resource/resources/all")
+ .then()
+ .statusCode(200)
+ .extract().asString();
+
+ assertTrue(resources.contains("appConfig"), "Expected appConfig in: "
+ resources);
+ assertTrue(resources.contains("latestReport"), "Expected latestReport
in: " + resources);
+ assertTrue(resources.contains("readme"), "Expected readme in: " +
resources);
+ assertTrue(resources.contains("failingResource"), "Expected
failingResource in: " + resources);
+ }
+
+ @Test
+ void resourceDescription() {
+ RestAssured.get("/ai-resource/resources/appConfig/description")
+ .then()
+ .statusCode(200)
+ .body(is("Current application configuration"));
+ }
+
+ @Test
+ void readTextResource() {
+ RestAssured.get("/ai-resource/read/appConfig")
+ .then()
+ .statusCode(200)
+ .body(is("{\"env\":\"test\"}"));
+ }
+
+ @Test
+ void readBinaryResource() {
+ byte[] content = RestAssured.get("/ai-resource/read/latestReport")
+ .then()
+ .statusCode(200)
+ .extract().asByteArray();
+
+ assertArrayEquals(new byte[] { 0x25, 0x50, 0x44, 0x46 }, content);
+ }
+
+ @Test
+ void readFileResourceOnEveryRead() {
+ writeNotes("first version");
+ RestAssured.get("/ai-resource/read/notes")
+ .then()
+ .statusCode(200)
+ .body(is("first version"));
+
+ // the route reads the file again, nothing is cached
+ writeNotes("second version");
+ RestAssured.get("/ai-resource/read/notes")
+ .then()
+ .statusCode(200)
+ .body(is("second version"));
+ }
+
+ @Test
+ void readJsonResourceMarshalledByJackson() {
+ RestAssured.get("/ai-resource/read/latestOrder")
+ .then()
+ .statusCode(200)
+ .contentType(ContentType.JSON)
+ .body("id", is("A-1"), "quantity", is(2));
+ }
+
+ @Test
+ void readFailingResource() {
+ RestAssured.get("/ai-resource/read/failingResource")
+ .then()
+ .statusCode(500);
+ }
+
+ @Test
+ void readNonExistentResource() {
+ RestAssured.get("/ai-resource/read/nonExistent")
+ .then()
+ .statusCode(404);
+ }
+
+ private static void writeNotes(String content) {
+ RestAssured.given()
+ .contentType(ContentType.TEXT)
+ .body(content)
+ .put("/ai-resource/notes")
+ .then()
+ .statusCode(204);
+ }
+}
diff --git a/integration-tests/mcp-server/pom.xml
b/integration-tests/mcp-server/pom.xml
index fead80ad89..5c8f4463a0 100644
--- a/integration-tests/mcp-server/pom.xml
+++ b/integration-tests/mcp-server/pom.xml
@@ -41,10 +41,18 @@
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-mcp-server</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-jackson</artifactId>
+ </dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-resteasy</artifactId>
@@ -118,6 +126,19 @@
</activation>
<dependencies>
<!-- The following dependencies guarantee that this module is
built after them. You can update them by running `mvn process-resources
-Pformat -N` from the source tree root directory -->
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+
<artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool-deployment</artifactId>
@@ -131,6 +152,19 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-jackson-deployment</artifactId>
+ <version>${project.version}</version>
+ <type>pom</type>
+ <scope>test</scope>
+ <exclusions>
+ <exclusion>
+ <groupId>*</groupId>
+ <artifactId>*</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-mcp-server-deployment</artifactId>
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerResource.java
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerResource.java
index 1012e173fa..fb724ec09a 100644
---
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerResource.java
+++
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerResource.java
@@ -16,9 +16,15 @@
*/
package org.apache.camel.quarkus.component.mcp.server.it;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
import jakarta.inject.Inject;
+import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
@@ -49,4 +55,12 @@ public class McpServerResource {
public String routeStatus(@PathParam("routeId") String routeId) {
return
camelContext.getRouteController().getRouteStatus(routeId).name();
}
+
+ @Path("/notes")
+ @PUT
+ @Consumes(MediaType.TEXT_PLAIN)
+ public void writeNotes(String content) throws IOException {
+ Files.createDirectories(Paths.get(McpServerRoutes.NOTES_DIR));
+ Files.writeString(Paths.get(McpServerRoutes.NOTES_DIR, "notes.txt"),
content);
+ }
}
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 b3aa7354e7..b8c1ab69ee 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,15 +16,24 @@
*/
package org.apache.camel.quarkus.component.mcp.server.it;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import jakarta.enterprise.context.ApplicationScoped;
import org.apache.camel.builder.RouteBuilder;
+import org.apache.camel.model.dataformat.JsonLibrary;
@ApplicationScoped
public class McpServerRoutes extends RouteBuilder {
+ public static final String NOTES_DIR = "target/test-files";
+ // 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);
+
@Override
public void configure() {
from("ai-tool:say_hello?tags=conformance&description=Say hello"
@@ -37,8 +46,9 @@ public class McpServerRoutes extends RouteBuilder {
throw new IllegalStateException("secret internal detail");
});
+ // between tool-timeout and resource-timeout, like delayed_resource
from("ai-tool:slow_tool?tags=conformance&description=Exceeds the tool
timeout")
- .delay(6000)
+ .delay(4000)
.setBody(constant("done"));
from("ai-tool:hidden_tool?description=Untagged tool, must not be
exposed")
@@ -69,5 +79,76 @@ public class McpServerRoutes extends RouteBuilder {
+
"&outputParameter.temperature=number&outputParameter.temperature.required=true"
+
"&outputParameter.unit=string&outputParameter.unit.enum=celsius,fahrenheit")
.setBody(constant("{\"temperature\":21.5,\"unit\":\"celsius\"}"));
+
+ // the name is taken by the tool registered in QuarkusDefinedTools, so
it is refused once started
+ from("ai-tool:user_tool?tags=conformance"
+ + "&description=Name already taken by an application tool,
must be refused")
+ .routeId("clashing-tool-route")
+ .autoStartup(false)
+ .setBody(constant("camel"));
+
+ // the name is taken 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"
+ + "&description=Name already taken by an annotated tool, must
be refused")
+ .setBody(constant("camel"));
+
+
from("ai-resource:app_config?resourceUri=camel:///config/app.json&tags=conformance"
+ + "&description=Application
configuration&mimeType=application/json&title=App config")
+ .routeId("app-config-route")
+ .setBody(constant("{\"env\":\"test\"}"));
+
+
from("ai-resource:latest_report?resourceUri=camel:///reports/latest.pdf&tags=conformance"
+ + "&description=Latest report&mimeType=application/pdf")
+ .setBody(constant(REPORT));
+
+ // an InputStream body, as returned by producers such as aws2-s3
getObject; the file is opened on every read
+
from("ai-resource:notes?resourceUri=camel:///notes.txt&tags=conformance"
+ + "&description=Notes read from a file&mimeType=text/plain")
+ .process(e ->
e.getMessage().setBody(Files.newInputStream(Paths.get(NOTES_DIR,
"notes.txt"))));
+
+
from("ai-resource:latest_order?resourceUri=camel:///orders/latest.json&tags=conformance"
+ + "&description=Latest order&mimeType=application/json")
+ .setBody(constant(new Order("A-1", 2)))
+ .marshal().json(JsonLibrary.Jackson);
+
+
from("ai-resource:fail_resource?resourceUri=camel:///fail&tags=conformance&description=Always
fails")
+ .process(e -> {
+ throw new IllegalStateException("secret internal detail");
+ });
+
+
from("ai-resource:slow_resource?resourceUri=camel:///slow&tags=conformance"
+ + "&description=Exceeds the resource timeout")
+ .delay(8000)
+ .setBody(constant("done"));
+
+ // between tool-timeout and resource-timeout, like slow_tool
+
from("ai-resource:delayed_resource?resourceUri=camel:///delayed&tags=conformance"
+ + "&description=Exceeds the tool timeout, not the resource
timeout")
+ .delay(3000)
+ .setBody(constant("delayed"));
+
+ from("ai-resource:hidden_resource?resourceUri=camel:///hidden"
+ + "&description=Untagged resource, must not be exposed")
+ .setBody(constant("hidden"));
+
+
from("ai-resource:other_resource?resourceUri=camel:///other&tags=untrusted"
+ + "&description=Not a selected tag, must not be exposed")
+ .setBody(constant("other"));
+
+
from("ai-resource:same_name?resourceUri=camel:///same/first&tags=conformance"
+ + "&description=First resource named same_name")
+ .setBody(constant("first"));
+
+
from("ai-resource:same_name?resourceUri=camel:///same/second&tags=conformance"
+ + "&description=Name already taken, must be refused")
+ .setBody(constant("second"));
+
+ // the uri is taken by the resource registered in
QuarkusDefinedResources, so it is refused once started
+
from("ai-resource:clashing_resource?resourceUri=camel:///user/clash&tags=conformance"
+ + "&description=Uri already taken by an application resource,
must be refused")
+ .routeId("clashing-resource-route")
+ .autoStartup(false)
+ .setBody(constant("camel"));
}
}
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/Order.java
similarity index 64%
copy from
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedTools.java
copy to
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/Order.java
index 88a6cf149f..8a45082ba7 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/Order.java
@@ -16,18 +16,8 @@
*/
package org.apache.camel.quarkus.component.mcp.server.it;
-import io.quarkiverse.mcp.server.Tool;
-import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkus.runtime.annotations.RegisterForReflection;
-/**
- * A plain quarkus-mcp-server annotated tool living on the same MCP server as
the Camel ai-tool routes.
- */
-public class QuarkusDefinedTools {
-
- @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);
- }
+@RegisterForReflection
+public record Order(String id, int quantity) {
}
diff --git
a/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedResources.java
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedResources.java
new file mode 100644
index 0000000000..6353dc7c16
--- /dev/null
+++
b/integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/QuarkusDefinedResources.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.mcp.server.it;
+
+import java.util.List;
+
+import io.quarkiverse.mcp.server.ResourceManager;
+import io.quarkiverse.mcp.server.ResourceResponse;
+import io.quarkiverse.mcp.server.TextResourceContents;
+import io.quarkus.arc.Arc;
+import io.quarkus.runtime.StartupEvent;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.event.Observes;
+
+/**
+ * Resources registered by the application itself through the
quarkus-mcp-server {@link ResourceManager}: one at the
+ * same uri as the clashing-resource-route ai-resource route, and one at the
uri of the app-config-route ai-resource
+ * route, on another MCP server.
+ */
+@ApplicationScoped
+public class QuarkusDefinedResources {
+
+ void registerResources(@Observes StartupEvent event) {
+ // looked up, not injected: any ResourceManager injection point makes
quarkus-mcp-server advertise
+ // resources listChanged, which would hide a missing injection in
QuarkusMcpServerEngineFactory
+ ResourceManager resourceManager =
Arc.container().instance(ResourceManager.class).get();
+ resourceManager.newResource("user_resource")
+ .setUri("camel:///user/clash")
+ .setDescription("Resource registered by the application")
+ .setHandler(arguments -> new ResourceResponse(
+
List.of(TextResourceContents.create(arguments.requestUri().value(), "user"))))
+ .register();
+ resourceManager.newResource("other_config")
+ .setUri("camel:///config/app.json")
+ .setServerName("other")
+ .setDescription("Resource registered by the application on
another server")
+ .setHandler(arguments -> new ResourceResponse(
+
List.of(TextResourceContents.create(arguments.requestUri().value(), "other"))))
+ .register();
+ }
+}
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 88a6cf149f..d863773a87 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
@@ -18,9 +18,16 @@ package org.apache.camel.quarkus.component.mcp.server.it;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolManager;
+import io.quarkiverse.mcp.server.ToolResponse;
+import io.quarkus.arc.Arc;
+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.
+ * A plain quarkus-mcp-server annotated tool 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 {
@@ -30,4 +37,18 @@ public class QuarkusDefinedTools {
@ToolArg(description = "Second addend") long b) {
return String.valueOf(a + b);
}
+
+ void registerTools(@Observes StartupEvent event) {
+ // looked up, not injected, as in QuarkusDefinedResources
+ ToolManager toolManager =
Arc.container().instance(ToolManager.class).get();
+ toolManager.newTool("user_tool")
+ .setDescription("Tool registered by the application")
+ .setHandler(arguments -> ToolResponse.success("user"))
+ .register();
+ toolManager.newTool("say_hello")
+ .setServerName("other")
+ .setDescription("Tool registered by the application on another
server")
+ .setHandler(arguments -> ToolResponse.success("other"))
+ .register();
+ }
}
diff --git
a/integration-tests/mcp-server/src/main/resources/application.properties
b/integration-tests/mcp-server/src/main/resources/application.properties
index 11d2cf0016..1b1a7a1526 100644
--- a/integration-tests/mcp-server/src/main/resources/application.properties
+++ b/integration-tests/mcp-server/src/main/resources/application.properties
@@ -16,5 +16,9 @@
## ---------------------------------------------------------------------------
quarkus.camel.mcp-server.tags=conformance
quarkus.camel.mcp-server.tool-timeout=2000
+# differs from tool-timeout, so that mixing up the two options fails a test
+quarkus.camel.mcp-server.resource-timeout=6000
+# a second MCP server, serving an application tool and resource under the name
and uri of Camel ones
+quarkus.mcp.server.other.http.root-path=/other-mcp
# the argSchema and outputSchema resources must be reachable in native mode
quarkus.native.resources.includes=schema/create-order.json,schema/order-status.json
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 67fc11dc10..54eda40360 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
@@ -17,19 +17,27 @@
package org.apache.camel.quarkus.component.mcp.server.it;
import java.net.URI;
+import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
+import io.quarkiverse.mcp.server.BlobResourceContents;
+import io.quarkiverse.mcp.server.JsonRpcErrorCodes;
import io.quarkiverse.mcp.server.TextContent;
+import io.quarkiverse.mcp.server.TextResourceContents;
import io.quarkiverse.mcp.server.ToolResponse;
import io.quarkiverse.mcp.server.test.McpAssured;
import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient;
+import io.quarkiverse.mcp.server.test.McpAssured.ResourceInfo;
+import io.quarkiverse.mcp.server.test.McpAssured.ResourcesPage;
+import io.quarkiverse.mcp.server.test.McpAssured.ServerCapability;
import io.quarkiverse.mcp.server.test.McpAssured.ToolInfo;
import io.quarkiverse.mcp.server.test.McpAssured.ToolsPage;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import org.eclipse.microprofile.config.ConfigProvider;
@@ -50,6 +58,7 @@ import static org.awaitility.Awaitility.await;
class McpServerTest {
private McpStreamableTestClient client;
+ private McpStreamableTestClient otherClient;
private McpStreamableTestClient client() {
if (client == null) {
@@ -62,12 +71,29 @@ class McpServerTest {
return client;
}
+ // a client of the other MCP server configured in application.properties
+ private McpStreamableTestClient otherClient() {
+ if (otherClient == null) {
+ otherClient = McpAssured.newStreamableClient()
+ .setBaseUri(URI.create("http://localhost:%d".formatted(
+
ConfigProvider.getConfig().getValue("quarkus.http.test-port", Integer.class))))
+ .setMcpPath("/other-mcp")
+ .build()
+ .connect();
+ }
+ return otherClient;
+ }
+
@AfterEach
void closeClient() {
if (client != null) {
client.disconnect();
client = null;
}
+ if (otherClient != null) {
+ otherClient.disconnect();
+ otherClient = null;
+ }
}
@Test
@@ -169,7 +195,7 @@ class McpServerTest {
@Test
void testQuarkusAnnotatedToolsCoexistWithCamelTools() {
- // both tool sources are served by the same MCP server
+ // both tool sources are served by the same MCP server; the
add_numbers ai-tool route is refused
client().when()
.toolsList(page ->
assertThat(toolNames(page)).contains("add_numbers", "say_hello"))
.toolsCall("add_numbers", Map.of("a", 17, "b", 25), response
-> {
@@ -212,37 +238,254 @@ class McpServerTest {
.thenAssertResults();
}
+ @Test
+ void testStoppingRefusedToolKeepsApplicationTool() {
+ // the route is refused, its name is taken by a tool the application
registered itself
+ controlRoute("clashing-tool-route", "start");
+ assertThat(routeStatus("clashing-tool-route")).isEqualTo("Started");
+ controlRoute("clashing-tool-route", "stop");
+ assertThat(routeStatus("clashing-tool-route")).isEqualTo("Stopped");
+
+ client().when()
+ .toolsCall("user_tool", response ->
assertThat(textOf(response)).isEqualTo("user"))
+ .thenAssertResults();
+ }
+
+ @Test
+ void testStoppingToolRouteKeepsToolOfOtherServer() {
+ // the application registered a tool with the same name on another MCP
server
+ controlRoute("say-hello-route", "stop");
+ try {
+ otherClient().when()
+ .toolsCall("say_hello", response ->
assertThat(textOf(response)).isEqualTo("other"))
+ .thenAssertResults();
+ } finally {
+ controlRoute("say-hello-route", "start");
+ }
+ }
+
@Test
void testToolsListReflectsRouteStopAndStart() {
client().when()
.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) {
+ // The resource scenarios below mirror
McpServerResourceConformanceTestSupport from camel-mcp-server-api
+
+ @Test
+ void testServerAdvertisesResourcesCapability() {
+
assertThat(client().initResult().capabilities()).extracting(ServerCapability::name).contains("resources");
+ }
+
+ @Test
+ void testServerAdvertisesListChangedForToolsAndResources() {
+ // both lists follow the route lifecycle
+ assertThat(client().initResult().capabilities())
+ .filteredOn(capability -> List.of("tools",
"resources").contains(capability.name()))
+ .hasSize(2)
+ .allSatisfy(capability ->
assertThat(capability.properties()).containsEntry("listChanged", true));
+ }
+
+ @Test
+ void testListResourcesExposesOnlySelectedTags() {
+ client().when()
+ .resourcesList(page -> {
+ assertThat(resourceUris(page))
+ .contains("camel:///config/app.json",
"camel:///reports/latest.pdf", "camel:///fail",
+ "camel:///slow")
+ .doesNotContain("camel:///hidden",
"camel:///other");
+
+ ResourceInfo config =
page.findByUri("camel:///config/app.json");
+ assertThat(config.name()).isEqualTo("app_config");
+ assertThat(config.title()).isEqualTo("App config");
+ assertThat(config.description()).isEqualTo("Application
configuration");
+
assertThat(config.mimeType()).isEqualTo("application/json");
+ })
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadTextResource() {
+ client().when()
+ .resourcesRead("camel:///config/app.json", response -> {
+ assertThat(response.contents()).hasSize(1);
+ TextResourceContents contents =
response.contents().get(0).asText();
+
assertThat(contents.uri()).isEqualTo("camel:///config/app.json");
+
assertThat(contents.mimeType()).isEqualTo("application/json");
+
assertThat(contents.text()).isEqualTo("{\"env\":\"test\"}");
+ })
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadBinaryResource() {
+ client().when()
+ .resourcesRead("camel:///reports/latest.pdf", response -> {
+ assertThat(response.contents()).hasSize(1);
+ BlobResourceContents contents =
response.contents().get(0).asBlob();
+
assertThat(contents.mimeType()).isEqualTo("application/pdf");
+ // the basic decoder rejects the line breaks of a MIME
encoder
+
assertThat(Base64.getDecoder().decode(contents.blob())).isEqualTo(McpServerRoutes.REPORT);
+ })
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadFileResourceOnEveryRead() {
+ writeNotes("first version");
+ client().when()
+ .resourcesRead("camel:///notes.txt", response -> {
+ TextResourceContents contents =
response.contents().get(0).asText();
+ assertThat(contents.mimeType()).isEqualTo("text/plain");
+ assertThat(contents.text()).isEqualTo("first version");
+ })
+ .thenAssertResults();
+
+ // the route reads the file again, nothing is cached
+ writeNotes("second version");
+ client().when()
+ .resourcesRead("camel:///notes.txt",
+ response ->
assertThat(response.contents().get(0).asText().text()).isEqualTo("second
version"))
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadJsonResourceMarshalledByJackson() {
+ client().when()
+ .resourcesRead("camel:///orders/latest.json", response -> {
+ TextResourceContents contents =
response.contents().get(0).asText();
+
assertThat(contents.mimeType()).isEqualTo("application/json");
+ JsonObject order = new JsonObject(contents.text());
+ assertThat(order.getString("id")).isEqualTo("A-1");
+ assertThat(order.getInteger("quantity")).isEqualTo(2);
+ })
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadResourceErrorIsSanitized() {
+ client().when()
+ .resourcesRead("camel:///fail")
+ .withErrorAssert(error -> {
+
assertThat(error.code()).isEqualTo(JsonRpcErrorCodes.INTERNAL_ERROR);
+ assertThat(error.message())
+ .doesNotContain("secret internal detail")
+ .isEqualTo("Resource read failed");
+ })
+ .send()
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadResourceTimeout() {
+ client().when()
+ .resourcesRead("camel:///slow")
+ .withErrorAssert(error ->
assertThat(error.message()).contains("timed out"))
+ .send()
+ .thenAssertResults();
+ }
+
+ @Test
+ void testReadResourceWithinResourceTimeout() {
+ // exceeds tool-timeout, so it times out if the two options are mixed
up
+ client().when()
+ .resourcesRead("camel:///delayed",
+ response ->
assertThat(response.contents().get(0).asText().text()).isEqualTo("delayed"))
+ .thenAssertResults();
+ }
+
+ @Test
+ void testResourceWithDuplicateNameIsRefused() {
+ // quarkus-mcp-server requires unique resource names; the application
must still start
+ client().when()
+ .resourcesList(page -> assertThat(resourceUris(page))
+ .contains("camel:///same/first")
+ .doesNotContain("camel:///same/second"))
+ .thenAssertResults();
+ }
+
+ @Test
+ void testStoppingRefusedResourceKeepsApplicationResource() {
+ // the route is refused, its uri is taken by a resource the
application registered itself
+ controlRoute("clashing-resource-route", "start");
+
assertThat(routeStatus("clashing-resource-route")).isEqualTo("Started");
+ controlRoute("clashing-resource-route", "stop");
+
assertThat(routeStatus("clashing-resource-route")).isEqualTo("Stopped");
+
+ client().when()
+ .resourcesList(page ->
assertThat(resourceUris(page)).contains("camel:///user/clash"))
+ .resourcesRead("camel:///user/clash",
+ response ->
assertThat(response.contents().get(0).asText().text()).isEqualTo("user"))
+ .thenAssertResults();
+ }
+
+ @Test
+ void testStoppingResourceRouteKeepsResourceOfOtherServer() {
+ // the application registered a resource at the same uri on another
MCP server
+ controlRoute("app-config-route", "stop");
+ try {
+ otherClient().when()
+ .resourcesRead("camel:///config/app.json",
+ response ->
assertThat(response.contents().get(0).asText().text()).isEqualTo("other"))
+ .thenAssertResults();
+ } finally {
+ controlRoute("app-config-route", "start");
+ }
+ }
+
+ @Test
+ void testResourcesListReflectsRouteStopAndStart() {
+ client().when()
+ .resourcesList(page ->
assertThat(resourceUris(page)).contains("camel:///config/app.json"))
+ .thenAssertResults();
+
+ controlRoute("app-config-route", "stop");
+ assertThat(routeStatus("app-config-route")).isEqualTo("Stopped");
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
client().when()
+ .resourcesList(page ->
assertThat(resourceUris(page)).doesNotContain("camel:///config/app.json"))
+ .thenAssertResults());
+
+ controlRoute("app-config-route", "start");
+ assertThat(routeStatus("app-config-route")).isEqualTo("Started");
+ await().atMost(10, TimeUnit.SECONDS).untilAsserted(() ->
client().when()
+ .resourcesList(page ->
assertThat(resourceUris(page)).contains("camel:///config/app.json"))
+ .thenAssertResults());
+ }
+
+ 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 void writeNotes(String content) {
+ RestAssured.given()
+ .contentType(ContentType.TEXT)
+ .body(content)
+ .put("/mcp-server/notes")
+ .then()
+ .statusCode(204);
+ }
+
+ 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()
@@ -254,6 +497,10 @@ class McpServerTest {
return page.tools().stream().map(ToolInfo::name).toList();
}
+ private static List<String> resourceUris(ResourcesPage page) {
+ return page.resources().stream().map(ResourceInfo::uri).toList();
+ }
+
private static ToolInfo toolByName(ToolsPage page, String name) {
return page.tools().stream()
.filter(t -> name.equals(t.name()))
diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml
index 68cd138d83..f137b7be6b 100644
--- a/integration-tests/pom.xml
+++ b/integration-tests/pom.xml
@@ -51,6 +51,7 @@
<module>a2a</module>
<module>activemq</module>
<module>activemq6</module>
+ <module>ai-resource</module>
<module>ai-tool</module>
<module>ai-tool-langchain4j</module>
<module>amqp</module>
diff --git a/poms/bom/pom.xml b/poms/bom/pom.xml
index 781addce2a..9ff58d566f 100644
--- a/poms/bom/pom.xml
+++ b/poms/bom/pom.xml
@@ -3528,6 +3528,16 @@
<artifactId>camel-quarkus-activemq6-deployment</artifactId>
<version>${camel-quarkus.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <version>${camel-quarkus.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <version>${camel-quarkus.version}</version>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
diff --git a/poms/bom/src/main/generated/flattened-full-pom.xml
b/poms/bom/src/main/generated/flattened-full-pom.xml
index 107ac1e108..37c22d1909 100644
--- a/poms/bom/src/main/generated/flattened-full-pom.xml
+++ b/poms/bom/src/main/generated/flattened-full-pom.xml
@@ -3433,6 +3433,16 @@
<artifactId>camel-quarkus-activemq6-deployment</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
diff --git a/poms/bom/src/main/generated/flattened-reduced-pom.xml
b/poms/bom/src/main/generated/flattened-reduced-pom.xml
index 44d20d48ba..758f986c42 100644
--- a/poms/bom/src/main/generated/flattened-reduced-pom.xml
+++ b/poms/bom/src/main/generated/flattened-reduced-pom.xml
@@ -4828,6 +4828,16 @@
<artifactId>camel-quarkus-activemq6-deployment</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
diff --git a/poms/bom/src/main/generated/flattened-reduced-verbose-pom.xml
b/poms/bom/src/main/generated/flattened-reduced-verbose-pom.xml
index bed047fa93..989532e4fe 100644
--- a/poms/bom/src/main/generated/flattened-reduced-verbose-pom.xml
+++ b/poms/bom/src/main/generated/flattened-reduced-verbose-pom.xml
@@ -4828,6 +4828,16 @@
<artifactId>camel-quarkus-activemq6-deployment</artifactId>
<version>4.0.0-SNAPSHOT</version>
</dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.camel.quarkus</groupId>
+ <artifactId>camel-quarkus-ai-resource-deployment</artifactId>
+ <version>4.0.0-SNAPSHOT</version>
+ </dependency>
<dependency>
<groupId>org.apache.camel.quarkus</groupId>
<artifactId>camel-quarkus-ai-tool</artifactId>
diff --git a/tooling/scripts/test-categories.yaml
b/tooling/scripts/test-categories.yaml
index 9b5d9f8f07..f993067c61 100644
--- a/tooling/scripts/test-categories.yaml
+++ b/tooling/scripts/test-categories.yaml
@@ -232,6 +232,7 @@ group-11:
- xml-grouped
- xslt-saxon
group-12:
+ - ai-resource
- ai-tool
- aws2-grouped
- github2