You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by "Croway (via GitHub)" <gi...@apache.org> on 2026/10/14 15:14:09 UTC
[PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Croway opened a new pull request, #27839:
URL: https://github.com/apache/camel/pull/27839
[CAMEL-25541](https://issues.apache.org/jira/browse/CAMEL-25541) (absorbs CAMEL-25540 and CAMEL-25542). In an evaluation of coding agents on Mule-to-Camel migrations, the component answer of `camel_catalog_doc` was 15-38 K characters by default (clients rejected or spilled it to a file), an `optionsFilter` that matched nothing came back as an empty list that agents read as "not supported", and the page sections that decide correctness (xslt header parameters, mail `useHeader*`, `camel.server.*` for platform-http under `camel run`) were never in the answer.
### Changes
**Bounded answer**
- Without `optionsFilter` a component lists the required options first, then the endpoint options, then the component options, 20 in full (new `limit` argument changes it) and the rest by name in `moreOptions`, with `truncated: true`, `listedOptions`, the total in `matchedOptions` and a hint. A filtered call still lists every match in full, name matches first.
- Every `camel_catalog_doc` answer is capped at 16 000 characters: page text (`includeDoc`, `docPage`) is cut at a line (keeping at least 8 000 characters) with a marker listing the sections left out; then the longest list is cut from its end (cut options move to `moreOptions`, other lists get a `truncatedNote`).
- Default answer sizes: activemq 39 923 -> 12 450, file 37 344 -> 15 396, smtp 24 477 -> 10 832, http 20 440 -> 11 020, kafka 94 763 -> 13 156, activemq `includeOptions=all` 99 700 -> 14 078 characters.
**When nothing matches**
- `note: "no option matches '<filter>'"` plus `closestOptions` (name, group, scope) by edit distance over option names and their camelCase words, using the catalog's `EditDistanceSuggestionStrategy` (CAMEL-24666).
- `prefixOptions`: the multi-value options with a prefix (`additionalJavaMailProperties` / `mail.` for smtp), always listed, also when the filter matched.
**Page sections**
- `sections`: the `==` to `====` titles of the component page, those whose own text contains the filter first (smtp + `tls` starts with "SSL support", xslt + `param` with "Getting Usable Parameters into the XSLT").
- `docPage=<section title>` returns that section as text (by title ignoring case and punctuation, or the words of the title from one on). Sub-pages (`docPage=main`, `custom`, `streaming`) keep working and win over sections. For a language, `docPage` also finds a section of the page or of its sub-pages, so the cut marker of `simple`'s 73 K functions page leads somewhere.
- smtp, imap and pop3 (and other alternative schemes) now get the documentation of their artifact's page (`mail-component.adoc`); before, they had no excerpt or sub-pages at all.
**Name resolution**
- A `-component`, `-dataformat`, `-language` or `-eip` suffix (the page names `camel_catalog_docs` returns) is stripped and sets the kind; component, data format, language and EIP names are also matched ignoring case (`WireTap`).
- An EIP is found by its name, an alias or its whole title only, no longer by one word of the title: `retry` returned `optimisticLockRetryPolicy`. `retry`, `retries`, `redelivery`, `redeliver`, `backoff` answer not-found with `errorHandler`, `onException`, `redeliveryPolicy` first in the suggestions and an `errorHandling` line showing how redelivery is written (chosen over adding aliases, as aliases come from the model annotations in camel-core).
- "Example not found" names were already fixed by CAMEL-25535.
**Full keys**
- `name=camel.server.port` (any full main key) answers the `camel.server` group with the key first (`matchedKey`); an unknown key in a known group (`camel.server.prot`) answers the group with an error and the closest keys. `camel.jbang.*` keys (and `jbang`) are answered from `catalog.jbangModel()` as `jbang-options`.
- `platform-http` carries a `mainConfig` line (`camel.server.enabled=true`, `camel.server.port`, `camel run --port`), and with a filter the matching `camel.server.*` keys as `mainOptions`.
- `platform-http-component.adoc`: the first section ("Platform HTTP Provider") now says how Camel Main / `camel run` enable the embedded server and links `xref:others:platform-http-main.adoc[Platform HTTP Main]`, so the excerpt carries it and `docPages` offers `docPage=main`. Catalog mirror regenerated.
**`camel_component_properties` removed**
- Its description said endpoint options "can be set as component-level defaults via the same prefix", which is false (`AbstractCamelCatalog.validateConfigurationProperty` checks `camel.component.<scheme>.*` against the component options only), so it emitted keys `camel run` and `camel_configuration_validate` reject. Each component option in the `camel_catalog_doc` answer now carries its key as `configKey`; endpoint options carry none. Upgrade guide 4.24 entry and the MCP tool table updated.
Not in this PR: tool renames/consolidation (CAMEL-25639), a curated per-component `notes` list (the MCP server instructions already carry the mail and xslt rules), `camel.component.<scheme>.<option>` as a lookup name.
### Tests
- New `CatalogDocComponentAnswerTest` (camel-jbang-core, AssertJ, 6 tests): activemq default answer under 16 000 characters with `truncated`, required option first and `moreOptions`, `includeOptions=all` still under the cap, `limit` lists more; smtp `subjct` note + closest option with group, smtp `tls` prefix option + "SSL support" first; xslt `param` note + section first, `docPage` section fetch, `docPage=main` sub-page, unknown section lists sections; suffix stripping, case, `retry` bare word, alias still resolves; `camel.server.port`, `camel.jbang.dependencies`, `camel.server.prot`, platform-http + `port` (mainConfig, mainOptions, excerpt); `configKey` only on the component option.
- `CatalogDocsTest`: the `includeOptions=all` assertion accepts an option named in `moreOptions`. MCP `AuthoringToolsTest`: calls updated for the new `limit` argument. `ComponentPropertiesToolsTest` removed with the tool.
- `mvnd -o test -pl dsl/camel-jbang/camel-jbang-core`: 1811 tests, 0 failures, 2 skipped (1 flake on retry: `JbangDevMcpServerTest.exposesToolRegistryOnManagementServer`, server init, unrelated).
- `mvnd -o test -pl dsl/camel-jbang/camel-jbang-core,dsl/camel-jbang/camel-jbang-mcp -Dtest='org.apache.camel.dsl.jbang.core.commands.mcp.**.*Test,CatalogDocComponentAnswerTest'`: camel-jbang-mcp 447 tests, 0 failures (all 40 test classes, including `McpSecuredToolClassesTest` and the descriptor/argument parity tests).
_Claude Code on behalf of Croway_
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "Croway (via GitHub)" <gi...@apache.org>.
Croway commented on PR #27839:
URL: https://github.com/apache/camel/pull/27839#issuecomment-6219360751
Review follow-ups are in faefe187b4bd, one commit on top of the two existing ones. The branch is rebased on main to resolve the conflict in the MCP `AuthoringTools` (two import lines), hence the force push; the review threads below are answered in place.
- Prompt budget (blocking): I measured again after the rebase and tightened the shared `camel_catalog_doc` descriptions. The `AiPanelPromptBudgetTest` budgets are raised with comments: core 5000 -> 5100 (~5041), core+groups 5750 -> 5900 (~5820), full 10500 -> 10600 (~10485), core+semantic 6100 -> 6250 (~6170).
- configKey: an option of both the endpoint and the component is listed once, with `scope: "endpoint,component"` and `configKey`. `matchedOptions` counts each name once. The kafka default answer now has `brokers` with its key.
- platform-http: the doc paragraph and `mainConfig` now separate `camel run` (the server starts on its own, port 8080, `camel.server.port`/`--port`) from Camel Main (`camel.server.enabled=true`). The catalog mirror is regenerated.
- Bot threads: section fences close only on their own delimiter (`////` included), `ignoringCase` is called once per lookup, and `capAnswer` has a last-resort text cut. The `optionsHint` now follows `listedOptions` when options are cut.
- Tests: camel-jbang-core `CatalogDoc*Test,ToolRegistryTest,AuthoringToolsTest` 100/0 failures, camel-jbang-mcp 461/0, TUI `AiPanelPromptBudgetTest` 6/0.
_Claude Code on behalf of Croway_
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4275139428
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
Review Comment:
Endpoint options come first, so with the limit of 20 the component option with the same name is past the limit. This check then removes its name from `moreOptions` too. For kafka, the default answer then has no `configKey` at all: 112 of its 116 common component options share a name with an endpoint option. Could an endpoint option that has a component option of the same name carry the `configKey` (or be listed once with both scopes)? Then the key is there without `optionsFilter`, and `matchedOptions` stops counting those options twice.
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java:
##########
@@ -103,11 +103,9 @@ static void register(Consumer<ToolDescriptor> registry) {
"common (default: no deprecated or advanced), required, all or false", false)
.param("includeHeaders", "boolean", "Include the message headers of a component (default false)", false)
.param("includeDoc", "boolean", "Include the full AsciiDoc page (default false)", false)
- .param("docPage", "string", "simple doc sub-page to return as text (functions, operators, ognl, advanced)",
- false)
- .param("optionsFilter", "string",
- "Keyword to match in option names or descriptions; for an EIP whose own options do not match, the options of its elements are searched (nestedOptions)",
- false)
+ .param("docPage", "string", CatalogDocs.DOC_PAGE_DESC, false)
+ .param("optionsFilter", "string", CatalogDocs.OPTIONS_FILTER_DESC, false)
+ .param("limit", "integer", CatalogDocs.OPTIONS_LIMIT_DESC, false)
Review Comment:
The TUI AI panel sends this schema too, because `camel_catalog_doc` is a core tool. The new `limit` argument and the longer `docPage` and `optionsFilter` descriptions, together with the full-key sentence on line 94, add about 400 characters (~100 tokens). That pushes all four `AiPanelPromptBudgetTest` budgets over their limits in `camel-jbang-plugin-tui`, which is why the Java 17 build fails. Please shorten these descriptions, or raise the budgets in that test with a comment that says why.
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
Review Comment:
Under `camel run` the server does not need `camel.server.enabled=true`. `DependencyDownloaderComponentResolver` starts it as soon as platform-http is used, and since CAMEL-25533 `MainHttpServerFactory.configureHttpServer` applies `camel.server.*` to it and ignores `enabled`. Only plain Camel Main needs `enabled=true`. Something like: "with camel run the HTTP server starts when a route uses platform-http (port 8080; camel.server.port or --port changes it); with Camel Main set camel.server.enabled=true".
##########
components/camel-platform-http/src/main/docs/platform-http-component.adoc:
##########
@@ -40,6 +40,11 @@ include::partial$component-endpoint-headers.adoc[]
To use Platform HTTP, a provider (engine) is required to be available on the classpath.
The purpose is to have drivers for different runtimes such as Quarkus, or Spring Boot.
+With Camel Main and `camel run`, the provider is the embedded HTTP server of `camel-platform-http-main`,
+which is disabled by default. Enable it and set its port in `application.properties` with
+`camel.server.enabled=true` and `camel.server.port=8080` (`camel run --port=8080` sets both).
+See xref:others:platform-http-main.adoc[Platform HTTP Main] for the other `camel.server.*` options.
Review Comment:
This is accurate for Camel Main, but not for `camel run`. There the embedded server starts by itself when a route uses platform-http, and the `camel.server.*` keys (port, host, ...) are applied to it (CAMEL-25533, #27667). Suggest: "With `camel run`, the embedded HTTP server of `camel-platform-http-main` starts when a route uses platform-http, on port 8080 (`camel.server.port` or `camel run --port` changes it). With Camel Main it is disabled by default: enable it with `camel.server.enabled=true` and set `camel.server.port`." Remember to regenerate the catalog mirror.
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "Croway (via GitHub)" <gi...@apache.org>.
Croway commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4296593780
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -267,14 +330,30 @@ public static JsonObject catalogDoc(
}
if (kind == null || "language".equals(kind)) {
LanguageModel lm = catalog.languageModel(name);
+ if (lm == null && ignoringCase(catalog.findLanguageNames(), name) != null) {
+ name = ignoringCase(catalog.findLanguageNames(), name);
Review Comment:
Fixed: the result is computed once into a local.
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -283,24 +362,32 @@ public static JsonObject catalogDoc(
// the start of the page unless the whole of it, or one of its sub-pages, was asked for
String excerpt = docPageOnly || includeDoc
? null : docExcerpt(catalog.asciiDoc(name + "-language"), DOC_EXCERPT_BUDGET_PROSE);
- return languageDoc(lm, lowerFilter, scope, doc, languageDocPages(catalog, name), docPageOnly, excerpt);
+ JsonObject answer
+ = languageDoc(lm, lowerFilter, scope, doc, languageDocPages(catalog, name), docPageOnly, excerpt);
+ if (sectionTitle != null) {
+ answer.put("section", sectionTitle);
+ }
+ return answer;
}
if (kind != null) {
return notFound("Language", name, catalog.suggestLanguageNames(name, 5));
}
}
if (kind == null || "eip".equals(kind)) {
EipModel em = catalog.eipModel(name);
+ if (em == null && ignoringCase(catalog.findModelNames(), name) != null) {
+ em = catalog.eipModel(ignoringCase(catalog.findModelNames(), name));
Review Comment:
Fixed: the result is computed once into a local.
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o : optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog, ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", " ").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4}) +(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page; empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
+ fenced = !fenced;
+ continue;
+ }
+ Matcher m = fenced ? null : HEADING.matcher(lines[i]);
+ if (m != null && m.matches()) {
+ starts.add(i);
+ levels.add(m.group(1).length());
+ titles.add(m.group(2).strip());
+ }
+ }
+ for (int h = 0; h < starts.size(); h++) {
+ int end = lines.length;
+ for (int n = h + 1; n < starts.size(); n++) {
+ if (levels.get(n) <= levels.get(h)) {
+ end = starts.get(n);
+ break;
+ }
+ }
+ int ownEnd = h + 1 < starts.size() ? Math.min(end, starts.get(h + 1)) : end;
+ String text = String.join("\n", Arrays.asList(lines).subList(starts.get(h), end)).strip();
+ String own = String.join("\n", Arrays.asList(lines).subList(starts.get(h), ownEnd)).strip();
+ answer.add(new DocSection(titles.get(h), levels.get(h), text, own));
+ }
+ return answer;
+ }
+
+ /**
+ * The titles of the sections, those whose own text contains the filter first (SSL support before the Usage section
+ * it is part of).
+ */
+ static JsonArray sectionTitles(List<DocSection> sections, String filter) {
+ JsonArray first = new JsonArray();
+ JsonArray rest = new JsonArray();
+ for (DocSection s : sections) {
+ if (filter != null && s.own().toLowerCase(Locale.ROOT).contains(filter)) {
+ first.add(s.title());
+ } else {
+ rest.add(s.title());
+ }
+ }
+ first.addAll(rest);
+ return first;
+ }
+
+ private static int sectionsContaining(List<DocSection> sections, String filter) {
+ return (int) sections.stream().filter(s -> s.own().toLowerCase(Locale.ROOT).contains(filter)).count();
+ }
+
+ /**
+ * The section a docPage names: by its title ignoring case and punctuation (also the AsciiDoc id, such as
+ * _ssl_support), else the first whose title contains it; null when none does.
+ */
+ static DocSection findSection(List<DocSection> sections, String page) {
+ String key = normalizeName(page);
+ if (key.isEmpty()) {
+ return null;
+ }
+ for (DocSection s : sections) {
+ if (key.equals(normalizeName(s.title()))) {
+ return s;
+ }
+ }
+ // else the words of the title from one of them on (usable parameters), not a run of letters across words
+ String words = " " + titleWords(page);
+ for (DocSection s : sections) {
+ if ((" " + titleWords(s.title())).contains(words)) {
+ return s;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Cuts an answer longer than {@link #MAX_ANSWER_LENGTH} characters, with a marker that says what was cut and how to
+ * ask for it: first the text of a page, at a line and down to {@link #MIN_DOC_KEPT} characters, with the titles of
+ * the sections left out; then the longest list, from its end (the options left out are named in moreOptions).
+ */
+ static JsonObject capAnswer(JsonObject answer) {
+ String doc = answer.getString("doc");
+ int excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess > 0 && doc != null && doc.length() > MIN_DOC_KEPT) {
+ // the text is escaped in the JSON, so cut by the share of the escaped length that has to go
+ int escaped = Jsoner.serialize(doc).length();
+ int keep = (int) ((long) doc.length() * Math.max(0, escaped - excess - 600) / escaped);
+ keep = Math.max(MIN_DOC_KEPT, keep);
+ int at = doc.lastIndexOf('\n', keep);
+ at = at > keep / 2 ? at : keep;
+ List<String> left = new ArrayList<>();
+ for (DocSection s : docSections(doc.substring(at))) {
+ if (left.size() < 30) {
+ left.add(s.title());
+ }
+ }
+ answer.put("doc", doc.substring(0, at) + "\n\n[cut at " + MAX_ANSWER_LENGTH + " characters"
+ + (left.isEmpty() ? "" : "; the page goes on with: " + String.join(" | ", left))
+ + "; docPage=<section title> returns one section]");
+ answer.put("truncated", true);
+ }
+ Map<String, Integer> cut = new LinkedHashMap<>();
+ for (int round = 0; round < 10; round++) {
+ excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess <= 0) {
+ return answer;
+ }
+ String longest = null;
+ int longestSize = 0;
+ for (Map.Entry<String, Object> e : answer.entrySet()) {
+ if (e.getValue() instanceof JsonArray arr && arr.size() > 1) {
+ int size = arr.toJson().length();
+ if (size > longestSize) {
+ longest = e.getKey();
+ longestSize = size;
+ }
+ }
+ }
+ if (longest == null) {
+ return answer;
Review Comment:
Fixed. When no list with more than one entry is left, `capAnswer` now cuts the longest text field as a last resort, with a `[cut at 16000 characters]` marker and a comment. This applies to the page text below `MIN_DOC_KEPT` too. So the answer stays within `MAX_ANSWER_LENGTH`, and a test covers that case.
_Claude Code on behalf of Croway_
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #27839:
URL: https://github.com/apache/camel/pull/27839#issuecomment-6173613673
<!-- ci-tested-modules -->
:test_tube: **CI tested the following changed modules:**
- `catalog/camel-catalog`
- `components/camel-platform-http`
- `docs`
- `dsl/camel-jbang/camel-jbang-core`
- `dsl/camel-jbang/camel-jbang-mcp`
---
<details><summary>:microscope: Scalpel shadow comparison — Scalpel: 23 of 703 tested, 24 compile-only — current: 23 all tested</summary>
[Maveniverse Scalpel](https://github.com/maveniverse/scalpel) detected **23 affected modules** (current approach: 23).
**Skip-tests mode would test 23 modules** (5 direct + 21 downstream), **skip tests for 24** (generated code, meta-modules)
<details><summary>Modules Scalpel would test (23)</summary>
- `camel-a2a` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-jbang-mcp` ← dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringTools.java, dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ComponentPropertiesTools.java, dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/AuthoringToolsTest.java, dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ComponentPropertiesToolsTest.java
- `camel-jbang-plugin-mcp` ← downstream of org.apache.camel:camel-jbang-core
- `camel-jbang-plugin-tui` ← downstream of org.apache.camel:camel-catalog
- `camel-jbang-plugin-validate` ← downstream of org.apache.camel:camel-jbang-core
- `camel-jsonpath` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-knative-http` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-launcher-container` ← downstream of org.apache.camel:camel-launcher
- `camel-mcp-server` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-micrometer-prometheus` ← downstream of org.apache.camel:camel-platform-http-main
- `camel-oauth` ← downstream of org.apache.camel:camel-platform-http-main
- `camel-observability-services` ← downstream of org.apache.camel:camel-micrometer-prometheus
- `camel-openai` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-openapi-validator` ← downstream of org.apache.camel:camel-rest-openapi
- `camel-platform-http` ← components/camel-platform-http/src/main/docs/platform-http-component.adoc
- `camel-platform-http-jolokia` ← downstream of org.apache.camel:camel-platform-http
- `camel-platform-http-main` ← downstream of org.apache.camel:camel-platform-http-vertx
- `camel-platform-http-vertx` ← downstream of org.apache.camel:camel-platform-http
- `camel-rest-openapi` ← downstream of org.apache.camel:camel-platform-http
- `camel-rest-postman` ← downstream of org.apache.camel:camel-platform-http
- `camel-restdsl-openapi-plugin` ← downstream of org.apache.camel:camel-platform-http
- `camel-yaml-dsl-validator` ← downstream of org.apache.camel:camel-catalog
- `camel-yaml-dsl-validator-maven-plugin` ← downstream of org.apache.camel:camel-yaml-dsl-validator
</details>
<details><summary>Modules with tests skipped (24)</summary>
- `apache-camel`
- `camel-allcomponents`
- `camel-catalog-console`
- `camel-catalog-maven`
- `camel-catalog-suggest`
- `camel-componentdsl`
- `camel-endpointdsl`
- `camel-endpointdsl-support`
- `camel-itest`
- `camel-jbang-it`
- `camel-jbang-main`
- `camel-jbang-plugin-edit`
- `camel-jbang-plugin-generate`
- `camel-jbang-plugin-kubernetes`
- `camel-jbang-plugin-test`
- `camel-kamelet-main`
- `camel-launcher`
- `camel-report-maven-plugin`
- `camel-route-parser`
- `camel-yaml-dsl`
- `camel-yaml-dsl-deserializers`
- `camel-yaml-dsl-maven-plugin`
- `coverage`
- `dummy-component`
</details>
> :information_source: Shadow mode — Scalpel observes but does not affect test execution. [Learn more](https://github.com/maveniverse/scalpel)
</details>
:warning: **Some tests are disabled on GitHub Actions** (`@DisabledIfSystemProperty(named = "ci.env.name")`) and require manual verification:
- `dsl/camel-jbang/camel-jbang-core`: 2 test(s) disabled on GitHub Actions
- `dsl/camel-jbang/camel-jbang-mcp`: 1 test(s) disabled on GitHub Actions
:bulb: **Manual integration tests recommended:**
> You modified `dsl/camel-jbang/camel-jbang-core`. The related integration tests in `dsl/camel-jbang/camel-jbang-it` are excluded from CI. Consider running them manually:
> ```
> mvn verify -f dsl/camel-jbang/camel-jbang-it -Djbang-it-test
> ```
<details><summary>All tested modules (50 modules, 8m 47s total)</summary>
**Total reactor time:** 8m 47s
| Module | Duration | Status |
| --- | --- | --- |
| Camel :: AI :: MCP Server | 54.1s | SUCCESS |
| Camel :: AI :: OpenAI | 48.5s | SUCCESS |
| Camel :: JBang :: Plugin :: Validate | 35.0s | SUCCESS |
| Camel :: OAuth | 31.7s | SUCCESS |
| Camel :: REST OpenApi | 31.2s | SUCCESS |
| Camel :: Knative HTTP | 28.3s | SUCCESS |
| Camel :: YAML DSL :: Validator | 23.3s | SUCCESS |
| Camel :: Catalog :: Camel Catalog | 21.9s | SUCCESS |
| Camel :: Platform HTTP :: Vert.x | 21.9s | SUCCESS |
| Camel :: AI :: A2A | 19.5s | SUCCESS |
| Camel :: YAML DSL | 18.4s | SUCCESS |
| Camel :: Component DSL | 18.2s | SUCCESS |
| Camel :: Platform HTTP | 14.9s | SUCCESS |
| Camel :: REST Postman | 14.2s | SUCCESS |
| Camel :: JSon Path | 13.9s | SUCCESS |
| Camel :: Platform HTTP :: Main | 13.3s | SUCCESS |
| Camel :: Kamelet Main | 12.8s | SUCCESS |
| Camel :: JBang :: Plugin :: Kubernetes | 12.7s | SUCCESS |
| Camel :: Catalog :: Camel Report Maven Plugin | 11.7s | SUCCESS |
| Camel :: Docs | 11.5s | SUCCESS |
| Camel :: JBang :: Plugin :: Testing | 10.6s | SUCCESS |
| Camel :: OpenAPI :: Validator | 9.6s | SUCCESS |
| Camel :: YAML DSL :: Validator Maven Plugin | 6.5s | SUCCESS |
| Camel :: Catalog :: Camel Route Parser | 6.3s | SUCCESS |
| Camel :: YAML DSL :: Deserializers | 5.8s | SUCCESS |
| Camel :: Platform HTTP :: Jolokia | 5.8s | SUCCESS |
| Camel :: Maven Plugins :: OpenApi REST DSL Generator | 4.8s | SUCCESS |
| Camel :: All Components Sync point | 4.5s | SUCCESS |
| Camel :: Catalog :: Maven | 2.8s | SUCCESS |
| Camel :: Catalog :: Suggest (deprecated) | 2.2s | SUCCESS |
| Camel :: Assembly | 1.9s | SUCCESS |
| Camel :: Micrometer :: Prometheus | 1.7s | SUCCESS |
| Camel :: JBang :: Plugin :: Edit | 1.4s | SUCCESS |
| Camel :: YAML DSL :: Maven Plugins | 1.2s | SUCCESS |
| Camel :: JBang :: Main | 1.1s | SUCCESS |
| Camel :: JBang :: Plugin :: Generate | 1.0s | SUCCESS |
| Camel :: Endpoint DSL :: Support | 0.8s | SUCCESS |
| Camel :: JBang :: Plugin :: MCP | 0.7s | SUCCESS |
| Camel :: Catalog :: Console | 0.6s | SUCCESS |
| Camel :: Catalog :: Dummy Component | 0.5s | SUCCESS |
| Camel :: Observability Services | 0.4s | SUCCESS |
| Camel :: Coverage | n/a | |
| Camel :: Endpoint DSL | n/a | |
| Camel :: Integration Tests | n/a | |
| Camel :: JBang :: Core | n/a | |
| Camel :: JBang :: Integration tests | n/a | |
| Camel :: JBang :: MCP | n/a | |
| Camel :: JBang :: Plugin :: TUI | n/a | |
| Camel :: Launcher | n/a | |
| Camel :: Launcher :: Container | n/a | |
**Top 20 slowest modules:**
- `Camel :: AI :: MCP Server` (54.1s)
- `Camel :: AI :: OpenAI` (48.5s)
- `Camel :: JBang :: Plugin :: Validate` (35.0s)
- `Camel :: OAuth` (31.7s)
- `Camel :: REST OpenApi` (31.2s)
- `Camel :: Knative HTTP` (28.3s)
- `Camel :: YAML DSL :: Validator` (23.3s)
- `Camel :: Catalog :: Camel Catalog` (21.9s)
- `Camel :: Platform HTTP :: Vert.x` (21.9s)
- `Camel :: AI :: A2A` (19.5s)
- `Camel :: YAML DSL` (18.4s)
- `Camel :: Component DSL` (18.2s)
- `Camel :: Platform HTTP` (14.9s)
- `Camel :: REST Postman` (14.2s)
- `Camel :: JSon Path` (13.9s)
- `Camel :: Platform HTTP :: Main` (13.3s)
- `Camel :: Kamelet Main` (12.8s)
- `Camel :: JBang :: Plugin :: Kubernetes` (12.7s)
- `Camel :: Catalog :: Camel Report Maven Plugin` (11.7s)
- `Camel :: Docs` (11.5s)
</details>
---
:gear: [View full build and test results](https://github.com/apache/camel/actions/runs/38587539089)
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus merged PR #27839:
URL: https://github.com/apache/camel/pull/27839
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "github-actions[bot] (via GitHub)" <gi...@apache.org>.
github-actions[bot] commented on PR #27839:
URL: https://github.com/apache/camel/pull/27839#issuecomment-6171675853
:star2: Thank you for your contribution to the Apache Camel project! :star2:
:robot: CI automation will test this PR automatically.
:camel: Apache Camel Committers, please review the following items:
* First-time contributors **require MANUAL approval** for the GitHub Actions to run
* You can use the command `/component-test (camel-)component-name1 (camel-)component-name2..` to request a test from the test bot although they are normally detected and executed by CI.
* You can label PRs using `skip-tests` and `test-dependents` to fine-tune the checks executed by this PR.
* Build and test logs are available in the summary page. **Only** [Apache Camel committers](https://camel.apache.org/community/team/#committers) have access to the summary.
:warning: Be careful when sharing logs. Review their contents before sharing them publicly.
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "gnodet-bot (via GitHub)" <gi...@apache.org>.
gnodet-bot commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4273015074
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -250,6 +309,10 @@ public static JsonObject catalogDoc(
// (jackson): both are answered, with the YAML shape in the answer (CAMEL-24898)
String artifact = YAML_DATAFORMAT_NAMES.getOrDefault(name.toLowerCase(), name);
DataFormatModel dm = catalog.dataFormatModel(artifact);
+ if (dm == null && ignoringCase(catalog.findDataFormatNames(), artifact) != null) {
+ artifact = ignoringCase(catalog.findDataFormatNames(), artifact);
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findDataFormatNames(), artifact)` called twice**
The null-guard check and the actual lookup both invoke `ignoringCase` with identical arguments, which iterates the names list twice. Cache the result:
```suggestion
String resolvedArtifact = ignoringCase(catalog.findDataFormatNames(), artifact);
if (dm == null && resolvedArtifact != null) {
artifact = resolvedArtifact;
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -283,24 +362,32 @@ public static JsonObject catalogDoc(
// the start of the page unless the whole of it, or one of its sub-pages, was asked for
String excerpt = docPageOnly || includeDoc
? null : docExcerpt(catalog.asciiDoc(name + "-language"), DOC_EXCERPT_BUDGET_PROSE);
- return languageDoc(lm, lowerFilter, scope, doc, languageDocPages(catalog, name), docPageOnly, excerpt);
+ JsonObject answer
+ = languageDoc(lm, lowerFilter, scope, doc, languageDocPages(catalog, name), docPageOnly, excerpt);
+ if (sectionTitle != null) {
+ answer.put("section", sectionTitle);
+ }
+ return answer;
}
if (kind != null) {
return notFound("Language", name, catalog.suggestLanguageNames(name, 5));
}
}
if (kind == null || "eip".equals(kind)) {
EipModel em = catalog.eipModel(name);
+ if (em == null && ignoringCase(catalog.findModelNames(), name) != null) {
+ em = catalog.eipModel(ignoringCase(catalog.findModelNames(), name));
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findModelNames(), name)` called twice**
Same pattern — cache the result:
```suggestion
String resolvedName = ignoringCase(catalog.findModelNames(), name);
if (em == null && resolvedName != null) {
em = catalog.eipModel(resolvedName);
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -267,14 +330,30 @@ public static JsonObject catalogDoc(
}
if (kind == null || "language".equals(kind)) {
LanguageModel lm = catalog.languageModel(name);
+ if (lm == null && ignoringCase(catalog.findLanguageNames(), name) != null) {
+ name = ignoringCase(catalog.findLanguageNames(), name);
Review Comment:
🔧 **Nit: `ignoringCase(catalog.findLanguageNames(), name)` called twice**
Same pattern as data format — cache the result:
```suggestion
String resolvedName = ignoringCase(catalog.findLanguageNames(), name);
if (lm == null && resolvedName != null) {
name = resolvedName;
```
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o : optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog, ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", " ").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4}) +(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page; empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
Review Comment:
⚠️ **Bug: mixed fenced-block delimiters share one boolean**
AsciiDoc has two distinct fenced-block types:
- `----` delimiters open/close source/listing blocks
- `....` delimiters open/close literal blocks
A literal block (`....`) can legally appear *inside* a source block (e.g. to show sample output). When that happens the current code toggles `fenced` back to `false` on the inner `....`, causing heading-detection to run inside the still-open outer `----` block and producing phantom `DocSection` entries.
Fix: track the two delimiter types separately, or at least ensure you only toggle back on the *same* delimiter that opened the block:
```suggestion
if (trimmed.startsWith("----")) {
fenced = !fenced;
continue;
}
if (trimmed.equals("....") && !fenced) {
fenced = true;
continue;
}
if (trimmed.equals("....")) {
fenced = false;
continue;
}
```
A cleaner alternative is to record the *opening* delimiter and only close on a matching one.
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o : optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog, ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", " ").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4}) +(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page; empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
+ fenced = !fenced;
+ continue;
+ }
+ Matcher m = fenced ? null : HEADING.matcher(lines[i]);
+ if (m != null && m.matches()) {
+ starts.add(i);
+ levels.add(m.group(1).length());
+ titles.add(m.group(2).strip());
+ }
+ }
+ for (int h = 0; h < starts.size(); h++) {
+ int end = lines.length;
+ for (int n = h + 1; n < starts.size(); n++) {
+ if (levels.get(n) <= levels.get(h)) {
+ end = starts.get(n);
+ break;
+ }
+ }
+ int ownEnd = h + 1 < starts.size() ? Math.min(end, starts.get(h + 1)) : end;
+ String text = String.join("\n", Arrays.asList(lines).subList(starts.get(h), end)).strip();
+ String own = String.join("\n", Arrays.asList(lines).subList(starts.get(h), ownEnd)).strip();
+ answer.add(new DocSection(titles.get(h), levels.get(h), text, own));
+ }
+ return answer;
+ }
+
+ /**
+ * The titles of the sections, those whose own text contains the filter first (SSL support before the Usage section
+ * it is part of).
+ */
+ static JsonArray sectionTitles(List<DocSection> sections, String filter) {
+ JsonArray first = new JsonArray();
+ JsonArray rest = new JsonArray();
+ for (DocSection s : sections) {
+ if (filter != null && s.own().toLowerCase(Locale.ROOT).contains(filter)) {
+ first.add(s.title());
+ } else {
+ rest.add(s.title());
+ }
+ }
+ first.addAll(rest);
+ return first;
+ }
+
+ private static int sectionsContaining(List<DocSection> sections, String filter) {
+ return (int) sections.stream().filter(s -> s.own().toLowerCase(Locale.ROOT).contains(filter)).count();
+ }
+
+ /**
+ * The section a docPage names: by its title ignoring case and punctuation (also the AsciiDoc id, such as
+ * _ssl_support), else the first whose title contains it; null when none does.
+ */
+ static DocSection findSection(List<DocSection> sections, String page) {
+ String key = normalizeName(page);
+ if (key.isEmpty()) {
+ return null;
+ }
+ for (DocSection s : sections) {
+ if (key.equals(normalizeName(s.title()))) {
+ return s;
+ }
+ }
+ // else the words of the title from one of them on (usable parameters), not a run of letters across words
+ String words = " " + titleWords(page);
+ for (DocSection s : sections) {
+ if ((" " + titleWords(s.title())).contains(words)) {
+ return s;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Cuts an answer longer than {@link #MAX_ANSWER_LENGTH} characters, with a marker that says what was cut and how to
+ * ask for it: first the text of a page, at a line and down to {@link #MIN_DOC_KEPT} characters, with the titles of
+ * the sections left out; then the longest list, from its end (the options left out are named in moreOptions).
+ */
+ static JsonObject capAnswer(JsonObject answer) {
+ String doc = answer.getString("doc");
+ int excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess > 0 && doc != null && doc.length() > MIN_DOC_KEPT) {
+ // the text is escaped in the JSON, so cut by the share of the escaped length that has to go
+ int escaped = Jsoner.serialize(doc).length();
+ int keep = (int) ((long) doc.length() * Math.max(0, escaped - excess - 600) / escaped);
+ keep = Math.max(MIN_DOC_KEPT, keep);
+ int at = doc.lastIndexOf('\n', keep);
+ at = at > keep / 2 ? at : keep;
+ List<String> left = new ArrayList<>();
+ for (DocSection s : docSections(doc.substring(at))) {
+ if (left.size() < 30) {
+ left.add(s.title());
+ }
+ }
+ answer.put("doc", doc.substring(0, at) + "\n\n[cut at " + MAX_ANSWER_LENGTH + " characters"
+ + (left.isEmpty() ? "" : "; the page goes on with: " + String.join(" | ", left))
+ + "; docPage=<section title> returns one section]");
+ answer.put("truncated", true);
+ }
+ Map<String, Integer> cut = new LinkedHashMap<>();
+ for (int round = 0; round < 10; round++) {
+ excess = answer.toJson().length() - MAX_ANSWER_LENGTH;
+ if (excess <= 0) {
+ return answer;
+ }
+ String longest = null;
+ int longestSize = 0;
+ for (Map.Entry<String, Object> e : answer.entrySet()) {
+ if (e.getValue() instanceof JsonArray arr && arr.size() > 1) {
+ int size = arr.toJson().length();
+ if (size > longestSize) {
+ longest = e.getKey();
+ longestSize = size;
+ }
+ }
+ }
+ if (longest == null) {
+ return answer;
Review Comment:
💡 **Edge case: `capAnswer` can return an oversized answer when no shrinkable array remains**
When all arrays have been reduced to `size() == 1` (or there are none), `longest == null` causes an early return even though `excess > 0`. Excess contributed by many small fields or by arrays already at size 1 is never reduced.
Consider logging a warning or — if the constraint is a hard requirement — truncating a string field (`"doc"`, `"description"`) or the entire answer as a last-resort fallback before returning:
```suggestion
if (longest == null) {
// No more arrays to shrink; return as-is (may still exceed MAX_ANSWER_LENGTH).
return answer;
}
```
At minimum, a comment noting this known limitation would help future maintainers understand the intent.
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "Croway (via GitHub)" <gi...@apache.org>.
Croway commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4296592178
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java:
##########
@@ -103,11 +103,9 @@ static void register(Consumer<ToolDescriptor> registry) {
"common (default: no deprecated or advanced), required, all or false", false)
.param("includeHeaders", "boolean", "Include the message headers of a component (default false)", false)
.param("includeDoc", "boolean", "Include the full AsciiDoc page (default false)", false)
- .param("docPage", "string", "simple doc sub-page to return as text (functions, operators, ognl, advanced)",
- false)
- .param("optionsFilter", "string",
- "Keyword to match in option names or descriptions; for an EIP whose own options do not match, the options of its elements are searched (nestedOptions)",
- false)
+ .param("docPage", "string", CatalogDocs.DOC_PAGE_DESC, false)
+ .param("optionsFilter", "string", CatalogDocs.OPTIONS_FILTER_DESC, false)
+ .param("limit", "integer", CatalogDocs.OPTIONS_LIMIT_DESC, false)
Review Comment:
Measured again after rebasing on main: core ~5064 (budget 5000), core+groups ~5843 (5750), full ~10508 (10500), core+semantic ~6193 (6100). I tightened the `docPage`, `optionsFilter` and `limit` descriptions and the full-key sentence without dropping anything a model needs (~23 tokens saved), then raised the budgets in `AiPanelPromptBudgetTest`, with a comment on each: core 5100 (measured ~5041), core+groups 5900 (~5820), full 10600 (~10485), core+semantic 6250 (~6170). Full+semantic is ~10822, under its 11000 limit, so that one is unchanged. All 6 tests pass.
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
Review Comment:
Done as you suggested. When an endpoint option has a component option with the same name, it is listed once, with `scope: "endpoint,component"` and its `configKey`. The component twin is no longer listed separately, so the `shown` filtering is gone and `matchedOptions` counts each name once. The kafka default answer now has `brokers` with `configKey: camel.component.kafka.brokers`. When the endpoint twin is outside the requested scope (timer's `bridgeErrorHandler` is advanced on the endpoint only), the component option is listed on its own and is not counted as omitted. A new test checks kafka's `brokers` configKey and that the listed and named options have no duplicates and add up to `matchedOptions`. The upgrade guide and the MCP page describe the merged scope.
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
Review Comment:
I checked the code: `DependencyDownloaderComponentResolver` calls `MainHttpServerFactory.setupHttpServer` for `PlatformHttpComponent`, and `configureHttpServer` applies `camel.server.*` and skips `enabled`. `mainConfig` now reads: "... with camel run it starts when a route uses platform-http, on port 8080 (camel.server.port or camel run --port changes it); with Camel Main set camel.server.enabled=true and camel.server.port; ...". The test checks both halves.
_Claude Code on behalf of Croway_
##########
components/camel-platform-http/src/main/docs/platform-http-component.adoc:
##########
@@ -40,6 +40,11 @@ include::partial$component-endpoint-headers.adoc[]
To use Platform HTTP, a provider (engine) is required to be available on the classpath.
The purpose is to have drivers for different runtimes such as Quarkus, or Spring Boot.
+With Camel Main and `camel run`, the provider is the embedded HTTP server of `camel-platform-http-main`,
+which is disabled by default. Enable it and set its port in `application.properties` with
+`camel.server.enabled=true` and `camel.server.port=8080` (`camel run --port=8080` sets both).
+See xref:others:platform-http-main.adoc[Platform HTTP Main] for the other `camel.server.*` options.
Review Comment:
Rewritten along your wording. With `camel run`, the server of `camel-platform-http-main` starts when a route uses platform-http, on port 8080 (`camel.server.port` or `camel run --port` changes it). With Camel Main it is disabled by default: enable it with `camel.server.enabled=true` and set `camel.server.port`. The catalog mirror is regenerated and is byte-identical to the source page.
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -1434,9 +1651,396 @@ private static JsonObject componentDoc(
result.put("docPages", new JsonArray(docPages));
result.put("docPagesHint", pagesHint(docPages, "page"));
}
+ if (!how.sections().isEmpty()) {
+ // what the excerpt leaves out is often what decides correctness (the mail useHeader* options, the xslt
+ // header parameters), so the page says what else it has, the sections with the filter term first
+ JsonArray titles = sectionTitles(how.sections(), filter);
+ result.put("sections", titles);
+ int matching = filter != null ? sectionsContaining(how.sections(), filter) : 0;
+ result.put("sectionsHint", "docPage=<section title> returns that section of the page as text"
+ + (matching > 0
+ ? "; the first " + matching + " contain '" + filter + "'"
+ : ""));
+ }
+ String mainGroup = MAIN_CONFIG_GROUPS.get(model.getScheme());
+ if (mainGroup != null) {
+ putMainConfig(result, how, mainGroup);
+ }
return result;
}
+ /**
+ * The options of a component: those the scope accepts and the filter matches, the required ones first, then the
+ * endpoint options (what a URI takes) and the component options, with their application.properties key. Without a
+ * filter only the first {@link #DEFAULT_OPTIONS_LIMIT} are listed in full and the rest by name. A filter that
+ * matches nothing gets a note and the closest option names, and the prefix options are always said, as they take
+ * keys no option list names (CAMEL-25541).
+ */
+ private static void putComponentOptions(
+ JsonObject result, ComponentModel model, ComponentAnswer how, OptionScope scope) {
+ String filter = how.filter();
+ List<JsonObject> required = new ArrayList<>();
+ List<JsonObject> byName = new ArrayList<>();
+ List<JsonObject> others = new ArrayList<>();
+ int omitted = 0;
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ if (!matchesOptionFilter(opt, filter)) {
+ continue;
+ }
+ if (!scope.accepts(opt, filter)) {
+ omitted++;
+ continue;
+ }
+ JsonObject o = optionToJson(opt, optionScope);
+ if ("component".equals(optionScope)) {
+ // the only valid application.properties key of an option: camel.component.<scheme>.* sets a
+ // component option, never an endpoint one
+ o.put("configKey", "camel.component." + model.getScheme() + "." + opt.getName());
+ }
+ if (opt.isRequired()) {
+ required.add(o);
+ } else if (filter != null && opt.getName().toLowerCase(Locale.ROOT).contains(filter)) {
+ // a name match before a match in a description
+ byName.add(o);
+ } else {
+ others.add(o);
+ }
+ }
+ }
+ List<JsonObject> ordered = new ArrayList<>(required);
+ ordered.addAll(byName);
+ ordered.addAll(others);
+ int max = how.limit() >= 0 ? how.limit() : filter == null ? DEFAULT_OPTIONS_LIMIT : Integer.MAX_VALUE;
+ // the required options are always listed
+ int listed = Math.min(ordered.size(), Math.max(max, required.size()));
+ putOptions(result, new JsonArray(ordered.subList(0, listed)), omitted, scope);
+ result.put("matchedOptions", ordered.size());
+ if (listed < ordered.size()) {
+ Set<String> shown = new HashSet<>();
+ ordered.subList(0, listed).forEach(o -> shown.add(o.getString("name")));
+ Set<String> rest = new LinkedHashSet<>();
+ for (JsonObject o : ordered.subList(listed, ordered.size())) {
+ if (!shown.contains(o.getString("name"))) {
+ rest.add(o.getString("name"));
+ }
+ }
+ result.put("truncated", true);
+ result.put("listedOptions", listed);
+ result.put("moreOptions", new JsonArray(new ArrayList<>(rest)));
+ String hint = listed + " of " + ordered.size() + " options listed in full (required first, then the"
+ + " endpoint options), the others by name in moreOptions; optionsFilter=<word or option name>"
+ + " returns the matching ones in full, includeOptions=all with limit=<n> lists more";
+ String omittedHint = result.getString("optionsHint");
+ result.put("optionsHint", omittedHint != null ? hint + "; " + omittedHint : hint);
+ }
+ if (filter != null && ordered.isEmpty() && omitted == 0) {
+ // an empty list read as "not supported" (smtp + tls, xslt + param): say that nothing matched, and where
+ // else to look
+ result.put("note", "no option matches '" + filter + "': see closestOptions, prefixOptions and the"
+ + " sections of the page below (docPage=<section title> returns one)");
+ JsonArray closest = closestOptions(model, filter);
+ if (!closest.isEmpty()) {
+ result.put("closestOptions", closest);
+ }
+ }
+ JsonArray prefixOptions = prefixOptions(model);
+ if (!prefixOptions.isEmpty()) {
+ result.put("prefixOptions", prefixOptions);
+ }
+ }
+
+ /**
+ * The options whose name, or a word of it, is closest to the filter by edit distance, with their group: ssl for a
+ * mistyped tsl.
+ */
+ private static JsonArray closestOptions(ComponentModel model, String filter) {
+ Map<String, List<BaseOptionModel>> byWord = new LinkedHashMap<>();
+ Map<BaseOptionModel, String> scopes = new LinkedHashMap<>();
+ for (String optionScope : List.of("endpoint", "component")) {
+ List<? extends BaseOptionModel> options
+ = "endpoint".equals(optionScope) ? model.getEndpointOptions() : model.getComponentOptions();
+ if (options == null) {
+ continue;
+ }
+ for (BaseOptionModel opt : options) {
+ scopes.putIfAbsent(opt, optionScope);
+ byWord.computeIfAbsent(opt.getName().toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ for (String word : opt.getName().split("(?<=[a-z0-9])(?=[A-Z])")) {
+ if (word.length() > 1) {
+ byWord.computeIfAbsent(word.toLowerCase(Locale.ROOT), k -> new ArrayList<>()).add(opt);
+ }
+ }
+ }
+ }
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ for (String word : EditDistanceSuggestionStrategy.suggest(byWord.keySet(), filter, 5)) {
+ for (BaseOptionModel opt : byWord.get(word)) {
+ if (answer.size() < 5 && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ if (opt.getGroup() != null) {
+ o.put("group", opt.getGroup());
+ }
+ o.put("scope", scopes.get(opt));
+ answer.add(o);
+ }
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * The multi-value options of a component, which take every URI parameter with their prefix: any mail.* parameter of
+ * smtp goes to JavaMail (mail.smtp.starttls.enable=true), which no option is named after.
+ */
+ private static JsonArray prefixOptions(ComponentModel model) {
+ JsonArray answer = new JsonArray();
+ Set<String> seen = new HashSet<>();
+ if (model.getEndpointOptions() == null) {
+ return answer;
+ }
+ for (BaseOptionModel opt : model.getEndpointOptions()) {
+ if (opt.isMultiValue() && opt.getPrefix() != null && seen.add(opt.getName())) {
+ JsonObject o = new JsonObject();
+ o.put("name", opt.getName());
+ o.put("prefix", opt.getPrefix());
+ o.put("usage", "any " + opt.getPrefix() + "* URI parameter is passed on as " + opt.getName() + ": ?"
+ + opt.getPrefix() + "<key>=<value>");
+ answer.add(o);
+ }
+ }
+ return answer;
+ }
+
+ /**
+ * Where a component that runs on a server of camel run or Camel Main gets its settings: the HTTP port of
+ * platform-http is camel.server.port, which an agent searched the component's options for (CAMEL-25541).
+ */
+ private static void putMainConfig(JsonObject result, ComponentAnswer how, String group) {
+ result.put("mainConfig", "with camel run or Camel Main the HTTP server is configured by the " + group
+ + ".* keys of application.properties, not by an option of this component:"
+ + " camel.server.enabled=true and camel.server.port=8080 (camel run --port=8080 sets"
+ + " both); camel_catalog_doc name=" + group + " lists the keys");
+ if (how.filter() == null) {
+ return;
+ }
+ JsonArray keys = new JsonArray();
+ try {
+ for (BaseOptionModel o : optionsUnder(how.catalog().mainModel().getOptions(), group)) {
+ if (matchesOptionFilter(o, how.filter())) {
+ keys.add(mainOptionToJson(o));
+ }
+ }
+ } catch (Exception e) {
+ return;
+ }
+ if (!keys.isEmpty()) {
+ result.put("mainOptions", keys);
+ }
+ }
+
+ /**
+ * The name the documentation page of a component has: its scheme, else the artifact (smtp, imap and pop3 are on the
+ * mail page of camel-mail); null when there is none.
+ */
+ private static String componentDocName(CamelCatalog catalog, ComponentModel model) {
+ if (catalog.asciiDoc(model.getScheme() + "-component") != null) {
+ return model.getScheme();
+ }
+ String artifact = model.getArtifactId();
+ if (artifact != null && artifact.startsWith("camel-")) {
+ String name = artifact.substring("camel-".length());
+ if (catalog.asciiDoc(name + "-component") != null) {
+ return name;
+ }
+ }
+ return null;
+ }
+
+ private static String titleWords(String text) {
+ return text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", " ").strip();
+ }
+
+ /**
+ * A section of an AsciiDoc page: its title, its level (2 for ==), its text with its subsections, and its own text
+ * up to its first subsection.
+ */
+ record DocSection(String title, int level, String text, String own) {
+ }
+
+ private static final Pattern HEADING = Pattern.compile("^(={2,4}) +(\\S.*)$");
+
+ /** The sections of a page, from == to ====, in the order of the page; empty when there is no page. */
+ static List<DocSection> docSections(String adoc) {
+ List<DocSection> answer = new ArrayList<>();
+ if (adoc == null) {
+ return answer;
+ }
+ String[] lines = adoc.split("\n", -1);
+ List<Integer> starts = new ArrayList<>();
+ List<Integer> levels = new ArrayList<>();
+ List<String> titles = new ArrayList<>();
+ boolean fenced = false;
+ for (int i = 0; i < lines.length; i++) {
+ String trimmed = lines[i].strip();
+ if (trimmed.startsWith("----") || trimmed.equals("....")) {
Review Comment:
Fixed. `docSections` now records the delimiter that opened a block and closes the block only on that same delimiter. Listing (`----`), literal (`....`), comment (`////`) and passthrough (`++++`) blocks are covered. A new test has a `....` block inside a `----` block and a `////` comment block, and the headings inside them are not sections. (The suggested patch would still have closed the outer block on `....`.)
_Claude Code on behalf of Croway_
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/CatalogDocs.java:
##########
@@ -250,6 +309,10 @@ public static JsonObject catalogDoc(
// (jackson): both are answered, with the YAML shape in the answer (CAMEL-24898)
String artifact = YAML_DATAFORMAT_NAMES.getOrDefault(name.toLowerCase(), name);
DataFormatModel dm = catalog.dataFormatModel(artifact);
+ if (dm == null && ignoringCase(catalog.findDataFormatNames(), artifact) != null) {
+ artifact = ignoringCase(catalog.findDataFormatNames(), artifact);
Review Comment:
Fixed: the result is computed once into a local.
_Claude Code on behalf of Croway_
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] CAMEL-25541: camel-jbang-mcp - camel_catalog_doc component answer: bounded options, an explicit answer when nothing matches, page sections, full config keys [camel]
Posted by "davsclaus (via GitHub)" <gi...@apache.org>.
davsclaus commented on code in PR #27839:
URL: https://github.com/apache/camel/pull/27839#discussion_r4279111120
##########
dsl/camel-jbang/camel-jbang-core/src/main/java/org/apache/camel/dsl/jbang/core/commands/ai/AuthoringTools.java:
##########
@@ -103,11 +103,9 @@ static void register(Consumer<ToolDescriptor> registry) {
"common (default: no deprecated or advanced), required, all or false", false)
.param("includeHeaders", "boolean", "Include the message headers of a component (default false)", false)
.param("includeDoc", "boolean", "Include the full AsciiDoc page (default false)", false)
- .param("docPage", "string", "simple doc sub-page to return as text (functions, operators, ognl, advanced)",
- false)
- .param("optionsFilter", "string",
- "Keyword to match in option names or descriptions; for an EIP whose own options do not match, the options of its elements are searched (nestedOptions)",
- false)
+ .param("docPage", "string", CatalogDocs.DOC_PAGE_DESC, false)
+ .param("optionsFilter", "string", CatalogDocs.OPTIONS_FILTER_DESC, false)
+ .param("limit", "integer", CatalogDocs.OPTIONS_LIMIT_DESC, false)
Review Comment:
its okay to increate the budget (mcp descrptions must also be accurate and somethging LLMs can understand and use)
--
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: commits-unsubscribe@camel.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org