This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new d4bfc1191ce6 CAMEL-25535: camel-jbang-mcp - camel_catalog_example_file
reads a bundled example by its short name (#27817)
d4bfc1191ce6 is described below
commit d4bfc1191ce6839f60e4b25802a3dfc1168d41e9
Author: Federico Mariani <[email protected]>
AuthorDate: Wed Oct 14 21:19:47 2026 +0200
CAMEL-25535: camel-jbang-mcp - camel_catalog_example_file reads a bundled
example by its short name (#27817)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../camel/catalog/docs/throwException-eip.adoc | 8 +-
.../camel/catalog/models/throwException.json | 2 +-
.../camel/catalog/schemas/camelYamlDsl-model.json | 4 +-
.../modules/eips/examples/json/throwException.json | 1 +
.../src/main/docs/modules/eips/nav.adoc | 2 +-
.../modules/eips/pages/throwException-eip.adoc | 8 +-
.../org/apache/camel/model/throwException.json | 2 +-
.../camel/model/ThrowExceptionDefinition.java | 2 +-
.../modules/ROOT/pages/camel-jbang-mcp.adoc | 17 ++-
.../dsl/jbang/core/commands/ai/ToolRegistry.java | 138 +++++++++++++------
.../camel/dsl/jbang/core/common/ExampleHelper.java | 31 +++++
.../core/commands/ai/ExampleToolsRegistryTest.java | 153 +++++++++++++++++++++
.../dsl/jbang/core/commands/mcp/ExampleTools.java | 143 +++----------------
.../jbang/core/commands/mcp/ExampleToolsTest.java | 84 +++++++----
.../resources/schema/camelYamlDsl-model.json | 4 +-
15 files changed, 384 insertions(+), 215 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/throwException-eip.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/throwException-eip.adoc
index 637c1ab60227..cece8b1ba4a9 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/throwException-eip.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/throwException-eip.adoc
@@ -1,7 +1,7 @@
-= Stop EIP
-:doctitle: Stop
-:shortname: stop
-:description: Stops the processing of the current message
+= Throw Exception EIP
+:doctitle: Throw Exception
+:shortname: throwException
+:description: Throws an exception during route processing, either creating a
new exception instance or re-throwing an existing one
:since:
:supportlevel: Stable
:tabs-sync-option:
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/throwException.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/throwException.json
index 8203c2384a2d..7ffb7e35602e 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/throwException.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/models/throwException.json
@@ -5,7 +5,7 @@
"title": "Throw Exception",
"description": "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
"deprecated": false,
- "label": "error,errorhandling",
+ "label": "eip,error,errorhandling",
"javaType": "org.apache.camel.model.ThrowExceptionDefinition",
"abstract": false,
"input": true,
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
index 8bee214ea736..60af524015b7 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/schemas/camelYamlDsl-model.json
@@ -532,7 +532,7 @@
"ref" : "throwException",
"title" : "Throw Exception",
"description" : "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
- "label" : "error,errorhandling"
+ "label" : "eip,error,errorhandling"
}, {
"name" : "to",
"type" : "object",
@@ -8944,7 +8944,7 @@
"throwException" : {
"title" : "Throw Exception",
"description" : "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
- "label" : "error,errorhandling",
+ "label" : "eip,error,errorhandling",
"input" : true,
"isListItem" : true,
"children" : [ {
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/examples/json/throwException.json
b/core/camel-core-engine/src/main/docs/modules/eips/examples/json/throwException.json
new file mode 120000
index 000000000000..cdff8d7ccba6
--- /dev/null
+++
b/core/camel-core-engine/src/main/docs/modules/eips/examples/json/throwException.json
@@ -0,0 +1 @@
+../../../../../../../../camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/throwException.json
\ No newline at end of file
diff --git a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
index 2fc8aa9654e3..b1b3e04fa256 100644
--- a/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
+++ b/core/camel-core-engine/src/main/docs/modules/eips/nav.adoc
@@ -103,11 +103,11 @@
** xref:step-eip.adoc[Step]
** xref:stickyLoadBalancer-eip.adoc[Sticky Load Balancer]
** xref:stop-eip.adoc[Stop]
-** xref:throwException-eip.adoc[Stop]
** xref:streamConfig-eip.adoc[StreamConfig]
** xref:switch-eip.adoc[Switch]
** xref:threads-eip.adoc[Threads]
** xref:throttle-eip.adoc[Throttle]
+** xref:throwException-eip.adoc[Throw Exception]
** xref:to-eip.adoc[To]
** xref:toD-eip.adoc[To D]
** xref:topicLoadBalancer-eip.adoc[Topic Load Balancer]
diff --git
a/core/camel-core-engine/src/main/docs/modules/eips/pages/throwException-eip.adoc
b/core/camel-core-engine/src/main/docs/modules/eips/pages/throwException-eip.adoc
index 637c1ab60227..cece8b1ba4a9 100644
---
a/core/camel-core-engine/src/main/docs/modules/eips/pages/throwException-eip.adoc
+++
b/core/camel-core-engine/src/main/docs/modules/eips/pages/throwException-eip.adoc
@@ -1,7 +1,7 @@
-= Stop EIP
-:doctitle: Stop
-:shortname: stop
-:description: Stops the processing of the current message
+= Throw Exception EIP
+:doctitle: Throw Exception
+:shortname: throwException
+:description: Throws an exception during route processing, either creating a
new exception instance or re-throwing an existing one
:since:
:supportlevel: Stable
:tabs-sync-option:
diff --git
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/throwException.json
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/throwException.json
index 8203c2384a2d..7ffb7e35602e 100644
---
a/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/throwException.json
+++
b/core/camel-core-model/src/generated/resources/META-INF/org/apache/camel/model/throwException.json
@@ -5,7 +5,7 @@
"title": "Throw Exception",
"description": "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
"deprecated": false,
- "label": "error,errorhandling",
+ "label": "eip,error,errorhandling",
"javaType": "org.apache.camel.model.ThrowExceptionDefinition",
"abstract": false,
"input": true,
diff --git
a/core/camel-core-model/src/main/java/org/apache/camel/model/ThrowExceptionDefinition.java
b/core/camel-core-model/src/main/java/org/apache/camel/model/ThrowExceptionDefinition.java
index 48aee5f0cb97..90eac05839cc 100644
---
a/core/camel-core-model/src/main/java/org/apache/camel/model/ThrowExceptionDefinition.java
+++
b/core/camel-core-model/src/main/java/org/apache/camel/model/ThrowExceptionDefinition.java
@@ -28,7 +28,7 @@ import org.apache.camel.spi.annotations.DslArg;
/**
* Throws an exception
*/
-@Metadata(label = "error,errorhandling",
+@Metadata(label = "eip,error,errorhandling",
description = "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one")
@XmlRootElement(name = "throwException")
@XmlAccessorType(XmlAccessType.FIELD)
diff --git a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
index 889424d6b4f1..8e2b9bd1962d 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-jbang-mcp.adoc
@@ -202,7 +202,7 @@ Here are example prompts you can give your AI assistant.
The assistant automatic
EIP aliases such as `fan-out`, `dedup` and `rate-limit` match)
* _"Show me all AWS source kamelets"_ — uses `camel_catalog_kamelets`
* _"What options does aws-s3-source accept?"_ — uses
`camel_catalog_kamelet_doc`
-* _"Show me beginner REST examples"_ — uses `camel_catalog_examples`
+* _"Show me the quick-start examples"_ — uses `camel_catalog_examples`
=== Building routes
@@ -519,13 +519,18 @@ client to the next.
| `camel_catalog_examples`
| List available Camel CLI examples with filtering by name, description, or
tag (case-insensitive substring match).
- Supports filtering by difficulty level (`beginner`, `intermediate`,
`advanced`) and limiting the number of results.
- Returns name, title, description, level, tags, and file list for each
example.
+ The examples are grouped by level, the steps of the examples ladder in
reading order: `quick-start`, `run`,
+ `transform`, `route`, `fail-well`, `connect`, `connect-service`,
`contracts`, `ai`, `cloud` and `showcase`.
+ Supports filtering by one level and limiting the number of results; an
unknown level is answered with the levels
+ that exist. Returns the groups, and for each example its name, title,
description, level, tags, what it teaches,
+ the infra services it needs, whether it is bundled and its files.
| `camel_catalog_example_file`
-| Get the content of a specific file from a Camel CLI example. For bundled
examples, returns the file content
- directly. For non-bundled examples, returns a GitHub URL where the file can
be found. Use `camel_catalog_examples`
- first to discover example names and their files.
+| Get the content of a specific file from a Camel CLI example, named by its
full name such as
+ `quick-start/timer-log` or by its short name `timer-log`, for example the
file `timer-log.camel.yaml`. For bundled
+ examples, returns the file content directly. For non-bundled examples,
returns a GitHub URL where the file can be
+ found. The answer gives the full name of the example; an unknown name is
answered with the closest example names.
+ Use `camel_catalog_examples` first to discover example names and their files.
|===
=== Route Understanding
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 2b9549d6f1ce..66214b408afd 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
@@ -16,6 +16,7 @@
*/
package org.apache.camel.dsl.jbang.core.commands.ai;
+import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
@@ -1051,6 +1052,17 @@ public final class ToolRegistry {
// ---- Example tools ----
+ /** Argument descriptions of the example tools, shared with the MCP server
(CAMEL-25535). */
+ public static final String EXAMPLE_LEVEL_DESC
+ = "Only the examples of one group (level): quick-start, run,
transform, route, fail-well, connect, "
+ + "connect-service, contracts, ai, cloud or showcase";
+ public static final String EXAMPLE_NAME_DESC
+ = "Example name as listed, e.g. quick-start/timer-log or
fail-well/circuit-breaker (the short name without "
+ + "the group, such as timer-log, works too)";
+ public static final String EXAMPLE_FILE_DESC
+ = "File name within the example, as in its files, e.g.
timer-log.camel.yaml or application.properties "
+ + "for quick-start/timer-log";
+
private static void registerExampleTools() {
register(tool("list_examples",
"List the Camel CLI examples, grouped as the ladder of the
examples (quick-start, run, transform, "
@@ -1061,10 +1073,7 @@ public final class ToolRegistry {
+ "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",
- "Only the examples of one group: quick-start, run,
transform, route, fail-well, connect, "
- + "connect-service, contracts, ai,
cloud or showcase",
- false)
+ .param("level", "string", EXAMPLE_LEVEL_DESC, false)
.param("limit", "integer", "Maximum number of examples to
return (default: 50)", false)
.executor((ctx, args) -> {
String filter = args.get("filter");
@@ -1082,6 +1091,16 @@ public final class ToolRegistry {
}
}
List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
+ if (level != null && !level.isBlank()) {
+ List<String> levels = new
ArrayList<>(ExampleHelper.groupByLevel(catalog2).keySet());
+ String wanted = level.trim();
+ if
(levels.stream().noneMatch(wanted::equalsIgnoreCase)) {
+ // an old level such as beginner answered 0
examples otherwise (CAMEL-25535)
+ throw new ToolExecutionException(
+ "No example at level " + wanted + "; the
levels are: " + String.join(", ", levels));
+ }
+ level = wanted;
+ }
List<JsonObject> filtered =
ExampleHelper.filterExamples(catalog2, filter);
List<JsonObject> groups = new ArrayList<>();
List<JsonObject> results = new ArrayList<>();
@@ -1117,6 +1136,7 @@ public final class ToolRegistry {
jo.put("needs", needs);
}
jo.put("bundled", ExampleHelper.isBundled(entry));
+ jo.put("requiresDocker",
ExampleHelper.requiresDocker(entry));
jo.put("files", ExampleHelper.getFiles(entry));
results.add(jo);
}
@@ -1130,45 +1150,77 @@ public final class ToolRegistry {
}));
register(tool("get_example_file",
- "Get the content of a file from a bundled Camel CLI example.")
- .param("example", "string",
- "Example name (e.g., timer-log, rest-api,
circuit-breaker)", true)
- .param("file", "string",
- "File name within the example (e.g.,
route.camel.yaml)", true)
- .executor((ctx, args) -> {
- String example = args.get("example");
- String file = args.get("file");
- if (example == null || example.isBlank()) {
- throw new ToolExecutionException("example name is
required");
- }
- if (file == null || file.isBlank()) {
- throw new ToolExecutionException("file name is
required");
- }
- List<JsonObject> catalog2 = ExampleHelper.loadCatalog();
- JsonObject entry = ExampleHelper.findExample(catalog2,
example);
- if (entry == null) {
- return "Example not found: " + example;
- }
- List<String> files = ExampleHelper.getFiles(entry);
- if (!files.contains(file)) {
- return "File '" + file + "' not found in example '" +
example + "'. Available: " + files;
- }
- if (ExampleHelper.isBundled(entry)) {
- String resourcePath = "examples/" + example + "/" +
file;
- try (InputStream is
- =
ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) {
- if (is != null) {
- return IOHelper.loadText(is);
- }
- } catch (Exception e) {
- return "Error reading file: " + e.getMessage();
- }
- return "Could not read bundled file: " + resourcePath;
- } else {
- return "This example is not bundled. View it on
GitHub: "
- + ExampleHelper.getGithubUrl(entry) + "/" +
file;
- }
- }));
+ "Get the content of a file from a Camel CLI example: the
content of a bundled example, the GitHub "
+ + "URL of the file for the others.
Returns the full name of the example.")
+ .param("example", "string", EXAMPLE_NAME_DESC, true)
+ .param("file", "string", EXAMPLE_FILE_DESC, true)
+ .executor((ctx, args) -> exampleFile(args.get("example"),
args.get("file")).toJson()));
+ }
+
+ /**
+ * A file of an example: the catalog entry is found by its full or short
name and the bundled resource is read from
+ * the entry's name, so a short name reads the file as well (CAMEL-25535).
+ */
+ static JsonObject exampleFile(String example, String file) {
+ if (example == null || example.isBlank()) {
+ throw new ToolExecutionException("example name is required");
+ }
+ if (file == null || file.isBlank()) {
+ throw new ToolExecutionException("file name is required");
+ }
+ String wanted = example.trim();
+ String fileName = file.trim();
+ JsonObject entry = resolveExample(ExampleHelper.loadCatalog(), wanted);
+ String name = entry.getString("name");
+ List<String> files = ExampleHelper.getFiles(entry);
+ if (!files.contains(fileName)) {
+ throw new ToolExecutionException(
+ "File '" + fileName + "' not found in example '" + name +
"'. Available files: " + files);
+ }
+ JsonObject answer = new JsonObject();
+ answer.put("example", name);
+ answer.put("file", fileName);
+ if (ExampleHelper.isBundled(entry)) {
+ // the resource path comes from the catalog entry, not from the
name the caller gave
+ String resourcePath = "examples/" + name + "/" + fileName;
+ try (InputStream is =
ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) {
+ if (is == null) {
+ throw new ToolExecutionException("Could not read bundled
file: " + resourcePath);
+ }
+ answer.put("content", IOHelper.loadText(is));
+ } catch (IOException e) {
+ throw new ToolExecutionException("Could not read bundled file:
" + resourcePath + ": " + e.getMessage());
+ }
+ } else {
+ answer.put("githubUrl", ExampleHelper.getGithubUrl(entry) + "/" +
fileName);
+ }
+ return answer;
+ }
+
+ /**
+ * The catalog entry of an example by its full name, or by its short name
when only one example has it, as camel run
+ * resolves it; an ambiguous short name answers with the candidates, an
unknown one with the closest names.
+ */
+ static JsonObject resolveExample(List<JsonObject> catalog, String example)
{
+ if (!example.contains("/")) {
+ List<JsonObject> same =
ExampleHelper.findExamplesByShortName(catalog, example);
+ if (same.size() > 1) {
+ throw new ToolExecutionException(
+ "Ambiguous example: " + example + ". Use one of: "
+ + same.stream().map(e ->
e.getString("name"))
+
.collect(Collectors.joining(", ")));
+ }
+ }
+ JsonObject entry = ExampleHelper.findExample(catalog, example);
+ if (entry == null) {
+ List<String> similar = ExampleHelper.suggestExamples(catalog,
example);
+ throw new ToolExecutionException(
+ "Example not found: " + example
+ + (similar.isEmpty()
+ ? ". Use list_examples to
see the examples."
+ : ". Did you mean: " +
String.join(", ", similar) + "?"));
+ }
+ return entry;
}
// ---- Processor detail enrichment ----
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 357113f3d11c..97bdf84b2097 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
@@ -29,10 +29,13 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
+import java.util.Set;
+import org.apache.camel.main.util.SuggestSimilarHelper;
import org.apache.camel.util.FileUtil;
import org.apache.camel.util.IOHelper;
import org.apache.camel.util.json.JsonArray;
@@ -97,6 +100,34 @@ public final class ExampleHelper {
return matches;
}
+ /**
+ * The full names of the examples closest to a name that matches none
(CAMEL-25535): first the nearest by edit
+ * distance to a full or short name, then the ones whose name contains it
or whose short name it contains (so an old
+ * group such as beginner/timer-log points at quick-start/timer-log), at
most five.
+ */
+ public static List<String> suggestExamples(List<JsonObject> catalog,
String name) {
+ if (name == null || name.isBlank()) {
+ return List.of();
+ }
+ Set<String> result = new LinkedHashSet<>();
+ for (String similar :
SuggestSimilarHelper.didYouMean(getExampleNames(catalog), name)) {
+ JsonObject entry = findExample(catalog, similar);
+ if (entry != null) {
+ result.add(entry.getString("name"));
+ }
+ }
+ String key = name.trim().toLowerCase(Locale.ROOT);
+ for (JsonObject entry : catalog) {
+ String full = entry.getString("name");
+ String shortName = getShortName(entry).toLowerCase(Locale.ROOT);
+ if (full != null && (full.toLowerCase(Locale.ROOT).contains(key)
+ || shortName.length() >= 3 && key.contains(shortName))) {
+ result.add(full);
+ }
+ }
+ return result.stream().limit(5).toList();
+ }
+
public static List<String> getExampleNames(List<JsonObject> catalog) {
List<String> names = new ArrayList<>();
for (JsonObject entry : catalog) {
diff --git
a/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExampleToolsRegistryTest.java
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExampleToolsRegistryTest.java
new file mode 100644
index 000000000000..843caf7bab0b
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-core/src/test/java/org/apache/camel/dsl/jbang/core/commands/ai/ExampleToolsRegistryTest.java
@@ -0,0 +1,153 @@
+/*
+ * 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.ai;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.camel.util.IOHelper;
+import org.apache.camel.util.json.JsonArray;
+import org.apache.camel.util.json.JsonObject;
+import org.apache.camel.util.json.Jsoner;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * The example tools of the shared registry (list_examples, get_example_file)
as an AI agent calls them (CAMEL-25535).
+ */
+class ExampleToolsRegistryTest {
+
+ @Test
+ void readsOneFileOfEveryBundledExampleByItsShortName() throws Exception {
+ List<JsonObject> catalog = rawCatalog();
+ List<String> bundled = new ArrayList<>();
+ for (JsonObject entry : catalog) {
+ String name = entry.getString("name");
+ String shortName = name.substring(name.indexOf('/') + 1);
+ List<String> files = entry.getCollection("files");
+ assertThat(files).as("files of %s", name).isNotEmpty();
+ // a route file when there is one, the file an agent asks for first
+ String file = files.stream().filter(f ->
f.contains(".camel.")).findFirst().orElse(files.get(0));
+
+ JsonObject answer = exampleFile(shortName, file);
+
+ assertThat(answer.getString("example")).as("example of %s",
shortName).isEqualTo(name);
+ assertThat(answer.getString("file")).isEqualTo(file);
+ if (entry.getBooleanOrDefault("bundled", false)) {
+ bundled.add(name);
+ assertThat(answer.getString("content")).as("content of %s/%s",
name, file)
+ .isEqualTo(resource("examples/" + name + "/" + file));
+ assertThat(answer.getString("githubUrl")).isNull();
+ } else {
+ assertThat(answer.getString("content")).isNull();
+ assertThat(answer.getString("githubUrl")).as("githubUrl of
%s", name).endsWith("/" + file);
+ }
+ }
+ assertThat(bundled).as("bundled
examples").contains("quick-start/timer-log").hasSizeGreaterThan(10);
+ }
+
+ @Test
+ void anUnknownExampleListsTheClosestNames() {
+ // the group of 4.22 (beginner), a typo, and a name that is nowhere
near an example
+ assertThatThrownBy(() -> exampleFile("beginner/timer-log",
"timer-log.camel.yaml"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("Example not found: beginner/timer-log")
+ .hasMessageContaining("Did you mean: ")
+ .hasMessageContaining("quick-start/timer-log");
+ assertThatThrownBy(() -> exampleFile("timer-lgo",
"timer-log.camel.yaml"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("quick-start/timer-log");
+ assertThatThrownBy(() -> exampleFile("zzzzzzzzzzzz", "x.yaml"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("Example not found: zzzzzzzzzzzz")
+ .hasMessageContaining("list_examples")
+ .hasMessageNotContaining("Did you mean");
+ }
+
+ @Test
+ void anAmbiguousShortNameListsTheCandidates() {
+ List<JsonObject> catalog = List.of(new JsonObject(Map.of("name",
"route/x")),
+ new JsonObject(Map.of("name", "transform/x")));
+
+ assertThatThrownBy(() -> ToolRegistry.resolveExample(catalog, "x"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessage("Ambiguous example: x. Use one of: route/x,
transform/x");
+ assertThat(ToolRegistry.resolveExample(catalog,
"transform/x").getString("name")).isEqualTo("transform/x");
+ }
+
+ @Test
+ void anUnknownFileListsTheFilesOfTheExample() {
+ assertThatThrownBy(() -> exampleFile("timer-log", "route.camel.yaml"))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("File 'route.camel.yaml' not found in
example 'quick-start/timer-log'")
+ .hasMessageContaining("timer-log.camel.yaml");
+ }
+
+ @Test
+ void anUnknownLevelSaysWhichLevelsExist() {
+ assertThatThrownBy(() -> ToolRegistry.execute("list_examples", new
ToolContext(), Map.of("level", "beginner")))
+ .isInstanceOf(ToolExecutionException.class)
+ .hasMessageContaining("No example at level beginner")
+ .hasMessageContaining("quick-start")
+ .hasMessageContaining("showcase");
+
+ Object fine = ToolRegistry.execute("list_examples", new ToolContext(),
Map.of("level", "Quick-Start"));
+ assertThat(fine.toString()).contains("\"level\":\"quick-start\"");
+ }
+
+ @Test
+ void theArgumentDescriptionsNameAnExampleAndAFileThatExist() {
+ ToolDescriptor tool = ToolRegistry.findTool("get_example_file");
+ assertThat(tool).isNotNull();
+
assertThat(ToolRegistry.EXAMPLE_NAME_DESC).contains("quick-start/timer-log");
+
assertThat(ToolRegistry.EXAMPLE_FILE_DESC).contains("timer-log.camel.yaml");
+ JsonObject answer = exampleFile("quick-start/timer-log",
"timer-log.camel.yaml");
+
assertThat(answer.getString("example")).isEqualTo("quick-start/timer-log");
+ assertThat(answer.getString("content")).contains("uri: timer");
+ }
+
+ private static JsonObject exampleFile(String example, String file) {
+ Object result = ToolRegistry.execute("get_example_file", new
ToolContext(),
+ Map.of("example", example, "file", file));
+ try {
+ return (JsonObject) Jsoner.deserialize(result.toString());
+ } catch (Exception e) {
+ throw new AssertionError("not a JSON object: " + result, e);
+ }
+ }
+
+ private static List<JsonObject> rawCatalog() throws Exception {
+ JsonArray array = (JsonArray)
Jsoner.deserialize(resource("examples/camel-jbang-example-catalog.json"));
+ List<JsonObject> catalog = new ArrayList<>();
+ for (Object o : array) {
+ catalog.add((JsonObject) o);
+ }
+ assertThat(catalog).isNotEmpty();
+ return catalog;
+ }
+
+ private static String resource(String path) throws Exception {
+ try (InputStream is =
ExampleToolsRegistryTest.class.getClassLoader().getResourceAsStream(path)) {
+ assertThat(is).as("resource %s", path).isNotNull();
+ return IOHelper.loadText(is);
+ }
+ }
+}
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 d857ff0b2737..2b5c0b735456 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
@@ -16,23 +16,20 @@
*/
package org.apache.camel.dsl.jbang.core.commands.mcp;
-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;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
-import io.quarkiverse.mcp.server.ToolCallException;
-import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
-import org.apache.camel.util.IOHelper;
+import org.apache.camel.dsl.jbang.core.commands.ai.ToolRegistry;
import org.apache.camel.util.json.JsonObject;
+import static org.apache.camel.dsl.jbang.core.commands.mcp.AuthoringTools.args;
+import static org.apache.camel.dsl.jbang.core.commands.mcp.AuthoringTools.call;
+
/**
- * MCP Tools for browsing Camel CLI examples.
+ * MCP Tools for browsing Camel CLI examples: thin wrappers over the example
tools of the {@link ToolRegistry}
+ * (list_examples and get_example_file), so the MCP server, {@code camel tui}
and {@code camel ask} answer from one
+ * implementation (CAMEL-25535).
*/
@McpSecured
@ApplicationScoped
@@ -48,132 +45,24 @@ public class ExampleTools {
+ "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(
+ public JsonObject camel_catalog_examples(
@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 = ToolRegistry.EXAMPLE_LEVEL_DESC, required =
false) String level,
@ToolArg(description = "Maximum number of examples to return
(default: 50)",
required = false) Integer limit) {
-
- 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<>();
- 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();
- 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(), total, groups, result);
- } catch (Throwable e) {
- throw new ToolCallException(
- "Failed to list examples (" + e.getClass().getName() + "):
" + e.getMessage(), null);
- }
+ return call("list_examples", args("filter", filter, "level", level,
"limit", limit));
}
@Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
description = "Get the content of a specific file from a Camel CLI
example. "
+ "Use camel_catalog_examples first to find the
example name and its files. "
+ "Only bundled examples can return file contents
directly; "
- + "for non-bundled examples, a GitHub URL is returned
instead.")
- public ExampleFileResult camel_catalog_example_file(
- @ToolArg(description = "Example name (e.g., timer-log, rest-api,
circuit-breaker)") String example,
- @ToolArg(description = "File name within the example (e.g.,
route.camel.yaml, application.properties)") String file) {
-
- if (example == null || example.isBlank()) {
- throw new ToolCallException("Example name is required", null);
- }
- if (file == null || file.isBlank()) {
- throw new ToolCallException("File name is required", null);
- }
-
- try {
- List<JsonObject> catalog = ExampleHelper.loadCatalog();
- JsonObject entry = ExampleHelper.findExample(catalog, example);
- if (entry == null) {
- throw new ToolCallException("Example not found: " + example,
null);
- }
-
- List<String> files = ExampleHelper.getFiles(entry);
- if (!files.contains(file)) {
- throw new ToolCallException(
- "File '" + file + "' not found in example '" + example
- + "'. Available files: " + files,
- null);
- }
-
- if (ExampleHelper.isBundled(entry)) {
- String resourcePath = "examples/" + example + "/" + file;
- try (InputStream is =
ExampleHelper.class.getClassLoader().getResourceAsStream(resourcePath)) {
- if (is != null) {
- String content = IOHelper.loadText(is);
- return new ExampleFileResult(example, file, content,
null);
- }
- }
- throw new ToolCallException("Could not read bundled file: " +
resourcePath, null);
- } else {
- String githubUrl = ExampleHelper.getGithubUrl(entry) + "/" +
file;
- return new ExampleFileResult(example, file, null, githubUrl);
- }
- } catch (ToolCallException e) {
- throw e;
- } catch (Throwable e) {
- throw new ToolCallException(
- "Failed to read example file (" + e.getClass().getName() +
"): " + e.getMessage(), null);
- }
- }
-
- @SuppressWarnings("unchecked")
- private ExampleInfo toExampleInfo(JsonObject entry) {
- Collection<String> tags = (Collection<String>) entry.get("tags");
- return new ExampleInfo(
- entry.getString("name"),
- 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.getNeeds(entry),
- ExampleHelper.isBundled(entry),
- ExampleHelper.requiresDocker(entry),
- ExampleHelper.getFiles(entry));
- }
-
- // Result records
-
- 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, Integer order,
- List<String> tags, Map<String, List<String>> teaches, List<String>
infraServices,
- // what the user does before the run, beyond the infra services: a
model to pull, a key to set
- String needs, boolean bundled, boolean requiresDocker,
List<String> files) {
- }
-
- public record ExampleFileResult(String example, String file, String
content, String githubUrl) {
+ + "for non-bundled examples, a GitHub URL is returned
instead. "
+ + "The answer names the example by its full name
(group/name).")
+ public JsonObject camel_catalog_example_file(
+ @ToolArg(description = ToolRegistry.EXAMPLE_NAME_DESC) String
example,
+ @ToolArg(description = ToolRegistry.EXAMPLE_FILE_DESC) String
file) {
+ return call("get_example_file", args("example", example, "file",
file));
}
}
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
index 05a14ce4e299..dc3ab34208c7 100644
---
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
@@ -19,54 +19,92 @@ package org.apache.camel.dsl.jbang.core.commands.mcp;
import java.util.Comparator;
import java.util.List;
+import io.quarkiverse.mcp.server.ToolCallException;
import org.apache.camel.dsl.jbang.core.common.ExampleHelper;
+import org.apache.camel.util.json.JsonObject;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ExampleToolsTest {
@Test
void listsTheWholeLadderInReadingOrderWithoutArguments() {
- ExampleTools.ExampleListResult all = new
ExampleTools().camel_catalog_examples(null, null, null);
+ JsonObject 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(all.getInteger("count")).isEqualTo(all.getInteger("total"));
+ List<JsonObject> groups = objects(all, "groups");
+ assertThat(groups).isNotEmpty();
+ List<String> levels = groups.stream().map(g ->
g.getString("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();
+ JsonObject first = groups.get(0);
+
assertThat(first.getString("title")).isEqualTo(ExampleHelper.getGroupTitle("quick-start"));
+ assertThat(first.getString("intro")).isNotBlank();
// the examples follow the groups in order
- List<String> exampleLevels =
all.examples().stream().map(ExampleTools.ExampleInfo::level).distinct().toList();
+ List<JsonObject> examples = objects(all, "examples");
+ List<String> exampleLevels = examples.stream().map(e ->
e.getString("level")).distinct().toList();
assertThat(exampleLevels).isEqualTo(levels);
-
assertThat(all.examples().stream().map(ExampleTools.ExampleInfo::name)).contains("quick-start/timer-log");
+ assertThat(examples.stream().map(e ->
e.getString("name"))).contains("quick-start/timer-log");
+ assertThat(examples).allMatch(e -> e.get("requiresDocker") instanceof
Boolean);
}
@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(
- Comparator.comparingInt(e -> e.order() != null ? e.order() :
Integer.MAX_VALUE));
- assertThat(run.examples()).anyMatch(e -> !e.teaches().isEmpty());
+ JsonObject run = new ExampleTools().camel_catalog_examples(null,
"run", null);
+
+ List<JsonObject> groups = objects(run, "groups");
+ assertThat(groups).hasSize(1);
+ assertThat(groups.get(0).getString("level")).isEqualTo("run");
+
assertThat(groups.get(0).getInteger("count")).isEqualTo(run.getInteger("total"));
+ List<JsonObject> examples = objects(run, "examples");
+ assertThat(examples).allMatch(e -> "run".equals(e.getString("level")));
+ assertThat(examples).isSortedAccordingTo(
+ Comparator.comparingInt(e -> e.getInteger("order") != null ?
e.getInteger("order") : Integer.MAX_VALUE));
+ assertThat(examples).anyMatch(e -> !e.getMap("teaches").isEmpty());
}
@Test
void searchesByNameAndHonoursTheLimit() {
- ExampleTools.ExampleListResult kafka = new
ExampleTools().camel_catalog_examples("kafka", null, 1);
+ JsonObject kafka = new ExampleTools().camel_catalog_examples("kafka",
null, 1);
+
+ assertThat(kafka.getInteger("total")).isGreaterThanOrEqualTo(1);
+ assertThat(kafka.getInteger("count")).isEqualTo(1);
+ JsonObject first = objects(kafka, "examples").get(0);
+ assertThat(first.getString("name")).contains("kafka");
+ assertThat(first.<List<String>>
getCollection("infraServices")).contains("kafka");
+ }
+
+ @Test
+ void readsAFileOfABundledExampleByItsShortName() {
+ // CAMEL-25535: the short name of the argument description used to end
in "Could not read bundled file"
+ JsonObject answer = new
ExampleTools().camel_catalog_example_file("timer-log", "timer-log.camel.yaml");
+
+
assertThat(answer.getString("example")).isEqualTo("quick-start/timer-log");
+ assertThat(answer.getString("file")).isEqualTo("timer-log.camel.yaml");
+ assertThat(answer.getString("content")).contains("uri: timer");
+ assertThat(answer.getString("githubUrl")).isNull();
+
+ JsonObject full = new
ExampleTools().camel_catalog_example_file("quick-start/timer-log",
"timer-log.camel.yaml");
+
assertThat(full.getString("content")).isEqualTo(answer.getString("content"));
+ }
+
+ @Test
+ void errorsAreToolErrors() {
+ // the messages are tested on the registry tools; here only that they
reach the client as tool errors
+ ExampleTools tools = new ExampleTools();
+ assertThatThrownBy(() -> tools.camel_catalog_examples(null,
"beginner", null))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("No example at
level beginner");
+ assertThatThrownBy(() ->
tools.camel_catalog_example_file("beginner/timer-log", "timer-log.camel.yaml"))
+
.isInstanceOf(ToolCallException.class).hasMessageContaining("Example not
found");
+ }
- 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");
+ @SuppressWarnings("unchecked")
+ private static List<JsonObject> objects(JsonObject json, String key) {
+ return (List<JsonObject>) json.get(key);
}
}
diff --git
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
index 8bee214ea736..60af524015b7 100644
---
a/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
+++
b/dsl/camel-yaml-dsl/camel-yaml-dsl/src/generated/resources/schema/camelYamlDsl-model.json
@@ -532,7 +532,7 @@
"ref" : "throwException",
"title" : "Throw Exception",
"description" : "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
- "label" : "error,errorhandling"
+ "label" : "eip,error,errorhandling"
}, {
"name" : "to",
"type" : "object",
@@ -8944,7 +8944,7 @@
"throwException" : {
"title" : "Throw Exception",
"description" : "Throws an exception during route processing, either
creating a new exception instance or re-throwing an existing one",
- "label" : "error,errorhandling",
+ "label" : "eip,error,errorhandling",
"input" : true,
"isListItem" : true,
"children" : [ {