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 73abc550c4e1fa570c4e1b37b866f86b177aba1e
Author: Andrus Adamchik <[email protected]>
AuthorDate: Sun May 17 18:35:33 2026 -0400

    CAY-2943 CayenneModeler MCP: open_project tool
    
    Modeler side MCP handshake part
---
 .../org/apache/cayenne/modeler/Application.java    |  30 ++---
 .../java/org/apache/cayenne/modeler/CliArgs.java   |  76 ++++++++++++
 .../cayenne/modeler/mcp/McpHandshakeWriter.java    |  71 +++++++++++
 .../modeler/project/ProjectFileChangeTracker.java  |   2 +-
 .../org/apache/cayenne/modeler/ui/MainFrame.java   |  15 ++-
 .../modeler/ui/action/NewProjectAction.java        |   2 +-
 .../modeler/ui/action/OpenProjectAction.java       |  16 ++-
 .../cayenne/modeler/ui/action/RevertAction.java    |   2 +-
 .../modeler/CayenneModelerValidationIT.java        |  14 ++-
 .../org/apache/cayenne/modeler/CliArgsTest.java    | 138 +++++++++++++++++++++
 .../modeler/mcp/McpHandshakeWriterTest.java        | 115 +++++++++++++++++
 11 files changed, 448 insertions(+), 33 deletions(-)

diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/Application.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/Application.java
index e430cf476..5bbeeff2e 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/Application.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/Application.java
@@ -66,6 +66,8 @@ public class Application {
 
     public static void launch(String[] args, UIInitializer 
platformInitializer) {
 
+        CliArgs cli = CliArgs.parse(args);
+
         LOGGER.info("Starting CayenneModeler.");
         LOGGER.info("JRE v.{} at {}", System.getProperty("java.version"), 
System.getProperty("java.home"));
 
@@ -77,21 +79,8 @@ public class Application {
                 new DbSyncModule(),
                 new ModelerModule());
 
-        SwingUtilities.invokeLater(() -> new Application(injector, 
platformInitializer).launch(initialProjectFromArgs(args)));
-    }
-
-    private static File initialProjectFromArgs(String[] args) {
-        if (args != null && args.length == 1) {
-            File f = new File(args[0]);
-
-            if (f.isFile()
-                    && f.getName().startsWith("cayenne")
-                    && f.getName().endsWith(".xml")) {
-                return f;
-            }
-        }
-
-        return null;
+        SwingUtilities.invokeLater(() ->
+                new Application(injector, platformInitializer, 
cli).launch(cli.initialProject()));
     }
 
     private final Injector injector;
@@ -99,15 +88,17 @@ public class Application {
     private final ModelerClassLoader classLoader;
     private final PreferencesRepository preferencesRepository;
     private final ProjectValidator projectValidator;
+    private final CliArgs cli;
     private GlobalActions actionManager;
     private LogConsole logConsole;
     private MainFrame frame;
     private CayenneUndoManager undoManager;
     private DBConnectors dbConnectors;
 
-    public Application(Injector injector, UIInitializer platformInitializer) {
+    public Application(Injector injector, UIInitializer platformInitializer, 
CliArgs cli) {
         this.injector = injector;
         this.platformInitializer = platformInitializer;
+        this.cli = cli;
 
         this.classLoader = new ModelerClassLoader();
         this.preferencesRepository = new 
PreferencesRepository(injector.getInstance(ConfigurationNameMapper.class));
@@ -175,6 +166,10 @@ public class Application {
         return logConsole;
     }
 
+    public CliArgs getCli() {
+        return cli;
+    }
+
     public void launch(File initialProject) {
         this.platformInitializer.initLookAndFeel();
         this.actionManager = new GlobalActions(
@@ -215,7 +210,8 @@ public class Application {
         }
 
         if (initialProject != null) {
-            
getActionManager().getAction(OpenProjectAction.class).openProject(initialProject);
+            getActionManager().getAction(OpenProjectAction.class)
+                    .openProject(initialProject, cli.mcpHandshakeNonce());
         }
     }
 
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/CliArgs.java 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/CliArgs.java
new file mode 100644
index 000000000..747ffd8ee
--- /dev/null
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/CliArgs.java
@@ -0,0 +1,76 @@
+/*****************************************************************
+ *   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.modeler;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.File;
+
+/**
+ * Parsed CayenneModeler command line. Grammar:
+ * <pre>
+ * CayenneModeler [--mcp-handshake &lt;nonce&gt;] [&lt;projectPath&gt;]
+ * </pre>
+ * Parsing is lenient: unknown flags and malformed values are logged and 
ignored so the
+ * Modeler always starts. The Modeler is end-user software, not a build tool.
+ */
+public record CliArgs(File initialProject, String mcpHandshakeNonce, String[] 
rawArgs) {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CliArgs.class);
+
+    static final String MCP_HANDSHAKE_FLAG = "--mcp-handshake";
+
+    public static CliArgs parse(String[] args) {
+        String[] safeArgs = args != null ? args : new String[0];
+
+        String nonce = null;
+        File project = null;
+
+        for (int i = 0; i < safeArgs.length; i++) {
+            String arg = safeArgs[i];
+            if (MCP_HANDSHAKE_FLAG.equals(arg)) {
+                if (i + 1 < safeArgs.length) {
+                    nonce = safeArgs[++i];
+                } else {
+                    LOGGER.warn("{} flag is missing a value, ignoring", 
MCP_HANDSHAKE_FLAG);
+                }
+            } else if (arg.startsWith("--")) {
+                LOGGER.warn("Ignoring unrecognised flag: {}", arg);
+            } else if (project == null) {
+                project = validProjectFile(arg);
+            } else {
+                LOGGER.warn("Ignoring extra positional argument: {}", arg);
+            }
+        }
+
+        return new CliArgs(project, nonce, safeArgs);
+    }
+
+    private static File validProjectFile(String pathArg) {
+        File f = new File(pathArg);
+        if (f.isFile()
+                && f.getName().startsWith("cayenne")
+                && f.getName().endsWith(".xml")) {
+            return f;
+        }
+        return null;
+    }
+}
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriter.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriter.java
new file mode 100644
index 000000000..10d9547cc
--- /dev/null
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriter.java
@@ -0,0 +1,71 @@
+/*****************************************************************
+ *   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.modeler.mcp;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.prefs.BackingStoreException;
+import java.util.prefs.Preferences;
+
+/**
+ * Writes a "Modeler is up and the requested project is loaded" handshake 
entry into
+ * {@link Preferences} under a nonce-scoped node. Used by the MCP server's 
{@code open_project}
+ * tool to confirm the launch succeeded without polling {@code 
Process.isAlive()}.
+ * <p>
+ * Note the namespace: handshake entries live at {@code 
/org/apache/cayenne/modeler/mcp-handshake/...},
+ * a sibling of the Modeler's regular {@code 
/org/apache/cayenne/modeler/v5/...} preferences tree.
+ */
+public final class McpHandshakeWriter {
+
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(McpHandshakeWriter.class);
+
+    static final String NODE_PREFIX = 
"/org/apache/cayenne/modeler/mcp-handshake/";
+
+    private McpHandshakeWriter() {
+    }
+
+    /**
+     * Asynchronously writes the handshake. Returns immediately; the actual 
write happens
+     * on a short-lived daemon thread so the EDT is not blocked by a slow 
preferences backend.
+     */
+    public static void write(String nonce, String[] originalArgs, String 
resolvedProjectPath) {
+        Thread t = new Thread(() -> doWrite(nonce, originalArgs, 
resolvedProjectPath),
+                "mcp-handshake-writer");
+        t.setDaemon(true);
+        t.start();
+    }
+
+    private static void doWrite(String nonce, String[] originalArgs, String 
resolvedProjectPath) {
+        try {
+            Preferences prefs = Preferences.userRoot().node(NODE_PREFIX + 
nonce);
+            prefs.put("startedAt", Instant.now().toString());
+            prefs.putLong("pid", ProcessHandle.current().pid());
+            prefs.put("args", originalArgs != null ? String.join(" ", 
originalArgs) : "");
+            prefs.put("projectPath", resolvedProjectPath != null ? 
resolvedProjectPath : "");
+            prefs.flush();
+        } catch (BackingStoreException | RuntimeException e) {
+            // Never propagate - a prefs failure must not crash the Modeler. 
The MCP-side
+            // wait loop will translate the missing handshake into 
LAUNCH_NOT_CONFIRMED.
+            LOGGER.warn("Failed to write MCP handshake for nonce {}: {}", 
nonce, e.toString());
+        }
+    }
+}
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/project/ProjectFileChangeTracker.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/project/ProjectFileChangeTracker.java
index 219896e57..e16743fac 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/project/ProjectFileChangeTracker.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/project/ProjectFileChangeTracker.java
@@ -107,7 +107,7 @@ class ProjectFileChangeTracker extends Thread {
                     session.app()
                             .getActionManager()
                             .getAction(OpenProjectAction.class)
-                            .openProject(fileDirectory);
+                            .openProject(fileDirectory, null);
                 }
             } else {
                 session.setDirty(true);
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/MainFrame.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/MainFrame.java
index 53296d359..402e74ec0 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/MainFrame.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/MainFrame.java
@@ -21,6 +21,7 @@ package org.apache.cayenne.modeler.ui;
 
 import org.apache.cayenne.CayenneRuntimeException;
 import org.apache.cayenne.modeler.Application;
+import org.apache.cayenne.modeler.mcp.McpHandshakeWriter;
 import org.apache.cayenne.modeler.pref.RecentProjectsPrefs;
 import org.apache.cayenne.modeler.service.action.GlobalActions;
 import org.apache.cayenne.modeler.service.os.OperatingSystem;
@@ -272,9 +273,13 @@ public class MainFrame extends AppFrame {
     }
 
     /**
-     * Handles project opening control. Updates main frame, then delegates 
control to child controllers.
+     * Handles project opening control. Updates main frame, then delegates 
control to
+     * child controllers. If {@code mcpHandshakeNonce} is non-null, also 
writes an MCP
+     * handshake entry once the project is loaded - signals to an MCP server 
that
+     * launched the Modeler with {@code --mcp-handshake} that the project is 
ready.
+     * Callers not in the MCP launch path pass {@code null}.
      */
-    public void onProjectOpened(Project project) {
+    public void onProjectOpened(Project project, String mcpHandshakeNonce) {
 
         session.projectOpened(project);
         this.projectView = new ProjectView(session);
@@ -311,6 +316,10 @@ public class MainFrame extends AppFrame {
         if (!allFailures.isEmpty()) {
             
app.getActionManager().getAction(ValidateAction.class).showFailures(allFailures);
         }
+
+        if (mcpHandshakeNonce != null) {
+            McpHandshakeWriter.write(mcpHandshakeNonce, 
app.getCli().rawArgs(), getProjectLocationString());
+        }
     }
 
     /**
@@ -360,7 +369,7 @@ public class MainFrame extends AppFrame {
         if (transferFile.isFile()) {
             FileFilter filter = FileFilters.getApplicationFilter();
             if (filter.accept(transferFile)) {
-                
app.getActionManager().getAction(OpenProjectAction.class).openProject(transferFile);
+                
app.getActionManager().getAction(OpenProjectAction.class).openProject(transferFile,
 null);
                 return true;
             }
         }
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/NewProjectAction.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/NewProjectAction.java
index 68d63d0c2..1d9ee18de 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/NewProjectAction.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/NewProjectAction.java
@@ -67,7 +67,7 @@ public class NewProjectAction extends AppAction {
         Project project = new Project(
                 new 
ConfigurationTree<DataChannelDescriptor>(dataChannelDescriptor));
 
-        controller.onProjectOpened(project);
+        controller.onProjectOpened(project, null);
 
         // select default domain
         getProjectSession().displayDomain(new DomainDisplayEvent(this, 
dataChannelDescriptor));
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/OpenProjectAction.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/OpenProjectAction.java
index 07f8753d5..78b4fea3d 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/OpenProjectAction.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/OpenProjectAction.java
@@ -120,16 +120,20 @@ public class OpenProjectAction extends AppAction {
                 return;
             }
 
-            openProject(f);
+            openProject(f, null);
         }
 
         app.getUndoManager().discardAllEdits();
     }
 
     /**
-     * Opens specified project file. File must already exist.
+     * Opens the specified project file. File must already exist. When
+     * {@code mcpHandshakeNonce} is non-null, the MCP-driven launch contract 
is honoured:
+     * on successful open, a handshake entry is written to {@link 
java.util.prefs.Preferences}
+     * so the MCP server's wait loop can confirm the project loaded. Pass 
{@code null}
+     * for normal user-driven opens.
      */
-    public void openProject(File file) {
+    public void openProject(File file, String mcpHandshakeNonce) {
         try {
             if (!file.exists()) {
                 JOptionPane.showMessageDialog(
@@ -178,7 +182,7 @@ public class OpenProjectAction extends AppAction {
                     break;
             }
 
-            openProjectResourse(rootSource, controller);
+            openProjectResourse(rootSource, controller, mcpHandshakeNonce);
 
 
         } catch (Exception ex) {
@@ -187,9 +191,9 @@ public class OpenProjectAction extends AppAction {
         }
     }
 
-    private Project openProjectResourse(Resource resource, MainFrame 
controller) {
+    private Project openProjectResourse(Resource resource, MainFrame 
controller, String mcpHandshakeNonce) {
         Project project = app.getProjectLoader().loadProject(resource);
-        controller.onProjectOpened(project);
+        controller.onProjectOpened(project, mcpHandshakeNonce);
         return project;
     }
 
diff --git 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/RevertAction.java
 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/RevertAction.java
index b2bbcebf3..beaa4a022 100644
--- 
a/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/RevertAction.java
+++ 
b/modeler/cayenne-modeler/src/main/java/org/apache/cayenne/modeler/ui/action/RevertAction.java
@@ -54,7 +54,7 @@ public class RevertAction extends AppAction {
         if (!isNew && fileDirectory.isFile()) {
             app
                     .getActionManager()
-                    
.getAction(OpenProjectAction.class).openProject(fileDirectory);
+                    
.getAction(OpenProjectAction.class).openProject(fileDirectory, null);
         }
 
         // create new
diff --git 
a/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CayenneModelerValidationIT.java
 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CayenneModelerValidationIT.java
index 7ca7977f2..6941c5a49 100644
--- 
a/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CayenneModelerValidationIT.java
+++ 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CayenneModelerValidationIT.java
@@ -58,16 +58,22 @@ public class CayenneModelerValidationIT {
 
     @Test
     public void validatorProvided() {
-        Application application = new Application(injector, new 
UIInitializer() {
-        });
+        Application application = new Application(
+                injector,
+                new UIInitializer() {
+                },
+                CliArgs.parse(new String[0]));
         assertTrue(application.getProjectValidator() instanceof 
ConfigurableProjectValidator);
     }
 
     @Test
     public void configLoaded() {
         URLResource projectResource = new 
URLResource(getClass().getResource(CAYENNE_CONFIGURED_VALIDATION_PROJECT));
-        Application application = new Application(injector, new 
UIInitializer() {
-        });
+        Application application = new Application(
+                injector,
+                new UIInitializer() {
+                },
+                CliArgs.parse(new String[0]));
         ProjectLoader projectLoader = 
injector.getInstance(ProjectLoader.class);
         Project project = projectLoader.loadProject(projectResource);
 
diff --git 
a/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CliArgsTest.java
 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CliArgsTest.java
new file mode 100644
index 000000000..69ddddb2c
--- /dev/null
+++ 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/CliArgsTest.java
@@ -0,0 +1,138 @@
+/*****************************************************************
+ *   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.modeler;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+public class CliArgsTest {
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    public void empty() {
+        CliArgs cli = CliArgs.parse(new String[0]);
+        assertNull(cli.initialProject());
+        assertNull(cli.mcpHandshakeNonce());
+        assertNotNull(cli.rawArgs());
+    }
+
+    @Test
+    public void nullArgs() {
+        CliArgs cli = CliArgs.parse(null);
+        assertNull(cli.initialProject());
+        assertNull(cli.mcpHandshakeNonce());
+        assertNotNull(cli.rawArgs());
+        assertEquals(0, cli.rawArgs().length);
+    }
+
+    @Test
+    public void positionalProjectPath() throws IOException {
+        File project = makeCayenneFile("cayenne-foo.xml");
+        CliArgs cli = CliArgs.parse(new String[]{project.getAbsolutePath()});
+        assertEquals(project, cli.initialProject());
+        assertNull(cli.mcpHandshakeNonce());
+    }
+
+    @Test
+    public void positionalPathRejectedWhenNotCayenneXml() throws IOException {
+        File f = 
Files.createFile(tmp.resolve("not-a-cayenne-project.xml")).toFile();
+        CliArgs cli = CliArgs.parse(new String[]{f.getAbsolutePath()});
+        assertNull(cli.initialProject());
+    }
+
+    @Test
+    public void positionalPathRejectedWhenNotXml() throws IOException {
+        File f = Files.createFile(tmp.resolve("cayenne-foo.txt")).toFile();
+        CliArgs cli = CliArgs.parse(new String[]{f.getAbsolutePath()});
+        assertNull(cli.initialProject());
+    }
+
+    @Test
+    public void positionalPathRejectedWhenFileMissing() {
+        CliArgs cli = CliArgs.parse(new 
String[]{tmp.resolve("cayenne-missing.xml").toString()});
+        assertNull(cli.initialProject());
+    }
+
+    @Test
+    public void handshakeOnly() {
+        CliArgs cli = CliArgs.parse(new String[]{"--mcp-handshake", "abc123"});
+        assertEquals("abc123", cli.mcpHandshakeNonce());
+        assertNull(cli.initialProject());
+    }
+
+    @Test
+    public void handshakeThenProject() throws IOException {
+        File project = makeCayenneFile("cayenne-foo.xml");
+        CliArgs cli = CliArgs.parse(new String[]{
+                "--mcp-handshake", "abc123", project.getAbsolutePath()});
+        assertEquals("abc123", cli.mcpHandshakeNonce());
+        assertEquals(project, cli.initialProject());
+    }
+
+    @Test
+    public void projectThenHandshake() throws IOException {
+        File project = makeCayenneFile("cayenne-foo.xml");
+        CliArgs cli = CliArgs.parse(new String[]{
+                project.getAbsolutePath(), "--mcp-handshake", "abc123"});
+        assertEquals("abc123", cli.mcpHandshakeNonce());
+        assertEquals(project, cli.initialProject());
+    }
+
+    @Test
+    public void handshakeWithoutValueIgnored() {
+        CliArgs cli = CliArgs.parse(new String[]{"--mcp-handshake"});
+        assertNull(cli.mcpHandshakeNonce());
+        assertNull(cli.initialProject());
+    }
+
+    @Test
+    public void unknownFlagIgnored() throws IOException {
+        File project = makeCayenneFile("cayenne-foo.xml");
+        CliArgs cli = CliArgs.parse(new String[]{
+                "--bogus-flag", project.getAbsolutePath()});
+        assertEquals(project, cli.initialProject());
+        assertNull(cli.mcpHandshakeNonce());
+    }
+
+    @Test
+    public void rawArgsPreserved() {
+        String[] in = {"--mcp-handshake", "n", "/some/path"};
+        CliArgs cli = CliArgs.parse(in);
+        assertEquals(3, cli.rawArgs().length);
+        assertEquals("--mcp-handshake", cli.rawArgs()[0]);
+        assertEquals("n", cli.rawArgs()[1]);
+        assertEquals("/some/path", cli.rawArgs()[2]);
+    }
+
+    private File makeCayenneFile(String name) throws IOException {
+        return Files.createFile(tmp.resolve(name)).toFile();
+    }
+}
diff --git 
a/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriterTest.java
 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriterTest.java
new file mode 100644
index 000000000..f0a89e8cf
--- /dev/null
+++ 
b/modeler/cayenne-modeler/src/test/java/org/apache/cayenne/modeler/mcp/McpHandshakeWriterTest.java
@@ -0,0 +1,115 @@
+/*****************************************************************
+ *   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.modeler.mcp;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.UUID;
+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.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+public class McpHandshakeWriterTest {
+
+    // Unique per test method - keeps parallel runs and prior aborted runs 
from colliding.
+    private final String nonce = UUID.randomUUID().toString().replace("-", "");
+
+    @AfterEach
+    public void cleanup() throws BackingStoreException {
+        Preferences node = 
Preferences.userRoot().node(McpHandshakeWriter.NODE_PREFIX + nonce);
+        if (node != null) {
+            node.removeNode();
+            Preferences.userRoot().flush();
+        }
+    }
+
+    @Test
+    public void writesAllFourKeys() throws Exception {
+        String[] argv = {"--mcp-handshake", nonce, "/path/to/cayenne-foo.xml"};
+        long before = System.currentTimeMillis() - 1;
+
+        McpHandshakeWriter.write(nonce, argv, "/path/to/cayenne-foo.xml");
+
+        Preferences prefs = pollForNode();
+
+        String startedAt = prefs.get("startedAt", null);
+        long pid = prefs.getLong("pid", -1);
+        String args = prefs.get("args", null);
+        String projectPath = prefs.get("projectPath", null);
+
+        assertNotNull(startedAt, "startedAt should be written");
+        Instant parsed = Instant.parse(startedAt);
+        assertTrue(parsed.toEpochMilli() >= before,
+                "startedAt should be a reasonable wall-clock timestamp");
+
+        assertEquals(ProcessHandle.current().pid(), pid, "pid should be 
current process pid");
+        assertEquals("--mcp-handshake " + nonce + " /path/to/cayenne-foo.xml", 
args);
+        assertEquals("/path/to/cayenne-foo.xml", projectPath);
+    }
+
+    @Test
+    public void nullArgvWritesEmptyArgsString() throws Exception {
+        McpHandshakeWriter.write(nonce, null, "/p");
+        Preferences prefs = pollForNode();
+        assertEquals("", prefs.get("args", null));
+    }
+
+    @Test
+    public void nullProjectPathWritesEmptyString() throws Exception {
+        McpHandshakeWriter.write(nonce, new String[]{"x"}, null);
+        Preferences prefs = pollForNode();
+        assertEquals("", prefs.get("projectPath", null));
+    }
+
+    /**
+     * Wait for the daemon writer thread to finish writing the node. Polls 
because the
+     * write is asynchronous; checks for all four keys (not just `startedAt`) 
so we don't
+     * race the writer's sequential puts.
+     */
+    private Preferences pollForNode() throws Exception {
+        String fullPath = McpHandshakeWriter.NODE_PREFIX + nonce;
+        long deadline = System.currentTimeMillis() + 5_000;
+        while (System.currentTimeMillis() < deadline) {
+            if (Preferences.userRoot().nodeExists(fullPath)) {
+                Preferences node = Preferences.userRoot().node(fullPath);
+                if (hasAllKeys(node)) {
+                    return node;
+                }
+            }
+            Thread.sleep(25);
+        }
+        fail("Handshake node did not appear within 5s");
+        return null; // unreachable
+    }
+
+    private boolean hasAllKeys(Preferences node) throws BackingStoreException {
+        var keys = java.util.Set.of(node.keys());
+        return keys.contains("startedAt")
+                && keys.contains("pid")
+                && keys.contains("args")
+                && keys.contains("projectPath");
+    }
+}

Reply via email to