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>