This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/cayenne.git
commit 8ad15a9db0bc0652403562fc490a9e611fb7fce3 Author: Andrus Adamchik <[email protected]> AuthorDate: Sun May 17 19:04:24 2026 -0400 CAY-2943 CayenneModeler MCP: open_project tool MCP server side: locates a CayenneModeler installation alongside the running MCP jar, spawns it with --mcp-handshake <nonce> <projectPath>, and waits for the Modeler's preferences-based handshake to confirm the project loaded. Discovery is OS-gated: Mac walks up for any .app ancestor (structural, not by name); Windows and generic use literal CayenneModeler.exe / CayenneModeler.jar; a source-tree fallback probes modeler/cayenne-modeler-{mac,win,generic}/target/classes/. The Modeler-side handshake landed in 73abc550c. --- .../org/apache/cayenne/mcp/CayenneMcpServer.java | 2 + .../mcp/tools/openproject/HandshakeWatcher.java | 165 +++++++++++++ .../mcp/tools/openproject/LauncherKind.java | 34 +++ .../mcp/tools/openproject/McpJarLocator.java | 69 ++++++ .../mcp/tools/openproject/ModelerDiscovery.java | 210 +++++++++++++++++ .../mcp/tools/openproject/ModelerLauncher.java | 111 +++++++++ .../mcp/tools/openproject/OpenProjectTool.java | 230 ++++++++++++++++++ .../cayenne/mcp/tools/openproject/OsKind.java | 48 ++++ .../protocol/OpenProjectDistribution.java | 31 +++ .../openproject/protocol/OpenProjectError.java | 24 ++ .../openproject/protocol/OpenProjectErrorCode.java | 35 +++ .../openproject/protocol/OpenProjectHandshake.java | 33 +++ .../openproject/protocol/OpenProjectResolved.java | 33 +++ .../openproject/protocol/OpenProjectResult.java | 32 +++ .../protocol/OpenProjectValidation.java | 31 +++ .../mcp/tools/openproject/HandshakeStubMain.java | 59 +++++ .../tools/openproject/HandshakeWatcherTest.java | 140 +++++++++++ .../tools/openproject/ModelerDiscoveryTest.java | 262 +++++++++++++++++++++ .../mcp/tools/openproject/ModelerLauncherTest.java | 81 +++++++ .../mcp/tools/openproject/OpenProjectStubIT.java | 95 ++++++++ .../openproject/OpenProjectValidationTest.java | 46 ++++ .../cayenne/mcp/tools/openproject/OsKindTest.java | 45 ++++ 22 files changed, 1816 insertions(+) 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 90fe457c4..6755fb5de 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 @@ -25,6 +25,7 @@ import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import org.apache.cayenne.mcp.tools.cgen.CgenRunTool; +import org.apache.cayenne.mcp.tools.openproject.OpenProjectTool; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -53,6 +54,7 @@ public class CayenneMcpServer { .serverInfo("cayenne-mcp-server", version) .capabilities(McpSchema.ServerCapabilities.builder().tools(true).build()) .tools(CgenRunTool.spec(jsonMapper)) + .tools(OpenProjectTool.spec(jsonMapper)) .build(); Runtime.getRuntime().addShutdownHook(new Thread(() -> { diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcher.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcher.java new file mode 100644 index 000000000..041bbc448 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcher.java @@ -0,0 +1,165 @@ +/***************************************************************** + * 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.openproject; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.time.Instant; +import java.util.function.BooleanSupplier; +import java.util.prefs.BackingStoreException; +import java.util.prefs.Preferences; + +/** + * Polls {@link Preferences} for the handshake entry written by + * {@code org.apache.cayenne.modeler.mcp.McpHandshakeWriter} once the Modeler has + * loaded the requested project. Each MCP-driven launch is keyed by a fresh nonce + * so stale entries and concurrent launches never collide. + * + * @since 5.0 + */ +final class HandshakeWatcher { + + private static final Logger LOGGER = LoggerFactory.getLogger(HandshakeWatcher.class); + + /** Must match {@code McpHandshakeWriter.NODE_PREFIX} on the Modeler side. */ + static final String NODE_PREFIX = "/org/apache/cayenne/modeler/mcp-handshake"; + + private static final long POLL_INTERVAL_MS = 200L; + private static final Duration STALE_NODE_TTL = Duration.ofHours(24); + + enum Outcome { HANDSHAKE_RECEIVED, SPAWNED_PROCESS_EXITED, TIMEOUT } + + record WatchResult(Outcome outcome, HandshakeData data, long waitMs) {} + + record HandshakeData(long pid, String startedAt, String resolvedProjectPath) {} + + private HandshakeWatcher() { + } + + /** + * Waits for the Modeler to write the handshake for the given nonce, or until + * the spawned process exits (when that signal is meaningful) or the timeout is hit. + * Regardless of outcome, the nonce's subnode is removed and stale siblings are pruned. + */ + static WatchResult await(String nonce, BooleanSupplier spawnedProcessAlive, Duration timeout) { + long start = System.currentTimeMillis(); + long deadline = start + timeout.toMillis(); + String nodePath = NODE_PREFIX + "/" + nonce; + + WatchResult result = null; + try { + while (true) { + if (nodeExists(nodePath)) { + HandshakeData data = readHandshake(nodePath); + long waitMs = System.currentTimeMillis() - start; + result = new WatchResult(Outcome.HANDSHAKE_RECEIVED, data, waitMs); + break; + } + + if (!spawnedProcessAlive.getAsBoolean()) { + long waitMs = System.currentTimeMillis() - start; + result = new WatchResult(Outcome.SPAWNED_PROCESS_EXITED, null, waitMs); + break; + } + + long now = System.currentTimeMillis(); + if (now >= deadline) { + result = new WatchResult(Outcome.TIMEOUT, null, now - start); + break; + } + + long sleepMs = Math.min(POLL_INTERVAL_MS, deadline - now); + try { + Thread.sleep(sleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + result = new WatchResult(Outcome.TIMEOUT, null, System.currentTimeMillis() - start); + break; + } + } + } finally { + removeNode(nodePath); + pruneStaleSiblings(); + } + return result; + } + + private static boolean nodeExists(String absolutePath) { + try { + return Preferences.userRoot().nodeExists(absolutePath); + } catch (BackingStoreException e) { + LOGGER.warn("Failed to query preferences for {}: {}", absolutePath, e.toString()); + return false; + } + } + + private static HandshakeData readHandshake(String absolutePath) { + Preferences node = Preferences.userRoot().node(absolutePath); + long pid = node.getLong("pid", -1L); + String startedAt = node.get("startedAt", null); + String resolvedProjectPath = node.get("projectPath", null); + return new HandshakeData(pid, startedAt, resolvedProjectPath); + } + + private static void removeNode(String absolutePath) { + try { + if (Preferences.userRoot().nodeExists(absolutePath)) { + Preferences.userRoot().node(absolutePath).removeNode(); + } + } catch (BackingStoreException | IllegalStateException e) { + LOGGER.warn("Failed to remove handshake node {}: {}", absolutePath, e.toString()); + } + } + + /** + * Removes handshake subnodes whose {@code startedAt} is older than {@link #STALE_NODE_TTL}. + * Belt-and-suspenders cleanup against an MCP server that crashed between launching + * the Modeler and reading the handshake. + */ + private static void pruneStaleSiblings() { + try { + Preferences root = Preferences.userRoot(); + if (!root.nodeExists(NODE_PREFIX)) { + return; + } + Preferences parent = root.node(NODE_PREFIX); + Instant cutoff = Instant.now().minus(STALE_NODE_TTL); + for (String child : parent.childrenNames()) { + Preferences childNode = parent.node(child); + String startedAt = childNode.get("startedAt", null); + if (startedAt == null || isBefore(startedAt, cutoff)) { + childNode.removeNode(); + } + } + } catch (BackingStoreException | IllegalStateException e) { + LOGGER.warn("Failed to prune stale handshake nodes: {}", e.toString()); + } + } + + private static boolean isBefore(String startedAtIso, Instant cutoff) { + try { + return Instant.parse(startedAtIso).isBefore(cutoff); + } catch (RuntimeException e) { + // Unparseable timestamp: treat as stale and prune. + return true; + } + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/LauncherKind.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/LauncherKind.java new file mode 100644 index 000000000..6d5386f4b --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/LauncherKind.java @@ -0,0 +1,34 @@ +/***************************************************************** + * 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.openproject; + +/** + * How a discovered Modeler is to be launched. Distinct from + * {@link org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectDistribution}, + * which is reported in the response and tracks <em>how it was found</em> (the + * source-tree probe maps to a launcher kind for argv construction while reporting + * {@code source_tree} as the matched distribution). + * + * @since 5.0 + */ +enum LauncherKind { + MAC_APP, + WINDOWS_EXE, + GENERIC_JAR +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/McpJarLocator.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/McpJarLocator.java new file mode 100644 index 000000000..4b6085a07 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/McpJarLocator.java @@ -0,0 +1,69 @@ +/***************************************************************** + * 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.openproject; + +import java.net.URL; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.CodeSource; +import java.security.ProtectionDomain; +import java.util.Optional; + +/** + * Resolves the directory that contains the running MCP server jar. Used as the + * starting point for {@link ModelerDiscovery}. + * + * @since 5.0 + */ +final class McpJarLocator { + + private McpJarLocator() { + } + + /** + * Locates the directory containing the jar (or class output dir) the given class + * was loaded from. Returns {@code Optional.empty()} in exotic launch configurations + * where the protection domain has no resolvable location. + */ + static Optional<Path> locate(Class<?> anchor) { + try { + ProtectionDomain pd = anchor.getProtectionDomain(); + if (pd == null) { + return Optional.empty(); + } + CodeSource cs = pd.getCodeSource(); + if (cs == null) { + return Optional.empty(); + } + URL url = cs.getLocation(); + if (url == null) { + return Optional.empty(); + } + Path location = Paths.get(url.toURI()); + // For a jar: location is the jar itself; we want its parent directory. + // For a class-file directory (IDE / surefire fork): location is the dir; + // its parent is also a reasonable starting point (target/), but here we + // want the dir that "would have been the jar's parent" — so use it directly. + return Optional.of(location.getParent() != null ? location.getParent() : location); + } catch (URISyntaxException | RuntimeException e) { + return Optional.empty(); + } + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscovery.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscovery.java new file mode 100644 index 000000000..5c48f3737 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscovery.java @@ -0,0 +1,210 @@ +/***************************************************************** + * 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.openproject; + +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectDistribution; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Discovers a CayenneModeler installation reachable from the directory that holds + * the running MCP jar. Pure logic: given a starting directory and an {@link OsKind}, + * it returns either a {@link Found} match or a {@link NotFound} with one note per + * <em>eligible</em> probe (probes filtered out by the OS gate are not listed). + * + * @since 5.0 + */ +final class ModelerDiscovery { + + /** Mac {@code .app} bundle launcher (literal, produced by {@code cayenne-modeler-mac}). */ + static final String WINDOWS_EXE_NAME = "CayenneModeler.exe"; + static final String GENERIC_JAR_NAME = "CayenneModeler.jar"; + + /** Max levels to climb when locating the source-tree git root. */ + private static final int SOURCE_TREE_CLIMB_LIMIT = 8; + + sealed interface DiscoveryResult permits Found, NotFound {} + + record Found(OpenProjectDistribution distribution, LauncherKind launcherKind, Path launcher) + implements DiscoveryResult {} + + record NotFound(List<String> probeNotes) implements DiscoveryResult {} + + private ModelerDiscovery() { + } + + static DiscoveryResult discover(Path mcpDir, OsKind osKind) { + List<String> notes = new ArrayList<>(); + + if (osKind == OsKind.MAC) { + Optional<Path> mac = probeMacApp(mcpDir); + if (mac.isPresent()) { + return new Found(OpenProjectDistribution.mac, LauncherKind.MAC_APP, mac.get()); + } + notes.add("mac: no .app bundle ancestor with Contents/Resources/mcp/ and Contents/MacOS/"); + } + + if (osKind == OsKind.WINDOWS) { + Optional<Path> win = probeWindowsExe(mcpDir); + if (win.isPresent()) { + return new Found(OpenProjectDistribution.windows, LauncherKind.WINDOWS_EXE, win.get()); + } + notes.add("windows: no " + WINDOWS_EXE_NAME + " sibling of the MCP jar"); + } + + Optional<Path> generic = probeGenericJar(mcpDir); + if (generic.isPresent()) { + return new Found(OpenProjectDistribution.generic, LauncherKind.GENERIC_JAR, generic.get()); + } + notes.add("generic: no " + GENERIC_JAR_NAME + " sibling of the MCP jar"); + + Optional<Found> sourceTree = probeSourceTree(mcpDir, osKind); + if (sourceTree.isPresent()) { + return sourceTree.get(); + } + notes.add("source_tree: no built CayenneModeler under <gitRoot>/modeler/cayenne-modeler-{mac,win,generic}/target/classes/" + + " (run `mvn -pl modeler/cayenne-modeler-<kind> -am package -P<kind>`)"); + + return new NotFound(List.copyOf(notes)); + } + + /** + * Mac bundle detection: walks up {@code mcpDir} and accepts any ancestor whose + * name ends in {@code .app} and contains a {@code Contents/MacOS/} subdirectory. + * The bundle name is intentionally <em>not</em> compared against a literal — users + * routinely rename {@code .app} bundles. + */ + static Optional<Path> probeMacApp(Path mcpDir) { + if (mcpDir == null || mcpDir.getFileName() == null + || !"mcp".equals(mcpDir.getFileName().toString())) { + return Optional.empty(); + } + Path resources = mcpDir.getParent(); + if (resources == null || resources.getFileName() == null + || !"Resources".equals(resources.getFileName().toString())) { + return Optional.empty(); + } + Path contents = resources.getParent(); + if (contents == null || contents.getFileName() == null + || !"Contents".equals(contents.getFileName().toString())) { + return Optional.empty(); + } + Path bundle = contents.getParent(); + if (bundle == null || bundle.getFileName() == null + || !bundle.getFileName().toString().endsWith(".app")) { + return Optional.empty(); + } + if (!Files.isDirectory(bundle.resolve("Contents/MacOS"))) { + return Optional.empty(); + } + return Optional.of(bundle); + } + + /** Strict literal match: {@code mcpDir/CayenneModeler.exe} must exist. */ + static Optional<Path> probeWindowsExe(Path mcpDir) { + if (mcpDir == null) { + return Optional.empty(); + } + Path candidate = mcpDir.resolve(WINDOWS_EXE_NAME); + return Files.isRegularFile(candidate) ? Optional.of(candidate) : Optional.empty(); + } + + /** Strict literal match: {@code mcpDir/CayenneModeler.jar} must exist. */ + static Optional<Path> probeGenericJar(Path mcpDir) { + if (mcpDir == null) { + return Optional.empty(); + } + Path candidate = mcpDir.resolve(GENERIC_JAR_NAME); + return Files.isRegularFile(candidate) ? Optional.of(candidate) : Optional.empty(); + } + + /** + * Source-tree probe — climbs to a {@code .git}-bearing root and looks under + * {@code modeler/cayenne-modeler-{mac,win,generic}/target/classes/} for the + * OS-appropriate launcher. The reported distribution is {@code source_tree} + * so callers can see when dev-mode discovery kicked in; the launcher kind + * still drives argv construction. + */ + static Optional<Found> probeSourceTree(Path mcpDir, OsKind osKind) { + Path gitRoot = findGitRoot(mcpDir); + if (gitRoot == null) { + return Optional.empty(); + } + + if (osKind == OsKind.MAC) { + Path macClasses = gitRoot.resolve("modeler/cayenne-modeler-mac/target/classes"); + Optional<Path> bundle = findAppBundle(macClasses); + if (bundle.isPresent()) { + return Optional.of(new Found( + OpenProjectDistribution.source_tree, LauncherKind.MAC_APP, bundle.get())); + } + } + + if (osKind == OsKind.WINDOWS) { + Path winExe = gitRoot.resolve( + "modeler/cayenne-modeler-win/target/classes/" + WINDOWS_EXE_NAME); + if (Files.isRegularFile(winExe)) { + return Optional.of(new Found( + OpenProjectDistribution.source_tree, LauncherKind.WINDOWS_EXE, winExe)); + } + } + + Path genericJar = gitRoot.resolve( + "modeler/cayenne-modeler-generic/target/classes/" + GENERIC_JAR_NAME); + if (Files.isRegularFile(genericJar)) { + return Optional.of(new Found( + OpenProjectDistribution.source_tree, LauncherKind.GENERIC_JAR, genericJar)); + } + + return Optional.empty(); + } + + private static Path findGitRoot(Path start) { + if (start == null) { + return null; + } + Path current = start; + for (int i = 0; i < SOURCE_TREE_CLIMB_LIMIT && current != null; i++) { + if (Files.isDirectory(current.resolve(".git"))) { + return current; + } + current = current.getParent(); + } + return null; + } + + private static Optional<Path> findAppBundle(Path dir) { + if (!Files.isDirectory(dir)) { + return Optional.empty(); + } + try (var stream = Files.list(dir)) { + return stream + .filter(p -> p.getFileName() != null + && p.getFileName().toString().endsWith(".app")) + .filter(p -> Files.isDirectory(p.resolve("Contents/MacOS"))) + .findFirst(); + } catch (java.io.IOException e) { + return Optional.empty(); + } + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncher.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncher.java new file mode 100644 index 000000000..515e3cb89 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncher.java @@ -0,0 +1,111 @@ +/***************************************************************** + * 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.openproject; + +import java.io.IOException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * Builds the {@link ProcessBuilder} argv for each Modeler launcher kind and starts + * the process. Stdout/stderr are discarded so they cannot pollute the MCP server's + * JSON-RPC stream. + * + * @since 5.0 + */ +final class ModelerLauncher { + + record LaunchResult(Process process, List<String> command, boolean processAlivenessMeaningful) {} + + private ModelerLauncher() { + } + + static List<String> buildCommand(LauncherKind kind, Path launcher, Path projectPath, String nonce) { + List<String> args = new ArrayList<>(); + switch (kind) { + case MAC_APP -> { + args.add("open"); + args.add("-n"); + args.add(launcher.toString()); + args.add("--args"); + args.add("--mcp-handshake"); + args.add(nonce); + args.add(projectPath.toString()); + } + case WINDOWS_EXE -> { + args.add(launcher.toString()); + args.add("--mcp-handshake"); + args.add(nonce); + args.add(projectPath.toString()); + } + case GENERIC_JAR -> { + args.add(currentJavaBinary().toString()); + args.add("-jar"); + args.add(launcher.toString()); + args.add("--mcp-handshake"); + args.add(nonce); + args.add(projectPath.toString()); + } + } + return List.copyOf(args); + } + + static LaunchResult launch(LauncherKind kind, Path launcher, Path projectPath, String nonce) + throws IOException { + + List<String> command = buildCommand(kind, launcher, projectPath, nonce); + + ProcessBuilder pb = new ProcessBuilder(command) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .directory(workingDirectory(kind, launcher).toFile()); + + Process process = pb.start(); + + // Process.isAlive() only carries signal for processes we own end-to-end. + // On Mac, the process we spawn is `open`, which exits in milliseconds with + // no relationship to whether the Modeler actually started. + boolean alivenessMeaningful = kind != LauncherKind.MAC_APP; + + return new LaunchResult(process, command, alivenessMeaningful); + } + + private static Path workingDirectory(LauncherKind kind, Path launcher) { + if (kind == LauncherKind.MAC_APP) { + // launcher is the .app directory; the .app's parent is the install directory. + Path parent = launcher.getParent(); + return parent != null ? parent : launcher; + } + Path parent = launcher.getParent(); + return parent != null ? parent : launcher; + } + + /** + * Returns the {@code java} binary from the JVM the MCP server is running on. + * We deliberately do <em>not</em> consult {@code JAVA_HOME} or {@code PATH} — + * those can point at a JRE older than the Modeler needs. + */ + static Path currentJavaBinary() { + String javaHome = System.getProperty("java.home"); + String exe = OsKind.detect() == OsKind.WINDOWS ? "java.exe" : "java"; + return Paths.get(javaHome, "bin", exe); + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectTool.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectTool.java new file mode 100644 index 000000000..b41682979 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectTool.java @@ -0,0 +1,230 @@ +/***************************************************************** + * 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.openproject; + +import io.modelcontextprotocol.json.McpJsonMapper; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.DiscoveryResult; +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.Found; +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.NotFound; +import org.apache.cayenne.mcp.tools.openproject.ModelerLauncher.LaunchResult; +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.HandshakeData; +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.WatchResult; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectError; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectErrorCode; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectHandshake; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectResolved; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectResult; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectValidation; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.BooleanSupplier; + +/** + * MCP tool that launches CayenneModeler with a project file pre-loaded. The tool + * locates the Modeler installation alongside the running MCP jar, spawns it with + * {@code --mcp-handshake <nonce>}, and waits for the Modeler to confirm + * successful project load via a {@link java.util.prefs.Preferences} handshake. + * + * @since 5.0 + */ +public class OpenProjectTool { + + public static final String NAME = "open_project"; + + static final Duration HANDSHAKE_TIMEOUT = Duration.ofSeconds(15); + + public static McpServerFeatures.SyncToolSpecification spec(McpJsonMapper jsonMapper) { + OpenProjectTool tool = new OpenProjectTool(); + + McpSchema.Tool descriptor = new McpSchema.Tool( + NAME, + null, + "Launch CayenneModeler with the given project file. Non-blocking; waits for " + + "the Modeler to report a startup handshake before returning.", + new McpSchema.JsonSchema( + "object", + Map.of( + "projectPath", Map.of("type", "string", "description", + "Absolute path to the top-level Cayenne project descriptor (cayenne-*.xml)") + ), + List.of("projectPath"), + 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", "") : ""; + + OpenProjectResult result = tool.run(projectPath); + + String json; + try { + json = jsonMapper.writeValueAsString(result); + } catch (IOException e) { + json = "{\"status\":\"error\",\"error\":{\"code\":\"launch_failed\"," + + "\"message\":\"Serialization failed: " + e.getMessage() + "\"}}"; + } + + return McpSchema.CallToolResult.builder() + .content(List.of(new McpSchema.TextContent(json))) + .isError(false) + .build(); + }); + } + + /** + * Runs the open-project flow for the given project file. See class javadoc for the + * end-to-end contract. + */ + public OpenProjectResult run(String projectPath) { + + // Step 1 — project file readable? + Path projectFile; + try { + projectFile = Path.of(projectPath); + } catch (RuntimeException e) { + return validationFailed(OpenProjectErrorCode.project_not_found, + "Invalid path '" + projectPath + "': " + e.getMessage(), + new OpenProjectValidation(false, null, null)); + } + if (!Files.isReadable(projectFile)) { + return validationFailed(OpenProjectErrorCode.project_not_found, + "No readable file at " + projectPath, + new OpenProjectValidation(false, null, null)); + } + + // Step 2 — locate the MCP jar's directory. + Optional<Path> mcpDir = McpJarLocator.locate(OpenProjectTool.class); + if (mcpDir.isEmpty()) { + return validationFailed(OpenProjectErrorCode.mcp_jar_location_unresolved, + "Could not resolve the running MCP server jar's location via " + + "ProtectionDomain. This is expected only in exotic launch configurations.", + new OpenProjectValidation(true, false, null)); + } + + // Step 3 — discover a Modeler installation. + OsKind osKind = OsKind.detect(); + DiscoveryResult discovery = ModelerDiscovery.discover(mcpDir.get(), osKind); + if (discovery instanceof NotFound nf) { + String notes = String.join("; ", nf.probeNotes()); + return validationFailed(OpenProjectErrorCode.modeler_not_found, + "No CayenneModeler installation found relative to MCP jar at " + + mcpDir.get() + ". Probes: " + notes, + new OpenProjectValidation(true, true, false)); + } + Found found = (Found) discovery; + OpenProjectValidation allPassed = new OpenProjectValidation(true, true, true); + + // Step 4 — launch. + String nonce = UUID.randomUUID().toString().replace("-", ""); + LaunchResult launch; + try { + launch = ModelerLauncher.launch(found.launcherKind(), found.launcher(), projectFile, nonce); + } catch (IOException e) { + return new OpenProjectResult( + "error", + new OpenProjectResolved(found.distribution(), found.launcher().toString(), List.of()), + allPassed, + null, + new OpenProjectError(OpenProjectErrorCode.launch_failed, + "Failed to start Modeler process: " + e.getMessage()) + ); + } + + OpenProjectResolved resolved = new OpenProjectResolved( + found.distribution(), + found.launcher().toString(), + launch.command()); + + // Step 5 — wait for the handshake. + BooleanSupplier alive = launch.processAlivenessMeaningful() + ? () -> launch.process().isAlive() + : () -> true; + WatchResult watch = HandshakeWatcher.await(nonce, alive, HANDSHAKE_TIMEOUT); + + return switch (watch.outcome()) { + case HANDSHAKE_RECEIVED -> { + HandshakeData data = watch.data(); + yield new OpenProjectResult( + "launched", + resolved, + allPassed, + new OpenProjectHandshake( + nonce, + data.pid(), + data.startedAt(), + data.resolvedProjectPath(), + watch.waitMs()), + null); + } + case SPAWNED_PROCESS_EXITED -> { + Process p = launch.process(); + String exit; + try { + exit = "exit code " + p.exitValue(); + } catch (IllegalThreadStateException e) { + exit = "exit code unavailable"; + } + yield new OpenProjectResult( + "error", + resolved, + allPassed, + null, + new OpenProjectError(OpenProjectErrorCode.launch_exited_early, + "Spawned Modeler process exited before reporting handshake (" + exit + ")")); + } + case TIMEOUT -> { + boolean stillAlive = launch.processAlivenessMeaningful() && launch.process().isAlive(); + String hint = stillAlive + ? "Modeler process is still running but did not confirm opening the project — check the Modeler window for an error dialog" + : "Modeler process is not alive at the timeout boundary"; + yield new OpenProjectResult( + "error", + resolved, + allPassed, + null, + new OpenProjectError(OpenProjectErrorCode.launch_not_confirmed, + "Handshake did not appear within " + HANDSHAKE_TIMEOUT.toSeconds() + + "s. " + hint + ".")); + } + }; + } + + private static OpenProjectResult validationFailed(OpenProjectErrorCode code, String message, + OpenProjectValidation validation) { + return new OpenProjectResult( + "validation_failed", + null, + validation, + null, + new OpenProjectError(code, message) + ); + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OsKind.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OsKind.java new file mode 100644 index 000000000..613125729 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/OsKind.java @@ -0,0 +1,48 @@ +/***************************************************************** + * 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.openproject; + +import java.util.Locale; + +/** + * Host operating system family. Drives which CayenneModeler distribution layouts + * the discovery probes consider. + * + * @since 5.0 + */ +public enum OsKind { + MAC, + WINDOWS, + OTHER; + + public static OsKind detect() { + return fromOsName(System.getProperty("os.name", "")); + } + + static OsKind fromOsName(String osName) { + String lower = osName.toLowerCase(Locale.ROOT); + if (lower.contains("mac")) { + return MAC; + } + if (lower.contains("windows")) { + return WINDOWS; + } + return OTHER; + } +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectDistribution.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectDistribution.java new file mode 100644 index 000000000..3f3eb0f19 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectDistribution.java @@ -0,0 +1,31 @@ +/***************************************************************** + * 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.openproject.protocol; + +/** + * Which CayenneModeler distribution layout the discovery probe matched. + * + * @since 5.0 + */ +public enum OpenProjectDistribution { + mac, + windows, + generic, + source_tree +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectError.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectError.java new file mode 100644 index 000000000..08b0b942b --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectError.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.openproject.protocol; + +/** + * @since 5.0 + */ +public record OpenProjectError(OpenProjectErrorCode code, String message) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectErrorCode.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectErrorCode.java new file mode 100644 index 000000000..86f3dae78 --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectErrorCode.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.openproject.protocol; + +/** + * Error codes returned by the {@code open_project} MCP tool. + * Enum names are lowercase and match the JSON serialization directly. + * + * @since 5.0 + */ +public enum OpenProjectErrorCode { + project_not_found, + mcp_jar_location_unresolved, + modeler_not_found, + modeler_not_built, + launch_failed, + launch_exited_early, + launch_not_confirmed +} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectHandshake.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectHandshake.java new file mode 100644 index 000000000..39d5e285f --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectHandshake.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.openproject.protocol; + +/** + * What the Modeler reported via its preferences-based handshake after it finished + * loading the requested project. + * + * @since 5.0 + */ +public record OpenProjectHandshake( + String nonce, + long modelerPid, + String startedAt, + String resolvedProjectPath, + long waitMs +) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResolved.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResolved.java new file mode 100644 index 000000000..588ada8ea --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResolved.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.openproject.protocol; + +import java.util.List; + +/** + * What the tool resolved before launching: which distribution layout matched, + * the path to the launcher, and the exact argv handed to {@code ProcessBuilder}. + * + * @since 5.0 + */ +public record OpenProjectResolved( + OpenProjectDistribution distribution, + String modelerPath, + List<String> command +) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResult.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResult.java new file mode 100644 index 000000000..79e1a193c --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectResult.java @@ -0,0 +1,32 @@ +/***************************************************************** + * 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.openproject.protocol; + +/** + * Top-level envelope returned by the {@code open_project} MCP tool. + * + * @since 5.0 + */ +public record OpenProjectResult( + String status, + OpenProjectResolved resolved, + OpenProjectValidation validation, + OpenProjectHandshake handshake, + OpenProjectError error +) {} diff --git a/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectValidation.java b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectValidation.java new file mode 100644 index 000000000..bd4c3a82b --- /dev/null +++ b/cayenne-mcp-server/src/main/java/org/apache/cayenne/mcp/tools/openproject/protocol/OpenProjectValidation.java @@ -0,0 +1,31 @@ +/***************************************************************** + * 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.openproject.protocol; + +/** + * Per-step validation outcome for the {@code open_project} tool. Slots that were + * not reached because an earlier check failed are {@code null}. + * + * @since 5.0 + */ +public record OpenProjectValidation( + Boolean projectFound, + Boolean mcpJarLocated, + Boolean modelerFound +) {} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeStubMain.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeStubMain.java new file mode 100644 index 000000000..1c647e045 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeStubMain.java @@ -0,0 +1,59 @@ +/***************************************************************** + * 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.openproject; + +import java.time.Instant; +import java.util.prefs.Preferences; + +/** + * Stand-alone main that mimics what CayenneModeler does on the handshake side: parses + * {@code --mcp-handshake <nonce>}, writes the four preferences keys, flushes, and + * sleeps. Used by {@code OpenProjectStubIT} to drive the launch + handshake round-trip + * without requiring a real Modeler build. + */ +public class HandshakeStubMain { + + public static void main(String[] args) throws Exception { + String nonce = null; + String projectPath = null; + for (int i = 0; i < args.length; i++) { + if ("--mcp-handshake".equals(args[i]) && i + 1 < args.length) { + nonce = args[i + 1]; + i++; + continue; + } + // Last positional arg is the project path (matches the launcher contract). + projectPath = args[i]; + } + + if (nonce != null) { + Preferences prefs = Preferences.userRoot() + .node("/org/apache/cayenne/modeler/mcp-handshake/" + nonce); + prefs.put("startedAt", Instant.now().toString()); + prefs.putLong("pid", ProcessHandle.current().pid()); + prefs.put("args", String.join(" ", args)); + prefs.put("projectPath", projectPath != null ? projectPath : ""); + prefs.flush(); + } + + // Sleep until killed by the test (or for an unreasonable bound that prevents + // a forgotten process from sticking around). + Thread.sleep(60_000); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcherTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcherTest.java new file mode 100644 index 000000000..79af1435a --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/HandshakeWatcherTest.java @@ -0,0 +1,140 @@ +/***************************************************************** + * 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.openproject; + +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.Outcome; +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.WatchResult; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.prefs.BackingStoreException; +import java.util.prefs.Preferences; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class HandshakeWatcherTest { + + private static final BooleanSupplier ALIVE = () -> true; + private final String nonce = "test-" + UUID.randomUUID().toString().replace("-", ""); + + @AfterEach + public void cleanup() throws BackingStoreException { + Preferences root = Preferences.userRoot(); + if (root.nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + nonce)) { + root.node(HandshakeWatcher.NODE_PREFIX + "/" + nonce).removeNode(); + } + } + + @Test + public void handshakeReceivedReadsPidAndPath() throws BackingStoreException { + Preferences node = Preferences.userRoot() + .node(HandshakeWatcher.NODE_PREFIX + "/" + nonce); + node.putLong("pid", 4242L); + node.put("startedAt", "2026-05-17T10:00:00Z"); + node.put("projectPath", "/abs/cayenne-project.xml"); + node.flush(); + + WatchResult result = HandshakeWatcher.await(nonce, ALIVE, Duration.ofSeconds(2)); + + assertEquals(Outcome.HANDSHAKE_RECEIVED, result.outcome()); + assertNotNull(result.data()); + assertEquals(4242L, result.data().pid()); + assertEquals("2026-05-17T10:00:00Z", result.data().startedAt()); + assertEquals("/abs/cayenne-project.xml", result.data().resolvedProjectPath()); + assertTrue(result.waitMs() >= 0); + + // Watcher must remove the node after reading. + assertFalse(Preferences.userRoot().nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + nonce)); + } + + @Test + public void timeoutOnMissingHandshake() { + WatchResult result = HandshakeWatcher.await(nonce, ALIVE, Duration.ofMillis(400)); + + assertEquals(Outcome.TIMEOUT, result.outcome()); + assertNull(result.data()); + assertTrue(result.waitMs() >= 400, "should have waited at least the full timeout: " + result.waitMs()); + assertTrue(result.waitMs() < 1500, "should not have waited much past the timeout: " + result.waitMs()); + } + + @Test + public void earlyExitWhenSpawnedProcessDies() { + AtomicBoolean alive = new AtomicBoolean(true); + // Flip to dead after a brief delay so the watcher gets at least one poll iteration + // showing alive=true. + new Thread(() -> { + try { + Thread.sleep(300); + } catch (InterruptedException ignored) { + } + alive.set(false); + }, "test-killer").start(); + + long start = System.currentTimeMillis(); + WatchResult result = HandshakeWatcher.await(nonce, alive::get, Duration.ofSeconds(10)); + long elapsed = System.currentTimeMillis() - start; + + assertEquals(Outcome.SPAWNED_PROCESS_EXITED, result.outcome()); + assertNull(result.data()); + assertTrue(elapsed < 3000, + "must return promptly after process death, not wait for the full timeout: " + elapsed); + } + + @Test + public void prunesSiblingsOlderThan24Hours() throws BackingStoreException { + String freshNonce = "test-fresh-" + UUID.randomUUID().toString().replace("-", ""); + String staleNonce = "test-stale-" + UUID.randomUUID().toString().replace("-", ""); + try { + Preferences fresh = Preferences.userRoot() + .node(HandshakeWatcher.NODE_PREFIX + "/" + freshNonce); + fresh.put("startedAt", Instant.now().toString()); + fresh.flush(); + + Preferences stale = Preferences.userRoot() + .node(HandshakeWatcher.NODE_PREFIX + "/" + staleNonce); + stale.put("startedAt", Instant.now().minus(Duration.ofDays(2)).toString()); + stale.flush(); + + // Trigger pruning by running the watcher with a missing-handshake nonce. + HandshakeWatcher.await(nonce, ALIVE, Duration.ofMillis(200)); + + assertTrue(Preferences.userRoot().nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + freshNonce), + "Recent sibling must be preserved"); + assertFalse(Preferences.userRoot().nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + staleNonce), + "Stale sibling must be pruned"); + } finally { + Preferences root = Preferences.userRoot(); + if (root.nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + freshNonce)) { + root.node(HandshakeWatcher.NODE_PREFIX + "/" + freshNonce).removeNode(); + } + if (root.nodeExists(HandshakeWatcher.NODE_PREFIX + "/" + staleNonce)) { + root.node(HandshakeWatcher.NODE_PREFIX + "/" + staleNonce).removeNode(); + } + } + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscoveryTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscoveryTest.java new file mode 100644 index 000000000..e4b133414 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerDiscoveryTest.java @@ -0,0 +1,262 @@ +/***************************************************************** + * 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.openproject; + +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.DiscoveryResult; +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.Found; +import org.apache.cayenne.mcp.tools.openproject.ModelerDiscovery.NotFound; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectDistribution; +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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ModelerDiscoveryTest { + + // -------- Mac -------- + + @Test + public void macAppWithLiteralName(@TempDir Path tmp) throws IOException { + Path bundle = tmp.resolve("CayenneModeler.app"); + Path mcpDir = makeMacBundle(bundle); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.MAC); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.mac, found.distribution()); + assertEquals(LauncherKind.MAC_APP, found.launcherKind()); + assertEquals(bundle, found.launcher()); + } + + @Test + public void macAppWithRenamedVersionedName(@TempDir Path tmp) throws IOException { + Path bundle = tmp.resolve("CayenneModeler-5.0.app"); + Path mcpDir = makeMacBundle(bundle); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.MAC); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(bundle, found.launcher(), + "modelerPath must reflect the actual on-disk bundle name, even if renamed"); + } + + @Test + public void macAppWithSpacesInName(@TempDir Path tmp) throws IOException { + Path bundle = tmp.resolve("My Cayenne Modeler.app"); + Path mcpDir = makeMacBundle(bundle); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.MAC); + + assertInstanceOf(Found.class, result); + } + + @Test + public void macAppRejectedWhenMacOsDirAbsent(@TempDir Path tmp) throws IOException { + // Build a .app that has Contents/Resources/mcp but no Contents/MacOS — corrupt bundle. + Path bundle = tmp.resolve("CayenneModeler.app"); + Path mcpDir = bundle.resolve("Contents/Resources/mcp"); + Files.createDirectories(mcpDir); + // Intentionally do NOT create Contents/MacOS. + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.MAC); + + assertInstanceOf(NotFound.class, result); + } + + // -------- Windows -------- + + @Test + public void windowsExeMatchesLiteralName(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME)); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.WINDOWS); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.windows, found.distribution()); + assertEquals(LauncherKind.WINDOWS_EXE, found.launcherKind()); + } + + @Test + public void windowsExeRejectsRenamedVersion(@TempDir Path tmp) throws IOException { + // A versioned/renamed .exe must NOT match — literal name only. + Files.createFile(tmp.resolve("CayenneModeler-5.0.exe")); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.WINDOWS); + + NotFound nf = assertInstanceOf(NotFound.class, result); + assertTrue(nf.probeNotes().stream().anyMatch(n -> n.contains("CayenneModeler.exe"))); + } + + @Test + public void windowsExeMatchesEvenWithUnrelatedSiblings(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME)); + Files.createFile(tmp.resolve("helper.exe")); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.WINDOWS); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME), found.launcher()); + } + + @Test + public void windowsNativeWinsOverGenericOnSameOs(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME)); + Files.createFile(tmp.resolve(ModelerDiscovery.GENERIC_JAR_NAME)); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.WINDOWS); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.windows, found.distribution(), + "Windows-native probe must precede generic on Windows"); + } + + // -------- Generic -------- + + @Test + public void genericJarMatchesLiteralName(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.GENERIC_JAR_NAME)); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.OTHER); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.generic, found.distribution()); + assertEquals(LauncherKind.GENERIC_JAR, found.launcherKind()); + } + + @Test + public void genericJarRejectsRenamedVersion(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve("cayenne-modeler-5.0.jar")); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.OTHER); + + assertInstanceOf(NotFound.class, result); + } + + @Test + public void genericJarIgnoresUnrelatedJars(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.GENERIC_JAR_NAME)); + Files.createFile(tmp.resolve("helper.jar")); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.OTHER); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(tmp.resolve(ModelerDiscovery.GENERIC_JAR_NAME), found.launcher()); + } + + // -------- OS gate enforcement -------- + + @Test + public void osGateRejectsExeOnMac(@TempDir Path tmp) throws IOException { + Files.createFile(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME)); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.MAC); + + NotFound nf = assertInstanceOf(NotFound.class, result); + // Mac eligible probes: mac, generic, source_tree (3) + assertEquals(3, nf.probeNotes().size()); + // None of the notes should mention .exe (Windows probe wasn't eligible). + assertTrue(nf.probeNotes().stream().noneMatch(n -> n.contains(".exe"))); + } + + @Test + public void osGateRejectsAppOnWindows(@TempDir Path tmp) throws IOException { + Path bundle = tmp.resolve("CayenneModeler.app"); + Path mcpDir = makeMacBundle(bundle); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.WINDOWS); + + assertInstanceOf(NotFound.class, result); + } + + @Test + public void osGateRestrictsProbesOnOther(@TempDir Path tmp) throws IOException { + Path bundle = tmp.resolve("CayenneModeler.app"); + makeMacBundle(bundle); + Files.createFile(tmp.resolve(ModelerDiscovery.WINDOWS_EXE_NAME)); + + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.OTHER); + + NotFound nf = assertInstanceOf(NotFound.class, result); + // OTHER eligible probes: generic, source_tree (2) + assertEquals(2, nf.probeNotes().size()); + } + + // -------- Source-tree fallback -------- + + @Test + public void sourceTreeMatchesGenericLauncher(@TempDir Path tmp) throws IOException { + Path root = tmp.resolve("repo"); + Files.createDirectories(root.resolve(".git")); + Path genericClasses = root.resolve("modeler/cayenne-modeler-generic/target/classes"); + Files.createDirectories(genericClasses); + Files.createFile(genericClasses.resolve(ModelerDiscovery.GENERIC_JAR_NAME)); + + Path mcpDir = root.resolve("cayenne-mcp-server/target"); + Files.createDirectories(mcpDir); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.OTHER); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.source_tree, found.distribution()); + assertEquals(LauncherKind.GENERIC_JAR, found.launcherKind()); + } + + @Test + public void sourceTreeMatchesMacBundle(@TempDir Path tmp) throws IOException { + Path root = tmp.resolve("repo"); + Files.createDirectories(root.resolve(".git")); + Path macClasses = root.resolve("modeler/cayenne-modeler-mac/target/classes"); + Path bundle = macClasses.resolve("CayenneModeler.app"); + Files.createDirectories(bundle.resolve("Contents/MacOS")); + + Path mcpDir = root.resolve("cayenne-mcp-server/target"); + Files.createDirectories(mcpDir); + + DiscoveryResult result = ModelerDiscovery.discover(mcpDir, OsKind.MAC); + + Found found = assertInstanceOf(Found.class, result); + assertEquals(OpenProjectDistribution.source_tree, found.distribution()); + assertEquals(LauncherKind.MAC_APP, found.launcherKind()); + assertEquals(bundle, found.launcher()); + } + + @Test + public void notFoundOnEmptyDir(@TempDir Path tmp) { + DiscoveryResult result = ModelerDiscovery.discover(tmp, OsKind.OTHER); + + NotFound nf = assertInstanceOf(NotFound.class, result); + assertEquals(2, nf.probeNotes().size(), + "OTHER has 2 eligible probes: generic and source_tree"); + } + + // -------- Helpers -------- + + private static Path makeMacBundle(Path bundle) throws IOException { + Files.createDirectories(bundle.resolve("Contents/MacOS")); + Path mcpDir = bundle.resolve("Contents/Resources/mcp"); + Files.createDirectories(mcpDir); + return mcpDir; + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncherTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncherTest.java new file mode 100644 index 000000000..41f1752c9 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/ModelerLauncherTest.java @@ -0,0 +1,81 @@ +/***************************************************************** + * 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.openproject; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ModelerLauncherTest { + + private static final String NONCE = "deadbeefcafebabe"; + private static final Path PROJECT = Paths.get("/abs/path/to/cayenne-project.xml"); + + @Test + public void macArgvWrapsWithOpenAndArgs() { + Path bundle = Paths.get("/Applications/CayenneModeler.app"); + List<String> argv = ModelerLauncher.buildCommand( + LauncherKind.MAC_APP, bundle, PROJECT, NONCE); + + assertEquals("open", argv.get(0)); + assertEquals("-n", argv.get(1)); + assertEquals(bundle.toString(), argv.get(2)); + assertEquals("--args", argv.get(3)); + assertEquals("--mcp-handshake", argv.get(4)); + assertEquals(NONCE, argv.get(5)); + assertEquals(PROJECT.toString(), argv.get(6)); + assertEquals(7, argv.size()); + } + + @Test + public void windowsArgvIsLauncherFirst() { + Path exe = Paths.get("C:/Program Files/Cayenne/CayenneModeler.exe"); + List<String> argv = ModelerLauncher.buildCommand( + LauncherKind.WINDOWS_EXE, exe, PROJECT, NONCE); + + assertEquals(exe.toString(), argv.get(0)); + assertEquals("--mcp-handshake", argv.get(1)); + assertEquals(NONCE, argv.get(2)); + assertEquals(PROJECT.toString(), argv.get(3)); + assertEquals(4, argv.size()); + } + + @Test + public void genericArgvUsesCurrentJavaBinary() { + Path jar = Paths.get("/opt/cayenne/bin/CayenneModeler.jar"); + List<String> argv = ModelerLauncher.buildCommand( + LauncherKind.GENERIC_JAR, jar, PROJECT, NONCE); + + // The first token must point inside the running JVM's home — never $JAVA_HOME or $PATH. + Path javaHome = Paths.get(System.getProperty("java.home")); + assertTrue(Paths.get(argv.get(0)).startsWith(javaHome), + "Generic launcher must use the running JVM: " + argv.get(0)); + assertEquals("-jar", argv.get(1)); + assertEquals(jar.toString(), argv.get(2)); + assertEquals("--mcp-handshake", argv.get(3)); + assertEquals(NONCE, argv.get(4)); + assertEquals(PROJECT.toString(), argv.get(5)); + assertEquals(6, argv.size()); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectStubIT.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectStubIT.java new file mode 100644 index 000000000..72a3a9919 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectStubIT.java @@ -0,0 +1,95 @@ +/***************************************************************** + * 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.openproject; + +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.Outcome; +import org.apache.cayenne.mcp.tools.openproject.HandshakeWatcher.WatchResult; +import org.apache.cayenne.mcp.tools.openproject.ModelerLauncher.LaunchResult; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.UUID; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Spawns a stub jar that plays the Modeler's role on the handshake side, and asserts + * the full {@code launch + await} round-trip succeeds. Validates that + * {@link ModelerLauncher} produces a correctly-detached child JVM and + * {@link HandshakeWatcher} reads the prefs entries it writes. + */ +public class OpenProjectStubIT { + + @Test + public void handshakeRoundtripViaGenericJar(@TempDir Path tmp) throws Exception { + Path stubJar = buildStubJar(tmp.resolve("stub.jar")); + Path fakeProject = tmp.resolve("fake-cayenne-project.xml"); + Files.writeString(fakeProject, "<?xml version=\"1.0\"?>\n<project/>"); + + String nonce = "it-" + UUID.randomUUID().toString().replace("-", ""); + LaunchResult launch = ModelerLauncher.launch( + LauncherKind.GENERIC_JAR, stubJar, fakeProject, nonce); + + try { + WatchResult watch = HandshakeWatcher.await( + nonce, () -> launch.process().isAlive(), Duration.ofSeconds(15)); + + assertEquals(Outcome.HANDSHAKE_RECEIVED, watch.outcome(), + "expected stub to write handshake within 15s"); + assertNotNull(watch.data()); + assertTrue(watch.data().pid() > 0, + "stub reported its pid: " + watch.data().pid()); + assertEquals(fakeProject.toString(), watch.data().resolvedProjectPath()); + assertNotNull(watch.data().startedAt()); + } finally { + launch.process().destroyForcibly(); + } + } + + private static Path buildStubJar(Path jar) throws IOException { + Manifest manifest = new Manifest(); + Attributes mainAttrs = manifest.getMainAttributes(); + mainAttrs.put(Attributes.Name.MANIFEST_VERSION, "1.0"); + mainAttrs.put(Attributes.Name.MAIN_CLASS, HandshakeStubMain.class.getName()); + + String classResource = HandshakeStubMain.class.getName().replace('.', '/') + ".class"; + try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar), manifest); + InputStream classBytes = OpenProjectStubIT.class.getClassLoader() + .getResourceAsStream(classResource)) { + if (classBytes == null) { + throw new IOException("Could not locate class resource: " + classResource); + } + jos.putNextEntry(new JarEntry(classResource)); + classBytes.transferTo(jos); + jos.closeEntry(); + } + return jar; + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectValidationTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectValidationTest.java new file mode 100644 index 000000000..f17c91057 --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OpenProjectValidationTest.java @@ -0,0 +1,46 @@ +/***************************************************************** + * 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.openproject; + +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectErrorCode; +import org.apache.cayenne.mcp.tools.openproject.protocol.OpenProjectResult; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class OpenProjectValidationTest { + + private final OpenProjectTool tool = new OpenProjectTool(); + + @Test + public void projectNotFound() { + OpenProjectResult result = tool.run("/no/such/file/cayenne-project.xml"); + + assertEquals("validation_failed", result.status()); + assertEquals(OpenProjectErrorCode.project_not_found, result.error().code()); + assertNull(result.resolved()); + assertNull(result.handshake()); + + assertFalse(result.validation().projectFound()); + assertNull(result.validation().mcpJarLocated()); + assertNull(result.validation().modelerFound()); + } +} diff --git a/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OsKindTest.java b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OsKindTest.java new file mode 100644 index 000000000..2e12e57dd --- /dev/null +++ b/cayenne-mcp-server/src/test/java/org/apache/cayenne/mcp/tools/openproject/OsKindTest.java @@ -0,0 +1,45 @@ +/***************************************************************** + * 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.openproject; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OsKindTest { + + @Test + public void detectMac() { + assertEquals(OsKind.MAC, OsKind.fromOsName("Mac OS X")); + assertEquals(OsKind.MAC, OsKind.fromOsName("macOS")); + } + + @Test + public void detectWindows() { + assertEquals(OsKind.WINDOWS, OsKind.fromOsName("Windows 10")); + assertEquals(OsKind.WINDOWS, OsKind.fromOsName("Windows Server 2019")); + } + + @Test + public void detectOther() { + assertEquals(OsKind.OTHER, OsKind.fromOsName("Linux")); + assertEquals(OsKind.OTHER, OsKind.fromOsName("FreeBSD")); + assertEquals(OsKind.OTHER, OsKind.fromOsName("")); + } +}
