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


The following commit(s) were added to refs/heads/master by this push:
     new a4fcb3f8a CAY-2942 Move warning-capture logic from CgenRunTool into 
StderrLogging
a4fcb3f8a is described below

commit a4fcb3f8a57491593d079e9e58768251b1279352
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sun May 10 11:38:13 2026 -0400

    CAY-2942 Move warning-capture logic from CgenRunTool into StderrLogging
---
 .../org/apache/cayenne/gen/CgenConfiguration.java  |  20 +-
 cayenne-mcp-server/pom.xml                         |   6 +
 .../org/apache/cayenne/mcp/CayenneMcpMain.java     |   6 +-
 .../org/apache/cayenne/mcp/CayenneMcpServer.java   |  13 +-
 .../McpLoggingHandler.java}                        |  45 +++-
 .../mcp/project/McpProjectLoaderModule.java        |  55 +++++
 .../apache/cayenne/mcp/tools/cgen/CgenRunTool.java | 257 ++++++++++++++++++++
 .../cgen/InstrumentedClassGenerationAction.java    | 141 +++++++++++
 .../cayenne/mcp/tools/cgen/protocol/CgenError.java |  24 ++
 .../mcp/tools/cgen/protocol/CgenErrorCode.java     |  35 +++
 .../mcp/tools/cgen/protocol/CgenFileEntry.java     |  26 ++
 .../mcp/tools/cgen/protocol/CgenFileKind.java      |  33 +++
 .../tools/cgen/protocol/CgenResolvedConfig.java    |  27 +++
 .../mcp/tools/cgen/protocol/CgenRunResult.java     |  36 +++
 .../mcp/tools/cgen/protocol/CgenSummary.java       |  24 ++
 .../mcp/tools/cgen/protocol/CgenValidation.java    |  33 +++
 .../cayenne/mcp/tools/{ => hello}/HelloTool.java   |   2 +-
 .../org/apache/cayenne/mcp/CayenneMcpServerIT.java |  59 -----
 .../java/org/apache/cayenne/mcp/McpHandle.java     |  42 ++++
 .../java/org/apache/cayenne/mcp/McpStarter.java    |  59 ++---
 .../apache/cayenne/mcp/tools/cgen/CgenRunIT.java   | 148 ++++++++++++
 .../cayenne/mcp/tools/cgen/CgenRunMcpIT.java       | 261 +++++++++++++++++++++
 .../mcp/tools/cgen/CgenRunValidationTest.java      | 136 +++++++++++
 .../cayenne/mcp/{ => tools/hello}/HelloMcpIT.java  |  26 +-
 .../mcp/{ => tools/hello}/HelloToolTest.java       |   3 +-
 .../cgen-fixtures/no-cgen/TestMap.map.xml          |   8 +
 .../cgen-fixtures/no-cgen/cayenne-project.xml      |   4 +
 .../cgen-fixtures/no-destdir/TestMap.map.xml       |  12 +
 .../cgen-fixtures/no-destdir/cayenne-project.xml   |   4 +
 29 files changed, 1400 insertions(+), 145 deletions(-)

diff --git 
a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/CgenConfiguration.java 
b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/CgenConfiguration.java
index 557d9ae97..a5d763fa0 100644
--- a/cayenne-cgen/src/main/java/org/apache/cayenne/gen/CgenConfiguration.java
+++ b/cayenne-cgen/src/main/java/org/apache/cayenne/gen/CgenConfiguration.java
@@ -19,6 +19,15 @@
 
 package org.apache.cayenne.gen;
 
+import org.apache.cayenne.configuration.ConfigurationNodeVisitor;
+import org.apache.cayenne.gen.xml.CgenExtension;
+import org.apache.cayenne.map.DataMap;
+import org.apache.cayenne.map.Embeddable;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.util.XMLEncoder;
+import org.apache.cayenne.util.XMLSerializable;
+import org.apache.cayenne.validation.ValidationException;
+
 import java.io.Serializable;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -29,15 +38,6 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
-import org.apache.cayenne.configuration.ConfigurationNodeVisitor;
-import org.apache.cayenne.gen.xml.CgenExtension;
-import org.apache.cayenne.map.DataMap;
-import org.apache.cayenne.map.Embeddable;
-import org.apache.cayenne.map.ObjEntity;
-import org.apache.cayenne.util.XMLEncoder;
-import org.apache.cayenne.util.XMLSerializable;
-import org.apache.cayenne.validation.ValidationException;
-
 /**
  * Stores configuration for the code generation tool.
  * CgenConfiguration is stored in the dataMap xml file and used by Modeler and 
CLI tools (Maven, Gradle and Ant).
@@ -371,7 +371,7 @@ public class CgenConfiguration implements Serializable, 
XMLSerializable {
         this.createPKProperties = createPKProperties;
     }
 
-    Collection<Artifact> getArtifacts() {
+    public Collection<Artifact> getArtifacts() {
         return artifacts;
     }
 
diff --git a/cayenne-mcp-server/pom.xml b/cayenne-mcp-server/pom.xml
index e6f989f7d..7eca6077f 100644
--- a/cayenne-mcp-server/pom.xml
+++ b/cayenne-mcp-server/pom.xml
@@ -44,6 +44,12 @@
             <artifactId>mcp</artifactId>
         </dependency>
 
+        <dependency>
+            <groupId>org.apache.cayenne</groupId>
+            <artifactId>cayenne-cgen</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/CayenneMcpMain.java 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java
index 4270e30be..cb6fcfa0e 100644
--- 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/CayenneMcpMain.java
@@ -18,13 +18,15 @@
  ****************************************************************/
 package org.apache.cayenne.mcp;
 
+import org.apache.cayenne.mcp.log.McpLoggingHandler;
+
 /**
  * Entry point for the Cayenne MCP server.
  */
 public class CayenneMcpMain {
 
     public static void main(String[] args) {
-        StderrLogging.install();
+        McpLoggingHandler.routeLogsToStderr();
 
         CayenneMcpOptions opts = CayenneMcpOptions.parse(args);
 
@@ -44,7 +46,7 @@ public class CayenneMcpMain {
             System.exit(0);
         }
 
-        new CayenneMcpServer().run(version);
+        new CayenneMcpServer().run(version, System.in, System.out);
     }
 
     static String version() {
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 459d26d24..9178f6e78 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
@@ -24,10 +24,13 @@ import io.modelcontextprotocol.server.McpServer;
 import io.modelcontextprotocol.server.McpSyncServer;
 import io.modelcontextprotocol.server.transport.StdioServerTransportProvider;
 import io.modelcontextprotocol.spec.McpSchema;
-import org.apache.cayenne.mcp.tools.HelloTool;
+import org.apache.cayenne.mcp.tools.cgen.CgenRunTool;
+import org.apache.cayenne.mcp.tools.hello.HelloTool;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.util.ServiceLoader;
 
 /**
@@ -37,7 +40,7 @@ public class CayenneMcpServer {
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(CayenneMcpServer.class);
 
-    public void run(String version) {
+    public void run(String version, InputStream in, OutputStream out) {
         LOGGER.info("Starting Cayenne MCP server {}", version);
 
         McpJsonMapper jsonMapper = 
ServiceLoader.load(McpJsonMapperSupplier.class)
@@ -45,21 +48,19 @@ public class CayenneMcpServer {
                 .orElseThrow(() -> new IllegalStateException("No 
McpJsonMapperSupplier found on classpath"))
                 .get();
 
-        StdioServerTransportProvider transport = new 
StdioServerTransportProvider(jsonMapper);
+        StdioServerTransportProvider transport = new 
StdioServerTransportProvider(jsonMapper, in, out);
 
         McpSyncServer server = McpServer.sync(transport)
                 .serverInfo("cayenne-mcp-server", version)
                 
.capabilities(McpSchema.ServerCapabilities.builder().tools(true).build())
-                .tools(HelloTool.spec())
+                .tools(HelloTool.spec(), CgenRunTool.spec(jsonMapper))
                 .build();
 
-        // Close server cleanly on SIGTERM / ctrl-C
         Runtime.getRuntime().addShutdownHook(new Thread(() -> {
             LOGGER.info("Cayenne MCP server stopping");
             server.closeGracefully();
         }));
 
         LOGGER.info("Cayenne MCP server started, listening on stdio");
-        // The transport's non-daemon threads keep the JVM alive until stdin 
closes.
     }
 }
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/log/McpLoggingHandler.java
similarity index 53%
rename from 
cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java
rename to 
cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/log/McpLoggingHandler.java
index f2a06b4e1..a3541dfb7 100644
--- a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/StderrLogging.java
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/log/McpLoggingHandler.java
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-package org.apache.cayenne.mcp;
+package org.apache.cayenne.mcp.log;
 
 import ch.qos.logback.classic.Level;
 import ch.qos.logback.classic.Logger;
@@ -24,17 +24,23 @@ import ch.qos.logback.classic.LoggerContext;
 import ch.qos.logback.classic.encoder.PatternLayoutEncoder;
 import ch.qos.logback.classic.spi.ILoggingEvent;
 import ch.qos.logback.core.ConsoleAppender;
+import ch.qos.logback.core.read.ListAppender;
 import org.slf4j.LoggerFactory;
 
+import java.util.List;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+
 /**
- * Forces all logging to stderr so that stdout remains clean for the MCP stdio 
transport.
- * Must be called before any other code that may log.
+ * Configures logging for the MCP server: routes everything to stderr so stdout
+ * stays clean for the MCP stdio transport, and provides per-invocation 
warning capture
+ * for tools that surface Cayenne log output in their response payloads.
  */
-class StderrLogging {
+public class McpLoggingHandler {
 
     private static final String PATTERN = "%d{HH:mm:ss.SSS} [%thread] %-5level 
%logger{36} - %msg%n";
 
-    public static void install() {
+    public static void routeLogsToStderr() {
         LoggerContext context = (LoggerContext) 
LoggerFactory.getILoggerFactory();
         context.reset();
 
@@ -55,4 +61,33 @@ class StderrLogging {
         root.setLevel(Level.INFO);
         root.addAppender(appender);
     }
+
+    /**
+     * Attaches a transient in-memory appender to {@code loggerName} and 
returns a
+     * {@code Supplier} that, when called, detaches it and returns all 
WARN-or-above
+     * messages collected since this call. Intended for use in a {@code 
finally} block:
+     * <pre>
+     *   var stopCapture = 
StderrLogging.startCapture("org.apache.cayenne.gen");
+     *   try { ... } finally { warnings = stopCapture.get(); }
+     * </pre>
+     * Returns an empty-list supplier if Logback is not the active SLF4J 
backend.
+     */
+    public static Supplier<List<String>> startCapture(String loggerName) {
+        try {
+            LoggerContext ctx = (LoggerContext) 
LoggerFactory.getILoggerFactory();
+            Logger logger = ctx.getLogger(loggerName);
+            ListAppender<ILoggingEvent> appender = new ListAppender<>();
+            appender.start();
+            logger.addAppender(appender);
+            return () -> {
+                ctx.getLogger(loggerName).detachAppender(appender);
+                return appender.list.stream()
+                        .filter(e -> e.getLevel().isGreaterOrEqual(Level.WARN))
+                        .map(ILoggingEvent::getFormattedMessage)
+                        .collect(Collectors.toList());
+            };
+        } catch (ClassCastException ignored) {
+            return List::of;
+        }
+    }
 }
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/project/McpProjectLoaderModule.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/project/McpProjectLoaderModule.java
new file mode 100644
index 000000000..da5e9820e
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/project/McpProjectLoaderModule.java
@@ -0,0 +1,55 @@
+/*****************************************************************
+ *   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.project;
+
+import org.apache.cayenne.configuration.DataChannelDescriptorLoader;
+import org.apache.cayenne.configuration.DataMapLoader;
+import org.apache.cayenne.configuration.xml.DataChannelMetaData;
+import org.apache.cayenne.configuration.xml.DefaultDataChannelMetaData;
+import org.apache.cayenne.configuration.xml.HandlerFactory;
+import org.apache.cayenne.configuration.xml.XMLDataChannelDescriptorLoader;
+import org.apache.cayenne.configuration.xml.XMLDataMapLoader;
+import org.apache.cayenne.configuration.xml.XMLReaderProvider;
+import org.apache.cayenne.di.Binder;
+import org.apache.cayenne.di.Module;
+import org.apache.cayenne.project.extension.ExtensionAwareHandlerFactory;
+import org.apache.cayenne.resource.ClassLoaderResourceLocator;
+import org.apache.cayenne.resource.ResourceLocator;
+import org.xml.sax.XMLReader;
+
+/**
+ * Wires the DI bindings needed to load a Cayenne project descriptor and read
+ * its embedded extension metadata (e.g., cgen configuration).
+ * Registered alongside {@link org.apache.cayenne.project.ProjectModule} and 
the
+ * auto-loaded {@code CgenModule} when building the MCP tools injector.
+ *
+ * @since 5.0
+ */
+public class McpProjectLoaderModule implements Module {
+
+    @Override
+    public void configure(Binder binder) {
+        
binder.bind(DataChannelDescriptorLoader.class).to(XMLDataChannelDescriptorLoader.class);
+        
binder.bind(HandlerFactory.class).to(ExtensionAwareHandlerFactory.class);
+        
binder.bind(DataChannelMetaData.class).to(DefaultDataChannelMetaData.class);
+        binder.bind(DataMapLoader.class).to(XMLDataMapLoader.class);
+        
binder.bind(ResourceLocator.class).to(ClassLoaderResourceLocator.class);
+        binder.bind(XMLReader.class).toProviderInstance(new 
XMLReaderProvider(false)).withoutScope();
+    }
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/CgenRunTool.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/CgenRunTool.java
new file mode 100644
index 000000000..0a6e45417
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/CgenRunTool.java
@@ -0,0 +1,257 @@
+/*****************************************************************
+ *   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.cgen;
+
+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.di.Injector;
+import org.apache.cayenne.gen.CgenConfigList;
+import org.apache.cayenne.gen.CgenConfiguration;
+import org.apache.cayenne.gen.MetadataUtils;
+import org.apache.cayenne.gen.ToolsUtilsFactory;
+import org.apache.cayenne.map.DataMap;
+import org.apache.cayenne.mcp.project.McpProjectLoaderModule;
+import org.apache.cayenne.mcp.log.McpLoggingHandler;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenError;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenErrorCode;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenFileEntry;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenResolvedConfig;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenRunResult;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenSummary;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenValidation;
+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.apache.cayenne.tools.ToolsInjectorBuilder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * MCP tool that runs Cayenne class generation (cgen) for a single DataMap 
inside
+ * a Cayenne project, using the cgen configuration stored in the DataMap XML.
+ * Returns a structured JSON delta describing what changed on disk.
+ *
+ * @since 5.0
+ */
+public class CgenRunTool {
+
+    public static final String NAME = "cgen_run";
+
+    private final Injector injector;
+
+    public CgenRunTool() {
+        this.injector = new ToolsInjectorBuilder()
+                .addModule(new ProjectModule())
+                .addModule(new McpProjectLoaderModule())
+                .create();
+    }
+
+    public static McpServerFeatures.SyncToolSpecification spec(McpJsonMapper 
jsonMapper) {
+        CgenRunTool tool = new CgenRunTool();
+
+        McpSchema.Tool descriptor = new McpSchema.Tool(
+                NAME,
+                null,
+                "Run Cayenne class generator (cgen) for a named DataMap. " +
+                        "Returns a JSON report of what was written, what was 
already up-to-date, and any errors.",
+                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", "") : "";
+
+            CgenRunResult result = tool.run(projectPath, dataMapName);
+
+            String json;
+            try {
+                json = jsonMapper.writeValueAsString(result);
+            } catch (IOException e) {
+                json = 
"{\"status\":\"error\",\"error\":{\"code\":\"cgen_runtime_error\"," +
+                        "\"message\":\"Serialization failed: " + 
e.getMessage() + "\"}}";
+            }
+
+            return McpSchema.CallToolResult.builder()
+                    .content(List.of(new McpSchema.TextContent(json)))
+                    .isError(false)
+                    .build();
+        });
+    }
+
+    /**
+     * Runs cgen for the named DataMap inside the given project file and 
returns the result envelope.
+     */
+    public CgenRunResult run(String projectPath, String dataMapName) {
+
+        // Step 1 — project file readable?
+        Path projectFile = Path.of(projectPath);
+        if (!Files.isReadable(projectFile)) {
+            return validationFailed(CgenErrorCode.project_not_found,
+                    "No readable file at " + projectPath,
+                    new CgenValidation(false, null, null, null, null));
+        }
+
+        // Step 2 — parses as Cayenne project?
+        Project project;
+        try {
+            ProjectLoader loader = injector.getInstance(ProjectLoader.class);
+            project = loader.loadProject(new 
URLResource(projectFile.toUri().toURL()));
+        } catch (Exception e) {
+            return validationFailed(CgenErrorCode.project_parse_failed,
+                    "Cayenne project loader rejected the descriptor: " + 
e.getMessage(),
+                    new CgenValidation(true, 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(CgenErrorCode.datamap_not_found,
+                    "Project loaded successfully but contains no DataMap named 
'" + dataMapName
+                            + "'. Available DataMaps: " + available + ".",
+                    new CgenValidation(true, false, null, null, null));
+        }
+
+        // Step 4 — cgen configuration present?
+        DataChannelMetaData metaData = 
injector.getInstance(DataChannelMetaData.class);
+        CgenConfigList configList = metaData.get(dataMap, 
CgenConfigList.class);
+        if (configList == null || configList.getAll().isEmpty()) {
+            return validationFailed(CgenErrorCode.cgen_config_missing,
+                    "DataMap '" + dataMapName + "' has no <cgen> 
configuration. "
+                            + "Configure code generation for this DataMap in 
CayenneModeler before invoking the tool.",
+                    new CgenValidation(true, true, false, null, null));
+        }
+        CgenConfiguration cgenConfig = configList.getAll().get(0);
+
+        // Step 5 — destDir specified?
+        Path destDir = cgenConfig.buildOutputPath();
+        if (destDir == null) {
+            return validationFailed(CgenErrorCode.destdir_not_specified,
+                    "cgen configuration for '" + dataMapName + "' does not 
specify a destination directory.",
+                    new CgenValidation(true, true, true, false, null));
+        }
+
+        // Step 6 — destDir writable (create if absent)?
+        if (!ensureWritable(destDir)) {
+            return validationFailed(CgenErrorCode.destdir_not_writable,
+                    "Destination directory '" + destDir + "' exists but is not 
writable by the MCP server process.",
+                    new CgenValidation(true, true, true, true, false));
+        }
+
+        CgenResolvedConfig resolvedConfig = new 
CgenResolvedConfig(destDir.toAbsolutePath().toString());
+        CgenValidation allPassed = new CgenValidation(true, true, true, true, 
true);
+
+        // Execute cgen
+        InstrumentedClassGenerationAction action = new 
InstrumentedClassGenerationAction(cgenConfig);
+        action.setUtilsFactory(injector.getInstance(ToolsUtilsFactory.class));
+        action.setMetadataUtils(injector.getInstance(MetadataUtils.class));
+
+        action.prepareArtifacts();
+        int filesConsidered = action.countFilesConsidered();
+
+        var stopCapture = 
McpLoggingHandler.startCapture("org.apache.cayenne.gen");
+        List<String> warnings;
+        Exception caughtException = null;
+        try {
+            action.execute();
+        } catch (Exception e) {
+            caughtException = e;
+        } finally {
+            warnings = stopCapture.get();
+        }
+
+        if (caughtException != null) {
+            List<CgenFileEntry> partial = action.getWrittenFiles();
+            String msg = caughtException.getMessage() != null
+                    ? caughtException.getMessage() : 
caughtException.getClass().getName();
+            return new CgenRunResult(
+                    "error",
+                    new CgenSummary(filesConsidered, partial.size()),
+                    partial,
+                    resolvedConfig,
+                    warnings,
+                    allPassed,
+                    new CgenError(CgenErrorCode.cgen_runtime_error, msg)
+            );
+        }
+
+        List<CgenFileEntry> writtenFiles = action.getWrittenFiles();
+        String status = writtenFiles.isEmpty() ? "up_to_date" : "generated";
+
+        return new CgenRunResult(
+                status,
+                new CgenSummary(filesConsidered, writtenFiles.size()),
+                writtenFiles,
+                resolvedConfig,
+                warnings,
+                allPassed,
+                null
+        );
+    }
+
+    private static CgenRunResult validationFailed(CgenErrorCode code, String 
message, CgenValidation validation) {
+        return new CgenRunResult(
+                "validation_failed",
+                new CgenSummary(0, 0),
+                List.of(),
+                null,
+                List.of(),
+                validation,
+                new CgenError(code, message)
+        );
+    }
+
+    private static boolean ensureWritable(Path dir) {
+        if (Files.exists(dir)) {
+            return Files.isWritable(dir);
+        }
+        try {
+            Files.createDirectories(dir);
+            return true;
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/InstrumentedClassGenerationAction.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/InstrumentedClassGenerationAction.java
new file mode 100644
index 000000000..d37cc0064
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/InstrumentedClassGenerationAction.java
@@ -0,0 +1,141 @@
+/*****************************************************************
+ *   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.cgen;
+
+import org.apache.cayenne.gen.Artifact;
+import org.apache.cayenne.gen.ArtifactGenerationMode;
+import org.apache.cayenne.gen.CgenConfiguration;
+import org.apache.cayenne.gen.ClassGenerationAction;
+import org.apache.cayenne.gen.StringUtils;
+import org.apache.cayenne.gen.TemplateType;
+import org.apache.cayenne.map.Embeddable;
+import org.apache.cayenne.map.ObjEntity;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenFileEntry;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenFileKind;
+
+import java.io.File;
+import java.io.Writer;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Extends {@link ClassGenerationAction} to record which files cgen wrote and 
how many
+ * were considered. Placed in {@code org.apache.cayenne.gen} so it shares the 
same Java
+ * package as {@link ClassGenerationAction} and can read its protected fields.
+ *
+ * @since 5.0
+ */
+class InstrumentedClassGenerationAction extends ClassGenerationAction {
+
+    private final List<CgenFileEntry> writtenFiles;
+    private Artifact currentArtifact;
+
+    public InstrumentedClassGenerationAction(CgenConfiguration cgenConfig) {
+        super(cgenConfig);
+        this.writtenFiles = new ArrayList<>();
+    }
+
+    @Override
+    protected void execute(Artifact artifact) throws Exception {
+        currentArtifact = artifact;
+        try {
+            super.execute(artifact);
+        } finally {
+            currentArtifact = null;
+        }
+    }
+
+    @Override
+    protected Writer openWriter(TemplateType templateType) throws Exception {
+        Writer w = super.openWriter(templateType);
+        if (w != null && currentArtifact != null) {
+            writtenFiles.add(buildFileEntry(currentArtifact, templateType));
+        }
+        return w;
+    }
+
+    public List<CgenFileEntry> getWrittenFiles() {
+        return List.copyOf(writtenFiles);
+    }
+
+    /**
+     * Counts the total number of files cgen will evaluate for this DataMap.
+     * Must be called after {@link #prepareArtifacts()}.
+     */
+    public int countFilesConsidered() {
+        ArtifactGenerationMode mode = cgenConfiguration.isMakePairs()
+                ? ArtifactGenerationMode.GENERATION_GAP
+                : ArtifactGenerationMode.SINGLE_CLASS;
+        int count = 0;
+        for (Artifact artifact : cgenConfiguration.getArtifacts()) {
+            count += artifact.getTemplateTypes(mode).length;
+        }
+        return count;
+    }
+
+    private CgenFileEntry buildFileEntry(Artifact artifact, TemplateType 
templateType) {
+        String packageName;
+        String className;
+        if (templateType.isSuperclass()) {
+            packageName = (String) context.get(Artifact.SUPER_PACKAGE_KEY);
+            className = (String) context.get(Artifact.SUPER_CLASS_KEY);
+        } else {
+            packageName = (String) context.get(Artifact.SUB_PACKAGE_KEY);
+            className = (String) context.get(Artifact.SUB_CLASS_KEY);
+        }
+
+        String filename = 
StringUtils.getInstance().replaceWildcardInStringWithString(
+                "*", cgenConfiguration.getOutputPattern(), className);
+        Path dir = cgenConfiguration.buildOutputPath();
+        if (cgenConfiguration.isUsePkgPath() && packageName != null) {
+            dir = dir.resolve(packageName.replace('.', File.separatorChar));
+        }
+        Path filePath = dir.resolve(filename).toAbsolutePath();
+
+        return new CgenFileEntry(filePath.toString(), toKind(templateType), 
sourceName(artifact));
+    }
+
+    private static CgenFileKind toKind(TemplateType type) {
+        return switch (type) {
+            case ENTITY_SUPERCLASS -> CgenFileKind.entity_super;
+            case ENTITY_SUBCLASS, ENTITY_SINGLE_CLASS -> 
CgenFileKind.entity_sub;
+            case EMBEDDABLE_SUPERCLASS -> CgenFileKind.embeddable_super;
+            case EMBEDDABLE_SUBCLASS, EMBEDDABLE_SINGLE_CLASS -> 
CgenFileKind.embeddable_sub;
+            case DATAMAP_SUPERCLASS, DATAMAP_SUBCLASS, DATAMAP_SINGLE_CLASS -> 
CgenFileKind.datamap;
+        };
+    }
+
+    private static String sourceName(Artifact artifact) {
+        Object obj = artifact.getObject();
+        return switch (obj) {
+            case ObjEntity entity -> entity.getName();
+            case Embeddable embeddable -> {
+                String fq = embeddable.getClassName();
+                int dot = fq.lastIndexOf('.');
+                yield dot >= 0 ? fq.substring(dot + 1) : fq;
+            }
+            default -> {
+                String fq = artifact.getQualifiedBaseClassName();
+                int dot = fq.lastIndexOf('.');
+                yield dot >= 0 ? fq.substring(dot + 1) : fq;
+            }
+        };
+    }
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenError.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenError.java
new file mode 100644
index 000000000..82315d127
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenError.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.cgen.protocol;
+
+/**
+ * @since 5.0
+ */
+public record CgenError(CgenErrorCode code, String message) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenErrorCode.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenErrorCode.java
new file mode 100644
index 000000000..e2fde135d
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenErrorCode.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.cgen.protocol;
+
+/**
+ * Error codes returned by the {@code cgen_run} MCP tool.
+ * Enum names are lowercase and match the JSON serialization directly.
+ *
+ * @since 5.0
+ */
+public enum CgenErrorCode {
+    project_not_found,
+    project_parse_failed,
+    datamap_not_found,
+    cgen_config_missing,
+    destdir_not_specified,
+    destdir_not_writable,
+    cgen_runtime_error
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileEntry.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileEntry.java
new file mode 100644
index 000000000..08226429c
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileEntry.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.cgen.protocol;
+
+/**
+ * Describes a single file written by a cgen run.
+ *
+ * @since 5.0
+ */
+public record CgenFileEntry(String path, CgenFileKind kind, String 
sourceEntity) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileKind.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileKind.java
new file mode 100644
index 000000000..fa6da1dd7
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenFileKind.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.cgen.protocol;
+
+/**
+ * Classifies the kind of source file produced by a cgen run.
+ * Enum names are lowercase and match the JSON serialization directly.
+ *
+ * @since 5.0
+ */
+public enum CgenFileKind {
+    entity_super,
+    entity_sub,
+    embeddable_super,
+    embeddable_sub,
+    datamap
+}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenResolvedConfig.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenResolvedConfig.java
new file mode 100644
index 000000000..f0e7d0220
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenResolvedConfig.java
@@ -0,0 +1,27 @@
+/*****************************************************************
+ *   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.cgen.protocol;
+
+/**
+ * Carries the absolutized {@code destDir} — the only cgen state that cannot be
+ * read directly from the DataMap XML (it may be stored as a relative path 
there).
+ *
+ * @since 5.0
+ */
+public record CgenResolvedConfig(String destDir) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenRunResult.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenRunResult.java
new file mode 100644
index 000000000..0afd2457d
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenRunResult.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.cgen.protocol;
+
+import java.util.List;
+
+/**
+ * Top-level envelope returned by the {@code cgen_run} MCP tool.
+ *
+ * @since 5.0
+ */
+public record CgenRunResult(
+        String status,
+        CgenSummary summary,
+        List<CgenFileEntry> files,
+        CgenResolvedConfig resolved,
+        List<String> warnings,
+        CgenValidation validation,
+        CgenError error
+) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenSummary.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenSummary.java
new file mode 100644
index 000000000..afd5b357e
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenSummary.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.cgen.protocol;
+
+/**
+ * @since 5.0
+ */
+public record CgenSummary(int filesConsidered, int filesWritten) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenValidation.java
 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenValidation.java
new file mode 100644
index 000000000..1b2e56b0e
--- /dev/null
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/cgen/protocol/CgenValidation.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.cgen.protocol;
+
+/**
+ * Per-step validation outcome. Slots that were not reached because an earlier
+ * check failed are {@code null}.
+ *
+ * @since 5.0
+ */
+public record CgenValidation(
+        Boolean projectFound,
+        Boolean dataMapFound,
+        Boolean cgenConfigPresent,
+        Boolean destDirSpecified,
+        Boolean destDirWritable
+) {}
diff --git 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/hello/HelloTool.java
similarity index 97%
rename from 
cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java
rename to 
cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/hello/HelloTool.java
index fedc309b9..81f3756c7 100644
--- 
a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/HelloTool.java
+++ 
b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/hello/HelloTool.java
@@ -16,7 +16,7 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-package org.apache.cayenne.mcp.tools;
+package org.apache.cayenne.mcp.tools.hello;
 
 import io.modelcontextprotocol.server.McpServerFeatures;
 import io.modelcontextprotocol.spec.McpSchema;
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java
deleted file mode 100644
index efcde70dd..000000000
--- 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/CayenneMcpServerIT.java
+++ /dev/null
@@ -1,59 +0,0 @@
-/*****************************************************************
- *   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;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.concurrent.TimeUnit;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-
-/**
- * Integration tests for server startup flags.
- * Requires the shaded jar to be built before this test runs (failsafe 
executes after package).
- */
-public class CayenneMcpServerIT {
-
-    @Test
-    public void unknownArgExitsWithCode2() throws Exception {
-        Process process = McpStarter.start("--unknown-flag");
-        assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not 
exit");
-        assertEquals(2, process.exitValue(), "Expected exit code 2 for unknown 
arg");
-    }
-
-    @Test
-    public void helpFlagExitsWithCode0() throws Exception {
-        Process process = McpStarter.start("--help");
-        assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not 
exit");
-        assertEquals(0, process.exitValue(), "Expected exit code 0 for 
--help");
-    }
-
-    @Test
-    public void versionFlagExitsWithCode0() throws Exception {
-        Process process = McpStarter.start("--version");
-        assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Process did not 
exit");
-        assertEquals(0, process.exitValue(), "Expected exit code 0 for 
--version");
-
-        String err = new String(process.getErrorStream().readAllBytes());
-        assertTrue(err.contains("cayenne-mcp-server"), "Version output missing 
server name: " + err);
-    }
-
-
-}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpHandle.java 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpHandle.java
new file mode 100644
index 000000000..14df0a075
--- /dev/null
+++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpHandle.java
@@ -0,0 +1,42 @@
+package org.apache.cayenne.mcp;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Handle to an in-process MCP server started by {@link McpStarter}.
+ * Exposes the piped streams the test uses to send and receive JSON-RPC 
messages.
+ */
+public class McpHandle {
+
+    private final OutputStream outputStream;
+    private final InputStream inputStream;
+    private final Thread serverThread;
+
+    McpHandle(OutputStream outputStream, InputStream inputStream, Thread 
serverThread) {
+        this.outputStream = outputStream;
+        this.inputStream = inputStream;
+        this.serverThread = serverThread;
+    }
+
+    /** Stream the test writes JSON-RPC requests to (→ server stdin). */
+    public OutputStream getOutputStream() {
+        return outputStream;
+    }
+
+    /** Stream the test reads JSON-RPC responses from (← server stdout). */
+    public InputStream getInputStream() {
+        return inputStream;
+    }
+
+    /**
+     * Waits for the server thread to finish, returning {@code true} if it 
stops
+     * within {@code timeout}. Closing {@link #getOutputStream()} signals EOF 
to
+     * the server and causes it to shut down.
+     */
+    public boolean waitFor(long timeout, TimeUnit unit) throws 
InterruptedException {
+        serverThread.join(unit.toMillis(timeout));
+        return !serverThread.isAlive();
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpStarter.java 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpStarter.java
index 690110317..4cc6d8cbe 100644
--- a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpStarter.java
+++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/McpStarter.java
@@ -1,52 +1,27 @@
 package org.apache.cayenne.mcp;
 
 import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.stream.Stream;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
 
-class McpStarter {
+public class McpStarter {
 
-    private static final String JAVA_EXE = javaExecutable();
-    private static final String MCP_JAR = mcpJar();
-
-    public static Process start(String... flags) {
-        List<String> cmd = new ArrayList<>();
-        cmd.add(JAVA_EXE);
-        cmd.add("-jar");
-        cmd.add(MCP_JAR);
-        cmd.addAll(List.of(flags));
+    public static McpHandle start() {
         try {
-            return new ProcessBuilder(cmd).start();
-        } catch (IOException e) {
-            throw new IllegalStateException("Error starting MCP server", e);
-        }
-    }
-
-    private static String javaExecutable() {
-        String name = System.getProperty("os.name", 
"").toLowerCase().contains("win") ? "java.exe" : "java";
-        return Path.of(System.getProperty("java.home"), "bin", 
name).toString();
-    }
-
-    private static String mcpJar() {
-        Path targetDir = Path.of(System.getProperty("user.dir"), "target");
-
-        if (!Files.isDirectory(targetDir)) {
-            throw new IllegalStateException("Shaded jar not found — run mvn 
package first");
-        }
-
-        try (Stream<Path> stream = Files.list(targetDir)) {
-            return stream
-                    .filter(p -> 
p.getFileName().toString().matches("cayenne-mcp-server-.*\\.jar"))
-                    .filter(p -> 
!p.getFileName().toString().contains("original"))
-                    .filter(p -> 
!p.getFileName().toString().contains("sources"))
-                    .map(Path::toString)
-                    .findFirst()
-                    .orElseThrow(() -> new IllegalStateException("Shaded jar 
not found — run mvn package first"));
+            PipedOutputStream clientOut = new PipedOutputStream();
+            PipedInputStream serverIn = new PipedInputStream(clientOut);
+            PipedOutputStream serverOut = new PipedOutputStream();
+            PipedInputStream clientIn = new PipedInputStream(serverOut);
+
+            Thread serverThread = new Thread(
+                    () -> new CayenneMcpServer().run("test", serverIn, 
serverOut),
+                    "mcp-server");
+            serverThread.setDaemon(true);
+            serverThread.start();
+
+            return new McpHandle(clientOut, clientIn, serverThread);
         } catch (IOException e) {
-            throw new RuntimeException(e);
+            throw new RuntimeException("Failed to start in-process MCP 
server", e);
         }
     }
 }
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunIT.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunIT.java
new file mode 100644
index 000000000..0243ca34d
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunIT.java
@@ -0,0 +1,148 @@
+/*****************************************************************
+ *   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.cgen;
+
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenFileEntry;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenFileKind;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenRunResult;
+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.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Integration tests that drive cgen against fixture projects written into a 
temp directory.
+ * These tests intentionally run via Failsafe (IT suffix) so the shaded JAR is 
available.
+ */
+public class CgenRunIT {
+
+    @TempDir
+    Path tempDir;
+
+    private CgenRunTool tool;
+    private Path projectFile;
+    private Path destDir;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        tool = new CgenRunTool();
+        destDir = tempDir.resolve("generated");
+        writeFixture("PersonMap", "com.example", destDir, true);
+        projectFile = tempDir.resolve("cayenne-project.xml");
+    }
+
+    @Test
+    public void generatesFilesOnFirstRun() {
+        CgenRunResult result = tool.run(projectFile.toString(), "PersonMap");
+
+        assertEquals("generated", result.status());
+        assertNotNull(result.resolved());
+        assertEquals(destDir.toAbsolutePath().toString(), 
result.resolved().destDir());
+
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertTrue(result.validation().cgenConfigPresent());
+        assertTrue(result.validation().destDirSpecified());
+        assertTrue(result.validation().destDirWritable());
+        assertNull(result.error());
+
+        // makePairs=true → superclass + subclass for each entity
+        int filesWritten = result.summary().filesWritten();
+        assertTrue(filesWritten > 0, "Expected at least one generated file");
+        assertEquals(filesWritten, result.files().size());
+        assertEquals(result.summary().filesConsidered(), result.files().size(),
+                "All considered files should have been written on a fresh 
run");
+
+        List<CgenFileKind> kinds = 
result.files().stream().map(CgenFileEntry::kind).toList();
+        assertTrue(kinds.contains(CgenFileKind.entity_super), "Expected 
entity_super file");
+        assertTrue(kinds.contains(CgenFileKind.entity_sub), "Expected 
entity_sub file");
+
+        for (CgenFileEntry entry : result.files()) {
+            assertTrue(Files.exists(Path.of(entry.path())),
+                    "Generated file should exist on disk: " + entry.path());
+            assertEquals("Person", entry.sourceEntity());
+        }
+    }
+
+    @Test
+    public void upToDateOnSecondRun() {
+        tool.run(projectFile.toString(), "PersonMap");
+
+        CgenRunResult second = tool.run(projectFile.toString(), "PersonMap");
+
+        assertEquals("up_to_date", second.status());
+        assertEquals(0, second.summary().filesWritten());
+        assertTrue(second.files().isEmpty());
+        assertTrue(second.summary().filesConsidered() > 0);
+        assertNull(second.error());
+    }
+
+    @Test
+    public void subclassNotOverwrittenWhenMakePairsIsTrue() throws IOException 
{
+        // Pre-create the subclass file so it already exists before cgen runs.
+        // With makePairs=true, cgen must never overwrite an existing subclass.
+        Path subclassFile = destDir.resolve("com/example/Person.java");
+        Files.createDirectories(subclassFile.getParent());
+        Files.writeString(subclassFile, "// existing subclass\n");
+
+        CgenRunResult result = tool.run(projectFile.toString(), "PersonMap");
+
+        // The superclass (_Person.java) should be generated; the subclass 
should not.
+        List<String> generatedPaths = 
result.files().stream().map(CgenFileEntry::path).toList();
+        assertTrue(generatedPaths.stream().noneMatch(p -> 
p.endsWith("Person.java") && !p.contains("_")),
+                "Existing subclass must not appear in written files");
+
+        int skipped = result.summary().filesConsidered() - 
result.summary().filesWritten();
+        assertTrue(skipped >= 1, "At least one file (the existing subclass) 
should have been skipped");
+    }
+    
+    private void writeFixture(String mapName, String pkg, Path destDir, 
boolean makePairs) throws IOException {
+        // Project descriptor
+        Files.writeString(tempDir.resolve("cayenne-project.xml"), 
String.format("""
+                <?xml version="1.0" encoding="utf-8"?>
+                <domain xmlns="http://cayenne.apache.org/schema/12/domain"; 
project-version="12">
+                    <map name="%s"/>
+                </domain>
+                """, mapName));
+
+        // DataMap with one entity and embedded cgen config
+        Files.writeString(tempDir.resolve(mapName + ".map.xml"), 
String.format("""
+                <?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";
+                          project-version="12">
+                    <property name="defaultPackage" value="%s"/>
+                    <obj-entity name="Person" className="%s.Person"/>
+                    <cgen xmlns="http://cayenne.apache.org/schema/12/cgen";>
+                        <destDir>%s</destDir>
+                        <mode>entity</mode>
+                        <makePairs>%s</makePairs>
+                        <usePkgPath>true</usePkgPath>
+                        <overwrite>false</overwrite>
+                    </cgen>
+                </data-map>
+                """, pkg, pkg, destDir.toAbsolutePath(), makePairs));
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunMcpIT.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunMcpIT.java
new file mode 100644
index 000000000..b2a58fa56
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunMcpIT.java
@@ -0,0 +1,261 @@
+/*****************************************************************
+ *   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.cgen;
+
+import org.apache.cayenne.mcp.McpHandle;
+import org.apache.cayenne.mcp.McpStarter;
+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 tools.jackson.databind.JsonNode;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStreamWriter;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Integration tests for the {@code cgen_run} tool over the MCP stdio protocol.
+ * Complements {@link CgenRunIT}, which calls the tool directly; this class 
exercises
+ * the full JSON-RPC round-trip through the in-process MCP server.
+ */
+public class CgenRunMcpIT {
+
+    private static final ObjectMapper MAPPER = JsonMapper.builder().build();
+
+    @TempDir
+    Path tempDir;
+
+    private McpHandle handle;
+    private BufferedWriter writer;
+    private BufferedReader reader;
+    private Path projectFile;
+    private Path destDir;
+
+    @BeforeEach
+    void setUp() throws Exception {
+        destDir = tempDir.resolve("generated");
+        writeFixture("PersonMap", "com.example", destDir, true);
+        projectFile = tempDir.resolve("cayenne-project.xml");
+
+        handle = McpStarter.start();
+        writer = new BufferedWriter(new 
OutputStreamWriter(handle.getOutputStream()));
+        reader = new BufferedReader(new 
InputStreamReader(handle.getInputStream()));
+
+        send("""
+                {"jsonrpc":"2.0","id":1,"method":"initialize","params":{\
+                "protocolVersion":"2024-11-05",\
+                "capabilities":{},\
+                "clientInfo":{"name":"test","version":"1.0"}}}""");
+        String initResponse = readLine(5_000);
+        assertTrue(initResponse.contains("\"id\":1"), "initialize response 
missing: " + initResponse);
+
+        send("""
+                
{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}""");
+    }
+
+    @AfterEach
+    void stopServer() throws Exception {
+        writer.close();
+        assertTrue(handle.waitFor(10, TimeUnit.SECONDS), "Server thread did 
not stop after stdin was closed");
+    }
+
+    @Test
+    public void toolsListIncludesCgenRun() throws Exception {
+        send("""
+                {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}""");
+
+        String response = readLine(5_000);
+        assertTrue(response.contains("\"cgen_run\""), "tools/list missing 
'cgen_run': " + response);
+    }
+
+    @Test
+    public void projectNotFoundReturnsValidationError() throws Exception {
+        send("""
+                {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{\
+                "name":"cgen_run","arguments":{\
+                "projectPath":"/no/such/file.xml","dataMap":"X"}}}""");
+
+        assertEquals("""
+                {
+                  "status" : "validation_failed",
+                  "summary" : {
+                    "filesConsidered" : 0,
+                    "filesWritten" : 0
+                  },
+                  "files" : [ ],
+                  "resolved" : null,
+                  "warnings" : [ ],
+                  "validation" : {
+                    "projectFound" : false,
+                    "dataMapFound" : null,
+                    "cgenConfigPresent" : null,
+                    "destDirSpecified" : null,
+                    "destDirWritable" : null
+                  },
+                  "error" : {
+                    "code" : "project_not_found",
+                    "message" : "No readable file at /no/such/file.xml"
+                  }
+                }""", extractPayload(readLine(5_000)));
+    }
+
+    @Test
+    public void generatesFilesOnFirstRun() throws Exception {
+        send(callJson(4, projectFile, "PersonMap"));
+
+        String superPath = 
destDir.resolve("com/example/auto/_Person.java").toAbsolutePath().toString();
+        String subPath   = 
destDir.resolve("com/example/Person.java").toAbsolutePath().toString();
+        String destPath  = destDir.toAbsolutePath().toString();
+
+        assertEquals("""
+                {
+                  "status" : "generated",
+                  "summary" : {
+                    "filesConsidered" : 2,
+                    "filesWritten" : 2
+                  },
+                  "files" : [ {
+                    "path" : "%s",
+                    "kind" : "entity_super",
+                    "sourceEntity" : "Person"
+                  }, {
+                    "path" : "%s",
+                    "kind" : "entity_sub",
+                    "sourceEntity" : "Person"
+                  } ],
+                  "resolved" : {
+                    "destDir" : "%s"
+                  },
+                  "warnings" : [ ],
+                  "validation" : {
+                    "projectFound" : true,
+                    "dataMapFound" : true,
+                    "cgenConfigPresent" : true,
+                    "destDirSpecified" : true,
+                    "destDirWritable" : true
+                  },
+                  "error" : null
+                }""".formatted(superPath, subPath, destPath), 
extractPayload(readLine(15_000)));
+    }
+
+    @Test
+    public void upToDateOnSecondRun() throws Exception {
+        send(callJson(5, projectFile, "PersonMap"));
+        readLine(15_000);
+
+        send(callJson(6, projectFile, "PersonMap"));
+
+        String destPath = destDir.toAbsolutePath().toString();
+
+        assertEquals("""
+                {
+                  "status" : "up_to_date",
+                  "summary" : {
+                    "filesConsidered" : 2,
+                    "filesWritten" : 0
+                  },
+                  "files" : [ ],
+                  "resolved" : {
+                    "destDir" : "%s"
+                  },
+                  "warnings" : [ ],
+                  "validation" : {
+                    "projectFound" : true,
+                    "dataMapFound" : true,
+                    "cgenConfigPresent" : true,
+                    "destDirSpecified" : true,
+                    "destDirWritable" : true
+                  },
+                  "error" : null
+                }""".formatted(destPath), extractPayload(readLine(15_000)));
+    }
+    
+    private String extractPayload(String mcpResponse) throws Exception {
+        JsonNode root = MAPPER.readTree(mcpResponse);
+        String text = root.at("/result/content/0/text").asText();
+        JsonNode payload = MAPPER.readTree(text);
+        return 
MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(payload);
+    }
+
+    private String callJson(int id, Path project, String dataMap) {
+        String escapedPath = project.toString().replace("\\", "\\\\");
+        return String.format(
+                
"{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"tools/call\",\"params\":{" +
+                "\"name\":\"cgen_run\",\"arguments\":{" +
+                "\"projectPath\":\"%s\",\"dataMap\":\"%s\"}}}",
+                id, escapedPath, dataMap);
+    }
+
+    private void send(String json) throws IOException {
+        writer.write(json);
+        writer.newLine();
+        writer.flush();
+    }
+
+    private String readLine(long timeoutMs) throws Exception {
+        long deadline = System.currentTimeMillis() + timeoutMs;
+        while (System.currentTimeMillis() < deadline) {
+            if (reader.ready()) {
+                String line = reader.readLine();
+                if (line != null && !line.isBlank()) {
+                    return line;
+                }
+            }
+            Thread.sleep(20);
+        }
+        throw new AssertionError("No response within " + timeoutMs + "ms");
+    }
+
+    private void writeFixture(String mapName, String pkg, Path destDir, 
boolean makePairs) throws IOException {
+        Files.writeString(tempDir.resolve("cayenne-project.xml"), 
String.format("""
+                <?xml version="1.0" encoding="utf-8"?>
+                <domain xmlns="http://cayenne.apache.org/schema/12/domain"; 
project-version="12">
+                    <map name="%s"/>
+                </domain>
+                """, mapName));
+
+        Files.writeString(tempDir.resolve(mapName + ".map.xml"), 
String.format("""
+                <?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";
+                          project-version="12">
+                    <property name="defaultPackage" value="%s"/>
+                    <obj-entity name="Person" className="%s.Person"/>
+                    <cgen xmlns="http://cayenne.apache.org/schema/12/cgen";>
+                        <destDir>%s</destDir>
+                        <mode>entity</mode>
+                        <makePairs>%s</makePairs>
+                        <usePkgPath>true</usePkgPath>
+                        <overwrite>false</overwrite>
+                    </cgen>
+                </data-map>
+                """, pkg, pkg, destDir.toAbsolutePath(), makePairs));
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunValidationTest.java
 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunValidationTest.java
new file mode 100644
index 000000000..716495dda
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/cgen/CgenRunValidationTest.java
@@ -0,0 +1,136 @@
+/*****************************************************************
+ *   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.cgen;
+
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenErrorCode;
+import org.apache.cayenne.mcp.tools.cgen.protocol.CgenRunResult;
+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.net.URISyntaxException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class CgenRunValidationTest {
+
+    // Shared tool instance — DI bootstrap is expensive, reuse across test 
methods.
+    private static CgenRunTool tool;
+
+    @BeforeAll
+    public static void setUp() {
+        tool = new CgenRunTool();
+    }
+
+    @Test
+    public void projectNotFound() {
+        CgenRunResult result = tool.run("/no/such/file/cayenne-project.xml", 
"TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(0, result.summary().filesConsidered());
+        assertEquals(0, result.summary().filesWritten());
+        assertTrue(result.files().isEmpty());
+        assertNull(result.resolved());
+        assertEquals(CgenErrorCode.project_not_found, result.error().code());
+
+        assertFalse(result.validation().projectFound());
+        assertNull(result.validation().dataMapFound());
+        assertNull(result.validation().cgenConfigPresent());
+        assertNull(result.validation().destDirSpecified());
+        assertNull(result.validation().destDirWritable());
+    }
+
+    @Test
+    public void projectParseFailed(@TempDir Path tmp) throws IOException {
+        Path badXml = tmp.resolve("cayenne-project.xml");
+        Files.writeString(badXml, "this is not xml <<< garbage");
+
+        CgenRunResult result = tool.run(badXml.toString(), "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(CgenErrorCode.project_parse_failed, 
result.error().code());
+
+        assertTrue(result.validation().projectFound());
+        assertNull(result.validation().dataMapFound());
+        assertNull(result.validation().cgenConfigPresent());
+        assertNull(result.validation().destDirSpecified());
+        assertNull(result.validation().destDirWritable());
+    }
+
+    @Test
+    public void dataMapNotFound() throws URISyntaxException {
+        String projectPath = fixtureProject("no-cgen");
+
+        CgenRunResult result = tool.run(projectPath, "NoSuchMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(CgenErrorCode.datamap_not_found, result.error().code(),
+                "Unexpected error: " + result.error().message());
+        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().cgenConfigPresent());
+        assertNull(result.validation().destDirSpecified());
+        assertNull(result.validation().destDirWritable());
+    }
+
+    @Test
+    public void cgenConfigMissing() throws URISyntaxException {
+        String projectPath = fixtureProject("no-cgen");
+
+        CgenRunResult result = tool.run(projectPath, "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(CgenErrorCode.cgen_config_missing, result.error().code());
+
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertFalse(result.validation().cgenConfigPresent());
+        assertNull(result.validation().destDirSpecified());
+        assertNull(result.validation().destDirWritable());
+    }
+
+    @Test
+    public void destDirNotSpecified() throws URISyntaxException {
+        String projectPath = fixtureProject("no-destdir");
+
+        CgenRunResult result = tool.run(projectPath, "TestMap");
+
+        assertEquals("validation_failed", result.status());
+        assertEquals(CgenErrorCode.destdir_not_specified, 
result.error().code());
+
+        assertTrue(result.validation().projectFound());
+        assertTrue(result.validation().dataMapFound());
+        assertTrue(result.validation().cgenConfigPresent());
+        assertFalse(result.validation().destDirSpecified());
+        assertNull(result.validation().destDirWritable());
+    }
+
+    private static String fixtureProject(String fixture) throws 
URISyntaxException {
+        return Paths.get(CgenRunValidationTest.class
+                .getResource("/cgen-fixtures/" + fixture + 
"/cayenne-project.xml")
+                .toURI()).toString();
+    }
+}
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloMcpIT.java
similarity index 83%
rename from 
cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java
rename to 
cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloMcpIT.java
index cab9bd4b7..eaeab552e 100644
--- a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloMcpIT.java
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloMcpIT.java
@@ -16,8 +16,10 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-package org.apache.cayenne.mcp;
+package org.apache.cayenne.mcp.tools.hello;
 
+import org.apache.cayenne.mcp.McpHandle;
+import org.apache.cayenne.mcp.McpStarter;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -25,9 +27,7 @@ import org.junit.jupiter.api.Test;
 import java.io.BufferedReader;
 import java.io.BufferedWriter;
 import java.io.IOException;
-import java.io.InputStream;
 import java.io.InputStreamReader;
-import java.io.OutputStream;
 import java.io.OutputStreamWriter;
 import java.util.concurrent.TimeUnit;
 
@@ -39,25 +39,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
  */
 public class HelloMcpIT {
 
-    private Process process;
+    private McpHandle handle;
     private BufferedWriter writer;
     private BufferedReader reader;
 
     @BeforeEach
     void startServer() throws Exception {
-        process = McpStarter.start();
-
-        Thread stderrDrain = new Thread(() -> {
-            try (InputStream in = process.getErrorStream()) {
-                in.transferTo(OutputStream.nullOutputStream());
-            } catch (IOException ignored) {
-            }
-        });
-        stderrDrain.setDaemon(true);
-        stderrDrain.start();
-
-        writer = new BufferedWriter(new 
OutputStreamWriter(process.getOutputStream()));
-        reader = new BufferedReader(new 
InputStreamReader(process.getInputStream()));
+        handle = McpStarter.start();
+        writer = new BufferedWriter(new 
OutputStreamWriter(handle.getOutputStream()));
+        reader = new BufferedReader(new 
InputStreamReader(handle.getInputStream()));
 
         sendMessage("""
                 {"jsonrpc":"2.0","id":1,"method":"initialize","params":{\
@@ -74,7 +64,7 @@ public class HelloMcpIT {
     @AfterEach
     void stopServer() throws Exception {
         writer.close();
-        assertTrue(process.waitFor(10, TimeUnit.SECONDS), "Server process did 
not exit after stdin was closed");
+        assertTrue(handle.waitFor(10, TimeUnit.SECONDS), "Server thread did 
not stop after stdin was closed");
     }
 
     @Test
diff --git 
a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloToolTest.java
similarity index 96%
rename from 
cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java
rename to 
cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloToolTest.java
index 0b57eceb3..a3a993334 100644
--- a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/HelloToolTest.java
+++ 
b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/hello/HelloToolTest.java
@@ -16,11 +16,10 @@
  *  specific language governing permissions and limitations
  *  under the License.
  ****************************************************************/
-package org.apache.cayenne.mcp;
+package org.apache.cayenne.mcp.tools.hello;
 
 import io.modelcontextprotocol.server.McpServerFeatures;
 import io.modelcontextprotocol.spec.McpSchema;
-import org.apache.cayenne.mcp.tools.HelloTool;
 import org.junit.jupiter.api.Test;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
diff --git 
a/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/TestMap.map.xml 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/TestMap.map.xml
new file mode 100644
index 000000000..fa73743e8
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/TestMap.map.xml
@@ -0,0 +1,8 @@
+<?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"/>
+    <obj-entity name="Person" className="com.example.Person"/>
+</data-map>
diff --git 
a/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/cayenne-project.xml
 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/cayenne-project.xml
new file mode 100644
index 000000000..f40e0ac36
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-cgen/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/cgen-fixtures/no-destdir/TestMap.map.xml
 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-destdir/TestMap.map.xml
new file mode 100644
index 000000000..578545e44
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-destdir/TestMap.map.xml
@@ -0,0 +1,12 @@
+<?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"/>
+    <obj-entity name="Person" className="com.example.Person"/>
+    <cgen xmlns="http://cayenne.apache.org/schema/12/cgen";>
+        <mode>entity</mode>
+        <makePairs>true</makePairs>
+    </cgen>
+</data-map>
diff --git 
a/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-destdir/cayenne-project.xml
 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-destdir/cayenne-project.xml
new file mode 100644
index 000000000..f40e0ac36
--- /dev/null
+++ 
b/cayenne-mcp-server/src/test/resources/cgen-fixtures/no-destdir/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