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

asf-gitbox-commits pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/cayenne.git

commit 5cf755ef8b4f2464834a42776a9df2f45da3cf38
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sun May 24 11:50:54 2026 -0400

    CAY-2949 CayenneModeler MCP: dbimport_run tool
---
 CLAUDE.md                                          |  10 +
 cayenne-mcp-server/pom.xml                         |   6 +
 .../org/apache/cayenne/mcp/CayenneMcpServer.java   |   2 +
 .../mcp/tools/dbimport/DbImportRunTool.java        | 352 +++++++++++++++++++++
 .../tools/dbimport/InstrumentedDbImportAction.java | 118 +++++++
 .../mcp/tools/dbimport/protocol/DbImportError.java |  24 ++
 .../tools/dbimport/protocol/DbImportErrorCode.java |  30 ++
 .../tools/dbimport/protocol/DbImportResolved.java  |  33 ++
 .../tools/dbimport/protocol/DbImportRunResult.java |  35 ++
 .../tools/dbimport/protocol/DbImportStatus.java    |  26 ++
 .../tools/dbimport/protocol/DbImportSummary.java   |  36 +++
 .../dbimport/protocol/DbImportValidation.java      |  36 +++
 .../cayenne/mcp/tools/dbimport/DbImportRunIT.java  | 198 ++++++++++++
 .../tools/dbimport/DbImportRunValidationTest.java  | 247 +++++++++++++++
 .../dbimport-fixtures/no-dbimport/TestMap.map.xml  |   7 +
 .../no-dbimport/cayenne-project.xml                |   4 +
 .../with-dbimport/TestMap.map.xml                  |  11 +
 .../with-dbimport/cayenne-project.xml              |   4 +
 18 files changed, 1179 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 5525ae86c..a2013a18f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -93,6 +93,16 @@ Do not set `<scope>` inside `<dependencyManagement>` (except 
`<scope>import</sco
 pins versions only — each consuming `<dependency>` declares its own scope 
explicitly.
 
 
+## Java Style
+
+Never use fully-qualified class names inline in code. Always add a proper 
`import` statement and use the simple name.
+
+Line width is 120 characters — applies to code, Javadoc, and other comments.
+
+Avoid `var` — only use it when the type is immediately obvious from the 
right-hand side of the assignment
+(e.g., `var list = new ArrayList<String>()`). Never use `var` for method 
return values, streams, or anything
+that requires reading the right-hand side carefully to determine the type.
+
 ## CI Matrix
 
 GitHub Actions runs on push to master/STABLE-* branches:
diff --git a/cayenne-mcp-server/pom.xml b/cayenne-mcp-server/pom.xml
index cf8f52b54..f440cb47e 100644
--- a/cayenne-mcp-server/pom.xml
+++ b/cayenne-mcp-server/pom.xml
@@ -56,6 +56,12 @@
             <version>${project.version}</version>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.cayenne</groupId>
+            <artifactId>cayenne-dbsync</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-api</artifactId>
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java
index f0eb6971d..3119e2ce7 100644
--- 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpServer.java
@@ -26,6 +26,7 @@ import 
io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
 import io.modelcontextprotocol.spec.McpSchema;
 import org.apache.cayenne.modeler.pref.PrefsLocator;
 import org.apache.cayenne.mcp.tools.cgen.CgenRunTool;
+import org.apache.cayenne.mcp.tools.dbimport.DbImportRunTool;
 import org.apache.cayenne.mcp.tools.openproject.OpenProjectTool;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -56,6 +57,7 @@ public class CayenneMcpServer {
                 .serverInfo("cayenne-mcp-server", version)
                 
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
                 .tools(CgenRunTool.spec(jsonMapper))
+                .tools(DbImportRunTool.spec(jsonMapper, prefsLocator))
                 .tools(OpenProjectTool.spec(jsonMapper, prefsLocator))
                 .build();
 
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java
new file mode 100644
index 000000000..a158ae0ed
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunTool.java
@@ -0,0 +1,352 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport;
+
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.server.McpServerFeatures;
+import io.modelcontextprotocol.spec.McpSchema;
+import org.apache.cayenne.configuration.DataChannelDescriptor;
+import org.apache.cayenne.configuration.xml.DataChannelMetaData;
+import org.apache.cayenne.datasource.DriverDataSource;
+import org.apache.cayenne.dbsync.DbSyncModule;
+import org.apache.cayenne.dbsync.reverse.configuration.ToolsModule;
+import org.apache.cayenne.dbsync.reverse.dbimport.DbImportConfiguration;
+import org.apache.cayenne.dbsync.reverse.dbimport.DbImportModule;
+import org.apache.cayenne.dbsync.reverse.dbimport.ReverseEngineering;
+import org.apache.cayenne.di.DIBootstrap;
+import org.apache.cayenne.di.Injector;
+import org.apache.cayenne.map.DataMap;
+import org.apache.cayenne.modeler.pref.PreferenceNodeIds;
+import org.apache.cayenne.modeler.pref.PrefsLocator;
+import org.apache.cayenne.modeler.pref.adapters.ClasspathPrefs;
+import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs;
+import org.apache.cayenne.modeler.pref.dbconnector.DBConnector;
+import org.apache.cayenne.mcp.log.McpLoggingHandler;
+import org.apache.cayenne.mcp.project.McpProjectLoaderModule;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportError;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportErrorCode;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportResolved;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportSummary;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportValidation;
+import org.apache.cayenne.project.Project;
+import org.apache.cayenne.project.ProjectLoader;
+import org.apache.cayenne.project.ProjectModule;
+import org.apache.cayenne.resource.URLResource;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.Driver;
+import java.sql.SQLException;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
+/**
+ * MCP tool that runs Cayenne dbimport for a single DataMap inside a Cayenne 
project.
+ * Reads reverse-engineering filter config from the DataMap's {@code 
<reverse-engineering>}
+ * block and JDBC connection info from the DBConnector stored in 
CayenneModeler preferences
+ * for this DataMap. Returns a structured JSON summary of what changed.
+ *
+ * @since 5.0
+ */
+public class DbImportRunTool {
+
+    public static final String NAME = "dbimport_run";
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(DbImportRunTool.class);
+
+    private final PrefsLocator prefsLocator;
+    private final Injector injector;
+
+    // Memoized driver classloader — reused while the classpath entries list 
is unchanged
+    private volatile CachedClassLoader cachedClassLoader;
+
+    public DbImportRunTool(PrefsLocator prefsLocator) {
+        this.prefsLocator = prefsLocator;
+        this.injector = DIBootstrap.createInjector(
+                new DbSyncModule(),
+                new ToolsModule(LOGGER),
+                new DbImportModule(),
+                new ProjectModule(),
+                new McpProjectLoaderModule(),
+                binder -> 
binder.bind(InstrumentedDbImportAction.class).to(InstrumentedDbImportAction.class)
+        );
+    }
+
+    public static McpServerFeatures.SyncToolSpecification spec(McpJsonMapper 
jsonMapper,
+                                                               PrefsLocator 
prefsLocator) {
+        DbImportRunTool tool = new DbImportRunTool(prefsLocator);
+
+        McpSchema.Tool descriptor = new McpSchema.Tool(
+                NAME,
+                null,
+                """
+                        Run Cayenne dbimport for a named DataMap. Reads 
reverse-engineering filters \
+                        from the DataMap's <reverse-engineering> block and 
JDBC connection from the \
+                        DBConnector that CayenneModeler stored for this 
DataMap. Returns token-count \
+                        summary and resolved connection metadata; actual 
schema changes are written \
+                        to the DataMap XML on disk.""",
+                new McpSchema.JsonSchema(
+                        "object",
+                        Map.of(
+                                "projectPath", Map.of(
+                                        "type", "string",
+                                        "description", "Absolute path to the 
top-level Cayenne project descriptor (cayenne-*.xml), not a DataMap file"),
+                                "dataMap", Map.of(
+                                        "type", "string",
+                                        "description", "Name of the target 
DataMap as it appears in the <map name='...'> element of the project 
descriptor")
+                        ),
+                        List.of("projectPath", "dataMap"),
+                        null, null, null
+                ),
+                null, null, null
+        );
+
+        return new McpServerFeatures.SyncToolSpecification(descriptor, 
(exchange, request) -> {
+            Map<String, Object> args = request.arguments();
+            String projectPath = args != null ? (String) 
args.getOrDefault("projectPath", "") : "";
+            String dataMapName = args != null ? (String) 
args.getOrDefault("dataMap", "") : "";
+
+            DbImportRunResult result = tool.run(projectPath, dataMapName);
+
+            String json;
+            try {
+                json = jsonMapper.writeValueAsString(result);
+            } catch (IOException e) {
+                json = """
+                        
{"status":"error","error":{"code":"dbimport_runtime_error",\
+                        "message":"Serialization failed: 
%s"}}""".formatted(e.getMessage());
+            }
+
+            return McpSchema.CallToolResult.builder()
+                    .content(List.of(new McpSchema.TextContent(json)))
+                    .isError(false)
+                    .build();
+        });
+    }
+
+    public DbImportRunResult run(String projectPath, String dataMapName) {
+
+        // Step 1 — project file readable?
+        Path projectFile = Path.of(projectPath);
+        if (!Files.isReadable(projectFile)) {
+            return validationFailed(DbImportErrorCode.project_not_found,
+                    "No readable file at %s".formatted(projectPath),
+                    new DbImportValidation(false, null, null, null, null, 
null));
+        }
+
+        // Step 2 — parses as Cayenne project?
+        Project project;
+        try {
+            // TODO: loading the project here, and then again within 
InstrumentedDbImportAction
+            project = injector
+                    .getInstance(ProjectLoader.class)
+                    .loadProject(new URLResource(projectFile.toUri().toURL()));
+        } catch (Exception e) {
+            return validationFailed(DbImportErrorCode.project_parse_failed,
+                    "Cayenne project loader rejected the descriptor: 
%s".formatted(e.getMessage()),
+                    new DbImportValidation(true, null, null, null, null, 
null));
+        }
+
+        // Step 3 — DataMap present?
+        DataChannelDescriptor descriptor = (DataChannelDescriptor) 
project.getRootNode();
+        DataMap dataMap = descriptor.getDataMap(dataMapName);
+        if (dataMap == null) {
+            String available = descriptor.getDataMaps().stream()
+                    .map(DataMap::getName)
+                    .sorted()
+                    .collect(Collectors.joining("', '", "'", "'"));
+            return validationFailed(DbImportErrorCode.datamap_not_found,
+                    "Project loaded successfully but contains no DataMap named 
'%s'. Available DataMaps: %s."
+                            .formatted(dataMapName, available),
+                    new DbImportValidation(true, false, null, null, null, 
null));
+        }
+
+        // Step 4 — <reverse-engineering> config present?
+        DataChannelMetaData metaData = 
injector.getInstance(DataChannelMetaData.class);
+        ReverseEngineering reverseEngineering = metaData.get(dataMap, 
ReverseEngineering.class);
+        if (reverseEngineering == null) {
+            return 
validationFailed(DbImportErrorCode.reverse_engineering_config_missing,
+                    "DataMap '%s' has no <reverse-engineering> configuration. 
Configure dbimport for this DataMap in CayenneModeler before invoking the tool."
+                            .formatted(dataMapName),
+                    new DbImportValidation(true, true, false, null, null, 
null));
+        }
+
+        // Resolve DataMap file path for preference node lookup
+        Path dataMapFile = resolveDataMapFile(dataMap);
+
+        // Step 5 — DBConnector stored in preferences?
+        String dataMapId = 
PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString());
+        DBConnector connector = new 
DataMapPrefs(prefsLocator.dataMapNode(dataMapId)).getConnector();
+        if (connector == null) {
+            return 
validationFailed(DbImportErrorCode.dbconnector_not_configured,
+                    """
+                            No DBConnector is stored in preferences for 
DataMap '%s' (project '%s'). \
+                            In CayenneModeler, open this project, right-click 
the DataMap, \
+                            choose 'Reengineer Database Schema…', and pick a 
connection — \
+                            its values will be saved to this DataMap's 
preferences.\
+                            """.formatted(dataMapName, projectPath),
+                    new DbImportValidation(true, true, true, false, null, 
null));
+        }
+
+        DbImportResolved resolved = new DbImportResolved(
+                dataMapFile.toAbsolutePath().toString(),
+                connector.getUrl(),
+                connector.getJdbcDriver(),
+                connector.getDbAdapter()
+        );
+
+        // Step 6 — JDBC driver loadable from Preferences → Classpath?
+        ClassLoader driverCl = buildDriverClassLoader();
+        Driver driver;
+        try {
+            driver = (Driver) 
driverCl.loadClass(connector.getJdbcDriver()).getDeclaredConstructor().newInstance();
+        } catch (ClassNotFoundException e) {
+            return validationFailed(DbImportErrorCode.jdbc_driver_not_loadable,
+                    "JDBC driver class '%s' could not be loaded. In 
CayenneModeler, open Preferences → Classpath, add the driver jar, then re-run — 
the MCP server reads the classpath fresh on each call."
+                            .formatted(connector.getJdbcDriver()),
+                    new DbImportValidation(true, true, true, true, false, 
null),
+                    resolved);
+        } catch (Exception e) {
+            return validationFailed(DbImportErrorCode.jdbc_driver_not_loadable,
+                    "JDBC driver class '%s' could not be instantiated: %s"
+                            .formatted(connector.getJdbcDriver(), 
e.getMessage()),
+                    new DbImportValidation(true, true, true, true, false, 
null),
+                    resolved);
+        }
+
+        // Step 7 — JDBC connection opens?
+        Connection conn;
+        try {
+            conn = new DriverDataSource(
+                    driver,
+                    connector.getUrl(),
+                    connector.getUserName(),
+                    connector.getPassword()).getConnection();
+
+        } catch (SQLException e) {
+            String sqlState = e.getSQLState() != null ? " [SQLState: 
%s]".formatted(e.getSQLState()) : "";
+            return validationFailed(DbImportErrorCode.jdbc_connection_failed,
+                    "Could not open JDBC connection to '%s' as '%s': %s%s."
+                            .formatted(connector.getUrl(), 
connector.getUserName(), e.getMessage(), sqlState),
+                    new DbImportValidation(true, true, true, true, true, 
false),
+                    resolved);
+        }
+
+        // All validation passed — run dbimport
+        Supplier<List<String>> stopCapture = 
McpLoggingHandler.startCapture("org.apache.cayenne.dbsync");
+        List<String> warnings;
+        try {
+            InstrumentedDbImportAction action = 
injector.getInstance(InstrumentedDbImportAction.class);
+            DbImportConfiguration config = buildConfig(connector, dataMapFile, 
projectFile);
+            action.execute(config);
+
+            int considered = action.getCapturedTokens().size();
+            DbImportSummary summary = action.buildSummary(considered);
+            String status = considered == 0 ? "up_to_date" : "imported";
+            warnings = stopCapture.get();
+            return new DbImportRunResult(status, summary, resolved, warnings, 
DbImportValidation.ALL_PASSED, null);
+        } catch (Exception e) {
+            warnings = stopCapture.get();
+            InstrumentedDbImportAction action = 
injector.getInstance(InstrumentedDbImportAction.class);
+            DbImportSummary partial = action.buildSummary(0);
+            String msg = e.getMessage() != null ? e.getMessage() : 
e.getClass().getName();
+            return new DbImportRunResult("error", partial, resolved, warnings, 
DbImportValidation.ALL_PASSED,
+                    new 
DbImportError(DbImportErrorCode.dbimport_runtime_error, msg));
+        } finally {
+            try {
+                conn.close();
+            } catch (SQLException ignored) {
+            }
+        }
+    }
+
+    private DbImportConfiguration buildConfig(DBConnector connector, Path 
dataMapFile, Path projectFile) {
+        DbImportConfiguration config = new DbImportConfiguration();
+        config.setDriver(connector.getJdbcDriver());
+        config.setUrl(connector.getUrl());
+        config.setUsername(connector.getUserName());
+        config.setPassword(connector.getPassword());
+        config.setAdapter(connector.getDbAdapter());
+        config.setTargetDataMap(dataMapFile.toFile());
+        config.setCayenneProject(projectFile.toFile());
+        config.setUseDataMapReverseEngineering(true);
+        return config;
+    }
+
+    private Path resolveDataMapFile(DataMap dataMap) {
+        try {
+            URL sourceUrl = dataMap.getConfigurationSource().getURL();
+            return Path.of(sourceUrl.toURI());
+        } catch (Exception e) {
+            throw new IllegalStateException("Cannot resolve DataMap file path 
for '%s'".formatted(dataMap.getName()), e);
+        }
+    }
+
+    private ClassLoader buildDriverClassLoader() {
+        List<String> entries = new 
ClasspathPrefs(prefsLocator.appNode(ClasspathPrefs.NODE)).getEntries();
+        CachedClassLoader cached = this.cachedClassLoader;
+        if (cached != null && cached.entries.equals(entries)) {
+            return cached.loader;
+        }
+        URL[] urls = entries.stream()
+                .map(e -> {
+                    try {
+                        return Path.of(e).toUri().toURL();
+                    } catch (Exception ex) {
+                        return null;
+                    }
+                })
+                .filter(Objects::nonNull)
+                .toArray(URL[]::new);
+        ClassLoader loader = new URLClassLoader(urls, 
getClass().getClassLoader());
+        this.cachedClassLoader = new CachedClassLoader(entries, loader);
+        return loader;
+    }
+
+    private static DbImportRunResult validationFailed(DbImportErrorCode code, 
String message,
+                                                      DbImportValidation 
validation) {
+        return validationFailed(code, message, validation, null);
+    }
+
+    private static DbImportRunResult validationFailed(DbImportErrorCode code, 
String message,
+                                                      DbImportValidation 
validation,
+                                                      DbImportResolved 
resolved) {
+        return new DbImportRunResult(
+                "validation_failed",
+                DbImportSummary.ZERO,
+                resolved,
+                List.of(),
+                validation,
+                new DbImportError(code, message)
+        );
+    }
+
+    private record CachedClassLoader(List<String> entries, ClassLoader loader) 
{
+    }
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java
new file mode 100644
index 000000000..2c0cc5b4a
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/InstrumentedDbImportAction.java
@@ -0,0 +1,118 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport;
+
+import org.apache.cayenne.configuration.DataChannelDescriptorLoader;
+import org.apache.cayenne.configuration.DataMapLoader;
+import org.apache.cayenne.configuration.runtime.DataSourceFactory;
+import org.apache.cayenne.configuration.runtime.DbAdapterFactory;
+import org.apache.cayenne.configuration.xml.DataChannelMetaData;
+import org.apache.cayenne.dbsync.merge.token.MergerToken;
+import org.apache.cayenne.dbsync.merge.token.model.AddRelationshipToModel;
+import org.apache.cayenne.dbsync.merge.token.model.CreateTableToModel;
+import org.apache.cayenne.dbsync.merge.token.model.DropTableToModel;
+import org.apache.cayenne.dbsync.merge.factory.MergerTokenFactoryProvider;
+import org.apache.cayenne.dbsync.reverse.dbimport.DefaultDbImportAction;
+import org.apache.cayenne.di.Inject;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportSummary;
+import org.apache.cayenne.project.ProjectSaver;
+import org.slf4j.Logger;
+
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Extends {@link DefaultDbImportAction} to capture the merger token list for 
summary
+ * reporting. Overrides {@link #log(List)} — the single protected hook that 
receives
+ * the final, sorted token list immediately before application.
+ *
+ * @since 5.0
+ */
+public class InstrumentedDbImportAction extends DefaultDbImportAction {
+
+    private List<MergerToken> capturedTokens = List.of();
+
+    public InstrumentedDbImportAction(
+            @Inject Logger logger,
+            @Inject ProjectSaver projectSaver,
+            @Inject DataSourceFactory dataSourceFactory,
+            @Inject DbAdapterFactory adapterFactory,
+            @Inject DataMapLoader mapLoader,
+            @Inject MergerTokenFactoryProvider mergerTokenFactoryProvider,
+            @Inject DataChannelDescriptorLoader dataChannelDescriptorLoader,
+            @Inject DataChannelMetaData metaData) {
+        super(logger, projectSaver, dataSourceFactory, adapterFactory, 
mapLoader,
+                mergerTokenFactoryProvider, dataChannelDescriptorLoader, 
metaData);
+    }
+
+    @Override
+    protected Collection<MergerToken> log(List<MergerToken> tokens) {
+        capturedTokens = List.copyOf(tokens);
+        return super.log(tokens);
+    }
+
+    /**
+     * Builds a {@link DbImportSummary} from the captured token list.
+     *
+     * @param tokensApplied number of tokens actually applied (equals 
tokensConsidered on
+     *                      a successful run, 0 if an exception aborted before 
the apply phase)
+     */
+    public DbImportSummary buildSummary(int tokensApplied) {
+        int entitiesAdded = 0;
+        int entitiesRemoved = 0;
+        int relationshipsAdded = 0;
+        Set<String> modifiedEntities = new HashSet<>();
+
+        Set<String> addedEntityNames = new HashSet<>();
+        for (MergerToken token : capturedTokens) {
+            switch (token) {
+                case CreateTableToModel ignored -> {
+                    entitiesAdded++;
+                    addedEntityNames.add(token.getTokenValue());
+                }
+                case DropTableToModel ignored -> entitiesRemoved++;
+                case AddRelationshipToModel ignored -> relationshipsAdded++;
+                default -> {
+                    // Column-level and other entity-scoped tokens: tokenValue 
is "entity.column" or "entity"
+                    String tv = token.getTokenValue();
+                    int dot = tv.indexOf('.');
+                    modifiedEntities.add(dot >= 0 ? tv.substring(0, dot) : tv);
+                }
+            }
+        }
+
+        // Entities added this run don't count as modified
+        modifiedEntities.removeAll(addedEntityNames);
+
+        return new DbImportSummary(
+                capturedTokens.size(),
+                tokensApplied,
+                entitiesAdded,
+                entitiesRemoved,
+                modifiedEntities.size(),
+                relationshipsAdded
+        );
+    }
+
+    public List<MergerToken> getCapturedTokens() {
+        return capturedTokens;
+    }
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java
new file mode 100644
index 000000000..ac1619361
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportError.java
@@ -0,0 +1,24 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+/**
+ * @since 5.0
+ */
+public record DbImportError(DbImportErrorCode code, String message) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java
new file mode 100644
index 000000000..352a6d351
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportErrorCode.java
@@ -0,0 +1,30 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+public enum DbImportErrorCode {
+    project_not_found,
+    project_parse_failed,
+    datamap_not_found,
+    reverse_engineering_config_missing,
+    dbconnector_not_configured,
+    jdbc_driver_not_loadable,
+    jdbc_connection_failed,
+    dbimport_runtime_error
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java
new file mode 100644
index 000000000..d03e1d2ec
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportResolved.java
@@ -0,0 +1,33 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+/**
+ * Connection and DataMap metadata resolved at runtime by {@code dbimport_run}.
+ * Deliberately omits {@code userName} and {@code password} — no field means no
+ * accidental serialization.
+ *
+ * @since 5.0
+ */
+public record DbImportResolved(
+        String dataMapFile,
+        String jdbcUrl,
+        String jdbcDriver,
+        String dbAdapter
+) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java
new file mode 100644
index 000000000..504169232
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportRunResult.java
@@ -0,0 +1,35 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+import java.util.List;
+
+/**
+ * Top-level envelope returned by the {@code dbimport_run} MCP tool.
+ *
+ * @since 5.0
+ */
+public record DbImportRunResult(
+        String status,
+        DbImportSummary summary,
+        DbImportResolved resolved,
+        List<String> warnings,
+        DbImportValidation validation,
+        DbImportError error
+) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java
new file mode 100644
index 000000000..06aae2618
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportStatus.java
@@ -0,0 +1,26 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+public enum DbImportStatus {
+    imported,
+    up_to_date,
+    validation_failed,
+    error
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java
new file mode 100644
index 000000000..baea98b8e
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportSummary.java
@@ -0,0 +1,36 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+/**
+ * Merger-token counters returned by {@code dbimport_run}.
+ *
+ * @since 5.0
+ */
+public record DbImportSummary(
+        int tokensConsidered,
+        int tokensApplied,
+        int entitiesAdded,
+        int entitiesRemoved,
+        int entitiesModified,
+        int relationshipsAdded
+) {
+    public static final DbImportSummary ZERO =
+            new DbImportSummary(0, 0, 0, 0, 0, 0);
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java
new file mode 100644
index 000000000..f869b3f11
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/dbimport/protocol/DbImportValidation.java
@@ -0,0 +1,36 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport.protocol;
+
+/**
+ * Per-step validation flags for {@code dbimport_run}. Boxed {@link Boolean} 
so unchecked slots can be {@code null}
+ * (short-circuit on first failure).
+ *
+ * @since 5.0
+ */
+public record DbImportValidation(
+        Boolean projectFound,
+        Boolean dataMapFound,
+        Boolean reverseEngineeringConfigPresent,
+        Boolean dbConnectorPresent,
+        Boolean jdbcDriverLoadable,
+        Boolean jdbcConnectionOpened
+) {
+    public static final DbImportValidation ALL_PASSED = new 
DbImportValidation(true, true, true, true, true, true);
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java
new file mode 100644
index 000000000..caf34e10b
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunIT.java
@@ -0,0 +1,198 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport;
+
+import org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult;
+import org.apache.cayenne.modeler.pref.PreferenceNodeIds;
+import org.apache.cayenne.modeler.pref.PrefsLocator;
+import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs;
+import org.apache.cayenne.modeler.pref.dbconnector.DBConnector;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.Statement;
+import java.util.UUID;
+import java.util.prefs.Preferences;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class DbImportRunIT {
+
+    private static final String HSQL_ADAPTER = 
HSQLDBNoSchemaAdapter.class.getName();
+    private static final String HSQL_DRIVER = "org.hsqldb.jdbc.JDBCDriver";
+
+    @TempDir
+    Path tempDir;
+
+    private String dbUrl;
+    private Connection dbConn;
+    private Preferences prefsRoot;
+    private PrefsLocator prefsLocator;
+    private DbImportRunTool tool;
+    private Path projectFile;
+    private Path dataMapFile;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        dbUrl = "jdbc:hsqldb:mem:dbimport_it_%s;shutdown=true"
+                .formatted(UUID.randomUUID().toString().replace("-", ""));
+        dbConn = DriverManager.getConnection(dbUrl, "SA", "");
+        try (Statement s = dbConn.createStatement()) {
+            s.execute("""
+                    CREATE TABLE artist (
+                        id   INTEGER NOT NULL PRIMARY KEY,
+                        name VARCHAR(255)
+                    )""");
+            s.execute("""
+                    CREATE TABLE painting (
+                        id        INTEGER NOT NULL PRIMARY KEY,
+                        title     VARCHAR(255),
+                        artist_id INTEGER,
+                        FOREIGN KEY (artist_id) REFERENCES artist(id)
+                    )""");
+        }
+
+        prefsRoot = Preferences.userRoot()
+                .node("cayenne-test/dbimport-it-" + 
UUID.randomUUID().toString().replace("-", ""));
+        prefsLocator = new PrefsLocator(prefsRoot);
+        tool = new DbImportRunTool(prefsLocator);
+
+        // Write fixture files
+        projectFile = DbImportRunValidationTest.copyFixture("with-dbimport", 
tempDir);
+        dataMapFile = tempDir.resolve("TestMap.map.xml");
+
+        // Store connector in prefs
+        storeConnector(dbUrl, "SA", "", HSQL_DRIVER, HSQL_ADAPTER);
+    }
+
+    @AfterEach
+    public void tearDown() throws Exception {
+        prefsRoot.removeNode();
+        if (dbConn != null && !dbConn.isClosed()) {
+            dbConn.close();
+        }
+    }
+
+    @Test
+    public void happyPath() throws IOException {
+        DbImportRunResult result = tool.run(projectFile.toString(), "TestMap");
+
+        assertEquals("imported", result.status());
+        assertNull(result.error());
+        assertNotNull(result.resolved());
+        assertEquals(dbUrl, result.resolved().jdbcUrl());
+        assertEquals(HSQL_DRIVER, result.resolved().jdbcDriver());
+
+        assertTrue(result.summary().tokensApplied() > 0);
+        assertEquals(result.summary().tokensConsidered(), 
result.summary().tokensApplied());
+        assertEquals(2, result.summary().entitiesAdded(), "Expected 2 new 
entities (artist, painting)");
+        assertEquals(0, result.summary().entitiesRemoved());
+        assertTrue(result.summary().relationshipsAdded() >= 0, "Relationship 
count must be non-negative");
+
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertTrue(result.validation().reverseEngineeringConfigPresent());
+        assertTrue(result.validation().dbConnectorPresent());
+        assertTrue(result.validation().jdbcDriverLoadable());
+        assertTrue(result.validation().jdbcConnectionOpened());
+
+        // DataMap XML on disk should now contain the two db-entities
+        String xml = Files.readString(dataMapFile);
+        assertTrue(xml.contains("artist"), "DataMap XML should contain 
'artist' entity");
+        assertTrue(xml.contains("painting"), "DataMap XML should contain 
'painting' entity");
+    }
+
+    @Test
+    public void idempotency() {
+        tool.run(projectFile.toString(), "TestMap");
+
+        DbImportRunResult second = tool.run(projectFile.toString(), "TestMap");
+
+        assertEquals("up_to_date", second.status());
+        assertNull(second.error());
+        assertEquals(0, second.summary().tokensConsidered());
+        assertEquals(0, second.summary().tokensApplied());
+        assertEquals(0, second.summary().entitiesAdded());
+        assertEquals(0, second.summary().entitiesModified());
+        assertEquals(0, second.summary().entitiesRemoved());
+        assertEquals(0, second.summary().relationshipsAdded());
+    }
+
+    @Test
+    public void drift() throws Exception {
+        // First import — establishes baseline
+        tool.run(projectFile.toString(), "TestMap");
+
+        // Add a column to the live DB
+        try (Statement s = dbConn.createStatement()) {
+            s.execute("ALTER TABLE artist ADD COLUMN bio VARCHAR(1000)");
+        }
+
+        DbImportRunResult result = tool.run(projectFile.toString(), "TestMap");
+
+        assertEquals("imported", result.status(), result.error() != null ? 
result.error().message() : "");
+        assertEquals(1, result.summary().tokensApplied());
+        assertEquals(1, result.summary().entitiesModified());
+        assertEquals(0, result.summary().entitiesAdded());
+
+        // Verify column is in the XML
+        String xml = Files.readString(dataMapFile);
+        assertTrue(xml.contains("bio"), "bio attribute should be present in 
DataMap XML");
+    }
+
+    @Test
+    public void connectorNotConfigured() {
+        // Clear the connector from prefs
+        new DataMapPrefs(prefsLocator.dataMapNode(
+                
PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString())))
+                .setConnector(new DBConnector()); // empty connector clears 
URL sentinel
+
+        DbImportRunResult result = tool.run(projectFile.toString(), "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertFalse(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+    }
+
+    private void storeConnector(
+            String url,
+            String user,
+            String password,
+            String driver,
+            String adapter) {
+
+        DBConnector c = new DBConnector();
+        c.setUrl(url);
+        c.setUserName(user);
+        c.setPassword(password);
+        c.setJdbcDriver(driver);
+        c.setDbAdapter(adapter);
+
+        Preferences mapPrefs = 
prefsLocator.dataMapNode(PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString()));
+        new DataMapPrefs(mapPrefs).setConnector(c);
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java
new file mode 100644
index 000000000..c4f09307c
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/dbimport/DbImportRunValidationTest.java
@@ -0,0 +1,247 @@
+/*****************************************************************
+ *   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
+ *
+ *    https://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.cayenne.mcp.tools.dbimport;
+
+import org.apache.cayenne.modeler.pref.PreferenceNodeIds;
+import org.apache.cayenne.modeler.pref.PrefsLocator;
+import org.apache.cayenne.modeler.pref.adapters.DataMapPrefs;
+import org.apache.cayenne.modeler.pref.dbconnector.DBConnector;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportErrorCode;
+import org.apache.cayenne.mcp.tools.dbimport.protocol.DbImportRunResult;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.UUID;
+import java.util.prefs.BackingStoreException;
+import java.util.prefs.Preferences;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class DbImportRunValidationTest {
+
+    private static DbImportRunTool tool;
+    private static PrefsLocator prefsLocator;
+
+    private Preferences testPrefsRoot;
+
+    @BeforeAll
+    public static void setUpClass() {
+        prefsLocator = new 
PrefsLocator(Preferences.userRoot().node("cayenne-test/dbimport-validation"));
+        tool = new DbImportRunTool(prefsLocator);
+    }
+
+    @AfterEach
+    public void cleanupPrefs() throws BackingStoreException {
+        if (testPrefsRoot != null) {
+            testPrefsRoot.removeNode();
+            testPrefsRoot = null;
+        }
+    }
+
+    @Test
+    public void projectNotFound() {
+        DbImportRunResult result = 
tool.run("/no/such/file/cayenne-project.xml", "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.project_not_found, 
result.error().code());
+        assertFalse(result.validation().projectFound());
+        assertNull(result.validation().dataMapFound());
+        assertNull(result.validation().reverseEngineeringConfigPresent());
+        assertNull(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNull(result.resolved());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void projectParseFailed(@TempDir Path tmp) throws IOException {
+        Path badXml = tmp.resolve("cayenne-project.xml");
+        Files.writeString(badXml, "this is not xml <<< garbage");
+
+        DbImportRunResult result = tool.run(badXml.toString(), "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.project_parse_failed, 
result.error().code());
+        assertTrue(result.validation().projectFound());
+        assertNull(result.validation().dataMapFound());
+        assertNull(result.validation().reverseEngineeringConfigPresent());
+        assertNull(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void dataMapNotFound() throws URISyntaxException {
+        String projectPath = fixtureProject("no-dbimport");
+
+        DbImportRunResult result = tool.run(projectPath, "NoSuchMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.datamap_not_found, 
result.error().code());
+        assertTrue(result.error().message().contains("NoSuchMap"));
+        assertTrue(result.error().message().contains("TestMap"), "Available 
maps should be listed");
+        assertTrue(result.validation().projectFound());
+        assertFalse(result.validation().dataMapFound());
+        assertNull(result.validation().reverseEngineeringConfigPresent());
+        assertNull(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void reverseEngineeringConfigMissing() throws URISyntaxException {
+        String projectPath = fixtureProject("no-dbimport");
+
+        DbImportRunResult result = tool.run(projectPath, "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.reverse_engineering_config_missing, 
result.error().code());
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertFalse(result.validation().reverseEngineeringConfigPresent());
+        assertNull(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void dbConnectorNotConfigured(@TempDir Path tmp) throws IOException 
{
+        Path projectFile = copyFixture("with-dbimport", tmp);
+        Preferences isolatedRoot = isolatedPrefsRoot();
+        // No connector stored — DataMapPrefs node is empty
+
+        DbImportRunTool isolatedTool = new DbImportRunTool(new 
PrefsLocator(isolatedRoot));
+        DbImportRunResult result = isolatedTool.run(projectFile.toString(), 
"TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.dbconnector_not_configured, 
result.error().code());
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertTrue(result.validation().reverseEngineeringConfigPresent());
+        assertFalse(result.validation().dbConnectorPresent());
+        assertNull(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNull(result.resolved());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void jdbcDriverNotLoadable(@TempDir Path tmp) throws IOException {
+        Path projectFile = copyFixture("with-dbimport", tmp);
+        Path dataMapFile = tmp.resolve("TestMap.map.xml");
+
+        Preferences isolatedRoot = isolatedPrefsRoot();
+        PrefsLocator locator = new PrefsLocator(isolatedRoot);
+
+        DBConnector connector = new DBConnector();
+        connector.setUrl("jdbc:hsqldb:mem:drivertest");
+        connector.setUserName("SA");
+        connector.setPassword("");
+        connector.setJdbcDriver("com.nonexistent.Driver");
+        
connector.setDbAdapter("org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter");
+        new DataMapPrefs(locator.dataMapNode(
+                
PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString())))
+                .setConnector(connector);
+
+        DbImportRunTool isolatedTool = new DbImportRunTool(locator);
+        DbImportRunResult result = isolatedTool.run(projectFile.toString(), 
"TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.jdbc_driver_not_loadable, 
result.error().code());
+        
assertTrue(result.error().message().contains("com.nonexistent.Driver"));
+        assertTrue(result.validation().dbConnectorPresent());
+        assertFalse(result.validation().jdbcDriverLoadable());
+        assertNull(result.validation().jdbcConnectionOpened());
+        assertNotNull(result.resolved(), "resolved should be populated even on 
driver failure");
+        assertEquals("jdbc:hsqldb:mem:drivertest", 
result.resolved().jdbcUrl());
+        assertNoCredentialsInResult(result);
+    }
+
+    @Test
+    public void jdbcConnectionFailed(@TempDir Path tmp) throws IOException {
+        Path projectFile = copyFixture("with-dbimport", tmp);
+        Path dataMapFile = tmp.resolve("TestMap.map.xml");
+
+        Preferences isolatedRoot = isolatedPrefsRoot();
+        PrefsLocator locator = new PrefsLocator(isolatedRoot);
+
+        DBConnector connector = new DBConnector();
+        // HSQL network server on localhost:1 — not running, will refuse
+        connector.setUrl("jdbc:hsqldb:hsql://localhost:1/nonexistent");
+        connector.setUserName("SA");
+        connector.setPassword("");
+        connector.setJdbcDriver("org.hsqldb.jdbc.JDBCDriver");
+        
connector.setDbAdapter("org.apache.cayenne.dba.hsqldb.HSQLDBNoSchemaAdapter");
+        new DataMapPrefs(locator.dataMapNode(
+                
PreferenceNodeIds.idForPath(dataMapFile.toAbsolutePath().toString())))
+                .setConnector(connector);
+
+        DbImportRunTool isolatedTool = new DbImportRunTool(locator);
+        DbImportRunResult result = isolatedTool.run(projectFile.toString(), 
"TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(DbImportErrorCode.jdbc_connection_failed, 
result.error().code());
+        assertTrue(result.validation().jdbcDriverLoadable());
+        assertFalse(result.validation().jdbcConnectionOpened());
+        assertNotNull(result.resolved());
+        assertNoCredentialsInResult(result);
+    }
+
+    private static String fixtureProject(String fixture) throws 
URISyntaxException {
+        return Paths.get(DbImportRunValidationTest.class
+                .getResource("/dbimport-fixtures/" + fixture + 
"/cayenne-project.xml")
+                .toURI()).toString();
+    }
+
+    private Preferences isolatedPrefsRoot() {
+        testPrefsRoot = Preferences.userRoot()
+                .node("cayenne-test/dbimport-val-" + 
UUID.randomUUID().toString().replace("-", ""));
+        return testPrefsRoot;
+    }
+
+    static Path copyFixture(String fixture, Path dir) throws IOException {
+        for (String name : new String[]{"cayenne-project.xml", 
"TestMap.map.xml"}) {
+            try (InputStream in = DbImportRunValidationTest.class
+                    .getResourceAsStream("/dbimport-fixtures/" + fixture + "/" 
+ name)) {
+                Files.copy(in, dir.resolve(name));
+            }
+        }
+        return dir.resolve("cayenne-project.xml");
+    }
+
+    private static void assertNoCredentialsInResult(DbImportRunResult result) {
+        // Structural guarantee: DbImportResolved has no password/userName 
fields.
+        // Belt-and-suspenders: verify the toString representation contains 
neither.
+        String s = result.toString();
+        assertFalse(s.contains("password"), "password must not appear in 
result");
+        assertFalse(s.contains("userName"), "userName must not appear in 
result");
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml
 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml
new file mode 100644
index 000000000..2f2bf0e1d
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/TestMap.map.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-map xmlns="http://cayenne.apache.org/schema/12/modelMap";
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+          xsi:schemaLocation="http://cayenne.apache.org/schema/12/modelMap 
http://cayenne.apache.org/schema/12/modelMap.xsd";
+          project-version="12">
+    <property name="defaultPackage" value="com.example"/>
+</data-map>
diff --git 
a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml
 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml
new file mode 100644
index 000000000..f40e0ac36
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/no-dbimport/cayenne-project.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<domain xmlns="http://cayenne.apache.org/schema/12/domain"; 
project-version="12">
+    <map name="TestMap"/>
+</domain>
diff --git 
a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml
 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml
new file mode 100644
index 000000000..6793a11a5
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/TestMap.map.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<data-map xmlns="http://cayenne.apache.org/schema/12/modelMap";
+          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance";
+          xsi:schemaLocation="http://cayenne.apache.org/schema/12/modelMap 
http://cayenne.apache.org/schema/12/modelMap.xsd";
+          project-version="12">
+    <property name="defaultPackage" value="com.example"/>
+    <dbImport xmlns="http://cayenne.apache.org/schema/12/dbimport";>
+        <skipRelationshipsLoading>false</skipRelationshipsLoading>
+        <skipPrimaryKeyLoading>false</skipPrimaryKeyLoading>
+    </dbImport>
+</data-map>
diff --git 
a/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml
 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml
new file mode 100644
index 000000000..f40e0ac36
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/dbimport-fixtures/with-dbimport/cayenne-project.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<domain xmlns="http://cayenne.apache.org/schema/12/domain"; 
project-version="12">
+    <map name="TestMap"/>
+</domain>

Reply via email to