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(""));
+    }
+}


Reply via email to