This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch fix/CAMEL-24390 in repository https://gitbox.apache.org/repos/asf/camel.git
commit 0f07b447294dc9cffa1a4a5388e7cf8a4056b627 Author: Claus Ibsen <[email protected]> AuthorDate: Thu Aug 13 15:05:05 2026 +0200 CAMEL-24390: camel-mcp-server - support wildcard patterns in tag matching Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: Claus Ibsen <[email protected]> --- .../component/mcp/server/McpServerBridge.java | 29 ++++----- .../mcp/server/McpServerConfiguration.java | 5 +- .../mcp/server/McpServerBridgePrefixTagTest.java | 68 ++++++++++++++++++++++ .../mcp/server/McpServerBridgeWildcardTagTest.java | 65 +++++++++++++++++++++ .../camel-mcp-server/src/main/docs/mcp-server.adoc | 17 ++++-- .../main/HttpServerConfigurationProperties.java | 10 ++-- 6 files changed, 168 insertions(+), 26 deletions(-) diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java index 70caf40f3570..91abc3f11f1c 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerBridge.java @@ -16,11 +16,8 @@ */ package org.apache.camel.component.mcp.server; -import java.util.Arrays; import java.util.HashMap; -import java.util.LinkedHashSet; import java.util.Map; -import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; @@ -40,6 +37,7 @@ import org.apache.camel.component.ai.tool.AiToolRegistry; import org.apache.camel.component.ai.tool.AiToolRegistryListener; import org.apache.camel.component.ai.tool.AiToolResult; import org.apache.camel.component.ai.tool.AiToolSpec; +import org.apache.camel.support.PatternHelper; import org.apache.camel.support.ResolverHelper; import org.apache.camel.support.service.ServiceHelper; import org.apache.camel.support.service.ServiceSupport; @@ -52,9 +50,9 @@ import org.slf4j.LoggerFactory; * <p> * Security notes: * <ul> - * <li>Only tools whose tags intersect the configured {@code tags} are exposed. The untagged default pool is never - * exposed — external MCP clients are untrusted senders and crossing that trust boundary is an explicit per-tool - * opt-in.</li> + * <li>Only tools whose tags match the configured tag patterns are exposed. Tag patterns support exact match, wildcard + * prefix ({@code foo*}), and {@code *} to match all tags. The untagged default pool is never exposed — external MCP + * clients are untrusted senders and crossing that trust boundary is an explicit per-tool opt-in.</li> * <li>MCP has a flat tool namespace: a tool whose name collides with an already published tool is refused with an ERROR * log, never silently replaced.</li> * <li>Raw route exception messages never reach the engine: execution failures map to a generic error message and the @@ -78,7 +76,7 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware private CamelContext camelContext; private McpServerEngine engine; private AiToolRegistry registry; - private Set<String> selectedTags = Set.of(); + private String[] tagPatterns = new String[0]; private ExecutorService executor; public McpServerBridge(McpServerConfiguration configuration) { @@ -106,9 +104,9 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware @Override protected void doInit() throws Exception { if (configuration.getTags() != null) { - selectedTags = new LinkedHashSet<>(Arrays.asList(AiToolParameterHelper.splitTags(configuration.getTags()))); + tagPatterns = AiToolParameterHelper.splitTags(configuration.getTags()); } - if (selectedTags.isEmpty()) { + if (tagPatterns.length == 0) { LOG.warn("No MCP tags configured: no ai-tool routes will be exposed as MCP tools. " + "Set tags to opt-in the tools to expose."); } @@ -150,7 +148,7 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware // subscribe before snapshotting so no concurrent registration is missed; publishing is idempotent registry.addListener(listener); registry.getTools().forEach((tag, specs) -> { - if (selectedTags.contains(tag)) { + if (matchesTag(tag)) { specs.forEach(this::publish); } }); @@ -213,7 +211,7 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware } // the same spec may be registered under several selected tags; only remove when it is gone from all boolean stillSelected = registry.getTools().entrySet().stream() - .anyMatch(e -> selectedTags.contains(e.getKey()) && e.getValue().contains(spec)); + .anyMatch(e -> matchesTag(e.getKey()) && e.getValue().contains(spec)); if (!stillSelected) { published.remove(spec.getName()); engine.toolRemoved(spec.getName()); @@ -305,19 +303,22 @@ public class McpServerBridge extends ServiceSupport implements CamelContextAware } } + private boolean matchesTag(String tag) { + return tag != null && PatternHelper.matchPatterns(tag, tagPatterns); + } + private final class RegistryListener implements AiToolRegistryListener { @Override public void toolRegistered(String tag, AiToolSpec spec) { - // the untagged default pool (tag == null) is never exposed - if (tag != null && selectedTags.contains(tag) && isStartingOrStarted()) { + if (matchesTag(tag) && isStartingOrStarted()) { publish(spec); } } @Override public void toolDeregistered(String tag, AiToolSpec spec) { - if (tag != null && selectedTags.contains(tag) && isStartingOrStarted()) { + if (matchesTag(tag) && isStartingOrStarted()) { unpublish(spec); } } diff --git a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java index 376b4c37637a..e2f69c04d099 100644 --- a/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java +++ b/components/camel-ai/camel-mcp-server-api/src/main/java/org/apache/camel/component/mcp/server/McpServerConfiguration.java @@ -35,8 +35,9 @@ public class McpServerConfiguration { private long sessionIdleTtl = McpServerConstants.DEFAULT_SESSION_IDLE_TTL; /** - * Comma-separated list of ai-tool tags to expose as MCP tools. Only tools registered under one of these tags are - * published; the untagged default pool is never exposed. When not set, no tools are published. + * Comma-separated list of ai-tool tag patterns to expose as MCP tools. Patterns support exact match, wildcard + * prefix ({@code foo*}), and {@code *} to match all tags. Only tools registered under a matching tag are published; + * the untagged default pool is never exposed. When not set, no tools are published. */ public String getTags() { return tags; diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgePrefixTagTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgePrefixTagTest.java new file mode 100644 index 000000000000..9be6e551da47 --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgePrefixTagTest.java @@ -0,0 +1,68 @@ +/* + * 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.component.mcp.server; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerBridgePrefixTagTest extends CamelTestSupport { + + 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*,notify"); + camelContext.addService(new McpServerBridge(configuration)); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:tool_a?tags=crm&description=CRM tool") + .setBody(constant("a")); + + from("ai-tool:tool_b?tags=crm-sales&description=CRM sales tool") + .setBody(constant("b")); + + from("ai-tool:tool_c?tags=notify&description=Notify tool") + .setBody(constant("c")); + + from("ai-tool:tool_d?tags=billing&description=Billing tool") + .setBody(constant("d")); + + from("ai-tool:hidden?description=Untagged tool") + .setBody(constant("hidden")); + } + }; + } + + @Test + void testPrefixPatternMatchesTags() { + assertThat(engine.tools()) + .containsKeys("tool_a", "tool_b", "tool_c") + .doesNotContainKeys("tool_d", "hidden"); + } +} diff --git a/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeWildcardTagTest.java b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeWildcardTagTest.java new file mode 100644 index 000000000000..6b63dd36dc2a --- /dev/null +++ b/components/camel-ai/camel-mcp-server-api/src/test/java/org/apache/camel/component/mcp/server/McpServerBridgeWildcardTagTest.java @@ -0,0 +1,65 @@ +/* + * 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.component.mcp.server; + +import org.apache.camel.CamelContext; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.test.junit6.CamelTestSupport; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class McpServerBridgeWildcardTagTest extends CamelTestSupport { + + 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("*"); + camelContext.addService(new McpServerBridge(configuration)); + return camelContext; + } + + @Override + protected RouteBuilder createRouteBuilder() { + return new RouteBuilder() { + public void configure() { + from("ai-tool:tool_a?tags=crm&description=CRM tool") + .setBody(constant("a")); + + from("ai-tool:tool_b?tags=notify&description=Notify tool") + .setBody(constant("b")); + + from("ai-tool:tool_c?tags=billing&description=Billing tool") + .setBody(constant("c")); + + from("ai-tool:hidden?description=Untagged tool") + .setBody(constant("hidden")); + } + }; + } + + @Test + void testWildcardMatchesAllTaggedTools() { + assertThat(engine.tools()) + .containsKeys("tool_a", "tool_b", "tool_c") + .doesNotContainKey("hidden"); + } +} diff --git a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc index fdc8fdef6e3a..6b761dc26029 100644 --- a/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc +++ b/components/camel-ai/camel-mcp-server/src/main/docs/mcp-server.adoc @@ -120,6 +120,9 @@ camel.server.mcp-tags = crm,notify camel.server.mcp-server-name = my-integration-app ---- +Tag patterns support wildcards: use `*` to expose all tagged tools, or a +prefix pattern like `crm*` to expose all tags starting with `crm`: + On other runtimes, or when wiring programmatically, add the `McpServerBridge` service to the CamelContext instead: @@ -157,10 +160,11 @@ JBang (see the xref:main.adoc[camel-main] options) or on | `camel.server.mcp-enabled` | Whether to expose ai-tool routes as MCP tools over streamable HTTP. | `false` | bridge -| `camel.server.mcp-tags` | Comma-separated list of ai-tool tags to expose as - MCP tools. Only tools registered under one of these tags are published; the - untagged default pool is never exposed. When not set, no tools are - published. | | bridge +| `camel.server.mcp-tags` | Comma-separated list of ai-tool tag patterns to + expose as MCP tools. Patterns support exact match, wildcard prefix + (`foo*`), and `*` to match all tags. Only tools registered under a matching + tag are published; the untagged default pool is never exposed. When not + set, no tools are published. | | bridge | `camel.server.mcp-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 @@ -215,8 +219,9 @@ External MCP clients are *untrusted senders* under the xref:manual::security-model.adoc[Camel security model]. The module applies the following rules: -* *Explicit opt-in per tool*: only tools whose tags intersect the configured - `tags` are exposed. The untagged default pool is never exposed implicitly. +* *Explicit opt-in per tool*: only tools whose tags match the configured tag + patterns are exposed. The untagged default pool is never exposed, even when + using the `*` wildcard. * *Flat namespace protection*: a tool whose name collides with an already exposed tool is refused with an ERROR log — never silently replaced. * *Error sanitization*: route exceptions are mapped to a generic error diff --git a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java index 5285a09a12af..b319500f6e36 100644 --- a/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java +++ b/core/camel-main/src/main/java/org/apache/camel/main/HttpServerConfigurationProperties.java @@ -358,8 +358,9 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { } /** - * 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 ai-tool tag patterns to expose as MCP tools. Patterns support exact match, wildcard prefix + * ({@code foo*}), and {@code *} to match all tags. Only tools registered under a matching tag are exposed; the + * untagged default pool is never exposed. When not set, no tools are exposed. */ public void setMcpTags(String mcpTags) { this.mcpTags = mcpTags; @@ -613,8 +614,9 @@ public class HttpServerConfigurationProperties implements BootstrapCloseable { } /** - * 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 ai-tool tag patterns to expose as MCP tools. Patterns support exact match, wildcard prefix + * ({@code foo*}), and {@code *} to match all tags. Only tools registered under a matching tag are exposed; the + * untagged default pool is never exposed. When not set, no tools are exposed. */ public HttpServerConfigurationProperties withMcpTags(String mcpTags) { this.mcpTags = mcpTags;
