This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new b32abd8ff294 CAMEL-23179: camel-jbang-mcp - Add
camel_configuration_validate tool
b32abd8ff294 is described below
commit b32abd8ff294601f12e54d90fa6360df5612e14a
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Apr 29 19:36:07 2026 +0200
CAMEL-23179: camel-jbang-mcp - Add camel_configuration_validate tool
Exposes CamelCatalog.validateConfigurationProperty() as an MCP tool so
an agent can validate one or more application.properties lines and
receive per-property errors, suggestions for misspelled options and
human-readable summaries.
The tool accepts a single property line or a multi-line string, skips
blank lines and # / ! comments, and reports each line with accepted /
valid flags, error and warning counts, a structured list of issues
tagged by kind (unknown, required, deprecated, invalidEnum,
invalidBoolean, invalidInteger, invalidNumber, invalidDuration,
invalidReference, invalidMap, invalidArray, unknownComponent,
incapable, syntax) carrying option name, message and suggestions, and
a human-readable summary.
Property placeholder values ({{...}}, ${...}, $simple{...}) are
accepted for any option type since the underlying
AbstractCamelCatalog.doValidateConfigurationProperty already skips
type-based validation when a placeholder is detected.
Adds a public getInvalidEnumSuggestions() getter to
PropertiesValidationResult (additive, backwards-compatible) so the new
tool can surface enum suggestions without reflection.
Includes 10 unit tests covering valid input, unknown options, invalid
boolean, multi-line aggregation, comment/blank-line skipping,
non-camel keys, missing input validation, and placeholder values
across multiple types for both {{...}} and \${...} forms.
Closes #22830
---
.../camel/catalog/PropertiesValidationResult.java | 4 +
.../commands/mcp/ConfigurationValidateTools.java | 215 +++++++++++++++++++++
.../mcp/ConfigurationValidateToolsTest.java | 190 ++++++++++++++++++
3 files changed, 409 insertions(+)
diff --git
a/core/camel-api/src/main/java/org/apache/camel/catalog/PropertiesValidationResult.java
b/core/camel-api/src/main/java/org/apache/camel/catalog/PropertiesValidationResult.java
index 115a420113a4..f6cb8698cdb7 100644
---
a/core/camel-api/src/main/java/org/apache/camel/catalog/PropertiesValidationResult.java
+++
b/core/camel-api/src/main/java/org/apache/camel/catalog/PropertiesValidationResult.java
@@ -257,6 +257,10 @@ abstract class PropertiesValidationResult implements
Serializable {
return invalidEnumChoices;
}
+ public Map<String, String[]> getInvalidEnumSuggestions() {
+ return invalidEnumSuggestions;
+ }
+
public List<String> getEnumChoices(String optionName) {
if (invalidEnumChoices != null) {
String[] enums = invalidEnumChoices.get(optionName);
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java
new file mode 100644
index 000000000000..523fbd4062a5
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateTools.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.mcp.server.Tool;
+import io.quarkiverse.mcp.server.ToolArg;
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.apache.camel.catalog.CamelCatalog;
+import org.apache.camel.catalog.ConfigurationPropertiesValidationResult;
+
+/**
+ * MCP Tool for validating Camel configuration properties (e.g. {@code
application.properties} for camel-main, Spring
+ * Boot or Quarkus). Wraps {@link
CamelCatalog#validateConfigurationProperty(String)} so that misspelled option
names,
+ * invalid values and suggested corrections can be surfaced to LLMs.
+ */
+@ApplicationScoped
+public class ConfigurationValidateTools {
+
+ @Inject
+ CatalogService catalogService;
+
+ /**
+ * Tool to validate one or more Camel configuration property lines.
+ */
+ @Tool(annotations = @Tool.Annotations(readOnlyHint = true, destructiveHint
= false, openWorldHint = false),
+ description = "Validate one or more Camel configuration property
lines (e.g. lines from application.properties). "
+ +
+ "Detects misspelled option names, invalid values
(boolean/integer/number/duration/enum) and "
+ +
+ "returns suggestions when available. The input can be
a single line or a multi-line string with "
+ +
+ "one property per line; blank lines and comments
(starting with # or !) are skipped.")
+ public ConfigurationValidateResult camel_configuration_validate(
+ @ToolArg(description = "Configuration property lines to validate.
Can be a single line "
+ + "(e.g. \"camel.main.streamCaching=true\")
or multiple lines separated by newlines.") String properties,
+ @ToolArg(description = "Runtime type: main, spring-boot, or
quarkus (default: main)") String runtime,
+ @ToolArg(description = "Camel version to query. If not specified,
uses the default catalog version.") String camelVersion,
+ @ToolArg(description = "Platform BOM coordinates in GAV format
(groupId:artifactId:version). "
+ + "When provided, overrides camelVersion.")
String platformBom) {
+
+ if (properties == null || properties.isBlank()) {
+ throw new ToolCallException("properties argument is required",
null);
+ }
+
+ try {
+ CamelCatalog catalog = catalogService.loadCatalog(runtime,
camelVersion, platformBom);
+
+ List<PropertyValidationInfo> results = new ArrayList<>();
+ int totalErrors = 0;
+ int totalWarnings = 0;
+ int validCount = 0;
+
+ String[] lines = properties.split("\\r?\\n");
+ for (int i = 0; i < lines.length; i++) {
+ String line = lines[i];
+ String trimmed = line == null ? "" : line.trim();
+ if (trimmed.isEmpty() || trimmed.startsWith("#") ||
trimmed.startsWith("!")) {
+ continue;
+ }
+
+ ConfigurationPropertiesValidationResult result =
catalog.validateConfigurationProperty(line);
+ PropertyValidationInfo info = toPropertyValidationInfo(i + 1,
line, result);
+ results.add(info);
+ totalErrors += info.errors();
+ totalWarnings += info.warnings();
+ if (info.valid()) {
+ validCount++;
+ }
+ }
+
+ ConfigurationValidateSummary summary
+ = new ConfigurationValidateSummary(results.size(),
validCount, totalErrors, totalWarnings);
+
+ return new
ConfigurationValidateResult(catalog.getCatalogVersion(), summary, results);
+ } catch (ToolCallException e) {
+ throw e;
+ } catch (Throwable e) {
+ throw new ToolCallException(
+ "Failed to validate configuration properties (" +
e.getClass().getName() + "): " + e.getMessage(),
+ null);
+ }
+ }
+
+ private PropertyValidationInfo toPropertyValidationInfo(
+ int lineNumber, String text,
ConfigurationPropertiesValidationResult result) {
+ boolean valid = result.isSuccess();
+ boolean accepted = result.isAccepted();
+ int errors = result.getNumberOfErrors();
+ int warnings = result.getNumberOfWarnings();
+
+ List<PropertyIssue> issues = collectIssues(result);
+ String summary = result.summaryErrorMessage(false, true, true);
+
+ return new PropertyValidationInfo(lineNumber, text, accepted, valid,
errors, warnings, summary, issues);
+ }
+
+ private List<PropertyIssue>
collectIssues(ConfigurationPropertiesValidationResult result) {
+ List<PropertyIssue> issues = new ArrayList<>();
+
+ if (result.getSyntaxError() != null) {
+ issues.add(new PropertyIssue(null, "syntax",
result.getSyntaxError(), Collections.emptyList()));
+ }
+ if (result.getUnknownComponent() != null) {
+ issues.add(new PropertyIssue(
+ result.getUnknownComponent(), "unknownComponent",
+ "Unknown component: " + result.getUnknownComponent(),
+ Collections.emptyList()));
+ }
+ if (result.getIncapable() != null) {
+ issues.add(new PropertyIssue(null, "incapable",
result.getIncapable(), Collections.emptyList()));
+ }
+
+ if (result.getUnknown() != null) {
+ Map<String, String[]> unknownSuggestions =
result.getUnknownSuggestions();
+ for (String name : result.getUnknown()) {
+ String[] suggestions = unknownSuggestions != null ?
unknownSuggestions.get(name) : null;
+ issues.add(new PropertyIssue(name, "unknown", "Unknown
option", asSuggestionList(suggestions)));
+ }
+ }
+ if (result.getRequired() != null) {
+ for (String name : result.getRequired()) {
+ issues.add(new PropertyIssue(name, "required", "Missing
required option", Collections.emptyList()));
+ }
+ }
+ if (result.getDeprecated() != null) {
+ for (String name : result.getDeprecated()) {
+ issues.add(new PropertyIssue(name, "deprecated", "Deprecated
option", Collections.emptyList()));
+ }
+ }
+
+ addInvalidValueIssues(issues, result.getInvalidEnum(), "invalidEnum",
"Invalid enum value",
+ result.getInvalidEnumChoices(),
result.getInvalidEnumSuggestions());
+ addInvalidValueIssues(issues, result.getInvalidReference(),
"invalidReference", "Invalid reference value", null,
+ null);
+ addInvalidValueIssues(issues, result.getInvalidBoolean(),
"invalidBoolean", "Invalid boolean value", null, null);
+ addInvalidValueIssues(issues, result.getInvalidInteger(),
"invalidInteger", "Invalid integer value", null, null);
+ addInvalidValueIssues(issues, result.getInvalidNumber(),
"invalidNumber", "Invalid number value", null, null);
+ addInvalidValueIssues(issues, result.getInvalidDuration(),
"invalidDuration", "Invalid duration value", null,
+ null);
+ addInvalidValueIssues(issues, result.getInvalidMap(), "invalidMap",
"Invalid map key/value", null, null);
+ addInvalidValueIssues(issues, result.getInvalidArray(),
"invalidArray", "Invalid array index/value", null, null);
+
+ return issues;
+ }
+
+ private void addInvalidValueIssues(
+ List<PropertyIssue> issues, Map<String, String> values, String
kind, String baseMessage,
+ Map<String, String[]> choicesMap, Map<String, String[]>
suggestionsMap) {
+ if (values == null) {
+ return;
+ }
+ for (Map.Entry<String, String> entry : values.entrySet()) {
+ String name = entry.getKey();
+ String value = entry.getValue();
+ StringBuilder msg = new StringBuilder(baseMessage);
+ if (value != null && !value.isBlank()) {
+ msg.append(": ").append(value);
+ }
+ if (choicesMap != null) {
+ String[] choices = choicesMap.get(name);
+ if (choices != null && choices.length > 0) {
+ msg.append(". Possible values:
").append(Arrays.asList(choices));
+ }
+ }
+ String[] suggestions = suggestionsMap != null ?
suggestionsMap.get(name) : null;
+ issues.add(new PropertyIssue(name, kind, msg.toString(),
asSuggestionList(suggestions)));
+ }
+ }
+
+ private static List<String> asSuggestionList(String[] suggestions) {
+ if (suggestions == null || suggestions.length == 0) {
+ return Collections.emptyList();
+ }
+ return new ArrayList<>(Arrays.asList(suggestions));
+ }
+
+ // Result record classes for Jackson serialization
+
+ public record ConfigurationValidateResult(String camelVersion,
ConfigurationValidateSummary summary,
+ List<PropertyValidationInfo> properties) {
+ }
+
+ public record ConfigurationValidateSummary(int total, int valid, int
errors, int warnings) {
+ }
+
+ public record PropertyValidationInfo(int lineNumber, String text, boolean
accepted, boolean valid, int errors,
+ int warnings, String summary, List<PropertyIssue> issues) {
+ }
+
+ public record PropertyIssue(String name, String kind, String message,
List<String> suggestions) {
+ }
+}
diff --git
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java
new file mode 100644
index 000000000000..ecbfe9248fe8
--- /dev/null
+++
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/ConfigurationValidateToolsTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.dsl.jbang.core.commands.mcp;
+
+import java.util.Optional;
+
+import io.quarkiverse.mcp.server.ToolCallException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class ConfigurationValidateToolsTest {
+
+ private ConfigurationValidateTools createTools() {
+ CatalogService catalogService = new CatalogService();
+ catalogService.catalogRepos = Optional.empty();
+
+ ConfigurationValidateTools tools = new ConfigurationValidateTools();
+ tools.catalogService = catalogService;
+ return tools;
+ }
+
+ @Test
+ void validatesValidMainProperty() {
+ ConfigurationValidateTools tools = createTools();
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ =
tools.camel_configuration_validate("camel.main.streamCachingEnabled=true",
null, null, null);
+
+ assertThat(result).isNotNull();
+ assertThat(result.camelVersion()).isNotNull();
+ assertThat(result.summary().total()).isEqualTo(1);
+ assertThat(result.summary().valid()).isEqualTo(1);
+ assertThat(result.summary().errors()).isZero();
+ assertThat(result.properties()).hasSize(1);
+
+ ConfigurationValidateTools.PropertyValidationInfo info =
result.properties().get(0);
+ assertThat(info.lineNumber()).isEqualTo(1);
+ assertThat(info.accepted()).isTrue();
+ assertThat(info.valid()).isTrue();
+ assertThat(info.errors()).isZero();
+ assertThat(info.issues()).isEmpty();
+ }
+
+ @Test
+ void detectsUnknownOption() {
+ ConfigurationValidateTools tools = createTools();
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ =
tools.camel_configuration_validate("camel.main.streamCachngEnabled=true", null,
null, null);
+
+ assertThat(result.summary().errors()).isPositive();
+ ConfigurationValidateTools.PropertyValidationInfo info =
result.properties().get(0);
+ assertThat(info.valid()).isFalse();
+ assertThat(info.issues()).anyMatch(i -> "unknown".equals(i.kind()));
+ }
+
+ @Test
+ void detectsInvalidBoolean() {
+ ConfigurationValidateTools tools = createTools();
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ =
tools.camel_configuration_validate("camel.main.streamCachingEnabled=notabool",
null, null, null);
+
+ ConfigurationValidateTools.PropertyValidationInfo info =
result.properties().get(0);
+ assertThat(info.valid()).isFalse();
+ assertThat(info.issues()).anyMatch(i ->
"invalidBoolean".equals(i.kind())
+ && i.message().contains("notabool"));
+ }
+
+ @Test
+ void acceptsPlaceholderValuesAcrossTypes() {
+ ConfigurationValidateTools tools = createTools();
+
+ // Property placeholders ({{...}}) should be accepted as a value for
any option type
+ // (boolean, integer, string, ...) since the actual value is resolved
at runtime.
+ String input = "camel.main.streamCachingEnabled={{myCacheEnabled}}\n"
+ +
"camel.main.durationMaxSeconds={{myDurationSeconds}}\n"
+ + "camel.main.uuidGenerator={{myUuidGenerator}}";
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ = tools.camel_configuration_validate(input, null, null, null);
+
+ assertThat(result.summary().total()).isEqualTo(3);
+ assertThat(result.summary().valid()).isEqualTo(3);
+ assertThat(result.summary().errors()).isZero();
+ assertThat(result.properties())
+ .allSatisfy(info -> {
+ assertThat(info.accepted()).isTrue();
+ assertThat(info.valid()).isTrue();
+ assertThat(info.errors()).isZero();
+ assertThat(info.issues()).isEmpty();
+ });
+ }
+
+ @Test
+ void acceptsSpringStylePlaceholderValue() {
+ ConfigurationValidateTools tools = createTools();
+
+ // Spring/Quarkus style ${...} placeholders must also be accepted for
typed options.
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ =
tools.camel_configuration_validate("camel.main.streamCachingEnabled=${my.cache.enabled}",
null, null,
+ null);
+
+ ConfigurationValidateTools.PropertyValidationInfo info =
result.properties().get(0);
+ assertThat(info.accepted()).isTrue();
+ assertThat(info.valid()).isTrue();
+ assertThat(info.errors()).isZero();
+ assertThat(info.issues()).isEmpty();
+ }
+
+ @Test
+ void skipsBlankLinesAndComments() {
+ ConfigurationValidateTools tools = createTools();
+
+ String input = """
+ # this is a comment
+ ! shell-style comment
+
+ camel.main.streamCachingEnabled=true
+ """;
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ = tools.camel_configuration_validate(input, null, null, null);
+
+ assertThat(result.summary().total()).isEqualTo(1);
+ assertThat(result.properties()).hasSize(1);
+ assertThat(result.properties().get(0).lineNumber()).isEqualTo(4);
+ }
+
+ @Test
+ void multipleLinesAggregated() {
+ ConfigurationValidateTools tools = createTools();
+
+ String input = "camel.main.streamCachingEnabled=true\n"
+ + "camel.main.streamCachngEnabled=true\n"
+ + "camel.main.streamCachingEnabled=notabool";
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ = tools.camel_configuration_validate(input, null, null, null);
+
+ assertThat(result.summary().total()).isEqualTo(3);
+ assertThat(result.summary().valid()).isEqualTo(1);
+ assertThat(result.summary().errors()).isGreaterThanOrEqualTo(2);
+ }
+
+ @Test
+ void nonCamelKeyMarkedNotAccepted() {
+ ConfigurationValidateTools tools = createTools();
+
+ ConfigurationValidateTools.ConfigurationValidateResult result
+ = tools.camel_configuration_validate("server.port=8080", null,
null, null);
+
+ ConfigurationValidateTools.PropertyValidationInfo info =
result.properties().get(0);
+ assertThat(info.accepted()).isFalse();
+ }
+
+ @Test
+ void blankInputThrows() {
+ ConfigurationValidateTools tools = createTools();
+
+ assertThatThrownBy(() -> tools.camel_configuration_validate(" ",
null, null, null))
+ .isInstanceOf(ToolCallException.class)
+ .hasMessageContaining("required");
+ }
+
+ @Test
+ void nullInputThrows() {
+ ConfigurationValidateTools tools = createTools();
+
+ assertThatThrownBy(() -> tools.camel_configuration_validate(null,
null, null, null))
+ .isInstanceOf(ToolCallException.class)
+ .hasMessageContaining("required");
+ }
+}