This is an automated email from the ASF dual-hosted git repository.

luigidemasi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git

commit 856a1bbaf176629286010b2d933b90ad1e57cb28
Author: Luigi De Masi <[email protected]>
AuthorDate: Wed Sep 30 11:14:33 2026 +0200

    CAMEL-25138: Preserve XML source positions and decouple MCP guard
    
    Parse routes from their original resource bytes while skipping the validated
    semantic declaration subtree. Accept dotted XML filenames through the
    component loader and retain custom loader precedence.
    
    Discover the optional semantic registry at runtime before generic MCP
    exports, preserving rejection of lossy conversions without a production
    dependency on camel-semantic. Cover original locations and diagnostics,
    dotted filenames, and empty registries; document the XML filename support.
    
    Co-authored-by: Codex <[email protected]>
    Signed-off-by: Luigi De Masi <[email protected]>
---
 .../camel/catalog/docs/semantic-language.adoc      |  2 +
 .../src/main/docs/semantic-language.adoc           |  2 +
 .../apache/camel/semantic/SemanticXmlLoader.java   |  2 +-
 .../semantic/SemanticXmlRoutesBuilderLoader.java   | 44 +++++++++----
 .../semantic/SemanticXmlAutoDiscoveryTest.java     | 76 +++++++++++++++++++++-
 dsl/camel-jbang/camel-jbang-mcp/pom.xml            | 10 +--
 .../jbang/core/commands/mcp/TransformTools.java    | 12 ++--
 .../core/commands/mcp/TransformToolsTest.java      | 19 ++++++
 8 files changed, 141 insertions(+), 26 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
index 4f37730d403b..41c3797b928b 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/semantic-language.adoc
@@ -148,6 +148,8 @@ automatically at startup: use ordinary `*.xml` files 
without registering a loade
 the Camel core model. XML documents without semantic declarations are handled 
by the standard
 XML loader, including its bean and route configuration support. XML loaders 
registered by the
 application before route loading retain precedence over automatic discovery.
+Filenames may contain dots, such as `my.tickets.xml`. Routes retain their 
original resource
+locations and line numbers for debugging and error messages.
 
 Both layouts are supported. To keep declarations alongside routes, use 
`tickets.xml`:
 
diff --git 
a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc 
b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
index 4f37730d403b..41c3797b928b 100644
--- a/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
+++ b/components/camel-ai/camel-semantic/src/main/docs/semantic-language.adoc
@@ -148,6 +148,8 @@ automatically at startup: use ordinary `*.xml` files 
without registering a loade
 the Camel core model. XML documents without semantic declarations are handled 
by the standard
 XML loader, including its bean and route configuration support. XML loaders 
registered by the
 application before route loading retain precedence over automatic discovery.
+Filenames may contain dots, such as `my.tickets.xml`. Routes retain their 
original resource
+locations and line numbers for debugging and error messages.
 
 Both layouts are supported. To keep declarations alongside routes, use 
`tickets.xml`:
 
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
index dd2d06e2e723..e5d829e9e62b 100644
--- 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlLoader.java
@@ -47,7 +47,7 @@ final class SemanticXmlLoader extends 
RoutesBuilderLoaderSupport {
     @Override
     public boolean isSupportedExtension(String extension) {
         // DefaultCamelContext may build before application beans are bound. 
Give later custom loaders precedence too.
-        return ("xml".equals(extension) || "camel.xml".equals(extension))
+        return ("xml".equals(extension) || extension.endsWith(".xml"))
                 && 
getCamelContext().getRegistry().findByType(RoutesBuilderLoader.class).stream()
                         .noneMatch(loader -> loader != this && 
loader.isSupportedExtension(extension));
     }
diff --git 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
index 7be5c330fa00..fcbc0e9986b7 100644
--- 
a/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
+++ 
b/components/camel-ai/camel-semantic/src/main/java/org/apache/camel/semantic/SemanticXmlRoutesBuilderLoader.java
@@ -16,18 +16,14 @@
  */
 package org.apache.camel.semantic;
 
+import java.io.IOException;
 import java.io.InputStream;
-import java.io.StringReader;
-import java.io.StringWriter;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
 import javax.xml.XMLConstants;
 import javax.xml.parsers.DocumentBuilderFactory;
-import javax.xml.transform.TransformerFactory;
-import javax.xml.transform.dom.DOMSource;
-import javax.xml.transform.stream.StreamResult;
 
 import org.w3c.dom.Element;
 import org.w3c.dom.Node;
@@ -40,6 +36,7 @@ import org.apache.camel.spi.Resource;
 import org.apache.camel.spi.annotations.RoutesLoader;
 import org.apache.camel.support.RoutesBuilderLoaderSupport;
 import org.apache.camel.xml.in.ModelParser;
+import org.apache.camel.xml.io.XmlPullParserException;
 
 /** Parses standalone semantic declarations or declarations alongside XML 
routes. */
 @RoutesLoader("semantic.xml")
@@ -104,19 +101,14 @@ public class SemanticXmlRoutesBuilderLoader extends 
RoutesBuilderLoaderSupport {
                     }
                     found = true;
                     declarations(child, questions);
-                    root.removeChild(child);
                 } else if (!"route".equals(child.getLocalName())) {
                     throw new IllegalArgumentException("Unexpected element in 
routes: " + child.getTagName());
                 }
             }
-            TransformerFactory transformer = TransformerFactory.newInstance();
-            transformer.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, 
true);
-            transformer.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
-            transformer.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, 
"");
-            StringWriter xml = new StringWriter();
-            transformer.newTransformer().transform(new DOMSource(root), new 
StreamResult(xml));
-            routes = new ModelParser(new StringReader(xml.toString()), 
namespace).parseRoutesDefinition()
-                    .orElseThrow(() -> new IllegalArgumentException("Expected 
XML routes"));
+            try (InputStream stream = resource.getInputStream()) {
+                routes = new SemanticModelParser(resource, stream, 
namespace).parseRoutesDefinition()
+                        .orElseThrow(() -> new 
IllegalArgumentException("Expected XML routes"));
+            }
         } else {
             throw new IllegalArgumentException("Expected semantic or routes 
root element");
         }
@@ -125,6 +117,30 @@ public class SemanticXmlRoutesBuilderLoader extends 
RoutesBuilderLoaderSupport {
         return routes;
     }
 
+    private static final class SemanticModelParser extends ModelParser {
+        private SemanticModelParser(
+                                    Resource resource, InputStream stream, 
String namespace)
+                                                                               
              throws IOException,
+                                                                               
              XmlPullParserException {
+            super(stream, namespace);
+            this.resource = resource;
+        }
+
+        @Override
+        protected boolean handleUnexpectedElement(String namespace, String 
name) throws XmlPullParserException {
+            if ("semantic".equals(name) && parser.getDepth() == 2) {
+                // The DOM pass already validated this block. Keep the 
original route bytes and source positions.
+                try {
+                    parser.skipSubTree();
+                } catch (IOException e) {
+                    throw new XmlPullParserException("Cannot read semantic 
declaration", parser, e);
+                }
+                return true;
+            }
+            return super.handleUnexpectedElement(namespace, name);
+        }
+    }
+
     private static void declarations(Element semantic, 
SemanticQuestionsBuilder questions) {
         attributes(semantic, Set.of());
         for (Element element : children(semantic)) {
diff --git 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
index ee784482f1fb..8a3a3f5fcb5c 100644
--- 
a/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
+++ 
b/components/camel-ai/camel-semantic/src/test/java/org/apache/camel/semantic/SemanticXmlAutoDiscoveryTest.java
@@ -32,6 +32,7 @@ import org.apache.camel.support.PluginHelper;
 import org.apache.camel.support.ResourceHelper;
 import org.apache.camel.support.SimpleRegistry;
 import org.apache.camel.support.service.ServiceSupport;
+import org.apache.camel.xml.io.XmlPullParserLocationException;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -39,13 +40,34 @@ import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 class SemanticXmlAutoDiscoveryTest {
+    private static final String LOCATED_ROUTES = """
+            <?xml version="1.0"?>
+            <routes xmlns="http://camel.apache.org/schema/xml-io";>
+              <semantic xmlns="http://camel.apache.org/schema/semantic";>
+                <question name="urgent" type="boolean">
+                  <instructions>Urgent?</instructions>
+                </question>
+              </semantic>
+              <route id="located">
+                <from uri="direct:located"/>
+                <filter>
+                  <simple>${body} != null</simple>
+                  <log message="Hello"/>
+                </filter>
+              </route>
+            </routes>
+            """;
+
     @TempDir
     Path directory;
 
     @ParameterizedTest
-    @CsvSource({ "false,routes.xml", "true,routes.xml", 
"false,routes.camel.xml", "true,routes.camel.xml" })
+    @CsvSource({
+            "false,routes.xml", "true,routes.xml", "false,routes.camel.xml", 
"true,routes.camel.xml",
+            "false,my.tickets.xml", "true,my.tickets.xml", 
"false,my.tickets.semantic.xml", "true,my.tickets.semantic.xml" })
     void mainLoadsOrdinaryXmlWithoutLoaderRegistration(boolean standalone, 
String filename) throws Exception {
         String declarations = """
                 <semantic xmlns="http://camel.apache.org/schema/semantic";>
@@ -63,7 +85,7 @@ class SemanticXmlAutoDiscoveryTest {
         Path routes = directory.resolve(filename);
         String files;
         if (standalone) {
-            Path questions = directory.resolve("questions.xml");
+            Path questions = directory.resolve("my.questions.xml");
             Files.writeString(questions, declarations);
             Files.writeString(routes, "<routes>" + route + "</routes>");
             // The consumer is deliberately listed first.
@@ -86,6 +108,56 @@ class SemanticXmlAutoDiscoveryTest {
         }
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = { "tickets.xml", "tickets.semantic.xml", 
"my.tickets.xml", "my.tickets.semantic.xml" })
+    void declarationsPreserveOriginalRouteSourceLocations(String filename) 
throws Exception {
+        try (var context = new DefaultCamelContext()) {
+            context.setSourceLocationEnabled(true);
+            
PluginHelper.getRoutesLoader(context).loadRoutes(ResourceHelper.fromString(filename,
 LOCATED_ROUTES));
+            var route = context.getRouteDefinitions().get(0);
+            assertThat(route.getLocation()).isEqualTo(filename);
+            assertThat(route.getLineNumber()).isEqualTo(8);
+            assertThat(route.getInput().getLocation()).isEqualTo(filename);
+            assertThat(route.getInput().getLineNumber()).isEqualTo(9);
+            var filter = route.getOutputs().get(0);
+            assertThat(filter.getLocation()).isEqualTo(filename);
+            assertThat(filter.getLineNumber()).isEqualTo(10);
+            
assertThat(filter.getOutputs().get(0).getLocation()).isEqualTo(filename);
+            
assertThat(filter.getOutputs().get(0).getLineNumber()).isEqualTo(12);
+        }
+    }
+
+    @Test
+    void nestedSemanticElementIsRejectedWithOriginalSourceLocation() throws 
Exception {
+        try (var context = new DefaultCamelContext()) {
+            context.setSourceLocationEnabled(true);
+            String xml = LOCATED_ROUTES.replace("<log message=\"Hello\"/>", 
"<semantic/>");
+            assertThatThrownBy(() -> PluginHelper.getRoutesLoader(context)
+                    
.loadRoutes(ResourceHelper.fromString("invalid.tickets.xml", xml)))
+                    
.isInstanceOfSatisfying(XmlPullParserLocationException.class, error -> {
+                        
assertThat(error.getResource().getLocation()).isEqualTo("invalid.tickets.xml");
+                        assertThat(error.getLineNumber()).isEqualTo(12);
+                        
assertThat(error).hasMessageContaining("invalid.tickets.xml, line 12")
+                                .hasMessageContaining("<semantic/>");
+                    });
+            assertThat(SemanticQuestions.get(context).isEmpty()).isTrue();
+        }
+    }
+
+    @Test
+    void applicationLoaderForDottedExtensionTakesPrecedence() throws Exception 
{
+        XmlRoutesBuilderLoader custom = new XmlRoutesBuilderLoader() {
+            @Override
+            public boolean isSupportedExtension(String extension) {
+                return "tickets.xml".equals(extension);
+            }
+        };
+        try (var context = new DefaultCamelContext()) {
+            context.getRegistry().bind("ticketsLoader", custom);
+            
assertThat(PluginHelper.getRoutesLoader(context).getRoutesLoader("tickets.xml")).isSameAs(custom);
+        }
+    }
+
     @Test
     void ordinaryXmlRetainsBeansRouteConfigurationsAndDelegateLifecycle() 
throws Exception {
         AtomicReference<RoutesBuilderLoader> delegate = new 
AtomicReference<>();
diff --git a/dsl/camel-jbang/camel-jbang-mcp/pom.xml 
b/dsl/camel-jbang/camel-jbang-mcp/pom.xml
index 227616abc2bb..40786dbc0ab9 100644
--- a/dsl/camel-jbang/camel-jbang-mcp/pom.xml
+++ b/dsl/camel-jbang/camel-jbang-mcp/pom.xml
@@ -116,11 +116,6 @@
             <artifactId>camel-java-joor-dsl</artifactId>
         </dependency>
 
-        <dependency>
-            <groupId>org.apache.camel</groupId>
-            <artifactId>camel-semantic</artifactId>
-        </dependency>
-
         <!-- Swagger/OpenAPI parser for contract-first OpenAPI tools -->
         <dependency>
             <groupId>io.swagger.core.v3</groupId>
@@ -152,6 +147,11 @@
         </dependency>
 
         <!-- test dependencies -->
+        <dependency>
+            <groupId>org.apache.camel</groupId>
+            <artifactId>camel-semantic</artifactId>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>org.apache.camel</groupId>
             <artifactId>camel-xml-io-dsl</artifactId>
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java
index 8ab79c7e752c..d068f2403245 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/main/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformTools.java
@@ -34,7 +34,6 @@ import org.apache.camel.model.ExpressionNode;
 import org.apache.camel.model.ProcessorDefinitionHelper;
 import org.apache.camel.model.RouteDefinition;
 import org.apache.camel.model.RoutesDefinition;
-import org.apache.camel.semantic.SemanticQuestions;
 import org.apache.camel.spi.Resource;
 import org.apache.camel.support.PluginHelper;
 import org.apache.camel.support.ResourceHelper;
@@ -170,9 +169,14 @@ public class TransformTools {
         }
     }
 
-    private static void requireSeparateDeclarations(DefaultCamelContext 
context) {
-        SemanticQuestions questions = 
context.getCamelContextExtension().getContextPlugin(SemanticQuestions.class);
-        if (questions != null && !questions.isEmpty()) {
+    private static void requireSeparateDeclarations(DefaultCamelContext 
context) throws ReflectiveOperationException {
+        // Semantic declarations are optional and live outside the model 
exported by this converter.
+        Class<?> type = 
context.getClassResolver().resolveClass("org.apache.camel.semantic.SemanticQuestions");
+        if (type == null) {
+            return;
+        }
+        Object questions = 
context.getCamelContextExtension().getContextPlugin(type);
+        if (questions != null && !(boolean) 
type.getMethod("isEmpty").invoke(questions)) {
             throw new IllegalArgumentException(
                     "Semantic declarations cannot be exported by the generic 
route converter. "
                                                + "Keep them in a separate 
declaration resource and convert only the routes.");
diff --git 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java
 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java
index ee2b90c181be..a5dc8ed53fb3 100644
--- 
a/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java
+++ 
b/dsl/camel-jbang/camel-jbang-mcp/src/test/java/org/apache/camel/dsl/jbang/core/commands/mcp/TransformToolsTest.java
@@ -69,6 +69,25 @@ class TransformToolsTest {
                 
.isInstanceOf(ToolCallException.class).hasMessageContaining("Keep them in a 
separate declaration resource");
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = { "xml", "yaml" })
+    void emptySemanticRegistryDoesNotPreventConversion(String target) {
+        String route = """
+                import org.apache.camel.builder.RouteBuilder;
+                import static 
org.apache.camel.semantic.SemanticQuestionsBuilder.semanticQuestions;
+                public class EmptySemanticRoute extends RouteBuilder {
+                    public void configure() {
+                        semanticQuestions(this).register();
+                        from("direct:input").log("Hello");
+                    }
+                }
+                """;
+        var result = createTools().camel_transform_route(route, "java", 
target);
+
+        assertThat(result.supported).isTrue();
+        
assertThat(result.result).contains("direct:input").doesNotContain("semantic");
+    }
+
     @ParameterizedTest
     @CsvSource({ "yaml,xml", "java,xml", "java,yaml" })
     void semanticConversionReportsMissingNumericProperty(String source, String 
target) {

Reply via email to