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 <nonce>] [<projectPath>] + * </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"); + } +}
