ramu11 commented on PR #29722: URL: https://github.com/apache/camel/pull/29722#issuecomment-6844639646
> Thanks @ramu11. In addition to the earlier reviews, I ran reproducers against the PR head ([9a5ae37](https://github.com/apache/camel/commit/9a5ae376014bcbd8bcdd89f8cdd3e7398590350a)). They use the Vert.x engine with the MCP Java SDK 2.1.0 client, and I ran a control on the merge base ([fe2f3ce](https://github.com/apache/camel/commit/fe2f3ce5dfbe9cee5379f5fc9626f10353472a94)). The tests are at the end of this comment; each one passes when the problem it describes holds. > > **1. The client's URI reaches variables and the server log unescaped.** Both `AiResourceUriTemplate` and the SDK match a variable with `[^/]+`. As a result: > > * `crm://customers/42?admin=true#frag` gives `id=42?admin=true#frag`. > * `crm://customers/..` gives `id=..`. > * A newline is accepted. > > Because the spec is built with `asResourceSpec(resourceUri)`, a failing read logs the client's URI: > > ``` > ERROR AiResourceExecutor Error reading resource 'camel:///failing/1<LF>2026-11-12 12:00:00,000 ERROR [main] o.a.c.Security - FORGED admin login ok': lookup failed > WARN McpServerBridge MCP resource 'camel:///failing/1<LF>2026-11-12 ... FORGED admin login ok' read failed: ... > ``` > > On the merge base, no server-side logger writes the client URI. Limiting variables to RFC 3986 `pchar` characters, as davsclaus suggested, fixes all three cases. Logging the template rather than the concrete URI would also help. > > **2. Which of two overlapping templates gets served is not stable.** `doStart` loops over `getResourceTemplates()`, which returns a `Map.copyOf(...)` whose iteration order changes from one JVM run to the next. I used `crm://customers/{id}` (tag `crm`) and `crm://customers/{number}` (tag `ops`), with the bridge started after the routes. Over 10 fresh JVMs, each template won 5 times. The winner in `McpServerBridgeResourceTemplateTest`'s setup also changed between two runs. Stopping and starting the exposed route hands the URIs to the other route for good: > > ``` > exposed before restart of by-id: crm://customers/{id}, after: crm://customers/{number} > ``` > > Could the snapshot keep a stable order, and could overlaps be rejected at route startup the way a duplicate `resourceUri` is? > > **3. `{id}` and `{ID}` collapse into one header.** The duplicate-variable check is case-sensitive, but Camel header names are not. For `camel:///orders/{id}/items/{ID}`, reading `camel:///orders/1/items/2` gives `id=2,ID=2`. Comparing names case-insensitively in the duplicate check would fix it. > > **4. A `resourceUri` containing `{...}` silently hides a template.** I set `resourceUri=camel:///accounts/{id}` (meaning to write `uriTemplate`) next to `uriTemplate=camel:///accounts/{accountId}`. Reading `camel:///accounts/7` reaches the fixed route without a `CamelAiResourceUri` header, and the template route is never called. The SDK treats a fixed URI with braces as a pattern, and that already happens on main. Now that `uriTemplate` exists, rejecting `{` and `}` in `resourceUri` at startup would turn this into a clear error. > > ### Reproducer tests > Run with: > > ``` > mvn verify -pl components/camel-ai/camel-ai-resource,components/camel-ai/camel-mcp-server-api,components/camel-ai/camel-mcp-server -Dtest='Pr29722*' -Dsurefire.failIfNoSpecifiedTests=false > ``` > > Pr29722TemplateReproducerTest.java — findings 1, 3, 4 (PR head, `camel-mcp-server`, package `org.apache.camel.component.mcp.server.vertx`) > ```java > package org.apache.camel.component.mcp.server.vertx; > > import java.time.Duration; > import java.util.List; > import java.util.concurrent.CopyOnWriteArrayList; > import java.util.concurrent.atomic.AtomicInteger; > > import io.modelcontextprotocol.client.McpClient; > import io.modelcontextprotocol.client.McpSyncClient; > import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; > import io.modelcontextprotocol.spec.McpSchema; > import org.apache.camel.CamelContext; > import org.apache.camel.builder.RouteBuilder; > import org.apache.camel.component.mcp.server.McpServerBridge; > import org.apache.camel.component.mcp.server.McpServerConfiguration; > import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer; > import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration; > import org.apache.camel.test.AvailablePortFinder; > import org.apache.camel.test.junit6.CamelTestSupport; > import org.apache.logging.log4j.LogManager; > import org.apache.logging.log4j.core.LogEvent; > import org.apache.logging.log4j.core.LoggerContext; > import org.apache.logging.log4j.core.appender.AbstractAppender; > import org.apache.logging.log4j.core.config.Property; > import org.junit.jupiter.api.AfterEach; > import org.junit.jupiter.api.Test; > > import static org.assertj.core.api.Assertions.assertThat; > import static org.assertj.core.api.Assertions.assertThatThrownBy; > > /** > * Reproducers for the PR #29722 review, end to end: real Vert.x engine, official MCP Java SDK client. Each test passes > * when the reported problem holds. > */ > class Pr29722TemplateReproducerTest extends CamelTestSupport { > > private static final String TAG = "repro"; > private static final String FORGED = "2026-11-12 12:00:00,000 ERROR [main] o.a.c.Security - FORGED admin login ok"; > > private final int port = AvailablePortFinder.getNextAvailable(); > private final AtomicInteger accountTemplateInvocations = new AtomicInteger(); > private final CapturingAppender appender = new CapturingAppender(); > private McpSyncClient client; > > @Override > protected CamelContext createCamelContext() throws Exception { > CamelContext camelContext = super.createCamelContext(); > VertxPlatformHttpServerConfiguration http = new VertxPlatformHttpServerConfiguration(); > http.setBindPort(port); > camelContext.addService(new VertxPlatformHttpServer(http)); > McpServerConfiguration configuration = new McpServerConfiguration(); > configuration.setTags(TAG); > camelContext.addService(new McpServerBridge(configuration)); > return camelContext; > } > > @Override > protected RouteBuilder createRouteBuilder() { > return new RouteBuilder() { > @Override > public void configure() { > // finding 1: variable value is raw client input > from("ai-resource:customer?uriTemplate=camel:///customers/{id}&tags=" + TAG) > .setBody(simple("id=${header.CamelAiResourceTemplateVariable.id}")); > > // finding 1: a failing template route logs the client URI > from("ai-resource:failing?uriTemplate=camel:///failing/{id}&tags=" + TAG) > .process(e -> { > throw new IllegalStateException("lookup failed"); > }); > > // finding 3: {id} and {ID} are two variables for the parser, one header for Camel > from("ai-resource:order_item?uriTemplate=camel:///orders/{id}/items/{ID}&tags=" + TAG) > .setBody(simple("id=${header.CamelAiResourceTemplateVariable.id}," > + "ID=${header.CamelAiResourceTemplateVariable.ID}")); > > // finding 4: resourceUri where uriTemplate was meant, next to the real template route > from("ai-resource:account_typo?resourceUri=camel:///accounts/{id}&tags=" + TAG) > .setBody(simple("fixed-route uriHeader=${header.CamelAiResourceUri}")); > from("ai-resource:account?uriTemplate=camel:///accounts/{accountId}&tags=" + TAG) > .process(e -> accountTemplateInvocations.incrementAndGet()) > .setBody(simple("template-route id=${header.CamelAiResourceTemplateVariable.accountId}")); > } > }; > } > > private McpSyncClient client() { > if (client == null) { > client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build()) > .requestTimeout(Duration.ofSeconds(10)) > .initializationTimeout(Duration.ofSeconds(10)) > .build(); > client.initialize(); > } > return client; > } > > private String readText(String uri) { > McpSchema.ReadResourceResult result = client().readResource(new McpSchema.ReadResourceRequest(uri)); > return ((McpSchema.TextResourceContents) result.contents().get(0)).text(); > } > > @AfterEach > void cleanup() { > if (client != null) { > client.closeGracefully(); > client = null; > } > appender.detach(); > } > > // ---- finding 1: query, fragment, dot-segments and CR/LF reach the route as the variable value > > @Test > void finding1QueryAndFragmentEndUpInsideTheVariable() { > String text = readText("camel:///customers/42?admin=true#frag"); > System.out.println("[finding1] query+fragment -> " + text); > assertThat(text).isEqualTo("id=42?admin=true#frag"); > } > > @Test > void finding1DotSegmentIsAcceptedAsVariable() { > String text = readText("camel:///customers/.."); > System.out.println("[finding1] dot-segment -> " + text); > assertThat(text).isEqualTo("id=.."); > } > > @Test > void finding1NewlineIsAcceptedAsVariable() { > String text = readText("camel:///customers/42\nX-Injected: yes"); > System.out.println("[finding1] newline -> " + text.replace("\n", "\\n")); > assertThat(text).isEqualTo("id=42\nX-Injected: yes"); > } > > @Test > void finding1ClientUriIsWrittenUnescapedToTheServerLog() { > appender.attach(); > String uri = "camel:///failing/1\n" + FORGED; > > assertThatThrownBy(() -> client().readResource(new McpSchema.ReadResourceRequest(uri))) > .hasMessageContaining("Resource read failed"); > > List<String> forged = appender.lines.stream().filter(l -> l.contains("\n" + FORGED)) > // the SDK client runs in the same JVM; only server-side loggers count > .filter(l -> !l.contains("McpClientSession")).toList(); > forged.forEach(l -> System.out.println("[finding1] logged -> " + l.replace("\n", "<LF>"))); > assertThat(forged) > .as("a WARN/ERROR line whose message contains a raw newline followed by the forged log line") > .isNotEmpty(); > } > > // ---- finding 3: case-insensitive headers collapse {id} and {ID} > > @Test > void finding3CaseVariantVariablesCollapseIntoOneHeader() { > String text = readText("camel:///orders/1/items/2"); > System.out.println("[finding3] camel:///orders/1/items/2 -> " + text); > // independent variables would give id=1,ID=2 > assertThat(text).isEqualTo("id=2,ID=2"); > } > > // ---- finding 4: a resourceUri containing {..} is a pattern for the SDK and shadows the real template > > @Test > void finding4BracedResourceUriShadowsTemplate() { > accountTemplateInvocations.set(0); > String text = readText("camel:///accounts/7"); > System.out.println("[finding4] camel:///accounts/7 -> " + text + " (template invocations=" > + accountTemplateInvocations.get() + ")"); > assertThat(text).isEqualTo("fixed-route uriHeader="); > assertThat(accountTemplateInvocations).hasValue(0); > } > > static final class CapturingAppender extends AbstractAppender { > final List<String> lines = new CopyOnWriteArrayList<>(); > > CapturingAppender() { > super("pr29722-capture", null, null, true, Property.EMPTY_ARRAY); > } > > @Override > public void append(LogEvent event) { > lines.add(event.getLevel() + " " + event.getLoggerName() + " " + event.getMessage().getFormattedMessage()); > } > > void attach() { > start(); > LoggerContext ctx = (LoggerContext) LogManager.getContext(false); > ctx.getConfiguration().getRootLogger().addAppender(this, null, null); > ctx.updateLoggers(); > } > > void detach() { > LoggerContext ctx = (LoggerContext) LogManager.getContext(false); > ctx.getConfiguration().getRootLogger().removeAppender(getName()); > ctx.updateLoggers(); > } > } > } > ``` > > Pr29722OverlapReproducerTest.java — finding 2 (PR head, `camel-mcp-server-api`, package `org.apache.camel.component.mcp.server`) > ```java > package org.apache.camel.component.mcp.server; > > import java.io.BufferedReader; > import java.io.InputStreamReader; > import java.nio.charset.StandardCharsets; > import java.util.ArrayList; > import java.util.List; > import java.util.Map; > import java.util.TreeMap; > import java.util.concurrent.TimeUnit; > > import org.apache.camel.CamelContext; > import org.apache.camel.builder.RouteBuilder; > import org.apache.camel.impl.DefaultCamelContext; > import org.apache.camel.test.junit6.CamelTestSupport; > import org.junit.jupiter.api.Test; > > import static org.assertj.core.api.Assertions.assertThat; > > /** > * Reproducers for finding 2 of the PR #29722 review: which of two overlapping templates is exposed is not stable. > */ > class Pr29722OverlapReproducerTest extends CamelTestSupport { > > static final String ID_TEMPLATE = "crm://customers/{id}"; > static final String NUMBER_TEMPLATE = "crm://customers/{number}"; > > private final RecordingMcpServerEngine engine = new RecordingMcpServerEngine(); > > @Override > protected CamelContext createCamelContext() throws Exception { > CamelContext camelContext = super.createCamelContext(); > camelContext.getRegistry().bind("mcpServerEngine", engine); > McpServerConfiguration configuration = new McpServerConfiguration(); > configuration.setTags("crm,ops"); > camelContext.addService(new McpServerBridge(configuration)); > return camelContext; > } > > @Override > protected RouteBuilder createRouteBuilder() { > return routes(); > } > > static RouteBuilder routes() { > return new RouteBuilder() { > @Override > public void configure() { > from("ai-resource:customer_by_id?uriTemplate=" + ID_TEMPLATE + "&tags=crm") > .routeId("by-id").setBody(constant("by-id")); > from("ai-resource:customer_by_number?uriTemplate=" + NUMBER_TEMPLATE + "&tags=ops") > .routeId("by-number").setBody(constant("by-number")); > } > }; > } > > /** > * Stopping and starting the exposed route hands crm://customers/* to the other route for good. > */ > @Test > void routeRestartPermanentlyMovesTheUrisToTheOtherRoute() throws Exception { > String before = engine.resourceTemplates().keySet().iterator().next(); > String acceptedRoute = ID_TEMPLATE.equals(before) ? "by-id" : "by-number"; > String otherTemplate = ID_TEMPLATE.equals(before) ? NUMBER_TEMPLATE : ID_TEMPLATE; > > context.getRouteController().stopRoute(acceptedRoute); > context.getRouteController().startRoute(acceptedRoute); > > String after = engine.resourceTemplates().keySet().iterator().next(); > System.out.println("[finding2] exposed before restart of " + acceptedRoute + ": " + before + ", after: " + after); > assertThat(context.getRouteController().getRouteStatus(acceptedRoute).isStarted()).isTrue(); > assertThat(engine.resourceTemplates()).containsOnlyKeys(otherTemplate); > } > > /** > * When the bridge starts after the routes (bridge restart, or a runtime that starts it late), the winner comes from > * the iteration order of Map.copyOf, which is randomized per JVM. Ten fresh JVMs disagree on the winner. > */ > @Test > void winnerDependsOnJvmWhenBridgeStartsAfterRoutes() throws Exception { > Map<String, Integer> winners = new TreeMap<>(); > for (int i = 0; i < 10; i++) { > String winner = runChildJvm(); > winners.merge(winner, 1, Integer::sum); > } > System.out.println("[finding2] winners over 10 JVMs: " + winners); > assertThat(winners).containsOnlyKeys(ID_TEMPLATE, NUMBER_TEMPLATE); > } > > private static String runChildJvm() throws Exception { > List<String> command = new ArrayList<>(); > command.add(System.getProperty("java.home") + "/bin/java"); > command.add("-cp"); > command.add(System.getProperty("java.class.path")); > command.add(LateBridgeMain.class.getName()); > Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); > String winner = null; > try (BufferedReader reader = new BufferedReader( > new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { > String line; > while ((line = reader.readLine()) != null) { > if (line.startsWith("WINNER=")) { > winner = line.substring("WINNER=".length()); > } > } > } > assertThat(process.waitFor(60, TimeUnit.SECONDS)).isTrue(); > assertThat(winner).isNotNull(); > return winner; > } > > public static final class LateBridgeMain { > public static void main(String[] args) throws Exception { > RecordingMcpServerEngine engine = new RecordingMcpServerEngine(); > try (DefaultCamelContext context = new DefaultCamelContext()) { > context.getRegistry().bind("mcpServerEngine", engine); > context.addRoutes(routes()); > context.start(); > // bridge starts after the routes registered their templates > McpServerConfiguration configuration = new McpServerConfiguration(); > configuration.setTags("crm,ops"); > context.addService(new McpServerBridge(configuration), true, true); > System.out.println("WINNER=" + String.join(",", engine.resourceTemplates().keySet())); > } > System.exit(0); > } > } > } > ``` > > Pr29722MainControlTest.java — control on the merge base [fe2f3ce](https://github.com/apache/camel/commit/fe2f3ce5dfbe9cee5379f5fc9626f10353472a94) (`camel-mcp-server`) > ```java > package org.apache.camel.component.mcp.server.vertx; > > import java.time.Duration; > import java.util.List; > import java.util.concurrent.CopyOnWriteArrayList; > > import io.modelcontextprotocol.client.McpClient; > import io.modelcontextprotocol.client.McpSyncClient; > import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; > import io.modelcontextprotocol.spec.McpSchema; > import org.apache.camel.CamelContext; > import org.apache.camel.builder.RouteBuilder; > import org.apache.camel.component.mcp.server.McpServerBridge; > import org.apache.camel.component.mcp.server.McpServerConfiguration; > import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServer; > import org.apache.camel.component.platform.http.vertx.VertxPlatformHttpServerConfiguration; > import org.apache.camel.test.AvailablePortFinder; > import org.apache.camel.test.junit6.CamelTestSupport; > import org.apache.logging.log4j.LogManager; > import org.apache.logging.log4j.core.LogEvent; > import org.apache.logging.log4j.core.LoggerContext; > import org.apache.logging.log4j.core.appender.AbstractAppender; > import org.apache.logging.log4j.core.config.Property; > import org.junit.jupiter.api.AfterEach; > import org.junit.jupiter.api.Test; > > import static org.assertj.core.api.Assertions.assertThat; > import static org.assertj.core.api.Assertions.assertThatThrownBy; > > /** > * Main/merge-base control for the PR #29722 reproducers (no uriTemplate support here). > */ > class Pr29722MainControlTest extends CamelTestSupport { > > private static final String TAG = "repro"; > private static final String FORGED = "2026-11-12 12:00:00,000 ERROR [main] o.a.c.Security - FORGED admin login ok"; > > private final int port = AvailablePortFinder.getNextAvailable(); > private final CapturingAppender appender = new CapturingAppender(); > private McpSyncClient client; > > @Override > protected CamelContext createCamelContext() throws Exception { > CamelContext camelContext = super.createCamelContext(); > VertxPlatformHttpServerConfiguration http = new VertxPlatformHttpServerConfiguration(); > http.setBindPort(port); > camelContext.addService(new VertxPlatformHttpServer(http)); > McpServerConfiguration configuration = new McpServerConfiguration(); > configuration.setTags(TAG); > camelContext.addService(new McpServerBridge(configuration)); > return camelContext; > } > > @Override > protected RouteBuilder createRouteBuilder() { > return new RouteBuilder() { > @Override > public void configure() { > from("ai-resource:failing?resourceUri=camel:///failing/1&tags=" + TAG) > .process(e -> { > throw new IllegalStateException("lookup failed"); > }); > from("ai-resource:account_typo?resourceUri=camel:///accounts/{id}&tags=" + TAG) > .setBody(constant("fixed-route")); > } > }; > } > > private McpSyncClient client() { > if (client == null) { > client = McpClient.sync(HttpClientStreamableHttpTransport.builder("http://localhost:" + port).build()) > .requestTimeout(Duration.ofSeconds(10)) > .initializationTimeout(Duration.ofSeconds(10)) > .build(); > client.initialize(); > } > return client; > } > > @AfterEach > void cleanup() { > if (client != null) { > client.closeGracefully(); > client = null; > } > appender.detach(); > } > > /** Finding 1 control: on main the client URI never reaches the read logs. */ > @Test > void finding1ControlClientUriIsNotLogged() { > appender.attach(); > String uri = "camel:///failing/1\n" + FORGED; > > Throwable t = null; > try { > client().readResource(new McpSchema.ReadResourceRequest(uri)); > } catch (Exception e) { > t = e; > } > System.out.println("[control1] read of forged uri -> " + (t == null ? "no error" : t.getMessage())); > List<String> forged = appender.lines.stream().filter(l -> l.contains("\n" + FORGED)) > // the SDK client runs in the same JVM; only server-side loggers count > .filter(l -> !l.contains("McpClientSession")).toList(); > System.out.println("[control1] forged log lines: " + forged.size()); > forged.forEach(l -> System.out.println("[control1] logged -> " + l.replace("\n", "<LF>"))); > assertThat(t).isNotNull(); > assertThat(forged).isEmpty(); > } > > /** Finding 4 control: is a braced resourceUri already a pattern on main? */ > @Test > void finding4ControlBracedResourceUriOnMain() { > McpSchema.ReadResourceResult result > = client().readResource(new McpSchema.ReadResourceRequest("camel:///accounts/7")); > String text = ((McpSchema.TextResourceContents) result.contents().get(0)).text(); > System.out.println("[control4] camel:///accounts/7 -> " + text); > assertThat(text).isEqualTo("fixed-route"); > } > > /** Findings 2/3 control: uriTemplate does not exist on main. */ > @Test > void finding23ControlUriTemplateUnknownOnMain() { > assertThatThrownBy(() -> context.getEndpoint("ai-resource:x?uriTemplate=camel:///orders/{id}")) > .hasMessageContaining("uriTemplate"); > } > > static final class CapturingAppender extends AbstractAppender { > final List<String> lines = new CopyOnWriteArrayList<>(); > > CapturingAppender() { > super("pr29722-capture", null, null, true, Property.EMPTY_ARRAY); > } > > @Override > public void append(LogEvent event) { > lines.add(event.getLevel() + " " + event.getLoggerName() + " " + event.getMessage().getFormattedMessage()); > } > > void attach() { > start(); > LoggerContext ctx = (LoggerContext) LogManager.getContext(false); > ctx.getConfiguration().getRootLogger().addAppender(this, null, null); > ctx.updateLoggers(); > } > > void detach() { > LoggerContext ctx = (LoggerContext) LogManager.getContext(false); > ctx.getConfiguration().getRootLogger().removeAppender(getName()); > ctx.updateLoggers(); > } > } > } > ``` > > _Claude Code on behalf of Croway_ Thanks for the detailed review and for providing the end-to-end reproducers. I've pushed the fixes in commit `e56bb697b79a`: 1. **URI validation and log injection:** Restricted template variables to RFC 3986 `pchar` segments (including valid `%HH` escapes) and explicitly reject `.` and `..` segments. Query strings, fragments, control characters and malformed percent escapes are rejected. Server-side logging in `AiResourceExecutor` and `McpServerBridge` now uses the stable resource name rather than the concrete client-supplied URI. The bridge also no longer includes the execution error message in its read-failure warning. 2. **Deterministic template selection:** The registry now returns a deterministically ordered snapshot, with tags sorted lexicographically. Overlapping templates across tagged pools are rejected during route registration, causing the conflicting route to fail startup rather than allowing an order-dependent winner. The default untagged pool remains separate from the tagged templates exposed by the MCP bridge. 3. **Case-insensitive variable names:** Duplicate variable names are now checked case-insensitively, preventing `{id}` and `{ID}` from overwriting each other in Camel headers. 4. **Braces in fixed resource URIs:** Route startup now rejects `{` and `}` in `resourceUri` with a message directing users to configure `uriTemplate` instead. I've added regression coverage for these cases, including route-startup validation and template lifecycle behaviour. I also reran the Maven verification across the three affected modules. The build completed successfully: 44 tests run, zero failures, zero errors, and one skipped test. Thanks again for identifying these issues. -- 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]
