You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@cayenne.apache.org by aa...@apache.org on 2026/05/19 20:58:21 UTC
(cayenne) 02/09: CAY-2943 CayenneModeler MCP: open_project tool
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 <an...@objectstyle.com>
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(""));
+ }
+}