jiriOndrusek-agent commented on code in PR #9368:
URL: https://github.com/apache/camel-quarkus/pull/9368#discussion_r4359049082
##########
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java:
##########
@@ -107,6 +123,59 @@ public void toolRemoved(String toolName) {
}
}
+ @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 {
+ resourceManager.removeResource(resourceUri);
Review Comment:
Good catch, thanks. It now calls `removeResource(uri, McpServer.DEFAULT)`,
mirroring the registration (71fbb8776c). The new
`testStoppingResourceRouteKeepsResourceOfOtherServer` registers a resource with
the same uri on a named server; it failed before the fix.
_Generated with [Claude Code](https://claude.com/claude-code) on behalf of
@JiriOndrusek_
##########
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/QuarkusMcpServerEngine.java:
##########
@@ -107,6 +123,59 @@ public void toolRemoved(String toolName) {
}
}
+ @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) {
Review Comment:
Agreed on the analysis, and that the root fix belongs in Camel:
`McpServerBridge.publishResource` stores the spec before the void
`resourceAdded`, so the engine cannot report the refusal. The catch here keeps
the app starting, as auto-started routes are published from the bridge's start
snapshot; point 2 is also how the bridge treats its own refusals. I'd keep the
refusal for Camel 4.23. I opened
[CAMEL-26102](https://issues.apache.org/jira/browse/CAMEL-26102) to let the SPI
report whether a tool or resource was accepted.
_Generated with [Claude Code](https://claude.com/claude-code) on behalf of
@JiriOndrusek_
##########
extensions/mcp-server/runtime/src/main/java/org/apache/camel/quarkus/component/mcp/server/CamelMcpServerConfig.java:
##########
@@ -55,4 +57,13 @@ public interface CamelMcpServerConfig {
*/
@WithDefault("20000")
long toolTimeout();
+
+ /**
+ * 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 resourceTimeout();
Review Comment:
Agreed. `resource-timeout`, and `tool-timeout` with the same issue, moved to
a new `RUN_TIME` root, `CamelMcpServerRunTimeConfig`, read by the recorder at
runtime (71fbb8776c). Checked on the built app:
`-Dquarkus.camel.mcp-server.resource-timeout=1` now takes effect.
_Generated with [Claude Code](https://claude.com/claude-code) on behalf of
@JiriOndrusek_
##########
integration-tests/mcp-server/src/main/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerRoutes.java:
##########
@@ -69,5 +75,63 @@ public void configure() {
+
"&outputParameter.temperature=number&outputParameter.temperature.required=true"
+
"&outputParameter.unit=string&outputParameter.unit.enum=celsius,fahrenheit")
.setBody(constant("{\"temperature\":21.5,\"unit\":\"celsius\"}"));
+
+
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(new byte[] { 0x25, 0x50, 0x44, 0x46 }));
+
+ // 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(6000)
+ .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)
Review Comment:
Agreed. The within-timeout delay must stay above `tool-timeout` to catch a
mix-up of the two options, so I raised `resource-timeout` instead: 2000/6000
ms, with delays 4000 (`slow_tool`), 3000 (`delayed_resource`) and 8000
(`slow_resource`), so 2 s or more to each timeout a test relies on
(71fbb8776c). Swapping the two options still fails both tests.
_Generated with [Claude Code](https://claude.com/claude-code) on behalf of
@JiriOndrusek_
##########
integration-tests/mcp-server/src/test/java/org/apache/camel/quarkus/component/mcp/server/it/McpServerTest.java:
##########
@@ -216,31 +224,207 @@ void testToolsListReflectsRouteStopAndStart() {
.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");
+
assertThat(Base64.getDecoder().decode(contents.blob())).isEqualTo(new byte[] {
0x25, 0x50, 0x44, 0x46 });
Review Comment:
Agreed, thanks. The payload is now 74 bytes, so a MIME encoder would add a
line break, which `Base64.getDecoder()` rejects (71fbb8776c). With the engine
switched to the MIME encoder, the test now fails.
_Generated with [Claude Code](https://claude.com/claude-code) on behalf of
@JiriOndrusek_
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]