This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch feature/CAMEL-24828-examples-mcp-tools in repository https://gitbox.apache.org/repos/asf/camel.git
commit b63d3f470542fabf3767fd70bf4a3cbbf0ee3745 Author: Claus Ibsen <[email protected]> AuthorDate: Fri Sep 18 22:48:03 2026 +0200 CAMEL-24828: the examples tools of camel-jbang-mcp list the examples by group camel_catalog_examples (and list_examples in the core AI tool registry) now take optional arguments, return the groups of the ladder with title and intro and the examples in reading order with order, what they teach, the infra services they need and whether they are bundled. The stale beginner/intermediate/advanced descriptions are replaced by the group names, and the cap of 20 results in the core registry is replaced by a limit argument (default 50). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Bp3538HRBPMQkb5ta9xRaj --- .../dsl/jbang/core/commands/ai/ToolRegistry.java | 68 +++++++++++++++------ .../camel/dsl/jbang/core/common/ExampleHelper.java | 28 ++++++--- .../jbang/core/commands/ai/ToolRegistryTest.java | 25 ++++++++ .../dsl/jbang/core/commands/mcp/ExampleTools.java | 67 ++++++++++++++------- .../jbang/core/commands/mcp/ExampleToolsTest.java | 70 ++++++++++++++++++++++ 5 files changed, 209 insertions(+), 49 deletions(-) diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java index ca4b617cc884..907b64c77299 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistry.java @@ -942,39 +942,71 @@ public final class ToolRegistry { private static void registerExampleTools() { register(tool("list_examples", - "List available Camel CLI examples. Returns name, title, description, difficulty level, and tags.") + "List the Camel CLI examples, grouped as the ladder of the examples (quick-start, run, transform, " + + "route, fail-well, connect, connect-service, contracts, ai, cloud, showcase). " + + "Returns the groups (level, title, intro) and the examples in reading order with " + + "name, title, description, level, order, tags, what they teach, the infra " + + "services they need, whether they are bundled and their files. " + + "Call it without arguments for the whole ladder, with level for one group.") .param("filter", "string", "Filter by name, description, or tag (case-insensitive)", false) .param("level", "string", - "Filter by difficulty: beginner, intermediate, or advanced", false) + "Only the examples of one group: quick-start, run, transform, route, fail-well, connect, " + + "connect-service, contracts, ai, cloud or showcase", + false) + .param("limit", "integer", "Maximum number of examples to return (default: 50)", false) .executor((ctx, args) -> { String filter = args.get("filter"); String level = args.get("level"); + int limit = 50; + String limitArg = args.get("limit"); + if (limitArg != null && !limitArg.isBlank()) { + try { + limit = Integer.parseInt(limitArg.trim()); + } catch (NumberFormatException e) { + throw new ToolExecutionException("limit must be a number: " + limitArg); + } + } List<JsonObject> catalog2 = ExampleHelper.loadCatalog(); List<JsonObject> filtered = ExampleHelper.filterExamples(catalog2, filter); + List<JsonObject> groups = new ArrayList<>(); List<JsonObject> results = new ArrayList<>(); - for (JsonObject entry : filtered) { - if (level != null && !level.isBlank()) { - String entryLevel = entry.getString("level"); - if (entryLevel == null || !entryLevel.equalsIgnoreCase(level)) { + int total = 0; + for (Map.Entry<String, List<JsonObject>> group : ExampleHelper.groupByLevel(filtered).entrySet()) { + if (level != null && !level.isBlank() && !group.getKey().equalsIgnoreCase(level)) { + continue; + } + total += group.getValue().size(); + JsonObject g = new JsonObject(); + g.put("level", group.getKey()); + g.put("title", ExampleHelper.getGroupTitle(group.getKey())); + g.put("intro", ExampleHelper.getGroupIntro(group.getKey())); + g.put("count", group.getValue().size()); + groups.add(g); + for (JsonObject entry : group.getValue()) { + if (results.size() >= limit) { continue; } - } - JsonObject jo = new JsonObject(); - jo.put("name", entry.getString("name")); - jo.put("title", entry.getString("title")); - jo.put("description", entry.getString("description")); - jo.put("level", entry.getString("level")); - jo.put("tags", entry.get("tags")); - jo.put("bundled", ExampleHelper.isBundled(entry)); - jo.put("files", ExampleHelper.getFiles(entry)); - results.add(jo); - if (results.size() >= 20) { - break; + JsonObject jo = new JsonObject(); + jo.put("name", entry.getString("name")); + jo.put("title", entry.getString("title")); + jo.put("description", entry.getString("description")); + jo.put("level", entry.getString("level")); + if (ExampleHelper.getOrder(entry) != Integer.MAX_VALUE) { + jo.put("order", ExampleHelper.getOrder(entry)); + } + jo.put("tags", entry.get("tags")); + jo.put("teaches", ExampleHelper.getTeaches(entry)); + jo.put("infraServices", ExampleHelper.getInfraServices(entry)); + jo.put("bundled", ExampleHelper.isBundled(entry)); + jo.put("files", ExampleHelper.getFiles(entry)); + results.add(jo); } } JsonObject response = new JsonObject(); response.put("count", results.size()); + response.put("total", total); + response.put("groups", groups); response.put("examples", results); return response.toJson(); })); diff --git a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java index bfdc7cb4aafc..febdca5fd094 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java +++ b/dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/common/ExampleHelper.java @@ -255,14 +255,15 @@ public final class ExampleHelper { } /** - * What the example teaches, as one line: the components and the EIPs from its metadata, or an empty string. + * What the example teaches from its metadata: the components, EIPs, languages and data formats, each as a list of + * names in that order; keys without names are left out, so an example without metadata gives an empty map. */ - public static String getTeachesSummary(JsonObject entry) { + public static Map<String, List<String>> getTeaches(JsonObject entry) { + Map<String, List<String>> answer = new LinkedHashMap<>(); JsonObject teaches = entry.getMap("teaches"); if (teaches == null || teaches.isEmpty()) { - return ""; + return answer; } - StringBuilder sb = new StringBuilder(); for (String key : new String[] { "components", "eips", "languages", "dataformats" }) { // the catalog holds string arrays here; anything else in a hand-edited metadata file is skipped if (!(teaches.get(key) instanceof Collection<?> values) || values.isEmpty()) { @@ -275,11 +276,22 @@ public final class ExampleHelper { } } if (!names.isEmpty()) { - if (sb.length() > 0) { - sb.append(" · "); - } - sb.append(key).append(": ").append(String.join(", ", names)); + answer.put(key, names); + } + } + return answer; + } + + /** + * What the example teaches, as one line: the components and the EIPs from its metadata, or an empty string. + */ + public static String getTeachesSummary(JsonObject entry) { + StringBuilder sb = new StringBuilder(); + for (Map.Entry<String, List<String>> e : getTeaches(entry).entrySet()) { + if (sb.length() > 0) { + sb.append(" · "); } + sb.append(e.getKey()).append(": ").append(String.join(", ", e.getValue())); } return sb.toString(); } diff --git a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java index 514e2d77c0a9..e3aaf4c88b4e 100644 --- a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java +++ b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ToolRegistryTest.java @@ -88,6 +88,31 @@ class ToolRegistryTest { assertTrue(json.contains("split"), "Should find split EIP"); } + @Test + void listExamplesGroupsTheLadderWithoutArguments() { + ToolContext ctx = new ToolContext(); + String json = ToolRegistry.execute("list_examples", ctx, Map.of()).toString(); + assertTrue(json.contains("\"groups\""), "Should return the groups"); + assertTrue(json.contains("\"level\":\"quick-start\""), "Should start with the quick-start group"); + assertTrue(json.contains("timer-log"), "Should list the examples"); + assertTrue(json.contains("\"teaches\""), "Should tell what the examples teach"); + // more than the old cap of 20 examples + assertTrue(json.indexOf("\"total\":") > 0); + String total = json.replaceAll(".*\"total\":(\\d+).*", "$1"); + assertTrue(Integer.parseInt(total) > 20, "Should count all examples, got " + total); + } + + @Test + void listExamplesFiltersOneGroupAndLimits() { + ToolContext ctx = new ToolContext(); + String json = ToolRegistry.execute("list_examples", ctx, Map.of("level", "run", "limit", "1")).toString(); + assertTrue(json.contains("\"count\":1"), "Should honour the limit: " + json); + assertTrue(json.contains("\"level\":\"run\"")); + assertFalse(json.contains("\"level\":\"quick-start\""), "Should only return the run group"); + assertThrows(ToolExecutionException.class, + () -> ToolRegistry.execute("list_examples", ctx, Map.of("limit", "many"))); + } + @Test void runtimeToolThrowsWithoutProcess() { ToolContext ctx = new ToolContext(); diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleTools.java b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleTools.java index c2c2d3d5c6e6..7811acb5a0df 100644 --- a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleTools.java +++ b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleTools.java @@ -20,6 +20,7 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Map; import jakarta.enterprise.context.ApplicationScoped; @@ -38,38 +39,51 @@ import org.apache.camel.util.json.JsonObject; public class ExampleTools { @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint = false, openWorldHint = false), - description = "List available Camel CLI examples. " - + "Returns name, title, description, difficulty level, and tags. " - + "Use filter to search by name, description, or tag. " - + "Use level to filter by difficulty (beginner, intermediate, advanced).") + description = "List the Camel CLI examples, grouped as the ladder of the examples: " + + "quick-start, run, transform, route, fail-well, connect, connect-service, contracts, ai, " + + "cloud and showcase, in that reading order. Returns the groups (level, title, introduction) " + + "and the examples in reading order with name, title, description, level, order, tags, " + + "what they teach (components, EIPs, languages, data formats), the infra services they need " + + "(start them with camel infra run), whether they are bundled and their files. " + + "Call it without arguments for the whole ladder, with level for one group, " + + "or with filter to search by name, description or tag. " + + "Use camel_catalog_example_file to read a file of an example.") public ExampleListResult camel_catalog_examples( - @ToolArg(description = "Filter examples by name, description, or tag (case-insensitive substring match)") String filter, - @ToolArg(description = "Filter by difficulty level: beginner, intermediate, or advanced") String level, - @ToolArg(description = "Maximum number of results to return (default: 50)") Integer limit) { + @ToolArg(description = "Filter examples by name, description, or tag (case-insensitive substring match)", + required = false) String filter, + @ToolArg(description = "Only the examples of one group (level): quick-start, run, transform, route, " + + "fail-well, connect, connect-service, contracts, ai, cloud or showcase", + required = false) String level, + @ToolArg(description = "Maximum number of examples to return (default: 50)", + required = false) Integer limit) { - int maxResults = limit != null ? limit : 50; + int maxResults = limit != null && limit > 0 ? limit : 50; try { List<JsonObject> catalog = ExampleHelper.loadCatalog(); List<JsonObject> filtered = ExampleHelper.filterExamples(catalog, filter); + List<GroupInfo> groups = new ArrayList<>(); List<ExampleInfo> result = new ArrayList<>(); - for (JsonObject entry : filtered) { - if (level != null && !level.isBlank()) { - String entryLevel = entry.getString("level"); - if (entryLevel == null || !entryLevel.equalsIgnoreCase(level)) { - continue; - } + int total = 0; + for (Map.Entry<String, List<JsonObject>> group : ExampleHelper.groupByLevel(filtered).entrySet()) { + if (level != null && !level.isBlank() && !group.getKey().equalsIgnoreCase(level)) { + continue; } - - result.add(toExampleInfo(entry)); - - if (result.size() >= maxResults) { - break; + total += group.getValue().size(); + groups.add(new GroupInfo( + group.getKey(), + ExampleHelper.getGroupTitle(group.getKey()), + ExampleHelper.getGroupIntro(group.getKey()), + group.getValue().size())); + for (JsonObject entry : group.getValue()) { + if (result.size() < maxResults) { + result.add(toExampleInfo(entry)); + } } } - return new ExampleListResult(result.size(), result); + return new ExampleListResult(result.size(), total, groups, result); } catch (Throwable e) { throw new ToolCallException( "Failed to list examples (" + e.getClass().getName() + "): " + e.getMessage(), null); @@ -136,7 +150,10 @@ public class ExampleTools { entry.getString("title"), entry.getString("description"), entry.getString("level"), + ExampleHelper.getOrder(entry) == Integer.MAX_VALUE ? null : ExampleHelper.getOrder(entry), tags != null ? new ArrayList<>(tags) : List.of(), + ExampleHelper.getTeaches(entry), + ExampleHelper.getInfraServices(entry), ExampleHelper.isBundled(entry), ExampleHelper.requiresDocker(entry), ExampleHelper.getFiles(entry)); @@ -144,11 +161,15 @@ public class ExampleTools { // Result records - public record ExampleListResult(int count, List<ExampleInfo> examples) { + public record ExampleListResult(int count, int total, List<GroupInfo> groups, List<ExampleInfo> examples) { + } + + public record GroupInfo(String level, String title, String intro, int count) { } - public record ExampleInfo(String name, String title, String description, String level, - List<String> tags, boolean bundled, boolean requiresDocker, List<String> files) { + public record ExampleInfo(String name, String title, String description, String level, Integer order, + List<String> tags, Map<String, List<String>> teaches, List<String> infraServices, + boolean bundled, boolean requiresDocker, List<String> files) { } public record ExampleFileResult(String example, String file, String content, String githubUrl) { diff --git a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleToolsTest.java b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleToolsTest.java new file mode 100644 index 000000000000..9bb61e1d7e6f --- /dev/null +++ b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ExampleToolsTest.java @@ -0,0 +1,70 @@ +/* + * 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.dsl.jbang.core.commands.mcp; + +import java.util.List; + +import org.apache.camel.dsl.jbang.core.common.ExampleHelper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExampleToolsTest { + + @Test + void listsTheWholeLadderInReadingOrderWithoutArguments() { + ExampleTools.ExampleListResult all = new ExampleTools().camel_catalog_examples(null, null, null); + + assertThat(all.count()).isEqualTo(all.total()); + assertThat(all.groups()).isNotEmpty(); + List<String> levels = all.groups().stream().map(ExampleTools.GroupInfo::level).toList(); + assertThat(levels).isSubsetOf(ExampleHelper.getGroupOrder()); + assertThat(levels).isSortedAccordingTo((a, b) -> Integer.compare( + ExampleHelper.getGroupOrder().indexOf(a), ExampleHelper.getGroupOrder().indexOf(b))); + assertThat(levels.get(0)).isEqualTo("quick-start"); + ExampleTools.GroupInfo first = all.groups().get(0); + assertThat(first.title()).isEqualTo(ExampleHelper.getGroupTitle("quick-start")); + assertThat(first.intro()).isNotBlank(); + + // the examples follow the groups in order + List<String> exampleLevels = all.examples().stream().map(ExampleTools.ExampleInfo::level).distinct().toList(); + assertThat(exampleLevels).isEqualTo(levels); + assertThat(all.examples().stream().map(ExampleTools.ExampleInfo::name)).contains("quick-start/timer-log"); + } + + @Test + void filtersOneGroupAndReturnsWhatTheExamplesTeach() { + ExampleTools.ExampleListResult run = new ExampleTools().camel_catalog_examples(null, "run", null); + + assertThat(run.groups()).hasSize(1); + assertThat(run.groups().get(0).level()).isEqualTo("run"); + assertThat(run.groups().get(0).count()).isEqualTo(run.total()); + assertThat(run.examples()).allMatch(e -> "run".equals(e.level())); + assertThat(run.examples()).isSortedAccordingTo((a, b) -> Integer.compare(a.order(), b.order())); + assertThat(run.examples()).anyMatch(e -> !e.teaches().isEmpty()); + } + + @Test + void searchesByNameAndHonoursTheLimit() { + ExampleTools.ExampleListResult kafka = new ExampleTools().camel_catalog_examples("kafka", null, 1); + + assertThat(kafka.total()).isGreaterThanOrEqualTo(1); + assertThat(kafka.count()).isEqualTo(1); + assertThat(kafka.examples().get(0).name()).contains("kafka"); + assertThat(kafka.examples().get(0).infraServices()).contains("kafka"); + } +}
