From e42287fcb4d03b8259916f474bceda70b6b04e1e Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 1 May 2026 00:02:33 +0200 Subject: [PATCH 01/24] added check for update functionality --- AGENTS.md | 19 +- .../jsoneditor/controller/Controller.java | 9 + .../controller/impl/ControllerImpl.java | 45 +++++ .../settings/UpdateCheckResult.java | 14 ++ .../controller/settings/UpdateService.java | 185 ++++++++++++++++++ .../daniel/jsoneditor/view/impl/ViewImpl.java | 1 + .../components/menubar/JsonEditorMenuBar.java | 4 +- src/main/resources/version.properties | 2 +- .../settings/UpdateServiceTest.java | 51 +++++ 9 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/controller/settings/UpdateCheckResult.java create mode 100644 src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java create mode 100644 src/test/java/com/daniel/jsoneditor/controller/settings/UpdateServiceTest.java diff --git a/AGENTS.md b/AGENTS.md index 06c721c7..3a400975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,16 +31,19 @@ Every user mutation is a `Command` (`model/commands/Command.java`): 1. Instantiate via `model.getCommandFactory().()` – never use `new` directly from the View/Controller 2. Execute via `controller.getCommandManager().executeCommand(cmd)` – this handles the undo stack 3. `execute()` returns `List` – semantic diffs used for UI refresh and undo stack -4. `ModelChange` has static factories: `ModelChange.add(path, node)`, `.remove(path, old)`, `.set(path, old, new)`, `.move(...)` +4. `ModelChange` has static factories: `ModelChange.add(path, node)`, `.remove(path, old)`, `.replace(path, old, new)`, `.move(...)`, `.sort(path, oldSnapshot, newSnapshot)` 5. `isUndoable()` defaults `true`; override to `false` for non-reversible ops 6. Extend `BaseCommand` for any command that directly mutates `WritableModelInternal` +7. Commands have a `CommandCategory` (`STRUCTURE`, `VALUE`, `NODE`, `OTHER`) for classification +8. `ReferenceableObjectCommand` – marker interface for commands that create referenceable objects; exposes `getCreatedObjectPath()` All concrete commands live in `model/commands/impl/`. `CommandFactory` lists all available commands. ## Event / State Flow After any model change the controller calls `model.sendEvent(new Event(EventEnum.X))`. The `View` observes the model via `Observer.update()` and reads `model.getLatestEvent()` to decide what to refresh. -`EventEnum` (in `model/statemachine/impl/`) lists all state transitions – check it first when adding new UI reactions. +`EventEnum` (in `model/statemachine/impl/`) lists all state transitions – check it first when adding new UI reactions. +Notable event: `COMMAND_APPLIED` fires after any command execute/undo/redo with metadata attached. ## Key Data Type **`JsonNodeWithPath`** (`model/json/JsonNodeWithPath.java`) – a Jackson `JsonNode` + its JSON Pointer path string (`/foo/bar/0`). Used everywhere as the primary node reference. Paths use `/`-separated JSON Pointer syntax. @@ -61,6 +64,7 @@ UIHandlerImpl → SceneHandlerImpl └── editorwindow/ ├── tableview/ (array nodes → table) └── graph/ (reference graph view) + tooltips/ (TooltipHelper) ``` UI components are in `view/impl/jfx/impl/scenes/impl/editor/components/`. `EditorWindowManager` decides which sub-editor to render based on the selected node type. @@ -75,8 +79,17 @@ private static final Logger logger = LoggerFactory.getLogger(MyClass.class); - **`JsonDiffer`** (`model/diff/JsonDiffer.java`) – static `calculateDiff(savedJson, currentJson, model)` returns `List` of what changed vs. disk. Used for the unsaved-changes indicator. - **Git blame** (`model/git/`) – `GitBlameService` loads blame info per path; triggers `EventEnum.GIT_BLAME_LOADED`. `JsonPathToLineMapper` maps JSON Pointer paths to source line numbers. +## Validation +`model/validation/` contains `ReferenceValidator` for cross-node reference validation. `ValidationResult` and `ValidationError` carry structured error info. + ## MCP Server -`McpController` wraps `JsonEditorMcpServer` (model-context-protocol). Starts on a configured port; exposes model write operations to external AI agents. Port set via `SettingsController.getMcpServerPort()`. +`McpController` wraps `JsonEditorMcpServer` (model-context-protocol). Starts on a configured port; exposes model operations to external AI agents. Port set via `SettingsController.getMcpServerPort()`. + +Tools are registered in `McpToolRegistry` (`model/mcp/`). Two base classes: +- `ReadOnlyMcpTool` – reads from `ReadableModel` (e.g., `GetNodeTool`, `GetSchemaForPathTool`, `GetFileInfoTool`) +- `WriteMcpTool` – mutates via `WritableModel` (e.g., `SetNodeTool`) + +`McpArgumentValidator` validates tool input against schemas before execution. ## Build & Packaging ```bash diff --git a/src/main/java/com/daniel/jsoneditor/controller/Controller.java b/src/main/java/com/daniel/jsoneditor/controller/Controller.java index 8c74940f..530439ea 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/Controller.java +++ b/src/main/java/com/daniel/jsoneditor/controller/Controller.java @@ -106,4 +106,13 @@ public interface Controller */ void shutdown(); + /** + * Manually triggers an update check against GitHub releases. Always shows a result toast. + */ + void checkForUpdate(); + + /** + * Silently checks for updates (no toast if already on latest). Only runs once per session. + */ + void checkForUpdateSilently(); } diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index f747c30f..fbe82944 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -16,6 +16,7 @@ import com.daniel.jsoneditor.controller.impl.json.impl.JsonNodeMerger; import com.daniel.jsoneditor.controller.mcp.McpController; import com.daniel.jsoneditor.controller.settings.SettingsController; +import com.daniel.jsoneditor.controller.settings.UpdateService; import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.WritableModel; @@ -42,6 +43,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.networknt.schema.JsonSchema; +import javafx.application.Platform; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; import javafx.scene.paint.Color; @@ -70,6 +72,8 @@ public class ControllerImpl implements Controller, Observer private final McpController mcpController; + private boolean updateCheckDone; + public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage) { this.settingsController = new SettingsControllerImpl(); @@ -133,6 +137,47 @@ public void launchFinished() model.sendEvent(new Event(EventEnum.READ_JSON_AND_SCHEMA)); } + @Override + public void checkForUpdate() + { + UpdateService.checkForUpdateAsync(result -> + Platform.runLater(() -> + { + if (result.updateAvailable()) + { + view.showCustomToast(buildUpdateAvailableMessage(result.latestVersion()), Color.DODGERBLUE); + } + else + { + view.showCustomToast("You are on the latest version", Color.GREEN); + } + })); + } + + @Override + public void checkForUpdateSilently() + { + if (updateCheckDone) + { + return; + } + updateCheckDone = true; + UpdateService.checkForUpdateAsync(result -> + { + if (result.updateAvailable()) + { + Platform.runLater(() -> view.showCustomToast(buildUpdateAvailableMessage(result.latestVersion()), Color.DODGERBLUE)); + } + }); + } + + private static String buildUpdateAvailableMessage(final String latestVersion) + { + return "Update available: v" + latestVersion + " — visit GitHub to download"; + } + + + @Override public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsFile) { diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateCheckResult.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateCheckResult.java new file mode 100644 index 00000000..52671079 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateCheckResult.java @@ -0,0 +1,14 @@ +package com.daniel.jsoneditor.controller.settings; + + +/** + * Result of checking for application updates against GitHub releases. + * + * @param updateAvailable whether a newer version exists + * @param latestVersion the latest version string (e.g. "0.18.0"), or null on error + */ +public record UpdateCheckResult(boolean updateAvailable, String latestVersion) +{ +} + + diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java new file mode 100644 index 00000000..c274c04c --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -0,0 +1,185 @@ +package com.daniel.jsoneditor.controller.settings; + +import com.daniel.jsoneditor.util.VersionUtil; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.function.Consumer; + + +/** + * Checks GitHub Releases API for newer versions of the application. + * Runs the check asynchronously so it never blocks the UI thread. + */ +public final class UpdateService +{ + private static final Logger logger = LoggerFactory.getLogger(UpdateService.class); + + private static final String GITHUB_OWNER = "DanielKispert"; + private static final String GITHUB_REPO = "JSONEditor"; + private static final String RELEASES_URL = + "https://api.github.com/repos/" + GITHUB_OWNER + "/" + GITHUB_REPO + "/releases/latest"; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private static final int MAX_RETRIES = 1; + + private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(3)) + .build(); + + private UpdateService() + { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * Asynchronously checks whether a newer release exists on GitHub. + * Calls the callback on the calling thread pool – caller is responsible for switching to the FX thread if needed. + * + * @param callback receives the result; never null, but fields may be null on network errors + */ + public static void checkForUpdateAsync(final Consumer callback) + { + final Thread thread = new Thread(() -> + { + try + { + final UpdateCheckResult result = checkForUpdate(); + callback.accept(result); + } + catch (Exception ex) + { + logger.warn("Update check failed: {}", ex.getMessage()); + callback.accept(new UpdateCheckResult(false, null)); + } + }, "update-checker"); + thread.setDaemon(true); + thread.start(); + } + + private static UpdateCheckResult checkForUpdate() + { + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(RELEASES_URL)) + .header("Accept", "application/vnd.github+json") + .timeout(Duration.ofSeconds(5)) + .GET() + .build(); + + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) + { + try + { + final HttpResponse response = HTTP_CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + final int status = response.statusCode(); + + if (status == 200) + { + return parseResponse(response.body()); + } + // don't retry client errors (403 rate-limit, 404 no releases) + if (status >= 400 && status < 500) + { + logger.warn("GitHub API returned client error {}, not retrying", status); + return new UpdateCheckResult(false, null); + } + logger.warn("GitHub API returned status {}, attempt {}/{}", status, attempt + 1, MAX_RETRIES + 1); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + return new UpdateCheckResult(false, null); + } + catch (Exception e) + { + logger.warn("Update check attempt {}/{} failed: {}", attempt + 1, MAX_RETRIES + 1, e.getMessage()); + } + } + return new UpdateCheckResult(false, null); + } + + private static UpdateCheckResult parseResponse(final String body) + { + try + { + final JsonNode root = MAPPER.readTree(body); + final String tagName = root.path("tag_name").asText(""); + + final String latestVersion = tagName.startsWith("v") ? tagName.substring(1) : tagName; + final String currentVersion = VersionUtil.getVersion(); + + final boolean newer = isNewer(latestVersion, currentVersion); + logger.info("Update check: current={}, latest={}, updateAvailable={}", currentVersion, latestVersion, newer); + + return new UpdateCheckResult(newer, latestVersion); + } + catch (Exception e) + { + logger.warn("Failed to parse GitHub release response: {}", e.getMessage()); + return new UpdateCheckResult(false, null); + } + } + + /** + * Simple semver comparison (major.minor.patch). Returns true if latest > current. + */ + static boolean isNewer(final String latest, final String current) + { + if (latest == null || latest.isBlank() || current == null || "unknown".equals(current)) + { + return false; + } + try + { + final int[] l = parseSemver(latest); + final int[] c = parseSemver(current); + for (int i = 0; i < 3; i++) + { + if (l[i] > c[i]) + { + return true; + } + if (l[i] < c[i]) + { + return false; + } + } + return false; + } + catch (NumberFormatException e) + { + logger.warn("Could not parse version strings: latest={}, current={}", latest, current); + return false; + } + } + + private static int[] parseSemver(final String version) + { + final String[] parts = version.split("\\."); + final int[] result = new int[3]; + for (int i = 0; i < Math.min(parts.length, 3); i++) + { + result[i] = Integer.parseInt(parts[i]); + } + return result; + } +} + + + + + + + + + + + diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java index 600b76af..cc83d642 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java @@ -57,6 +57,7 @@ public void update() { controller.getMcpController().startMcpServer(); } + controller.checkForUpdateSilently(); break; case RESET_SUCCESSFUL: showToast(Toasts.REFRESH_SUCCESSFUL_TOAST); diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java index fe88017f..8dfaddee 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java @@ -65,9 +65,11 @@ public JsonEditorMenuBar(ReadableModel model, Controller controller, EditorWindo inspectMenu.getItems().add(findItem); Menu helpMenu = new Menu("Help"); + MenuItem checkForUpdatesItem = new MenuItem("Check for Updates..."); + checkForUpdatesItem.setOnAction(event -> controller.checkForUpdate()); MenuItem aboutItem = new MenuItem("About"); aboutItem.setOnAction(event -> new AboutDialog().showAndWait()); - helpMenu.getItems().add(aboutItem); + helpMenu.getItems().addAll(checkForUpdatesItem, aboutItem); getMenus().addAll(fileMenu, editMenu, viewMenu, inspectMenu, helpMenu); } diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties index cca85e8d..9fc8f3b3 100644 --- a/src/main/resources/version.properties +++ b/src/main/resources/version.properties @@ -1 +1 @@ -version=0.17.0 \ No newline at end of file +version=0.17.1 \ No newline at end of file diff --git a/src/test/java/com/daniel/jsoneditor/controller/settings/UpdateServiceTest.java b/src/test/java/com/daniel/jsoneditor/controller/settings/UpdateServiceTest.java new file mode 100644 index 00000000..e6cf5be3 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/settings/UpdateServiceTest.java @@ -0,0 +1,51 @@ +package com.daniel.jsoneditor.controller.settings; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + + +public class UpdateServiceTest +{ + @Test + void isNewer_newerVersionDetected() + { + assertTrue(UpdateService.isNewer("0.18.0", "0.17.0")); + assertTrue(UpdateService.isNewer("0.17.1", "0.17.0")); + assertTrue(UpdateService.isNewer("1.0.0", "0.17.0")); + } + + @Test + void isNewer_sameOrOlderVersion() + { + assertFalse(UpdateService.isNewer("0.17.0", "0.17.0")); + assertFalse(UpdateService.isNewer("0.16.0", "0.17.0")); + assertFalse(UpdateService.isNewer("0.17.0", "1.0.0")); + } + + @Test + void isNewer_handlesNullAndBlank() + { + assertFalse(UpdateService.isNewer(null, "0.17.0")); + assertFalse(UpdateService.isNewer("", "0.17.0")); + assertFalse(UpdateService.isNewer(" ", "0.17.0")); + assertFalse(UpdateService.isNewer("0.18.0", null)); + assertFalse(UpdateService.isNewer("0.18.0", "unknown")); + } + + @Test + void isNewer_handlesMalformedVersions() + { + assertFalse(UpdateService.isNewer("abc", "0.17.0")); + assertFalse(UpdateService.isNewer("0.17.0", "abc")); + } + + @Test + void isNewer_handlesPartialVersions() + { + assertTrue(UpdateService.isNewer("1.2", "0.17.0")); + assertFalse(UpdateService.isNewer("0.17", "0.17.0")); + } +} + + From 4254ae3833d259226dce55c0db1d6a5699023308 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 1 May 2026 01:23:28 +0200 Subject: [PATCH 02/24] first step --- build.gradle | 4 - .../jsoneditor/model/impl/ModelImpl.java | 10 +- .../jsoneditor/model/mcp/WriteMcpTool.java | 2 +- .../model/sessions/EditorSession.java | 19 +++ .../model/sessions/FileSessionManager.java | 145 ++++++++++++++++++ src/main/resources/version.properties | 2 +- 6 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java diff --git a/build.gradle b/build.gradle index 71757695..88bec4c4 100644 --- a/build.gradle +++ b/build.gradle @@ -39,14 +39,10 @@ java { dependencies { - implementation 'io.github.copilot-community-sdk:copilot-sdk:1.0.5' implementation 'com.networknt:json-schema-validator:1.0.87' implementation 'ch.qos.logback:logback-classic:1.5.17' implementation 'org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r' implementation 'com.brunomnsilva:smartgraph:2.3.0' - implementation('io.modelcontextprotocol.sdk:mcp:0.17.2') { - exclude group: 'com.networknt', module: 'json-schema-validator' - } testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.10.0' diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index 74f13234..0e9e156d 100644 --- a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java @@ -145,7 +145,15 @@ public void setCurrentJSONFile(File json) { gitBlameIntegration.initialize(json.toPath()).thenRun(() -> { logger.info("Git blame loading completed"); - Platform.runLater(() -> sendEvent(new Event(EventEnum.GIT_BLAME_LOADED))); + try + { + Platform.runLater(() -> sendEvent(new Event(EventEnum.GIT_BLAME_LOADED))); + } + catch (IllegalStateException e) + { + // JavaFX not initialized (headless mode) + sendEvent(new Event(EventEnum.GIT_BLAME_LOADED)); + } }); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java index c2aad5b9..3a1158b0 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java @@ -10,7 +10,7 @@ public abstract class WriteMcpTool extends McpTool { protected final WritableModel model; - + protected WriteMcpTool(final WritableModel model) { if (model == null) diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java b/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java new file mode 100644 index 00000000..e8d15ac2 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java @@ -0,0 +1,19 @@ +package com.daniel.jsoneditor.model.sessions; + +import com.daniel.jsoneditor.model.ReadableModel; + +import java.io.File; + + +/** + * Represents a single open file session with its model and metadata. + * + * @param id unique session identifier + * @param model the model for this session + * @param jsonFile the JSON file being edited + * @param schemaFile the schema file used for validation + * @param guiOwned true if this session is owned by the GUI (cannot be closed via MCP) + */ +public record EditorSession(String id, ReadableModel model, File jsonFile, File schemaFile, boolean guiOwned) +{ +} diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java new file mode 100644 index 00000000..94b29298 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -0,0 +1,145 @@ +package com.daniel.jsoneditor.model.sessions; + +import com.daniel.jsoneditor.controller.impl.json.impl.JsonFileReaderAndWriterImpl; +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + + +/** + * Manages multiple open file sessions. Used by both GUI and MCP server. + * GUI sessions are protected from being closed via MCP. + */ +public class FileSessionManager +{ + private static final Logger logger = LoggerFactory.getLogger(FileSessionManager.class); + + private final Map sessions = new ConcurrentHashMap<>(); + + /** + * Opens a JSON file with its schema and creates a new headless session. + * + * @param jsonPath absolute path to the JSON file + * @param schemaPath absolute path to the schema file + * @return session ID, or null if loading failed + */ + public String openFile(final String jsonPath, final String schemaPath) + { + final File jsonFile = new File(jsonPath); + final File schemaFile = new File(schemaPath); + + if (!jsonFile.exists()) + { + logger.error("JSON file does not exist: {}", jsonPath); + return null; + } + if (!schemaFile.exists()) + { + logger.error("Schema file does not exist: {}", schemaPath); + return null; + } + + final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); + final JsonNode json = reader.getJsonFromFile(jsonFile); + final JsonSchema schema = reader.getSchemaFromFileResolvingRefs(schemaFile); + + if (json == null || schema == null) + { + logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); + return null; + } + + final ModelImpl model = new ModelImpl(new EventSenderImpl()); + model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); + + final String sessionId = UUID.randomUUID().toString().substring(0, 8); + final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); + sessions.put(sessionId, session); + + logger.info("Opened file session {} for {}", sessionId, jsonPath); + return sessionId; + } + + /** + * Registers an existing GUI model as a session. Protected from MCP close. + * + * @param model the GUI's model + * @param jsonFile the JSON file + * @param schemaFile the schema file + * @return session ID + */ + public String registerGuiSession(final ReadableModel model, final File jsonFile, final File schemaFile) + { + final String sessionId = "gui-" + UUID.randomUUID().toString().substring(0, 8); + final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, true); + sessions.put(sessionId, session); + logger.info("Registered GUI session {} for {}", sessionId, jsonFile != null ? jsonFile.getAbsolutePath() : "null"); + return sessionId; + } + + /** + * Unregisters a GUI session (called when GUI closes a file). + * + * @param sessionId the session to unregister + */ + public void unregisterGuiSession(final String sessionId) + { + final EditorSession session = sessions.get(sessionId); + if (session != null && session.guiOwned()) + { + sessions.remove(sessionId); + logger.info("Unregistered GUI session {}", sessionId); + } + } + + /** + * Closes a headless session. Refuses to close GUI-owned sessions. + * + * @param sessionId the session to close + * @return true if closed, false if not found or GUI-owned + */ + public boolean closeFile(final String sessionId) + { + final EditorSession session = sessions.get(sessionId); + if (session == null) + { + return false; + } + if (session.guiOwned()) + { + logger.warn("Cannot close GUI-owned session {} via MCP", sessionId); + return false; + } + sessions.remove(sessionId); + logger.info("Closed file session {}", sessionId); + return true; + } + + /** + * @param sessionId the session ID + * @return the session or null if not found + */ + public EditorSession getSession(final String sessionId) + { + return sessions.get(sessionId); + } + + /** + * @return list of all active sessions + */ + public List listSessions() + { + return new ArrayList<>(sessions.values()); + } +} diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties index 9fc8f3b3..e2dd0a1f 100644 --- a/src/main/resources/version.properties +++ b/src/main/resources/version.properties @@ -1 +1 @@ -version=0.17.1 \ No newline at end of file +version=0.18.0 \ No newline at end of file From 00c7054d5df671c31726da8f060ebf7f4a0ffa04 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 1 May 2026 01:53:06 +0200 Subject: [PATCH 03/24] multi mcp to be tested --- AGENTS.md | 45 +++++++++-- README.md | 19 +++++ build.gradle | 7 ++ .../controller/impl/ControllerImpl.java | 17 +++- .../controller/mcp/McpController.java | 6 +- .../jsoneditor/model/impl/ModelImpl.java | 2 +- .../jsoneditor/model/mcp/CloseFileTool.java | 68 ++++++++++++++++ .../model/mcp/FindReferencesToTool.java | 16 +++- .../jsoneditor/model/mcp/GetExamplesTool.java | 17 +++- .../jsoneditor/model/mcp/GetFileInfoTool.java | 26 +++++- .../jsoneditor/model/mcp/GetNodeTool.java | 16 +++- .../mcp/GetReferenceableInstancesTool.java | 16 +++- .../mcp/GetReferenceableObjectsTool.java | 23 +++++- .../model/mcp/GetSchemaForPathTool.java | 17 +++- .../model/mcp/JsonEditorMcpServer.java | 16 ++-- .../jsoneditor/model/mcp/ListFilesTool.java | 58 +++++++++++++ .../jsoneditor/model/mcp/McpToolRegistry.java | 27 ++++--- .../jsoneditor/model/mcp/OpenFileTool.java | 81 +++++++++++++++++++ .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 45 +++++++++-- .../jsoneditor/model/mcp/SetNodeTool.java | 18 ++++- .../jsoneditor/model/mcp/WriteMcpTool.java | 15 +--- .../standalone/StandaloneMcpMain.java | 71 ++++++++++++++++ 22 files changed, 551 insertions(+), 75 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java diff --git a/AGENTS.md b/AGENTS.md index 3a400975..eb742de2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,18 +83,53 @@ private static final Logger logger = LoggerFactory.getLogger(MyClass.class); `model/validation/` contains `ReferenceValidator` for cross-node reference validation. `ValidationResult` and `ValidationError` carry structured error info. ## MCP Server -`McpController` wraps `JsonEditorMcpServer` (model-context-protocol). Starts on a configured port; exposes model operations to external AI agents. Port set via `SettingsController.getMcpServerPort()`. +The MCP server exposes JSON editor operations to external AI agents via HTTP JSON-RPC. -Tools are registered in `McpToolRegistry` (`model/mcp/`). Two base classes: -- `ReadOnlyMcpTool` – reads from `ReadableModel` (e.g., `GetNodeTool`, `GetSchemaForPathTool`, `GetFileInfoTool`) -- `WriteMcpTool` – mutates via `WritableModel` (e.g., `SetNodeTool`) +### Multi-File Sessions +`FileSessionManager` (`model/sessions/`) manages multiple open file sessions. Each session has a unique `file_id`. Two session types: +- **GUI sessions** – registered when the GUI opens a file, protected from MCP close +- **Headless sessions** – opened via `open_file` tool, closeable via `close_file` + +`EditorSession` (`model/sessions/`) is a record holding `id`, `ReadableModel`, file paths, and `guiOwned` flag. + +### Architecture +``` +GUI Mode: ControllerImpl → FileSessionManager → McpController → JsonEditorMcpServer +Standalone: StandaloneMcpMain → FileSessionManager → JsonEditorMcpServer +``` + +`McpController` wraps `JsonEditorMcpServer`. Port set via `SettingsController.getMcpServerPort()`. + +### Tools +Tools are registered in `McpToolRegistry` (`model/mcp/`). All per-file tools require a `file_id` argument. + +Base classes: +- `ReadOnlyMcpTool` – holds `FileSessionManager`, provides `resolveModel(arguments)` helper +- `WriteMcpTool` – extends `ReadOnlyMcpTool` (currently no write tools registered) + +Session management tools (extend `ReadOnlyMcpTool`, no `file_id` needed): +- `ListFilesTool` – list all open sessions +- `OpenFileTool` – open a JSON + schema file pair, returns `file_id` +- `CloseFileTool` – close a headless session + +Per-file read tools (require `file_id`): +- `GetFileInfoTool`, `GetNodeTool`, `GetSchemaForPathTool`, `GetExamplesTool` +- `GetReferenceableObjectsTool`, `GetReferenceableInstancesTool`, `FindReferencesToTool` `McpArgumentValidator` validates tool input against schemas before execution. +### Standalone Mode +`StandaloneMcpMain` (`standalone/`) runs the MCP server without JavaFX. Start with: +```bash +./gradlew runStandalone # default port 4500 +./gradlew runStandalone --args="--port 5000" +``` + ## Build & Packaging ```bash ./gradlew build # compile + test -./gradlew run # run locally +./gradlew run # run GUI locally +./gradlew runStandalone # run standalone MCP server (no GUI) ./gradlew jpackage # create native installer (build/jpackage/) ``` Version is read from `src/main/resources/version.properties`. diff --git a/README.md b/README.md index a9da7dbc..d0175a1d 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,25 @@ You can find the license in the resources folder or in their Github repository ( ## Column Visibility Empty non-required columns in array tables auto-hide (default ON). Click the eye (left of table name) to show all columns until the table refreshes; click again to hide. If you type a value into a previously empty column it stays visible. +# MCP Server + +The JSON Editor includes an MCP (Model Context Protocol) server that lets AI agents read your JSON files. + +## GUI Mode +Enable in Settings → MCP Server. The server exposes the currently open file to MCP clients on localhost. + +## Standalone Mode +Run the MCP server without the GUI — useful for CI, scripting, or AI agent workflows with multiple files: + +```bash +./gradlew runStandalone # default port 4500 +./gradlew runStandalone --args="--port 5000" # custom port +``` + +Use the `open_file` tool to load JSON + schema pairs, then query them with `get_node`, `get_schema_for_path`, etc. Each file gets a `file_id` for addressing. + +Available tools: `list_files`, `open_file`, `close_file`, `get_file_info`, `get_node`, `get_schema_for_path`, `get_examples`, `get_referenceable_objects`, `get_referenceable_instances`, `find_references_to` + # Installation Instructions ## MacOS diff --git a/build.gradle b/build.gradle index 88bec4c4..a46fedec 100644 --- a/build.gradle +++ b/build.gradle @@ -58,6 +58,13 @@ test { useJUnitPlatform() } +task runStandalone(type: JavaExec) { + description = 'Run the standalone MCP server without GUI' + group = 'application' + mainClass.set('com.daniel.jsoneditor.standalone.StandaloneMcpMain') + classpath = sourceSets.main.runtimeClasspath +} + runtime { options = ['--strip-debug', '--no-header-files', '--no-man-pages'] additive = true diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index fbe82944..54588b4b 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -28,6 +28,7 @@ import com.daniel.jsoneditor.model.json.schema.paths.PathHelper; import com.daniel.jsoneditor.model.observe.Observer; import com.daniel.jsoneditor.model.observe.Subject; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.daniel.jsoneditor.model.settings.Settings; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -72,6 +73,10 @@ public class ControllerImpl implements Controller, Observer private final McpController mcpController; + private final FileSessionManager fileSessionManager; + + private String guiSessionId; + private boolean updateCheckDone; public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage) @@ -84,7 +89,8 @@ public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage st this.subjects = new ArrayList<>(); this.view = new ViewImpl(readableModel, this, stage); this.view.observe(this.readableModel.getForObservation()); - this.mcpController = new McpController(model, settingsController); + this.fileSessionManager = new FileSessionManager(); + this.mcpController = new McpController(fileSessionManager, settingsController); // Set up callback for unsaved changes notifications from CommandManager this.commandManager.setUnsavedChangesCallback(this::updateWindowTitle); @@ -198,6 +204,11 @@ public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsF } } model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); + if (guiSessionId != null) + { + fileSessionManager.unregisterGuiSession(guiSessionId); + } + guiSessionId = fileSessionManager.registerGuiSession(readableModel, jsonFile, schemaFile); }); } @@ -673,6 +684,10 @@ public List calculateJsonDiff() public void shutdown() { logger.info("Shutting down application"); + if (guiSessionId != null) + { + fileSessionManager.unregisterGuiSession(guiSessionId); + } mcpController.stopMcpServer(); } } diff --git a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java index e823606c..a87d1873 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -3,8 +3,8 @@ import java.io.IOException; import com.daniel.jsoneditor.controller.settings.SettingsController; -import com.daniel.jsoneditor.model.WritableModel; import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -16,9 +16,9 @@ public class McpController private final SettingsController settingsController; - public McpController(final WritableModel writableModel, final SettingsController settingsController) + public McpController(final FileSessionManager sessionManager, final SettingsController settingsController) { - this.mcpServer = new JsonEditorMcpServer(writableModel); + this.mcpServer = new JsonEditorMcpServer(sessionManager); this.settingsController = settingsController; } diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index 0e9e156d..f20863ae 100644 --- a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java @@ -86,7 +86,7 @@ public File getCurrentJSONFile() @Override public File getCurrentSchemaFile() { - return null; + return schemaFile; } @Override diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java new file mode 100644 index 00000000..033642eb --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -0,0 +1,68 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + + +class CloseFileTool extends ReadOnlyMcpTool +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public CloseFileTool(final FileSessionManager sessionManager) + { + super(sessionManager); + } + + @Override + public String getName() + { + return "close_file"; + } + + @Override + public String getDescription() + { + return "Close a previously opened file session. Cannot close GUI-owned sessions."; + } + + @Override + public ObjectNode getInputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + addFileIdProperty(props); + return props; + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String fileId = arguments.path("file_id").asText(""); + + final boolean closed = sessionManager.closeFile(fileId); + if (!closed) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, + "Cannot close session: not found or GUI-owned"); + } + + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("success", true); + result.put("file_id", fileId); + + return McpToolRegistry.createToolResult(id, result); + } +} + + diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index ce05c1a5..e52d0778 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -2,6 +2,7 @@ import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.json.schema.reference.ReferenceToObjectInstance; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,9 +19,9 @@ class FindReferencesToTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(FindReferencesToTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public FindReferencesToTool(final ReadableModel model) + public FindReferencesToTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -38,14 +39,17 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return McpToolRegistry.createSchemaWithProperty("path", "string", + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path to a referenceable object instance to find references to (e.g., /processes/0)"); + addFileIdProperty(props); + return props; } @Override public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("path"); return arr; } @@ -53,6 +57,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final String path = arguments.path("path").asText(""); final List references = model.getReferencesToObjectForPath(path); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java index c15bca7a..6b625a68 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -17,9 +18,9 @@ class GetExamplesTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetExamplesTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetExamplesTool(final ReadableModel model) + public GetExamplesTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -37,13 +38,17 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path to get examples for (e.g., /processes/0)"); + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", + "JSON path to get examples for (e.g., /processes/0)"); + addFileIdProperty(props); + return props; } @Override public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("path"); return arr; } @@ -51,6 +56,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final String path = arguments.path("path").asText(""); final List examples = model.getStringExamplesForPath(path); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java index 03e87a8c..25b18883 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -1,9 +1,11 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -14,9 +16,9 @@ class GetFileInfoTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetFileInfoTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetFileInfoTool(final ReadableModel model) + public GetFileInfoTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -28,18 +30,34 @@ public String getName() @Override public String getDescription() { - return "Get information about the currently open JSON file and schema"; + return "Get information about an open JSON file and schema"; } @Override public ObjectNode getInputSchema() { - return OBJECT_MAPPER.createObjectNode(); + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + addFileIdProperty(props); + return props; + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); + return arr; } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final ObjectNode content = OBJECT_MAPPER.createObjectNode(); if (model.getCurrentJSONFile() != null) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index 23efb597..745fefd4 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -2,6 +2,7 @@ import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.json.JsonNodeWithPath; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,9 +17,9 @@ class GetNodeTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetNodeTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetNodeTool(final ReadableModel model) + public GetNodeTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -36,13 +37,16 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path (e.g., /processes/0)"); + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path (e.g., /processes/0)"); + addFileIdProperty(props); + return props; } @Override public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("path"); return arr; } @@ -50,6 +54,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final String path = arguments.path("path").asText(""); final JsonNodeWithPath node = model.getNodeForPath(path); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java index 99134e27..ff2b7d40 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -3,6 +3,7 @@ import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObject; import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObjectInstance; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -19,9 +20,9 @@ class GetReferenceableInstancesTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetReferenceableInstancesTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetReferenceableInstancesTool(final ReadableModel model) + public GetReferenceableInstancesTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -39,14 +40,17 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return McpToolRegistry.createSchemaWithProperty("referencing_key", "string", + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("referencing_key", "string", "The referencing key of the referenceable object type"); + addFileIdProperty(props); + return props; } @Override public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("referencing_key"); return arr; } @@ -54,6 +58,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final String referencingKey = arguments.path("referencing_key").asText(""); final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java index 78c50246..447f9c19 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -2,6 +2,7 @@ import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObject; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,9 +19,9 @@ class GetReferenceableObjectsTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetReferenceableObjectsTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetReferenceableObjectsTool(final ReadableModel model) + public GetReferenceableObjectsTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -38,12 +39,28 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return OBJECT_MAPPER.createObjectNode(); + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + addFileIdProperty(props); + return props; + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); + return arr; } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final List objects = model.getReferenceableObjects(); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index 8c4be675..640db35e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,9 +17,9 @@ class GetSchemaForPathTool extends ReadOnlyMcpTool private static final Logger logger = LoggerFactory.getLogger(GetSchemaForPathTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetSchemaForPathTool(final ReadableModel model) + public GetSchemaForPathTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -36,13 +37,17 @@ public String getDescription() @Override public ObjectNode getInputSchema() { - return McpToolRegistry.createSchemaWithProperty("path", "string", "JSON path to get schema for (e.g., /processes/0)"); + final ObjectNode props = McpToolRegistry.createSchemaWithProperty("path", "string", + "JSON path to get schema for (e.g., /processes/0)"); + addFileIdProperty(props); + return props; } @Override public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("path"); return arr; } @@ -50,6 +55,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel model = resolveModel(arguments); + if (model == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + } + final String path = arguments.path("path").asText(""); final JsonSchema schema = model.getSubschemaForPath(path); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index 5951768f..8c8f1b9d 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -1,6 +1,6 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.daniel.jsoneditor.util.VersionUtil; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -57,19 +57,17 @@ public class JsonEditorMcpServer private volatile boolean running; /** - * Creates MCP server with both readable and writable model. - * WritableModel is passed to registry for future write-tool support. - * Currently only read-only tools are enabled in registry. + * Creates MCP server backed by a FileSessionManager for multi-file support. * - * @param writableModel for read and write operations (passed to tools when enabled) + * @param sessionManager manages all open file sessions */ - public JsonEditorMcpServer(final WritableModel writableModel) + public JsonEditorMcpServer(final FileSessionManager sessionManager) { - if (writableModel == null) + if (sessionManager == null) { - throw new IllegalArgumentException("writableModel cannot be null"); + throw new IllegalArgumentException("sessionManager cannot be null"); } - this.toolRegistry = new McpToolRegistry(writableModel); + this.toolRegistry = new McpToolRegistry(sessionManager); this.running = false; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java new file mode 100644 index 00000000..89081e1f --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java @@ -0,0 +1,58 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + + +class ListFilesTool extends ReadOnlyMcpTool +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public ListFilesTool(final FileSessionManager sessionManager) + { + super(sessionManager); + } + + @Override + public String getName() + { + return "list_files"; + } + + @Override + public String getDescription() + { + return "List all currently open file sessions with their IDs and paths"; + } + + @Override + public ObjectNode getInputSchema() + { + return OBJECT_MAPPER.createObjectNode(); + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + for (final EditorSession session : sessionManager.listSessions()) + { + final ObjectNode entry = OBJECT_MAPPER.createObjectNode(); + entry.put("file_id", session.id()); + entry.put("json_path", session.jsonFile() != null ? session.jsonFile().getAbsolutePath() : null); + entry.put("schema_path", session.schemaFile() != null ? session.schemaFile().getAbsolutePath() : null); + entry.put("gui_owned", session.guiOwned()); + result.add(entry); + } + + return McpToolRegistry.createToolResult(id, result); + } +} + + diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java index 672ed902..fad6d0e8 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -1,6 +1,6 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,20 +24,23 @@ public class McpToolRegistry private final List tools; /** - * Create registry with both read-only and write tools. - * Write tools use WritableModel to modify the JSON document. - * WritableModel extends ReadableModel, so read-only tools work with it too. + * Create registry with all available tools backed by a FileSessionManager. + * + * @param sessionManager manages all open file sessions */ - public McpToolRegistry(final WritableModel model) + public McpToolRegistry(final FileSessionManager sessionManager) { this.tools = List.of( - new GetFileInfoTool(model), - new GetNodeTool(model), - new GetSchemaForPathTool(model), - new GetExamplesTool(model), - new GetReferenceableObjectsTool(model), - new GetReferenceableInstancesTool(model), - new FindReferencesToTool(model) + new ListFilesTool(sessionManager), + new OpenFileTool(sessionManager), + new CloseFileTool(sessionManager), + new GetFileInfoTool(sessionManager), + new GetNodeTool(sessionManager), + new GetSchemaForPathTool(sessionManager), + new GetExamplesTool(sessionManager), + new GetReferenceableObjectsTool(sessionManager), + new GetReferenceableInstancesTool(sessionManager), + new FindReferencesToTool(sessionManager) ); } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java new file mode 100644 index 00000000..f266ccc9 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -0,0 +1,81 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + + +class OpenFileTool extends ReadOnlyMcpTool +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public OpenFileTool(final FileSessionManager sessionManager) + { + super(sessionManager); + } + + @Override + public String getName() + { + return "open_file"; + } + + @Override + public String getDescription() + { + return "Open a JSON file with its schema for reading. Returns a file_id to use with other tools."; + } + + @Override + public ObjectNode getInputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + + final ObjectNode jsonPathProp = OBJECT_MAPPER.createObjectNode(); + jsonPathProp.put("type", "string"); + jsonPathProp.put("description", "Absolute path to the JSON file"); + props.set("json_path", jsonPathProp); + + final ObjectNode schemaPathProp = OBJECT_MAPPER.createObjectNode(); + schemaPathProp.put("type", "string"); + schemaPathProp.put("description", "Absolute path to the JSON schema file"); + props.set("schema_path", schemaPathProp); + + return props; + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("json_path"); + arr.add("schema_path"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String jsonPath = arguments.path("json_path").asText(""); + final String schemaPath = arguments.path("schema_path").asText(""); + + final String sessionId = sessionManager.openFile(jsonPath, schemaPath); + if (sessionId == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, + "Failed to open file. Check that both paths exist and are valid JSON/schema."); + } + + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("file_id", sessionId); + result.put("json_path", jsonPath); + result.put("schema_path", schemaPath); + + return McpToolRegistry.createToolResult(id, result); + } +} + + diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index 4f80f408..5f883b69 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -1,22 +1,53 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; /** - * Base class for read-only MCP tools that only query the model state. - * These tools cannot modify the JSON document. + * Base class for read-only MCP tools. Resolves the target model from a file_id argument. */ public abstract class ReadOnlyMcpTool extends McpTool { - protected final ReadableModel model; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - protected ReadOnlyMcpTool(final ReadableModel model) + protected final FileSessionManager sessionManager; + + protected ReadOnlyMcpTool(final FileSessionManager sessionManager) + { + if (sessionManager == null) + { + throw new IllegalArgumentException("sessionManager cannot be null"); + } + this.sessionManager = sessionManager; + } + + protected ReadableModel resolveModel(final JsonNode arguments) { - if (model == null) + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null) { - throw new IllegalArgumentException("model cannot be null"); + return null; } - this.model = model; + final EditorSession session = sessionManager.getSession(fileId); + return session != null ? session.model() : null; + } + + protected static void addFileIdProperty(final ObjectNode properties) + { + final ObjectNode fileIdProp = OBJECT_MAPPER.createObjectNode(); + fileIdProp.put("type", "string"); + fileIdProp.put("description", "Session ID of the file to operate on (from list_files or open_file)"); + properties.set("file_id", fileIdProp); + } + + protected static void addFileIdRequired(final ArrayNode required) + { + required.add("file_id"); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java index 7c456e22..0391b9af 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java @@ -1,6 +1,8 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -11,8 +13,8 @@ /** - * Example write tool that sets a value at a specific path. - * Uncomment in McpToolRegistry to enable. + * Write tool that sets a value at a specific path. + * Not registered in McpToolRegistry by default (read-only mode). */ class SetNodeTool extends WriteMcpTool { @@ -20,9 +22,9 @@ class SetNodeTool extends WriteMcpTool private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public SetNodeTool(final WritableModel model) + public SetNodeTool(final FileSessionManager sessionManager) { - super(model); + super(sessionManager); } @Override @@ -41,6 +43,7 @@ public String getDescription() public ObjectNode getInputSchema() { final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + addFileIdProperty(props); final ObjectNode pathProp = OBJECT_MAPPER.createObjectNode(); pathProp.put("type", "string"); @@ -63,6 +66,7 @@ public ObjectNode getInputSchema() public ArrayNode getRequiredInputProperties() { final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + addFileIdRequired(arr); arr.add("path"); arr.add("property"); arr.add("value"); @@ -72,6 +76,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final ReadableModel readableModel = resolveModel(arguments); + if (!(readableModel instanceof WritableModel model)) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id or session is read-only"); + } + final String path = arguments.path("path").asText(""); final String property = arguments.path("property").asText(""); final JsonNode valueNode = arguments.path("value"); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java index 3a1158b0..f8f7cc27 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java @@ -1,22 +1,15 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; /** * Base class for write MCP tools that can modify the model state. - * These tools can change the JSON document via WritableModel operations. */ -public abstract class WriteMcpTool extends McpTool +public abstract class WriteMcpTool extends ReadOnlyMcpTool { - protected final WritableModel model; - - protected WriteMcpTool(final WritableModel model) + protected WriteMcpTool(final FileSessionManager sessionManager) { - if (model == null) - { - throw new IllegalArgumentException("model cannot be null"); - } - this.model = model; + super(sessionManager); } } diff --git a/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java b/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java new file mode 100644 index 00000000..0ef2e321 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java @@ -0,0 +1,71 @@ +package com.daniel.jsoneditor.standalone; + +import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + + +/** + * Standalone entry point for the MCP server without JavaFX GUI. + * Files are opened/closed via MCP tools (open_file, close_file, list_files). + */ +public class StandaloneMcpMain +{ + private static final Logger logger = LoggerFactory.getLogger(StandaloneMcpMain.class); + + public static void main(final String[] args) + { + final int port = parsePort(args); + + final FileSessionManager sessionManager = new FileSessionManager(); + final JsonEditorMcpServer server = new JsonEditorMcpServer(sessionManager); + + try + { + server.start(port); + logger.info("Standalone MCP server running on port {}. Use open_file tool to load JSON files.", port); + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("Shutting down standalone MCP server"); + server.stop(); + })); + + // Block main thread + Thread.currentThread().join(); + } + catch (IOException e) + { + logger.error("Failed to start MCP server on port {}: {}", port, e.getMessage()); + System.exit(1); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + server.stop(); + } + } + + private static int parsePort(final String[] args) + { + for (int i = 0; i < args.length - 1; i++) + { + if ("--port".equals(args[i])) + { + try + { + return Integer.parseInt(args[i + 1]); + } + catch (NumberFormatException e) + { + logger.error("Invalid port: {}", args[i + 1]); + System.exit(1); + } + } + } + return JsonEditorMcpServer.DEFAULT_PORT; + } +} + From 345427183c533c3e3c9fb4bdb9fcd16abc72af0f Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 1 May 2026 23:26:57 +0200 Subject: [PATCH 04/24] current progress --- .../jsoneditor/controller/AppService.java | 85 +++++++++++++++++++ .../controller/impl/ControllerImpl.java | 18 ++-- .../daniel/jsoneditor/view/JFXLauncher.java | 22 ++--- 3 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/controller/AppService.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java new file mode 100644 index 00000000..20b59990 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -0,0 +1,85 @@ +package com.daniel.jsoneditor.controller; + +import com.daniel.jsoneditor.controller.mcp.McpController; +import com.daniel.jsoneditor.controller.settings.SettingsController; +import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + + +/** + * Central application service that owns shared state across all editor windows. + * Created once at app startup, lives until the app exits. + */ +public class AppService +{ + private static final Logger logger = LoggerFactory.getLogger(AppService.class); + + private final FileSessionManager fileSessionManager; + + private final SettingsController settingsController; + + private final McpController mcpController; + + private final List windows = new ArrayList<>(); + + public AppService() + { + this.fileSessionManager = new FileSessionManager(); + this.settingsController = new SettingsControllerImpl(); + this.mcpController = new McpController(fileSessionManager, settingsController); + } + + /** + * Creates and shows a new editor window. + * + * @return the new window + */ + public AppWindow createWindow() + { + final AppWindow window = new AppWindow(this); + windows.add(window); + window.setOnClose(() -> onWindowClosed(window)); + return window; + } + + private void onWindowClosed(final AppWindow window) + { + windows.remove(window); + if (windows.isEmpty()) + { + shutdown(); + Platform.exit(); + } + } + + public FileSessionManager getFileSessionManager() + { + return fileSessionManager; + } + + public SettingsController getSettingsController() + { + return settingsController; + } + + public McpController getMcpController() + { + return mcpController; + } + + /** + * Shuts down all shared services. Called when the application exits. + */ + public void shutdown() + { + logger.info("Shutting down AppService"); + mcpController.stopMcpServer(); + } +} + diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 54588b4b..330bed8e 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -7,6 +7,7 @@ import java.util.Map; import java.util.Set; +import com.daniel.jsoneditor.controller.AppService; import com.daniel.jsoneditor.controller.Controller; import com.daniel.jsoneditor.controller.impl.commands.CommandManager; import com.daniel.jsoneditor.controller.impl.commands.CommandManagerImpl; @@ -17,7 +18,6 @@ import com.daniel.jsoneditor.controller.mcp.McpController; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.controller.settings.UpdateService; -import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.WritableModel; import com.daniel.jsoneditor.model.commands.CommandFactory; @@ -75,13 +75,18 @@ public class ControllerImpl implements Controller, Observer private final FileSessionManager fileSessionManager; + private final AppService appService; + private String guiSessionId; private boolean updateCheckDone; - public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage) + public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage, AppService appService) { - this.settingsController = new SettingsControllerImpl(); + this.appService = appService; + this.settingsController = appService.getSettingsController(); + this.fileSessionManager = appService.getFileSessionManager(); + this.mcpController = appService.getMcpController(); this.commandManager = new CommandManagerImpl(model); this.commandFactory = readableModel.getCommandFactory(); this.model = model; @@ -89,8 +94,6 @@ public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage st this.subjects = new ArrayList<>(); this.view = new ViewImpl(readableModel, this, stage); this.view.observe(this.readableModel.getForObservation()); - this.fileSessionManager = new FileSessionManager(); - this.mcpController = new McpController(fileSessionManager, settingsController); // Set up callback for unsaved changes notifications from CommandManager this.commandManager.setUnsavedChangesCallback(this::updateWindowTitle); @@ -453,7 +456,7 @@ private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onS @Override public void openNewJson() { - launchFinished(); + appService.createWindow(); } @Override @@ -683,11 +686,10 @@ public List calculateJsonDiff() @Override public void shutdown() { - logger.info("Shutting down application"); + logger.info("Shutting down editor window"); if (guiSessionId != null) { fileSessionManager.unregisterGuiSession(guiSessionId); } - mcpController.stopMcpServer(); } } diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index 43ec7f81..d99f343b 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -1,19 +1,12 @@ package com.daniel.jsoneditor.view; -import com.daniel.jsoneditor.controller.Controller; -import com.daniel.jsoneditor.model.impl.ModelImpl; -import com.daniel.jsoneditor.model.statemachine.EventSender; -import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import com.daniel.jsoneditor.controller.AppService; import javafx.application.Application; import javafx.application.Platform; import javafx.stage.Stage; -import com.daniel.jsoneditor.controller.impl.ControllerImpl; public class JFXLauncher extends Application { - - - public static void launchJFXApplication(String[] args) { launch(args); @@ -23,14 +16,11 @@ public static void launchJFXApplication(String[] args) @Override public void start(Stage stage) { - stage.setTitle("JSON Editor"); - EventSender eventSender = new EventSenderImpl(); - ModelImpl model = new ModelImpl(eventSender); - Controller controller = new ControllerImpl(model, model, stage); + // Don't exit JavaFX when last window closes — AppService handles that + Platform.setImplicitExit(false); + stage.close(); - stage.setOnCloseRequest(event -> { - controller.shutdown(); - Platform.exit(); - }); + final AppService appService = new AppService(); + appService.createWindow(); } } From 763794ff145698ddbf3f83c093a877da39a30180 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Fri, 1 May 2026 23:27:15 +0200 Subject: [PATCH 05/24] appwindow --- .../jsoneditor/controller/AppWindow.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/java/com/daniel/jsoneditor/controller/AppWindow.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java new file mode 100644 index 00000000..432406c9 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -0,0 +1,55 @@ +package com.daniel.jsoneditor.controller; + +import com.daniel.jsoneditor.controller.impl.ControllerImpl; +import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; +import javafx.stage.Stage; + + +/** + * Encapsulates a single app window with its own Model, Controller, View, and Stage. + * Created by AppService, one per open file. + */ +public class AppWindow +{ + private final Controller controller; + + private final Stage stage; + + public AppWindow(final AppService appService) + { + this.stage = new Stage(); + stage.setTitle("JSON Editor"); + final ModelImpl model = new ModelImpl(new EventSenderImpl()); + this.controller = new ControllerImpl(model, model, stage, appService); + } + + /** + * Sets up close behavior: shuts down this window's controller. + * + * @param onClose callback to run after this window closes (e.g. app exit check) + */ + public void setOnClose(final Runnable onClose) + { + stage.setOnCloseRequest(event -> + { + controller.shutdown(); + if (onClose != null) + { + onClose.run(); + } + }); + } + + public Controller getController() + { + return controller; + } + + public Stage getStage() + { + return stage; + } +} + + From f9790031416fe674f5c08d130636e90aec95932b Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Sun, 3 May 2026 01:33:41 +0200 Subject: [PATCH 06/24] fix: concurrency, null-safety, and consistency improvements --- .../jsoneditor/controller/AppService.java | 17 ++++-- .../jsoneditor/controller/AppWindow.java | 3 +- .../controller/settings/UpdateService.java | 16 ++--- .../model/mcp/McpArgumentValidator.java | 2 +- .../model/sessions/FileSessionManager.java | 60 ++++++++++++------- 5 files changed, 64 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index 20b59990..1529d790 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -8,8 +8,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; /** @@ -26,7 +27,8 @@ public class AppService private final McpController mcpController; - private final List windows = new ArrayList<>(); + private final List windows = new CopyOnWriteArrayList<>(); + private final AtomicBoolean shuttingDown = new AtomicBoolean(false); public AppService() { @@ -36,7 +38,7 @@ public AppService() } /** - * Creates and shows a new editor window. + * Creates a new editor window. * * @return the new window */ @@ -58,16 +60,19 @@ private void onWindowClosed(final AppWindow window) } } + /** Returns the shared file session manager. */ public FileSessionManager getFileSessionManager() { return fileSessionManager; } + /** Returns the shared settings controller. */ public SettingsController getSettingsController() { return settingsController; } + /** Returns the shared MCP controller. */ public McpController getMcpController() { return mcpController; @@ -75,11 +80,15 @@ public McpController getMcpController() /** * Shuts down all shared services. Called when the application exits. + * Safe to call multiple times – only the first invocation performs work. */ public void shutdown() { + if (!shuttingDown.compareAndSet(false, true)) + { + return; + } logger.info("Shutting down AppService"); mcpController.stopMcpServer(); } } - diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index 432406c9..f083d62e 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -41,15 +41,16 @@ public void setOnClose(final Runnable onClose) }); } + /** Returns this window's controller. */ public Controller getController() { return controller; } + /** Returns this window's stage. */ public Stage getStage() { return stage; } } - diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java index c274c04c..9c14314e 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -57,7 +57,7 @@ public static void checkForUpdateAsync(final Consumer callbac } catch (Exception ex) { - logger.warn("Update check failed: {}", ex.getMessage()); + logger.warn("Update check failed", ex); callback.accept(new UpdateCheckResult(false, null)); } }, "update-checker"); @@ -70,6 +70,7 @@ private static UpdateCheckResult checkForUpdate() final HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(RELEASES_URL)) .header("Accept", "application/vnd.github+json") + .header("User-Agent", "JSONEditor-UpdateCheck") .timeout(Duration.ofSeconds(5)) .GET() .build(); @@ -100,7 +101,7 @@ private static UpdateCheckResult checkForUpdate() } catch (Exception e) { - logger.warn("Update check attempt {}/{} failed: {}", attempt + 1, MAX_RETRIES + 1, e.getMessage()); + logger.warn("Update check attempt {}/{} failed", attempt + 1, MAX_RETRIES + 1, e); } } return new UpdateCheckResult(false, null); @@ -123,7 +124,7 @@ private static UpdateCheckResult parseResponse(final String body) } catch (Exception e) { - logger.warn("Failed to parse GitHub release response: {}", e.getMessage()); + logger.warn("Failed to parse GitHub release response", e); return new UpdateCheckResult(false, null); } } @@ -163,17 +164,18 @@ static boolean isNewer(final String latest, final String current) private static int[] parseSemver(final String version) { - final String[] parts = version.split("\\."); + // Strip pre-release suffix (e.g., "1.0.0-beta" → "1.0.0") + final String cleanVersion = version.contains("-") ? version.substring(0, version.indexOf('-')) : version; + final String[] parts = cleanVersion.split("\\."); final int[] result = new int[3]; for (int i = 0; i < Math.min(parts.length, 3); i++) { - result[i] = Integer.parseInt(parts[i]); + result[i] = Integer.parseInt(parts[i].trim()); } return result; } -} - +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java index 1eb702c8..d9ee82e1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java @@ -16,7 +16,7 @@ */ public final class McpArgumentValidator { - private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); private McpArgumentValidator() { /* utility */ } diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index 94b29298..e6c8f982 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -63,9 +63,9 @@ public String openFile(final String jsonPath, final String schemaPath) final ModelImpl model = new ModelImpl(new EventSenderImpl()); model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); - final String sessionId = UUID.randomUUID().toString().substring(0, 8); + final String sessionId = generateUniqueId(""); final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); - sessions.put(sessionId, session); + sessions.putIfAbsent(sessionId, session); logger.info("Opened file session {} for {}", sessionId, jsonPath); return sessionId; @@ -81,9 +81,9 @@ public String openFile(final String jsonPath, final String schemaPath) */ public String registerGuiSession(final ReadableModel model, final File jsonFile, final File schemaFile) { - final String sessionId = "gui-" + UUID.randomUUID().toString().substring(0, 8); + final String sessionId = generateUniqueId("gui-"); final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, true); - sessions.put(sessionId, session); + sessions.putIfAbsent(sessionId, session); logger.info("Registered GUI session {} for {}", sessionId, jsonFile != null ? jsonFile.getAbsolutePath() : "null"); return sessionId; } @@ -95,12 +95,15 @@ public String registerGuiSession(final ReadableModel model, final File jsonFile, */ public void unregisterGuiSession(final String sessionId) { - final EditorSession session = sessions.get(sessionId); - if (session != null && session.guiOwned()) + sessions.computeIfPresent(sessionId, (key, session) -> { - sessions.remove(sessionId); - logger.info("Unregistered GUI session {}", sessionId); - } + if (session.guiOwned()) + { + logger.info("Unregistered GUI session {}", sessionId); + return null; // removes the entry + } + return session; + }); } /** @@ -111,19 +114,19 @@ public void unregisterGuiSession(final String sessionId) */ public boolean closeFile(final String sessionId) { - final EditorSession session = sessions.get(sessionId); - if (session == null) - { - return false; - } - if (session.guiOwned()) + final boolean[] closed = {false}; + sessions.computeIfPresent(sessionId, (key, session) -> { - logger.warn("Cannot close GUI-owned session {} via MCP", sessionId); - return false; - } - sessions.remove(sessionId); - logger.info("Closed file session {}", sessionId); - return true; + if (session.guiOwned()) + { + logger.warn("Cannot close GUI-owned session {} via MCP", sessionId); + return session; // keep it + } + logger.info("Closed file session {}", sessionId); + closed[0] = true; + return null; // removes the entry + }); + return closed[0]; } /** @@ -142,4 +145,19 @@ public List listSessions() { return new ArrayList<>(sessions.values()); } + + private String generateUniqueId(final String prefix) + { + for (int i = 0; i < 10; i++) + { + final String candidate = UUID.randomUUID().toString().substring(0, 8); + final String fullKey = prefix + candidate; + if (!sessions.containsKey(fullKey)) + { + return fullKey; + } + } + // Fallback to full UUID if collisions persist + return prefix + UUID.randomUUID().toString(); + } } From 3a6ccfad623cf160b61f1f7a0c895c30c9ba7613 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Sun, 3 May 2026 02:29:47 +0200 Subject: [PATCH 07/24] feature(mcp): headless-first architecture with always-on MCP server --- build.gradle | 15 ++ .../jsoneditor/controller/AppService.java | 48 +++++-- .../controller/mcp/McpController.java | 11 +- .../model/mcp/JsonEditorMcpServer.java | 11 +- .../jsoneditor/model/mcp/McpToolRegistry.java | 37 +++-- .../jsoneditor/model/mcp/SetNodeTool.java | 129 ------------------ .../jsoneditor/model/mcp/ShowGuiTool.java | 62 +++++++++ .../jsoneditor/model/mcp/WriteMcpTool.java | 15 -- .../standalone/StandaloneMcpMain.java | 3 +- .../daniel/jsoneditor/view/JFXLauncher.java | 82 ++++++++++- .../daniel/jsoneditor/view/impl/ViewImpl.java | 5 - .../com.danielkispert.jsoneditor.plist | 21 +++ 12 files changed, 251 insertions(+), 188 deletions(-) delete mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java delete mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java create mode 100644 src/main/resources/launchd/com.danielkispert.jsoneditor.plist diff --git a/build.gradle b/build.gradle index a46fedec..8cc3a052 100644 --- a/build.gradle +++ b/build.gradle @@ -83,3 +83,18 @@ runtime { skipInstaller = true } } + +task installLaunchAgent(type: Copy) +{ + description = 'Installs the macOS LaunchAgent to start the JSON Editor MCP server at login' + group = 'installation' + from 'src/main/resources/launchd/com.danielkispert.jsoneditor.plist' + into "${System.getProperty('user.home')}/Library/LaunchAgents" +} + +task uninstallLaunchAgent(type: Delete) +{ + description = 'Removes the macOS LaunchAgent for the JSON Editor MCP server' + group = 'installation' + delete "${System.getProperty('user.home')}/Library/LaunchAgents/com.danielkispert.jsoneditor.plist" +} diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index 1529d790..c837d8ef 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -4,7 +4,6 @@ import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; import com.daniel.jsoneditor.model.sessions.FileSessionManager; -import javafx.application.Platform; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -15,7 +14,9 @@ /** * Central application service that owns shared state across all editor windows. - * Created once at app startup, lives until the app exits. + * Starts the MCP server immediately so external clients (e.g. OpenCode) can connect + * regardless of whether any GUI windows are open. + * Created once at app startup, lives until explicitly quit. */ public class AppService { @@ -34,7 +35,30 @@ public AppService() { this.fileSessionManager = new FileSessionManager(); this.settingsController = new SettingsControllerImpl(); - this.mcpController = new McpController(fileSessionManager, settingsController); + this.mcpController = new McpController(fileSessionManager, settingsController, this); + startMcpServer(); + } + + /** + * Starts the MCP server if enabled in settings. + * Called automatically during construction so the server is available before any window opens. + */ + private void startMcpServer() + { + if (!settingsController.isMcpServerEnabled()) + { + logger.info("MCP server disabled in settings, skipping auto-start"); + return; + } + mcpController.startMcpServer(); + if (mcpController.isMcpServerRunning()) + { + logger.info("MCP server started on port {}", mcpController.getMcpServerPort()); + } + else + { + logger.error("MCP server failed to start — check port availability"); + } } /** @@ -50,16 +74,18 @@ public AppWindow createWindow() return window; } + /** + * Called when a window is closed. Does NOT exit the application — + * the service keeps running (MCP stays available) even without GUI windows. + */ private void onWindowClosed(final AppWindow window) { windows.remove(window); - if (windows.isEmpty()) - { - shutdown(); - Platform.exit(); - } + logger.info("Window closed. {} window(s) remaining. MCP server still running.", windows.size()); } + + /** Returns the shared file session manager. */ public FileSessionManager getFileSessionManager() { @@ -78,6 +104,12 @@ public McpController getMcpController() return mcpController; } + /** Returns the number of currently open windows. */ + public int getWindowCount() + { + return windows.size(); + } + /** * Shuts down all shared services. Called when the application exits. * Safe to call multiple times – only the first invocation performs work. diff --git a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java index a87d1873..56484353 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -2,12 +2,14 @@ import java.io.IOException; +import com.daniel.jsoneditor.controller.AppService; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; import com.daniel.jsoneditor.model.sessions.FileSessionManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; + public class McpController { private static final Logger logger = LoggerFactory.getLogger(McpController.class); @@ -16,9 +18,14 @@ public class McpController private final SettingsController settingsController; - public McpController(final FileSessionManager sessionManager, final SettingsController settingsController) + /** + * Creates the MCP controller. Pass appService for GUI integration (ShowGuiTool), + * or null for standalone/headless mode. + */ + public McpController(final FileSessionManager sessionManager, final SettingsController settingsController, + final AppService appService) { - this.mcpServer = new JsonEditorMcpServer(sessionManager); + this.mcpServer = new JsonEditorMcpServer(sessionManager, appService); this.settingsController = settingsController; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index 8c8f1b9d..cd3a9461 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -2,6 +2,7 @@ import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.daniel.jsoneditor.util.VersionUtil; +import com.daniel.jsoneditor.controller.AppService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -56,18 +57,14 @@ public class JsonEditorMcpServer private volatile boolean running; - /** - * Creates MCP server backed by a FileSessionManager for multi-file support. - * - * @param sessionManager manages all open file sessions - */ - public JsonEditorMcpServer(final FileSessionManager sessionManager) + /** Creates MCP server backed by a FileSessionManager for multi-file support. */ + public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppService appService) { if (sessionManager == null) { throw new IllegalArgumentException("sessionManager cannot be null"); } - this.toolRegistry = new McpToolRegistry(sessionManager); + this.toolRegistry = new McpToolRegistry(sessionManager, appService); this.running = false; } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java index fad6d0e8..59d4d8a8 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.daniel.jsoneditor.controller.AppService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,6 +11,7 @@ import org.slf4j.LoggerFactory; import java.util.List; +import java.util.ArrayList; /** @@ -24,24 +26,31 @@ public class McpToolRegistry private final List tools; /** - * Create registry with all available tools backed by a FileSessionManager. + * Create registry with all available tools. Pass null for headless mode. When + * appService is provided, the show_gui tool is registered for opening GUI + * windows on demand. * * @param sessionManager manages all open file sessions + * @param appService the app service for GUI integration, or null for headless mode */ - public McpToolRegistry(final FileSessionManager sessionManager) + public McpToolRegistry(final FileSessionManager sessionManager, final AppService appService) { - this.tools = List.of( - new ListFilesTool(sessionManager), - new OpenFileTool(sessionManager), - new CloseFileTool(sessionManager), - new GetFileInfoTool(sessionManager), - new GetNodeTool(sessionManager), - new GetSchemaForPathTool(sessionManager), - new GetExamplesTool(sessionManager), - new GetReferenceableObjectsTool(sessionManager), - new GetReferenceableInstancesTool(sessionManager), - new FindReferencesToTool(sessionManager) - ); + final List toolList = new ArrayList<>(); + toolList.add(new ListFilesTool(sessionManager)); + toolList.add(new OpenFileTool(sessionManager)); + toolList.add(new CloseFileTool(sessionManager)); + toolList.add(new GetFileInfoTool(sessionManager)); + toolList.add(new GetNodeTool(sessionManager)); + toolList.add(new GetSchemaForPathTool(sessionManager)); + toolList.add(new GetExamplesTool(sessionManager)); + toolList.add(new GetReferenceableObjectsTool(sessionManager)); + toolList.add(new GetReferenceableInstancesTool(sessionManager)); + toolList.add(new FindReferencesToTool(sessionManager)); + if (appService != null) + { + toolList.add(new ShowGuiTool(appService)); + } + this.tools = List.copyOf(toolList); } /** diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java deleted file mode 100644 index 0391b9af..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.daniel.jsoneditor.model.mcp; - -import com.daniel.jsoneditor.model.WritableModel; -import com.daniel.jsoneditor.model.ReadableModel; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - - -/** - * Write tool that sets a value at a specific path. - * Not registered in McpToolRegistry by default (read-only mode). - */ -class SetNodeTool extends WriteMcpTool -{ - private static final Logger logger = LoggerFactory.getLogger(SetNodeTool.class); - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - public SetNodeTool(final FileSessionManager sessionManager) - { - super(sessionManager); - } - - @Override - public String getName() - { - return "set_node"; - } - - @Override - public String getDescription() - { - return "Set a value at a specific path"; - } - - @Override - public ObjectNode getInputSchema() - { - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - addFileIdProperty(props); - - final ObjectNode pathProp = OBJECT_MAPPER.createObjectNode(); - pathProp.put("type", "string"); - pathProp.put("description", "JSON path (e.g., /root/child)"); - props.set("path", pathProp); - - final ObjectNode propertyProp = OBJECT_MAPPER.createObjectNode(); - propertyProp.put("type", "string"); - propertyProp.put("description", "Property name to set"); - props.set("property", propertyProp); - - final ObjectNode valueProp = OBJECT_MAPPER.createObjectNode(); - valueProp.put("description", "Value to set (string, number, boolean, null)"); - props.set("value", valueProp); - - return props; - } - - @Override - public ArrayNode getRequiredInputProperties() - { - final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); - addFileIdRequired(arr); - arr.add("path"); - arr.add("property"); - arr.add("value"); - return arr; - } - - @Override - public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - final ReadableModel readableModel = resolveModel(arguments); - if (!(readableModel instanceof WritableModel model)) - { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id or session is read-only"); - } - - final String path = arguments.path("path").asText(""); - final String property = arguments.path("property").asText(""); - final JsonNode valueNode = arguments.path("value"); - - final Object value; - if (valueNode.isTextual()) - { - value = valueNode.asText(); - } - else if (valueNode.isNumber()) - { - value = valueNode.numberValue(); - } - else if (valueNode.isBoolean()) - { - value = valueNode.asBoolean(); - } - else if (valueNode.isNull()) - { - value = null; - } - else - { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "value must be string, number, boolean, or null"); - } - - try - { - model.setValueAtPath(path, property, value); - - final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("success", true); - result.put("path", path); - result.put("property", property); - - return McpToolRegistry.createToolResult(id, result); - } - catch (Exception e) - { - logger.error("Error executing set_node for path: {}, property: {}", path, property, e); - return JsonEditorMcpServer.createErrorResponseStatic(id, -32603, - String.format("Failed to set value at path: %s, property: %s", path, property)); - } - } -} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java new file mode 100644 index 00000000..f4312a21 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -0,0 +1,62 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.controller.AppService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import javafx.application.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + + + + +/** + * MCP tool that opens a new GUI window on demand. + * Useful when the app runs in headless mode and an AI agent or user wants to see the editor. + */ +class ShowGuiTool extends McpTool +{ + private static final Logger logger = LoggerFactory.getLogger(ShowGuiTool.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final AppService appService; + + public ShowGuiTool(final AppService appService) + { + this.appService = appService; + } + + @Override + public String getName() + { + return "show_gui"; + } + + @Override + public String getDescription() + { + return "Opens a new JSON Editor GUI window. Use when running in headless mode to show the editor interface."; + } + + @Override + public ObjectNode getInputSchema() + { + return OBJECT_MAPPER.createObjectNode(); + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + Platform.runLater(() -> + { + appService.createWindow(); + logger.info("GUI window opened via MCP tool"); + }); + + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.put("status", "ok"); + return McpToolRegistry.createToolResult(id, result); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java deleted file mode 100644 index f8f7cc27..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.daniel.jsoneditor.model.mcp; - -import com.daniel.jsoneditor.model.sessions.FileSessionManager; - - -/** - * Base class for write MCP tools that can modify the model state. - */ -public abstract class WriteMcpTool extends ReadOnlyMcpTool -{ - protected WriteMcpTool(final FileSessionManager sessionManager) - { - super(sessionManager); - } -} diff --git a/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java b/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java index 0ef2e321..27f8e4ee 100644 --- a/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java +++ b/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java @@ -21,7 +21,7 @@ public static void main(final String[] args) final int port = parsePort(args); final FileSessionManager sessionManager = new FileSessionManager(); - final JsonEditorMcpServer server = new JsonEditorMcpServer(sessionManager); + final JsonEditorMcpServer server = new JsonEditorMcpServer(sessionManager, null); try { @@ -68,4 +68,3 @@ private static int parsePort(final String[] args) return JsonEditorMcpServer.DEFAULT_PORT; } } - diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index d99f343b..d6e58f94 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -4,23 +4,93 @@ import javafx.application.Application; import javafx.application.Platform; import javafx.stage.Stage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.awt.Desktop; +import java.awt.desktop.AppReopenedListener; +import java.util.List; + + +/** + * JavaFX entry point. Initializes the platform, creates the AppService (which starts + * the MCP server immediately), and optionally opens a GUI window. + *

+ * Supports {@code --headless} flag: starts the service without any GUI window. + * The MCP server runs regardless; GUI windows can be opened on demand. + */ public class JFXLauncher extends Application { - public static void launchJFXApplication(String[] args) + private static final Logger logger = LoggerFactory.getLogger(JFXLauncher.class); + + private AppService appService; + + public static void launchJFXApplication(final String[] args) { launch(args); } - @Override - public void start(Stage stage) + public void start(final Stage stage) { - // Don't exit JavaFX when last window closes — AppService handles that + // Keep JavaFX runtime alive even without windows Platform.setImplicitExit(false); stage.close(); - final AppService appService = new AppService(); - appService.createWindow(); + // Core service starts MCP server immediately + appService = new AppService(); + + // Parse launch arguments + final List args = getParameters().getRaw(); + final boolean headless = args.contains("--headless"); + + if (headless) + { + logger.info("Started in headless mode — MCP server running, no GUI window."); + } + else + { + appService.createWindow(); + } + + // macOS: reopen app when user clicks dock icon with no windows open + registerMacOsReopenHandler(); + } + + /** + * On macOS, clicking the dock icon when no windows are open triggers a "reopen" event. + * We respond by opening a new editor window. + */ + private void registerMacOsReopenHandler() + { + try + { + if (Desktop.isDesktopSupported()) + { + Desktop.getDesktop().addAppEventListener((AppReopenedListener) event -> + Platform.runLater(() -> + { + if (appService.getWindowCount() == 0) + { + logger.info("macOS reopen event — opening new window"); + appService.createWindow(); + } + })); + } + } + catch (Exception e) + { + // Not on macOS or AWT desktop not available — ignore silently + logger.debug("Could not register macOS reopen handler: {}", e.getMessage()); + } + } + + @Override + public void stop() + { + if (appService != null) + { + appService.shutdown(); + } } } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java index cc83d642..6d2f0ed2 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java @@ -52,11 +52,6 @@ public void update() break; case MAIN_EDITOR: uiHandler.showMainEditor(); - //autostart if enabled - if (controller.getSettingsController().isMcpServerEnabled()) - { - controller.getMcpController().startMcpServer(); - } controller.checkForUpdateSilently(); break; case RESET_SUCCESSFUL: diff --git a/src/main/resources/launchd/com.danielkispert.jsoneditor.plist b/src/main/resources/launchd/com.danielkispert.jsoneditor.plist new file mode 100644 index 00000000..316601d6 --- /dev/null +++ b/src/main/resources/launchd/com.danielkispert.jsoneditor.plist @@ -0,0 +1,21 @@ + + + + + Label + com.danielkispert.jsoneditor + ProgramArguments + + /Applications/JSON Editor.app/Contents/MacOS/JSON Editor + --headless + + RunAtLoad + + KeepAlive + + StandardOutPath + /tmp/jsoneditor-mcp.log + StandardErrorPath + /tmp/jsoneditor-mcp.log + + From ed0178aee446666ee2d3853f5d573fff16b749a9 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Mon, 11 May 2026 12:20:43 +0200 Subject: [PATCH 08/24] fix: adapt validateJsonWithSchema calls to List return type --- .../jsoneditor/controller/impl/ControllerImpl.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 330bed8e..c47fff64 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -262,7 +262,7 @@ public void importAtNode(String path, String content) final JsonNode mergedNode = JsonNodeMerger.createMergedNode(readableModel, existingNodeAtPath, contentNode); final JsonSchema schemaAtPath = readableModel.getSubschemaForPath(path); - if (mergedNode != null && SchemaHelper.validateJsonWithSchema(mergedNode, schemaAtPath)) + if (mergedNode != null && SchemaHelper.validateJsonWithSchema(mergedNode, schemaAtPath).isEmpty()) { commandManager.executeCommand(commandFactory.setNodeCommand(path, mergedNode)); view.showToast(Toasts.IMPORT_SUCCESSFUL_TOAST); @@ -443,7 +443,7 @@ public String searchForNode(String path, String value) private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onSuccess) { - if (SchemaHelper.validateJsonWithSchema(json, schema)) + if (SchemaHelper.validateJsonWithSchema(json, schema).isEmpty()) { onSuccess.run(); } @@ -488,7 +488,7 @@ public void setValueAtPath(String path, Object value) candidateParent.set(propertyName, buildCandidateNode(value)); } final JsonSchema parentSchema = readableModel.getSubschemaForPath(parentPath); - if (parentSchema != null && !SchemaHelper.validateJsonWithSchema(candidateParent, parentSchema)) + if (parentSchema != null && !SchemaHelper.validateJsonWithSchema(candidateParent, parentSchema).isEmpty()) { view.showToast(Toasts.VALUE_VALIDATION_FAILED_TOAST); return; @@ -566,14 +566,14 @@ public void pasteFromClipboardReplacingChild(String pathToInsert) view.showToast(Toasts.ERROR_TOAST); return; } - if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(pathToInsert))) + if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(pathToInsert)).isEmpty()) { commandManager.executeCommand(commandFactory.setNodeCommand(pathToInsert, jsonNode)); view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); } else if (itemToInsertAt.isArray() && SchemaHelper.validateJsonWithSchema(jsonNode, - readableModel.getSubschemaForPath(pathToInsert + "/0"))) + readableModel.getSubschemaForPath(pathToInsert + "/0")).isEmpty()) { commandManager.executeCommand(commandFactory.setNodeCommand(itemToInsertAt.getPath() + "/" + itemToInsertAt.getNode().size(), jsonNode)); view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); @@ -618,7 +618,7 @@ public void pasteFromClipboardIntoParent(String parentPath) try { final JsonNode jsonNode = new JsonFileReaderAndWriterImpl().getNodeFromString(jsonString); - if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(parentPath))) + if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(parentPath)).isEmpty()) { final int arraySize = parentNode.getNode().size(); commandManager.executeCommand(commandFactory.setNodeCommand(parentNode.getPath() + "/" + arraySize, jsonNode)); From 2be8ad3ad7cc2876a6afe278e6d6e5b0ff6301b6 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Mon, 11 May 2026 13:28:29 +0200 Subject: [PATCH 09/24] chore: remove StandaloneMcpMain, add MCP integration tests --- build.gradle | 7 - .../standalone/StandaloneMcpMain.java | 70 ---- .../mcp/McpMultiFileIntegrationTest.java | 314 ++++++++++++++++++ .../model/mcp/McpServerIntegrationTest.java | 298 +++++++++++++++++ .../sessions/FileSessionManagerTest.java | 222 +++++++++++++ 5 files changed, 834 insertions(+), 77 deletions(-) delete mode 100644 src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java create mode 100644 src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java create mode 100644 src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java create mode 100644 src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java diff --git a/build.gradle b/build.gradle index 8cc3a052..10a4c25f 100644 --- a/build.gradle +++ b/build.gradle @@ -58,13 +58,6 @@ test { useJUnitPlatform() } -task runStandalone(type: JavaExec) { - description = 'Run the standalone MCP server without GUI' - group = 'application' - mainClass.set('com.daniel.jsoneditor.standalone.StandaloneMcpMain') - classpath = sourceSets.main.runtimeClasspath -} - runtime { options = ['--strip-debug', '--no-header-files', '--no-man-pages'] additive = true diff --git a/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java b/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java deleted file mode 100644 index 27f8e4ee..00000000 --- a/src/main/java/com/daniel/jsoneditor/standalone/StandaloneMcpMain.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.daniel.jsoneditor.standalone; - -import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.IOException; - - -/** - * Standalone entry point for the MCP server without JavaFX GUI. - * Files are opened/closed via MCP tools (open_file, close_file, list_files). - */ -public class StandaloneMcpMain -{ - private static final Logger logger = LoggerFactory.getLogger(StandaloneMcpMain.class); - - public static void main(final String[] args) - { - final int port = parsePort(args); - - final FileSessionManager sessionManager = new FileSessionManager(); - final JsonEditorMcpServer server = new JsonEditorMcpServer(sessionManager, null); - - try - { - server.start(port); - logger.info("Standalone MCP server running on port {}. Use open_file tool to load JSON files.", port); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - logger.info("Shutting down standalone MCP server"); - server.stop(); - })); - - // Block main thread - Thread.currentThread().join(); - } - catch (IOException e) - { - logger.error("Failed to start MCP server on port {}: {}", port, e.getMessage()); - System.exit(1); - } - catch (InterruptedException e) - { - Thread.currentThread().interrupt(); - server.stop(); - } - } - - private static int parsePort(final String[] args) - { - for (int i = 0; i < args.length - 1; i++) - { - if ("--port".equals(args[i])) - { - try - { - return Integer.parseInt(args[i + 1]); - } - catch (NumberFormatException e) - { - logger.error("Invalid port: {}", args[i + 1]); - System.exit(1); - } - } - } - return JsonEditorMcpServer.DEFAULT_PORT; - } -} diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java new file mode 100644 index 00000000..ac291bb7 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java @@ -0,0 +1,314 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + + +public class McpMultiFileIntegrationTest +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final String JSON_COMPANY = + "{\"company\":\"Acme\",\"employees\":[{\"name\":\"Alice\",\"role\":\"dev\"}," + + "{\"name\":\"Bob\",\"role\":\"qa\"}],\"config\":{\"version\":2,\"darkMode\":true}}"; + + private static final String JSON_ITEMS = + "{\"items\":[{\"id\":1,\"label\":\"first\"},{\"id\":2,\"label\":\"second\"}]}"; + + private static final String JSON_FLAGS = + "{\"active\":true,\"count\":7}"; + + private static final String SCHEMA_COMPANY = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\"," + + "\"properties\":{" + + "\"company\":{\"type\":\"string\"}," + + "\"employees\":{\"type\":\"array\",\"items\":{\"type\":\"object\"," + + "\"properties\":{\"name\":{\"type\":\"string\"},\"role\":{\"type\":\"string\"}}}}," + + "\"config\":{\"type\":\"object\",\"properties\":{" + + "\"version\":{\"type\":\"integer\"},\"darkMode\":{\"type\":\"boolean\"}}}" + + "}}"; + + private static final String SCHEMA_ITEMS = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\"," + + "\"properties\":{" + + "\"items\":{\"type\":\"array\",\"items\":{\"type\":\"object\"," + + "\"properties\":{\"id\":{\"type\":\"integer\"},\"label\":{\"type\":\"string\"}}}}" + + "}}"; + + private static final String SCHEMA_FLAGS = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\"," + + "\"properties\":{" + + "\"active\":{\"type\":\"boolean\"}," + + "\"count\":{\"type\":\"integer\"}" + + "}}"; + + private final AtomicInteger requestIdCounter = new AtomicInteger(1); + + private JsonEditorMcpServer server; + private HttpClient httpClient; + private String baseUrl; + + @BeforeEach + void setUp() throws Exception + { + final int port; + try (final ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + final FileSessionManager sessionManager = new FileSessionManager(); + server = new JsonEditorMcpServer(sessionManager, null); + server.start(port); + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + baseUrl = "http://127.0.0.1:" + port; + } + + @AfterEach + void tearDown() + { + if (server != null) + { + server.stop(); + } + } + + @Test + void testMultipleFilesOpenSimultaneously() throws Exception + { + final String id0 = openFile(JSON_COMPANY, SCHEMA_COMPANY); + final String id1 = openFile(JSON_ITEMS, SCHEMA_ITEMS); + final String id2 = openFile(JSON_FLAGS, SCHEMA_FLAGS); + + // All IDs must be unique + assertNotEquals(id0, id1, "File IDs must be unique"); + assertNotEquals(id1, id2, "File IDs must be unique"); + assertNotEquals(id0, id2, "File IDs must be unique"); + + // list_files must show all 3 + final JsonNode listPayload = parseToolResultPayload( + callTool("list_files", OBJECT_MAPPER.createObjectNode())); + assertTrue(listPayload.isArray(), "list_files must return an array"); + final Set listedIds = new HashSet<>(); + for (final JsonNode entry : listPayload) + { + listedIds.add(entry.path("file_id").asText()); + } + assertTrue(listedIds.contains(id0), "list_files must include file 0"); + assertTrue(listedIds.contains(id1), "list_files must include file 1"); + assertTrue(listedIds.contains(id2), "list_files must include file 2"); + + // get_node returns correct content per file + final JsonNode root0 = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", id0).put("path", ""))); + assertEquals("Acme", root0.path("value").path("company").asText(), + "File 0 must contain company=Acme"); + + final JsonNode root1 = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", id1).put("path", ""))); + assertTrue(root1.path("value").path("items").isArray(), + "File 1 must contain an items array"); + + final JsonNode root2 = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", id2).put("path", ""))); + assertEquals(7, root2.path("value").path("count").asInt(), + "File 2 must contain count=7"); + } + + @Test + void testCloseOneOfMultipleFiles() throws Exception + { + final String id0 = openFile(JSON_COMPANY, SCHEMA_COMPANY); + final String id1 = openFile(JSON_ITEMS, SCHEMA_ITEMS); + final String id2 = openFile(JSON_FLAGS, SCHEMA_FLAGS); + + // Close the middle file + final JsonNode closePayload = parseToolResultPayload( + callTool("close_file", OBJECT_MAPPER.createObjectNode().put("file_id", id1))); + assertTrue(closePayload.path("success").asBoolean(), "Expected success=true from close_file"); + + // list_files must now show exactly id0 and id2 + final JsonNode listPayload = parseToolResultPayload( + callTool("list_files", OBJECT_MAPPER.createObjectNode())); + final Set remaining = new HashSet<>(); + for (final JsonNode entry : listPayload) + { + remaining.add(entry.path("file_id").asText()); + } + assertTrue(remaining.contains(id0), "id0 must still be listed after closing id1"); + assertFalse(remaining.contains(id1), "id1 must be gone after close"); + assertTrue(remaining.contains(id2), "id2 must still be listed after closing id1"); + + // Remaining files must still respond correctly + final JsonNode node0 = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", id0).put("path", ""))); + assertEquals("Acme", node0.path("value").path("company").asText(), + "id0 must still return correct content after id1 was closed"); + + final JsonNode node2 = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", id2).put("path", ""))); + assertEquals(7, node2.path("value").path("count").asInt(), + "id2 must still return correct content after id1 was closed"); + } + + @Test + void testSessionsAreIsolated() throws Exception + { + final String idCompany = openFile(JSON_COMPANY, SCHEMA_COMPANY); + final String idItems = openFile(JSON_ITEMS, SCHEMA_ITEMS); + + final JsonNode companyRoot = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", idCompany).put("path", ""))); + final JsonNode itemsRoot = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", idItems).put("path", ""))); + + // No cross-contamination: company session has "company" key, items session does not + assertTrue(companyRoot.path("value").has("company"), + "company session must have 'company' key"); + assertFalse(itemsRoot.path("value").has("company"), + "items session must NOT have 'company' key"); + + // Items session has "items" key, company session does not + assertTrue(itemsRoot.path("value").has("items"), + "items session must have 'items' key"); + assertFalse(companyRoot.path("value").has("items"), + "company session must NOT have 'items' key"); + + assertEquals("Acme", companyRoot.path("value").path("company").asText()); + assertEquals(2, itemsRoot.path("value").path("items").size()); + } + + @Test + void testComplexNestedJson() throws Exception + { + final String fileId = openFile(JSON_COMPANY, SCHEMA_COMPANY); + + // Query employees sub-path + final JsonNode employeesNode = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", fileId).put("path", "/employees"))); + assertTrue(employeesNode.path("value").isArray(), "Expected employees to be an array"); + assertEquals(2, employeesNode.path("value").size(), "Expected 2 employees"); + + // Query config sub-path + final JsonNode configNode = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", fileId).put("path", "/config"))); + assertTrue(configNode.path("value").isObject(), "Expected config to be an object"); + assertEquals(2, configNode.path("value").path("version").asInt(), "Expected version=2"); + assertTrue(configNode.path("value").path("darkMode").asBoolean(), "Expected darkMode=true"); + + // Query first employee by index + final JsonNode firstEmployee = parseToolResultPayload( + callTool("get_node", OBJECT_MAPPER.createObjectNode().put("file_id", fileId).put("path", "/employees/0"))); + assertEquals("Alice", firstEmployee.path("value").path("name").asText()); + assertEquals("dev", firstEmployee.path("value").path("role").asText()); + } + + @Test + void testGetNodeInvalidPath() throws Exception + { + final String fileId = openFile(JSON_COMPANY, SCHEMA_COMPANY); + + final JsonNode result = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", fileId) + .put("path", "/nonexistent/deeply/nested")); + + // The server handles non-existent paths gracefully. + // It either returns a JSON-RPC error, or a success response where the + // value field is absent (Jackson excludes MissingNode from serialization). + if (result.get("error") == null) + { + final JsonNode payload = parseToolResultPayload(result); + // value field is either absent or null for a non-existent path + assertTrue(!payload.has("value") || payload.path("value").isNull(), + "Expected absent or null value for non-existent path, got: " + payload); + } + } + + @Test + void testToolCallWithInvalidFileId() throws Exception + { + final JsonNode result = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", "bogus-file-id-does-not-exist") + .put("path", "")); + + assertNotNull(result.get("error"), + "Expected JSON-RPC error when using a bogus file_id"); + } + + // ── helpers ─────────────────────────────────────────────────────────────── + + private String openFile(final String jsonContent, final String schemaContent) throws Exception + { + final Path jsonFile = createTempFile("mcp-multi-", ".json", jsonContent); + final Path schemaFile = createTempFile("mcp-multi-schema-", ".json", schemaContent); + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + assertNull(openResult.get("error"), "Expected no error from open_file"); + final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); + assertFalse(fileId.isEmpty(), "Expected non-empty file_id"); + return fileId; + } + + private JsonNode callTool(final String toolName, final ObjectNode arguments) throws Exception + { + final ObjectNode params = OBJECT_MAPPER.createObjectNode(); + params.put("name", toolName); + params.set("arguments", arguments); + + final ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", requestIdCounter.getAndIncrement()); + request.put("method", "tools/call"); + request.set("params", params); + return sendRequest(request); + } + + private JsonNode sendRequest(final ObjectNode requestNode) throws Exception + { + final String body = OBJECT_MAPPER.writeValueAsString(requestNode); + final HttpResponse response = httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(10)) + .build(), + HttpResponse.BodyHandlers.ofString()); + return OBJECT_MAPPER.readTree(response.body()); + } + + private JsonNode parseToolResultPayload(final JsonNode rpcResponse) throws Exception + { + final String text = rpcResponse.path("result").path("content").get(0).path("text").asText(); + return OBJECT_MAPPER.readTree(text); + } + + private Path createTempFile(final String prefix, final String suffix, final String content) throws Exception + { + final Path tempFile = Files.createTempFile(prefix, suffix); + Files.writeString(tempFile, content); + tempFile.toFile().deleteOnExit(); + return tempFile; + } +} diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java new file mode 100644 index 00000000..3e7ef360 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java @@ -0,0 +1,298 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + + +public class McpServerIntegrationTest +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final AtomicInteger requestIdCounter = new AtomicInteger(1); + + private JsonEditorMcpServer server; + private HttpClient httpClient; + private String baseUrl; + + @BeforeEach + void setUp() throws Exception + { + final int port; + try (final ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + final FileSessionManager sessionManager = new FileSessionManager(); + server = new JsonEditorMcpServer(sessionManager, null); + server.start(port); + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + baseUrl = "http://127.0.0.1:" + port; + } + + @AfterEach + void tearDown() + { + if (server != null) + { + server.stop(); + } + } + + @Test + void testHealthEndpoint() throws Exception + { + final HttpResponse response = httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/health")) + .GET() + .timeout(Duration.ofSeconds(5)) + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + final JsonNode body = OBJECT_MAPPER.readTree(response.body()); + assertEquals("ok", body.path("status").asText()); + } + + @Test + void testInitialize() throws Exception + { + final JsonNode response = sendJsonRpc("initialize"); + assertNull(response.get("error"), "Expected no error in initialize response"); + final JsonNode result = response.path("result"); + assertEquals("json-editor", result.path("serverInfo").path("name").asText()); + assertEquals("2024-11-05", result.path("protocolVersion").asText()); + } + + @Test + void testToolsList() throws Exception + { + final JsonNode response = sendJsonRpc("tools/list"); + assertNull(response.get("error"), "Expected no error in tools/list response"); + final JsonNode tools = response.path("result").path("tools"); + assertTrue(tools.isArray()); + assertTrue(tools.size() > 0); + + final List toolNames = new ArrayList<>(); + for (final JsonNode tool : tools) + { + toolNames.add(tool.path("name").asText()); + } + assertTrue(toolNames.contains("open_file"), "Expected open_file tool"); + assertTrue(toolNames.contains("list_files"), "Expected list_files tool"); + assertTrue(toolNames.contains("close_file"), "Expected close_file tool"); + assertTrue(toolNames.contains("get_file_info"), "Expected get_file_info tool"); + assertTrue(toolNames.contains("get_node"), "Expected get_node tool"); + } + + @Test + void testOpenAndListFiles() throws Exception + { + final Path jsonFile = createTempJsonFile(); + final Path schemaFile = createTempSchemaFile(); + + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + assertNull(openResult.get("error"), "Expected no error from open_file"); + + final JsonNode openPayload = parseToolResultPayload(openResult); + final String fileId = openPayload.path("file_id").asText(); + assertFalse(fileId.isEmpty(), "Expected non-empty file_id"); + + final JsonNode listResult = callTool("list_files", OBJECT_MAPPER.createObjectNode()); + assertNull(listResult.get("error"), "Expected no error from list_files"); + + final JsonNode listPayload = parseToolResultPayload(listResult); + assertTrue(listPayload.isArray(), "Expected array result from list_files"); + + boolean found = false; + for (final JsonNode entry : listPayload) + { + if (fileId.equals(entry.path("file_id").asText())) + { + found = true; + break; + } + } + assertTrue(found, "Expected opened file to appear in list_files result"); + } + + @Test + void testGetNodeOnOpenedFile() throws Exception + { + final Path jsonFile = createTempJsonFile(); + final Path schemaFile = createTempSchemaFile(); + + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); + + final JsonNode nodeResult = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", fileId) + .put("path", "")); + assertNull(nodeResult.get("error"), "Expected no error from get_node"); + + final JsonNode nodePayload = parseToolResultPayload(nodeResult); + assertEquals("", nodePayload.path("path").asText()); + assertEquals("Root Element", nodePayload.path("display_name").asText()); + assertTrue(nodePayload.path("value").isObject(), "Expected root to be an object"); + assertEquals("test", nodePayload.path("value").path("name").asText()); + assertEquals(42, nodePayload.path("value").path("value").asInt()); + } + + @Test + void testGetFileInfo() throws Exception + { + final Path jsonFile = createTempJsonFile(); + final Path schemaFile = createTempSchemaFile(); + + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); + + final JsonNode infoResult = callTool("get_file_info", OBJECT_MAPPER.createObjectNode() + .put("file_id", fileId)); + assertNull(infoResult.get("error"), "Expected no error from get_file_info"); + + final JsonNode infoPayload = parseToolResultPayload(infoResult); + assertEquals(jsonFile.getFileName().toString(), infoPayload.path("file_name").asText()); + assertEquals(schemaFile.toString(), infoPayload.path("schema_path").asText()); + assertTrue(infoPayload.path("has_content").asBoolean(), "Expected has_content to be true"); + } + + @Test + void testCloseFile() throws Exception + { + final Path jsonFile = createTempJsonFile(); + final Path schemaFile = createTempSchemaFile(); + + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); + + final JsonNode closeResult = callTool("close_file", OBJECT_MAPPER.createObjectNode() + .put("file_id", fileId)); + assertNull(closeResult.get("error"), "Expected no error from close_file"); + + final JsonNode closePayload = parseToolResultPayload(closeResult); + assertTrue(closePayload.path("success").asBoolean(), "Expected success=true from close_file"); + + final JsonNode listResult = callTool("list_files", OBJECT_MAPPER.createObjectNode()); + final JsonNode listPayload = parseToolResultPayload(listResult); + for (final JsonNode entry : listPayload) + { + assertNotEquals(fileId, entry.path("file_id").asText(), "Closed file should not appear in list_files"); + } + } + + @Test + void testCloseNonExistentFile() throws Exception + { + final JsonNode result = callTool("close_file", OBJECT_MAPPER.createObjectNode() + .put("file_id", "nonexistent-id-12345")); + assertNotNull(result.get("error"), "Expected error when closing non-existent file_id"); + } + + @Test + void testOpenInvalidFile() throws Exception + { + final JsonNode result = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", "/nonexistent/path/file.json") + .put("schema_path", "/nonexistent/path/schema.json")); + assertNotNull(result.get("error"), "Expected error when opening non-existent files"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private JsonNode sendJsonRpc(final String method) throws Exception + { + final ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", requestIdCounter.getAndIncrement()); + request.put("method", method); + return sendRequest(request); + } + + private JsonNode callTool(final String toolName, final ObjectNode arguments) throws Exception + { + final ObjectNode params = OBJECT_MAPPER.createObjectNode(); + params.put("name", toolName); + params.set("arguments", arguments); + + final ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", requestIdCounter.getAndIncrement()); + request.put("method", "tools/call"); + request.set("params", params); + return sendRequest(request); + } + + private JsonNode sendRequest(final ObjectNode requestNode) throws Exception + { + final String body = OBJECT_MAPPER.writeValueAsString(requestNode); + final HttpResponse response = httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(10)) + .build(), + HttpResponse.BodyHandlers.ofString()); + return OBJECT_MAPPER.readTree(response.body()); + } + + private JsonNode parseToolResultPayload(final JsonNode rpcResponse) throws Exception + { + final String text = rpcResponse.path("result").path("content").get(0).path("text").asText(); + return OBJECT_MAPPER.readTree(text); + } + + private Path createTempJsonFile() throws Exception + { + final Path tempFile = Files.createTempFile("mcp-test-", ".json"); + Files.writeString(tempFile, "{\"name\":\"test\",\"value\":42}"); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + private Path createTempSchemaFile() throws Exception + { + final Path tempFile = Files.createTempFile("mcp-schema-", ".json"); + final String schema = "{" + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"}," + + "\"value\":{\"type\":\"number\"}" + + "}" + + "}"; + Files.writeString(tempFile, schema); + tempFile.toFile().deleteOnExit(); + return tempFile; + } +} diff --git a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java new file mode 100644 index 00000000..c12c3371 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -0,0 +1,222 @@ +package com.daniel.jsoneditor.model.sessions; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + + +public class FileSessionManagerTest +{ + @TempDir + Path tempDir; + + private static final String SIMPLE_JSON = "{\"name\":\"test\",\"value\":42}"; + + private static final String SIMPLE_SCHEMA = + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"}," + + "\"value\":{\"type\":\"number\"}" + + "}}"; + + private FileSessionManager sessionManager; + private Path jsonFile; + private Path schemaFile; + + @BeforeEach + void setUp() throws Exception + { + sessionManager = new FileSessionManager(); + jsonFile = tempDir.resolve("test.json"); + schemaFile = tempDir.resolve("schema.json"); + Files.writeString(jsonFile, SIMPLE_JSON); + Files.writeString(schemaFile, SIMPLE_SCHEMA); + } + + @Test + void testOpenFileCreatesSession() + { + final String id = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + + assertNotNull(id, "openFile must return a non-null session ID"); + assertFalse(id.isEmpty(), "Session ID must not be empty"); + + final EditorSession session = sessionManager.getSession(id); + assertNotNull(session, "getSession must return the opened session"); + assertEquals(id, session.id(), "Session ID must match"); + assertFalse(session.guiOwned(), "Headless session must not be GUI-owned"); + assertEquals(jsonFile.toFile(), session.jsonFile(), "JSON file must match"); + assertEquals(schemaFile.toFile(), session.schemaFile(), "Schema file must match"); + assertNotNull(session.model(), "Session model must not be null"); + } + + @Test + void testOpenInvalidFileReturnsNull() + { + final String nonExistentJson = tempDir.resolve("no-such.json").toString(); + final String nonExistentSchema = tempDir.resolve("no-such-schema.json").toString(); + + assertNull(sessionManager.openFile(nonExistentJson, nonExistentSchema), + "openFile with non-existent JSON and schema must return null"); + + assertNull(sessionManager.openFile(jsonFile.toString(), nonExistentSchema), + "openFile with existing JSON but missing schema must return null"); + } + + @Test + void testCloseFileRemovesSession() + { + final String id = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertNotNull(id, "Precondition: session must open successfully"); + + final boolean closed = sessionManager.closeFile(id); + assertTrue(closed, "closeFile must return true for a valid headless session"); + + assertNull(sessionManager.getSession(id), "getSession must return null after close"); + assertTrue(sessionManager.listSessions().isEmpty(), "listSessions must be empty after close"); + } + + @Test + void testCannotCloseGuiSession() + { + // Open a headless session to get a valid ReadableModel instance + final String headlessId = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertNotNull(headlessId, "Precondition: headless session must open"); + final EditorSession headlessSession = sessionManager.getSession(headlessId); + + // Register as GUI session (guiOwned=true) + final String guiId = sessionManager.registerGuiSession( + headlessSession.model(), jsonFile.toFile(), schemaFile.toFile()); + assertNotNull(guiId, "registerGuiSession must return a session ID"); + assertTrue(sessionManager.getSession(guiId).guiOwned(), "GUI session must be marked guiOwned"); + + // Attempting to close the GUI session via closeFile must fail + final boolean closed = sessionManager.closeFile(guiId); + assertFalse(closed, "closeFile must return false for a GUI-owned session"); + assertNotNull(sessionManager.getSession(guiId), "GUI session must still exist after failed close"); + } + + @Test + void testUnregisterGuiSession() + { + final String headlessId = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertNotNull(headlessId, "Precondition: headless session must open"); + final EditorSession headlessSession = sessionManager.getSession(headlessId); + + final String guiId = sessionManager.registerGuiSession( + headlessSession.model(), jsonFile.toFile(), schemaFile.toFile()); + assertNotNull(sessionManager.getSession(guiId), "GUI session must exist before unregister"); + + sessionManager.unregisterGuiSession(guiId); + assertNull(sessionManager.getSession(guiId), + "GUI session must be gone after unregisterGuiSession"); + } + + @Test + void testListSessionsReturnsAll() throws Exception + { + assertTrue(sessionManager.listSessions().isEmpty(), "Manager must start with no sessions"); + + final Path jsonFile2 = tempDir.resolve("test2.json"); + final Path schemaFile2 = tempDir.resolve("schema2.json"); + Files.writeString(jsonFile2, "{\"a\":1}"); + Files.writeString(schemaFile2, + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{\"a\":{\"type\":\"integer\"}}}"); + + final String id1 = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + final String id2 = sessionManager.openFile(jsonFile2.toString(), schemaFile2.toString()); + assertNotNull(id1, "First session must open"); + assertNotNull(id2, "Second session must open"); + + final List sessions = sessionManager.listSessions(); + assertEquals(2, sessions.size(), "listSessions must return exactly 2 sessions"); + final List ids = sessions.stream().map(EditorSession::id).toList(); + assertTrue(ids.contains(id1), "list must contain id1"); + assertTrue(ids.contains(id2), "list must contain id2"); + } + + @Test + void testConcurrentAccess() throws Exception + { + final int threadCount = 10; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final CountDownLatch startLatch = new CountDownLatch(1); + final CountDownLatch doneLatch = new CountDownLatch(threadCount); + final AtomicInteger errorCount = new AtomicInteger(0); + final List openedIds = new ArrayList<>(); + + // Pre-create temp files for each thread before concurrent execution + final List jsonFiles = new ArrayList<>(); + final List schemaFiles = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) + { + final Path jf = tempDir.resolve("concurrent-" + i + ".json"); + final Path sf = tempDir.resolve("concurrent-schema-" + i + ".json"); + Files.writeString(jf, "{\"n\":" + i + "}"); + Files.writeString(sf, + "{\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{\"n\":{\"type\":\"integer\"}}}"); + jsonFiles.add(jf); + schemaFiles.add(sf); + } + + // Each thread opens a file then immediately closes it + for (int i = 0; i < threadCount; i++) + { + final int index = i; + executor.submit(() -> + { + try + { + startLatch.await(); + final String id = sessionManager.openFile( + jsonFiles.get(index).toString(), schemaFiles.get(index).toString()); + if (id == null) + { + errorCount.incrementAndGet(); + } + else + { + synchronized (openedIds) + { + openedIds.add(id); + } + sessionManager.closeFile(id); + } + } + catch (final Exception e) + { + errorCount.incrementAndGet(); + } + finally + { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(30, TimeUnit.SECONDS), "All threads must finish within 30 seconds"); + executor.shutdown(); + + assertEquals(0, errorCount.get(), "No threads must encounter errors during concurrent access"); + assertEquals(threadCount, openedIds.size(), "All threads must have opened a session"); + assertTrue(sessionManager.listSessions().isEmpty(), "All sessions must be closed after concurrent test"); + } +} From 383d55f6d3ec8c369d6fbfb808cf2ae1be09d2ca Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Mon, 11 May 2026 15:47:18 +0200 Subject: [PATCH 10/24] feature: conditional exit behavior, dock menu with recent files --- .../jsoneditor/controller/AppService.java | 62 +++++--- .../controller/impl/ControllerImpl.java | 1 + .../settings/RecentFilesManager.java | 150 ++++++++++++++++++ .../daniel/jsoneditor/view/JFXLauncher.java | 91 ++++++++++- 4 files changed, 278 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index c837d8ef..6555bc52 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -1,12 +1,15 @@ package com.daniel.jsoneditor.controller; import com.daniel.jsoneditor.controller.mcp.McpController; +import com.daniel.jsoneditor.controller.settings.RecentFilesManager; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import javafx.application.Platform; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.io.File; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; @@ -21,24 +24,27 @@ public class AppService { private static final Logger logger = LoggerFactory.getLogger(AppService.class); - + private final FileSessionManager fileSessionManager; - + private final SettingsController settingsController; - + private final McpController mcpController; - + + private final RecentFilesManager recentFilesManager; + private final List windows = new CopyOnWriteArrayList<>(); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); - + public AppService() { this.fileSessionManager = new FileSessionManager(); this.settingsController = new SettingsControllerImpl(); this.mcpController = new McpController(fileSessionManager, settingsController, this); + this.recentFilesManager = new RecentFilesManager(); startMcpServer(); } - + /** * Starts the MCP server if enabled in settings. * Called automatically during construction so the server is available before any window opens. @@ -60,7 +66,7 @@ private void startMcpServer() logger.error("MCP server failed to start — check port availability"); } } - + /** * Creates a new editor window. * @@ -73,43 +79,63 @@ public AppWindow createWindow() window.setOnClose(() -> onWindowClosed(window)); return window; } - + + /** + * Opens a new editor window and immediately loads the given JSON+schema file pair. + * Must be called on the JavaFX Application Thread. + */ + public void openFileInNewWindow(final File jsonFile, final File schemaFile) + { + final AppWindow window = createWindow(); + window.getController().jsonAndSchemaSelected(jsonFile, schemaFile, null); + } + /** - * Called when a window is closed. Does NOT exit the application — - * the service keeps running (MCP stays available) even without GUI windows. + * Called when a window is closed. + * Exits the application when the last window closes and the MCP server is not running. + * When MCP is enabled and running the service stays alive in the background. */ private void onWindowClosed(final AppWindow window) { windows.remove(window); - logger.info("Window closed. {} window(s) remaining. MCP server still running.", windows.size()); + logger.info("Window closed. {} window(s) remaining.", windows.size()); + if (windows.isEmpty() && !mcpController.isMcpServerRunning()) + { + shutdown(); + Platform.exit(); + } } - - - + /** Returns the shared file session manager. */ public FileSessionManager getFileSessionManager() { return fileSessionManager; } - + /** Returns the shared settings controller. */ public SettingsController getSettingsController() { return settingsController; } - + /** Returns the shared MCP controller. */ public McpController getMcpController() { return mcpController; } - + + /** Returns the recent files manager. */ + public RecentFilesManager getRecentFilesManager() + { + return recentFilesManager; + } + /** Returns the number of currently open windows. */ public int getWindowCount() { return windows.size(); } - + /** * Shuts down all shared services. Called when the application exits. * Safe to call multiple times – only the first invocation performs work. diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index c47fff64..0cf5d329 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -207,6 +207,7 @@ public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsF } } model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); + appService.getRecentFilesManager().addRecentFile(jsonFile, schemaFile); if (guiSessionId != null) { fileSessionManager.unregisterGuiSession(guiSessionId); diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java new file mode 100644 index 00000000..82a78ab6 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -0,0 +1,150 @@ +package com.daniel.jsoneditor.controller.settings; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Properties; + + +/** + * Tracks recently opened JSON+schema file pairs and persists them across sessions. + * Stores up to {@value #MAX_RECENT_FILES} entries in a dedicated properties file. + */ +public class RecentFilesManager +{ + private static final Logger logger = LoggerFactory.getLogger(RecentFilesManager.class); + + private static final String RECENT_FILES_FILENAME = "jsoneditor_recent.properties"; + + private static final int MAX_RECENT_FILES = 10; + + private static final String KEY_JSON = "recent_%d_json"; + + private static final String KEY_SCHEMA = "recent_%d_schema"; + + /** A JSON+schema file pair. */ + public record RecentFile(File jsonFile, File schemaFile) {} + + private final List recentFiles; + + private final List changeListeners; + + public RecentFilesManager() + { + this.recentFiles = new ArrayList<>(); + this.changeListeners = new ArrayList<>(); + load(); + } + + /** + * Adds a file pair to the top of the recent list. + * Removes any existing entry for the same JSON file first. + * Trims to {@value #MAX_RECENT_FILES} entries and persists. + */ + public void addRecentFile(final File jsonFile, final File schemaFile) + { + if (jsonFile == null || schemaFile == null) + { + return; + } + recentFiles.removeIf(rf -> rf.jsonFile().equals(jsonFile)); + recentFiles.add(0, new RecentFile(jsonFile, schemaFile)); + while (recentFiles.size() > MAX_RECENT_FILES) + { + recentFiles.remove(recentFiles.size() - 1); + } + save(); + notifyChangeListeners(); + } + + /** Returns an unmodifiable view of recent files, newest first. */ + public List getRecentFiles() + { + return Collections.unmodifiableList(recentFiles); + } + + /** Clears all recent files and persists the empty list. */ + public void clear() + { + recentFiles.clear(); + save(); + notifyChangeListeners(); + } + + /** + * Registers a listener called whenever the recent files list changes. + * The listener is invoked on whichever thread triggered the change. + */ + public void addChangeListener(final Runnable listener) + { + changeListeners.add(listener); + } + + private void notifyChangeListeners() + { + for (final Runnable listener : changeListeners) + { + listener.run(); + } + } + + private void load() + { + final Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(RECENT_FILES_FILENAME)) + { + props.load(in); + } + catch (FileNotFoundException e) + { + return; // no file yet — start with empty list + } + catch (IOException e) + { + logger.error("Could not load recent files from {}", RECENT_FILES_FILENAME, e); + return; + } + for (int i = 0; i < MAX_RECENT_FILES; i++) + { + final String jsonPath = props.getProperty(String.format(KEY_JSON, i)); + final String schemaPath = props.getProperty(String.format(KEY_SCHEMA, i)); + if (jsonPath == null || schemaPath == null) + { + break; + } + final File jsonFile = new File(jsonPath); + final File schemaFile = new File(schemaPath); + if (jsonFile.exists() && schemaFile.exists()) + { + recentFiles.add(new RecentFile(jsonFile, schemaFile)); + } + } + } + + private void save() + { + final Properties props = new Properties(); + for (int i = 0; i < recentFiles.size(); i++) + { + final RecentFile rf = recentFiles.get(i); + props.setProperty(String.format(KEY_JSON, i), rf.jsonFile().getAbsolutePath()); + props.setProperty(String.format(KEY_SCHEMA, i), rf.schemaFile().getAbsolutePath()); + } + try (FileOutputStream out = new FileOutputStream(RECENT_FILES_FILENAME)) + { + props.store(out, null); + } + catch (IOException e) + { + logger.error("Could not save recent files to {}", RECENT_FILES_FILENAME, e); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index d6e58f94..ebd5cc42 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.view; import com.daniel.jsoneditor.controller.AppService; +import com.daniel.jsoneditor.controller.settings.RecentFilesManager; import javafx.application.Application; import javafx.application.Platform; import javafx.stage.Stage; @@ -8,7 +9,12 @@ import org.slf4j.LoggerFactory; import java.awt.Desktop; +import java.awt.Menu; +import java.awt.MenuItem; +import java.awt.PopupMenu; +import java.awt.Taskbar; import java.awt.desktop.AppReopenedListener; +import java.io.File; import java.util.List; @@ -22,28 +28,28 @@ public class JFXLauncher extends Application { private static final Logger logger = LoggerFactory.getLogger(JFXLauncher.class); - + private AppService appService; - + public static void launchJFXApplication(final String[] args) { launch(args); } - + @Override public void start(final Stage stage) { // Keep JavaFX runtime alive even without windows Platform.setImplicitExit(false); stage.close(); - + // Core service starts MCP server immediately appService = new AppService(); - + // Parse launch arguments final List args = getParameters().getRaw(); final boolean headless = args.contains("--headless"); - + if (headless) { logger.info("Started in headless mode — MCP server running, no GUI window."); @@ -52,11 +58,14 @@ public void start(final Stage stage) { appService.createWindow(); } - + // macOS: reopen app when user clicks dock icon with no windows open registerMacOsReopenHandler(); + + // macOS: right-click dock menu with New Window + Recent Projects + registerDockMenu(); } - + /** * On macOS, clicking the dock icon when no windows are open triggers a "reopen" event. * We respond by opening a new editor window. @@ -85,6 +94,72 @@ private void registerMacOsReopenHandler() } } + /** + * Registers a right-click dock menu on macOS with "New Window" and "Recent Projects". + * Silently skipped on platforms that do not support the Taskbar menu feature. + */ + private void registerDockMenu() + { + try + { + if (!Taskbar.isTaskbarSupported()) + { + return; + } + final Taskbar taskbar = Taskbar.getTaskbar(); + if (!taskbar.isSupported(Taskbar.Feature.MENU)) + { + return; + } + rebuildDockMenu(taskbar); + appService.getRecentFilesManager().addChangeListener(() -> rebuildDockMenu(taskbar)); + } + catch (Exception e) + { + logger.debug("Could not register dock menu: {}", e.getMessage()); + } + } + + /** + * Builds and sets a fresh dock {@link PopupMenu} with the current recent files list. + * Safe to call multiple times; each call replaces the previous menu. + */ + private void rebuildDockMenu(final Taskbar taskbar) + { + try + { + final PopupMenu menu = new PopupMenu(); + + final MenuItem newWindowItem = new MenuItem("New Window"); + newWindowItem.addActionListener(evt -> Platform.runLater(() -> appService.createWindow())); + menu.add(newWindowItem); + + final List recentFiles = + appService.getRecentFilesManager().getRecentFiles(); + if (!recentFiles.isEmpty()) + { + menu.addSeparator(); + final Menu recentMenu = new Menu("Recent Projects"); + for (final RecentFilesManager.RecentFile rf : recentFiles) + { + final File jsonFile = rf.jsonFile(); + final File schemaFile = rf.schemaFile(); + final MenuItem item = new MenuItem(jsonFile.getName()); + item.addActionListener(evt -> + Platform.runLater(() -> appService.openFileInNewWindow(jsonFile, schemaFile))); + recentMenu.add(item); + } + menu.add(recentMenu); + } + + taskbar.setMenu(menu); + } + catch (Exception e) + { + logger.debug("Could not rebuild dock menu: {}", e.getMessage()); + } + } + @Override public void stop() { From c2aa3bbed1f12b3cbcc41b974e5fdefb50cf4e08 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Mon, 11 May 2026 22:33:32 +0200 Subject: [PATCH 11/24] fix: address review findings (TOCTOU, sessions, recent files) --- .../jsoneditor/controller/AppWindow.java | 3 +- .../controller/impl/ControllerImpl.java | 2 +- .../settings/RecentFilesManager.java | 16 +++++--- .../controller/settings/UpdateService.java | 1 - .../jsoneditor/model/mcp/ShowGuiTool.java | 3 +- .../model/sessions/FileSessionManager.java | 41 +++++++++++-------- .../daniel/jsoneditor/view/JFXLauncher.java | 10 ++++- 7 files changed, 48 insertions(+), 28 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index f083d62e..d279cf3a 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -31,7 +31,7 @@ public AppWindow(final AppService appService) */ public void setOnClose(final Runnable onClose) { - stage.setOnCloseRequest(event -> + stage.setOnHiding(event -> { controller.shutdown(); if (onClose != null) @@ -53,4 +53,3 @@ public Stage getStage() return stage; } } - diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 0cf5d329..b9ae5b7c 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -81,7 +81,7 @@ public class ControllerImpl implements Controller, Observer private boolean updateCheckDone; - public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage, AppService appService) + public ControllerImpl(final WritableModel model, final ReadableModel readableModel, final Stage stage, final AppService appService) { this.appService = appService; this.settingsController = appService.getSettingsController(); diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java index 82a78ab6..921107ef 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -22,7 +22,8 @@ public class RecentFilesManager { private static final Logger logger = LoggerFactory.getLogger(RecentFilesManager.class); - private static final String RECENT_FILES_FILENAME = "jsoneditor_recent.properties"; + private static final File RECENT_FILES_PATH = new File( + System.getProperty("user.home") + "/.jsoneditor/recent.properties"); private static final int MAX_RECENT_FILES = 10; @@ -99,7 +100,7 @@ private void notifyChangeListeners() private void load() { final Properties props = new Properties(); - try (FileInputStream in = new FileInputStream(RECENT_FILES_FILENAME)) + try (FileInputStream in = new FileInputStream(RECENT_FILES_PATH)) { props.load(in); } @@ -109,7 +110,7 @@ private void load() } catch (IOException e) { - logger.error("Could not load recent files from {}", RECENT_FILES_FILENAME, e); + logger.error("Could not load recent files from {}", RECENT_FILES_PATH, e); return; } for (int i = 0; i < MAX_RECENT_FILES; i++) @@ -131,6 +132,11 @@ private void load() private void save() { + final File dir = RECENT_FILES_PATH.getParentFile(); + if (dir != null && !dir.exists()) + { + dir.mkdirs(); + } final Properties props = new Properties(); for (int i = 0; i < recentFiles.size(); i++) { @@ -138,13 +144,13 @@ private void save() props.setProperty(String.format(KEY_JSON, i), rf.jsonFile().getAbsolutePath()); props.setProperty(String.format(KEY_SCHEMA, i), rf.schemaFile().getAbsolutePath()); } - try (FileOutputStream out = new FileOutputStream(RECENT_FILES_FILENAME)) + try (FileOutputStream out = new FileOutputStream(RECENT_FILES_PATH)) { props.store(out, null); } catch (IOException e) { - logger.error("Could not save recent files to {}", RECENT_FILES_FILENAME, e); + logger.error("Could not save recent files to {}", RECENT_FILES_PATH, e); } } } diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java index 9c14314e..9ecc03bc 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -184,4 +184,3 @@ private static int[] parseSemver(final String version) - diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java index f4312a21..07ab2bde 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -56,7 +56,8 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr }); final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("status", "ok"); + result.put("status", "queued"); + result.put("note", "Window creation requested. The window will appear shortly."); return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index e6c8f982..c9fbd5a0 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -3,6 +3,7 @@ import com.daniel.jsoneditor.controller.impl.json.impl.JsonFileReaderAndWriterImpl; import com.daniel.jsoneditor.model.ReadableModel; import com.daniel.jsoneditor.model.impl.ModelImpl; +import com.daniel.jsoneditor.model.json.schema.SchemaHelper; import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; import com.fasterxml.jackson.databind.JsonNode; import com.networknt.schema.JsonSchema; @@ -59,13 +60,24 @@ public String openFile(final String jsonPath, final String schemaPath) logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); return null; } + + if (!SchemaHelper.validateJsonWithSchema(json, schema).isEmpty()) + { + logger.error("JSON does not validate against schema: {} / {}", jsonPath, schemaPath); + return null; + } final ModelImpl model = new ModelImpl(new EventSenderImpl()); model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); - final String sessionId = generateUniqueId(""); - final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); - sessions.putIfAbsent(sessionId, session); + EditorSession session; + String sessionId; + do + { + sessionId = generateUniqueId(""); + session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); + } + while (sessions.putIfAbsent(sessionId, session) != null); logger.info("Opened file session {} for {}", sessionId, jsonPath); return sessionId; @@ -81,9 +93,14 @@ public String openFile(final String jsonPath, final String schemaPath) */ public String registerGuiSession(final ReadableModel model, final File jsonFile, final File schemaFile) { - final String sessionId = generateUniqueId("gui-"); - final EditorSession session = new EditorSession(sessionId, model, jsonFile, schemaFile, true); - sessions.putIfAbsent(sessionId, session); + EditorSession session; + String sessionId; + do + { + sessionId = generateUniqueId("gui-"); + session = new EditorSession(sessionId, model, jsonFile, schemaFile, true); + } + while (sessions.putIfAbsent(sessionId, session) != null); logger.info("Registered GUI session {} for {}", sessionId, jsonFile != null ? jsonFile.getAbsolutePath() : "null"); return sessionId; } @@ -148,16 +165,6 @@ public List listSessions() private String generateUniqueId(final String prefix) { - for (int i = 0; i < 10; i++) - { - final String candidate = UUID.randomUUID().toString().substring(0, 8); - final String fullKey = prefix + candidate; - if (!sessions.containsKey(fullKey)) - { - return fullKey; - } - } - // Fallback to full UUID if collisions persist - return prefix + UUID.randomUUID().toString(); + return prefix + UUID.randomUUID().toString().substring(0, 8); } } diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index ebd5cc42..bdccd2dc 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -52,7 +52,15 @@ public void start(final Stage stage) if (headless) { - logger.info("Started in headless mode — MCP server running, no GUI window."); + if (appService.getMcpController().isMcpServerRunning()) + { + logger.info("Started in headless mode — MCP server running on port {}, no GUI window.", + appService.getMcpController().getMcpServerPort()); + } + else + { + logger.info("Started in headless mode — MCP server is disabled in settings, no GUI window."); + } } else { From d7374807be111b46a8959bcc1c41ee5d7749afaf Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Mon, 11 May 2026 22:55:08 +0200 Subject: [PATCH 12/24] fix: update docs, improve error reporting, add edge-case tests --- AGENTS.md | 15 ++-- .../controller/settings/UpdateService.java | 9 +-- .../jsoneditor/model/mcp/OpenFileTool.java | 11 ++- .../model/sessions/FileSessionManager.java | 19 +++-- .../model/sessions/OpenFileResult.java | 16 ++++ .../mcp/McpMultiFileIntegrationTest.java | 37 +++++++++ .../sessions/FileSessionManagerTest.java | 80 ++++++++++++++----- 7 files changed, 135 insertions(+), 52 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java diff --git a/AGENTS.md b/AGENTS.md index eb742de2..0c704f35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,7 +95,7 @@ The MCP server exposes JSON editor operations to external AI agents via HTTP JSO ### Architecture ``` GUI Mode: ControllerImpl → FileSessionManager → McpController → JsonEditorMcpServer -Standalone: StandaloneMcpMain → FileSessionManager → JsonEditorMcpServer +Headless: JFXLauncher --headless → FileSessionManager → JsonEditorMcpServer ``` `McpController` wraps `JsonEditorMcpServer`. Port set via `SettingsController.getMcpServerPort()`. @@ -118,18 +118,18 @@ Per-file read tools (require `file_id`): `McpArgumentValidator` validates tool input against schemas before execution. -### Standalone Mode -`StandaloneMcpMain` (`standalone/`) runs the MCP server without JavaFX. Start with: +### Headless Mode +Run the MCP server without the GUI (no JavaFX window). Start with: ```bash -./gradlew runStandalone # default port 4500 -./gradlew runStandalone --args="--port 5000" +./gradlew run --args="--headless" # default port 4500 +./gradlew run --args="--headless --port 5000" # custom port ``` ## Build & Packaging ```bash ./gradlew build # compile + test ./gradlew run # run GUI locally -./gradlew runStandalone # run standalone MCP server (no GUI) +./gradlew run --args="--headless" # run headless MCP server (no GUI) ./gradlew jpackage # create native installer (build/jpackage/) ``` Version is read from `src/main/resources/version.properties`. @@ -140,6 +140,3 @@ JUnit 5 + TestFX + Mockito. Tests in `src/test/java/`. Run with `./gradlew test` ## Settings App settings JSON (schema in `model/settings/`) can add custom toolbar buttons – see `example_settings.json` in project root. - - - diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java index 9ecc03bc..233bdc67 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -175,12 +175,5 @@ private static int[] parseSemver(final String version) return result; } -} - - - - - - - +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java index f266ccc9..d1937f3d 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.daniel.jsoneditor.model.sessions.OpenFileResult; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -62,15 +63,14 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final String jsonPath = arguments.path("json_path").asText(""); final String schemaPath = arguments.path("schema_path").asText(""); - final String sessionId = sessionManager.openFile(jsonPath, schemaPath); - if (sessionId == null) + final OpenFileResult openResult = sessionManager.openFile(jsonPath, schemaPath); + if (!openResult.success()) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, - "Failed to open file. Check that both paths exist and are valid JSON/schema."); + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, openResult.error()); } final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.put("file_id", sessionId); + result.put("file_id", openResult.sessionId()); result.put("json_path", jsonPath); result.put("schema_path", schemaPath); @@ -78,4 +78,3 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr } } - diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index c9fbd5a0..6991e62a 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -16,6 +16,7 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; /** @@ -33,9 +34,9 @@ public class FileSessionManager * * @param jsonPath absolute path to the JSON file * @param schemaPath absolute path to the schema file - * @return session ID, or null if loading failed + * @return result with sessionId on success, or error message on failure */ - public String openFile(final String jsonPath, final String schemaPath) + public OpenFileResult openFile(final String jsonPath, final String schemaPath) { final File jsonFile = new File(jsonPath); final File schemaFile = new File(schemaPath); @@ -43,12 +44,12 @@ public String openFile(final String jsonPath, final String schemaPath) if (!jsonFile.exists()) { logger.error("JSON file does not exist: {}", jsonPath); - return null; + return new OpenFileResult(null, "JSON file does not exist: " + jsonPath); } if (!schemaFile.exists()) { logger.error("Schema file does not exist: {}", schemaPath); - return null; + return new OpenFileResult(null, "Schema file does not exist: " + schemaPath); } final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); @@ -58,13 +59,15 @@ public String openFile(final String jsonPath, final String schemaPath) if (json == null || schema == null) { logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); - return null; + return new OpenFileResult(null, "Failed to parse JSON or schema files: " + jsonPath + " / " + schemaPath); } - if (!SchemaHelper.validateJsonWithSchema(json, schema).isEmpty()) + final List validationErrors = SchemaHelper.validateJsonWithSchema(json, schema); + if (!validationErrors.isEmpty()) { + final String errorDetails = String.join(", ", validationErrors); logger.error("JSON does not validate against schema: {} / {}", jsonPath, schemaPath); - return null; + return new OpenFileResult(null, "JSON does not validate against schema: " + errorDetails); } final ModelImpl model = new ModelImpl(new EventSenderImpl()); @@ -80,7 +83,7 @@ public String openFile(final String jsonPath, final String schemaPath) while (sessions.putIfAbsent(sessionId, session) != null); logger.info("Opened file session {} for {}", sessionId, jsonPath); - return sessionId; + return new OpenFileResult(sessionId, null); } /** diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java b/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java new file mode 100644 index 00000000..919a77b3 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/OpenFileResult.java @@ -0,0 +1,16 @@ +package com.daniel.jsoneditor.model.sessions; + + +/** + * Result of a {@link FileSessionManager#openFile} call. + * On success, {@link #sessionId()} is non-null and {@link #error()} is null. + * On failure, {@link #sessionId()} is null and {@link #error()} contains a human-readable reason. + */ +public record OpenFileResult(String sessionId, String error) +{ + /** @return true when the file was opened successfully */ + public boolean success() + { + return sessionId != null; + } +} diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java index ac291bb7..9fc8c482 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java @@ -255,6 +255,43 @@ void testToolCallWithInvalidFileId() throws Exception "Expected JSON-RPC error when using a bogus file_id"); } + @Test + void testOpenFileValidationRejection() throws Exception + { + final Path jsonFile = createTempFile("mcp-invalid-", ".json", + "{\"active\":\"not-a-boolean\",\"count\":\"not-a-number\"}"); + final Path schemaFile = createTempFile("mcp-invalid-schema-", ".json", SCHEMA_FLAGS); + + final JsonNode result = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + + assertNotNull(result.get("error"), "Expected JSON-RPC error when JSON fails schema validation"); + final String errorMessage = result.path("error").path("message").asText(""); + assertFalse(errorMessage.isBlank(), "Error message must not be blank"); + assertTrue( + errorMessage.toLowerCase().contains("schema") || errorMessage.toLowerCase().contains("validat"), + "Error message must mention schema or validation failure, got: " + errorMessage); + } + + @Test + void testCloseWhileReading() throws Exception + { + final String fileId = openFile(JSON_FLAGS, SCHEMA_FLAGS); + + // Close the session + final JsonNode closeResult = parseToolResultPayload( + callTool("close_file", OBJECT_MAPPER.createObjectNode().put("file_id", fileId))); + assertTrue(closeResult.path("success").asBoolean(), "close_file must succeed"); + + // Now try to read from the closed session - must return an error, not crash + final JsonNode getNodeResult = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", fileId) + .put("path", "")); + assertNotNull(getNodeResult.get("error"), + "Expected JSON-RPC error when accessing a closed session"); + } + // ── helpers ─────────────────────────────────────────────────────────────── private String openFile(final String jsonContent, final String schemaContent) throws Exception diff --git a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java index c12c3371..a8b5d6cd 100644 --- a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -49,9 +49,10 @@ void setUp() throws Exception @Test void testOpenFileCreatesSession() { - final String id = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - - assertNotNull(id, "openFile must return a non-null session ID"); + final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(openResult.success(), "openFile must succeed"); + assertNull(openResult.error(), "error must be null on success"); + final String id = openResult.sessionId(); assertFalse(id.isEmpty(), "Session ID must not be empty"); final EditorSession session = sessionManager.getSession(id); @@ -69,18 +70,23 @@ void testOpenInvalidFileReturnsNull() final String nonExistentJson = tempDir.resolve("no-such.json").toString(); final String nonExistentSchema = tempDir.resolve("no-such-schema.json").toString(); - assertNull(sessionManager.openFile(nonExistentJson, nonExistentSchema), - "openFile with non-existent JSON and schema must return null"); + final OpenFileResult r1 = sessionManager.openFile(nonExistentJson, nonExistentSchema); + assertFalse(r1.success(), "openFile with non-existent JSON and schema must fail"); + assertNotNull(r1.error(), "error message must be present"); + assertTrue(r1.error().contains("does not exist"), "error must mention missing file"); - assertNull(sessionManager.openFile(jsonFile.toString(), nonExistentSchema), - "openFile with existing JSON but missing schema must return null"); + final OpenFileResult r2 = sessionManager.openFile(jsonFile.toString(), nonExistentSchema); + assertFalse(r2.success(), "openFile with existing JSON but missing schema must fail"); + assertNotNull(r2.error(), "error message must be present"); + assertTrue(r2.error().contains("does not exist"), "error must mention missing schema"); } @Test void testCloseFileRemovesSession() { - final String id = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertNotNull(id, "Precondition: session must open successfully"); + final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(openResult.success(), "Precondition: session must open successfully"); + final String id = openResult.sessionId(); final boolean closed = sessionManager.closeFile(id); assertTrue(closed, "closeFile must return true for a valid headless session"); @@ -93,8 +99,9 @@ void testCloseFileRemovesSession() void testCannotCloseGuiSession() { // Open a headless session to get a valid ReadableModel instance - final String headlessId = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertNotNull(headlessId, "Precondition: headless session must open"); + final OpenFileResult headlessResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(headlessResult.success(), "Precondition: headless session must open"); + final String headlessId = headlessResult.sessionId(); final EditorSession headlessSession = sessionManager.getSession(headlessId); // Register as GUI session (guiOwned=true) @@ -112,8 +119,9 @@ void testCannotCloseGuiSession() @Test void testUnregisterGuiSession() { - final String headlessId = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - assertNotNull(headlessId, "Precondition: headless session must open"); + final OpenFileResult headlessResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(headlessResult.success(), "Precondition: headless session must open"); + final String headlessId = headlessResult.sessionId(); final EditorSession headlessSession = sessionManager.getSession(headlessId); final String guiId = sessionManager.registerGuiSession( @@ -138,10 +146,12 @@ void testListSessionsReturnsAll() throws Exception + "\"type\":\"object\"," + "\"properties\":{\"a\":{\"type\":\"integer\"}}}"); - final String id1 = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); - final String id2 = sessionManager.openFile(jsonFile2.toString(), schemaFile2.toString()); - assertNotNull(id1, "First session must open"); - assertNotNull(id2, "Second session must open"); + final OpenFileResult result1 = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + final OpenFileResult result2 = sessionManager.openFile(jsonFile2.toString(), schemaFile2.toString()); + assertTrue(result1.success(), "First session must open"); + assertTrue(result2.success(), "Second session must open"); + final String id1 = result1.sessionId(); + final String id2 = result2.sessionId(); final List sessions = sessionManager.listSessions(); assertEquals(2, sessions.size(), "listSessions must return exactly 2 sessions"); @@ -185,9 +195,9 @@ void testConcurrentAccess() throws Exception try { startLatch.await(); - final String id = sessionManager.openFile( + final OpenFileResult openResult = sessionManager.openFile( jsonFiles.get(index).toString(), schemaFiles.get(index).toString()); - if (id == null) + if (!openResult.success()) { errorCount.incrementAndGet(); } @@ -195,9 +205,9 @@ void testConcurrentAccess() throws Exception { synchronized (openedIds) { - openedIds.add(id); + openedIds.add(openResult.sessionId()); } - sessionManager.closeFile(id); + sessionManager.closeFile(openResult.sessionId()); } } catch (final Exception e) @@ -219,4 +229,32 @@ void testConcurrentAccess() throws Exception assertEquals(threadCount, openedIds.size(), "All threads must have opened a session"); assertTrue(sessionManager.listSessions().isEmpty(), "All sessions must be closed after concurrent test"); } + + @Test + void testOpenFileWithInvalidJson() throws Exception + { + // JSON has a string where the schema expects a number - validation must reject it + final Path invalidJson = tempDir.resolve("invalid.json"); + Files.writeString(invalidJson, "{\"name\":\"test\",\"value\":\"not-a-number\"}"); + + final OpenFileResult result = sessionManager.openFile(invalidJson.toString(), schemaFile.toString()); + + assertFalse(result.success(), "openFile must fail when JSON does not validate against schema"); + assertNotNull(result.error(), "error message must be present"); + assertFalse(result.error().isBlank(), "error message must not be blank"); + assertTrue(result.error().toLowerCase().contains("schema") || result.error().toLowerCase().contains("validat"), + "error must mention schema or validation, got: " + result.error()); + } + + @Test + void testCloseSessionThenAccess() + { + final OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(openResult.success(), "Precondition: session must open"); + final String id = openResult.sessionId(); + + sessionManager.closeFile(id); + + assertNull(sessionManager.getSession(id), "getSession must return null after session is closed"); + } } From bb0913f4b022edce5da3d6f314bf25845e9f805f Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 09:43:52 +0200 Subject: [PATCH 13/24] fix(core): consistency, null-safety, error handling, atomic writes --- README.md | 6 +- .../jsoneditor/controller/AppService.java | 2 +- .../controller/impl/ControllerImpl.java | 4 + .../settings/RecentFilesManager.java | 82 +++++++++---- .../controller/settings/UpdateService.java | 1 - .../jsoneditor/model/impl/ModelImpl.java | 13 ++ .../model/json/schema/SchemaHelper.java | 4 + .../model/mcp/FindReferencesToTool.java | 2 +- .../jsoneditor/model/mcp/GetExamplesTool.java | 2 +- .../jsoneditor/model/mcp/GetFileInfoTool.java | 2 +- .../jsoneditor/model/mcp/GetNodeTool.java | 2 +- .../mcp/GetReferenceableInstancesTool.java | 2 +- .../mcp/GetReferenceableObjectsTool.java | 2 +- .../model/mcp/GetSchemaForPathTool.java | 2 +- .../model/mcp/McpArgumentValidator.java | 2 + .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 16 +++ .../model/sessions/FileSessionManager.java | 14 ++- .../daniel/jsoneditor/view/JFXLauncher.java | 6 +- .../settings/RecentFilesManagerTest.java | 116 ++++++++++++++++++ 19 files changed, 242 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/daniel/jsoneditor/controller/settings/RecentFilesManagerTest.java diff --git a/README.md b/README.md index d0175a1d..9d32cf2c 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,8 @@ Enable in Settings → MCP Server. The server exposes the currently open file to Run the MCP server without the GUI — useful for CI, scripting, or AI agent workflows with multiple files: ```bash -./gradlew runStandalone # default port 4500 -./gradlew runStandalone --args="--port 5000" # custom port +./gradlew run --args="--headless" # default port 4500 +./gradlew run --args="--headless --port 5000" # custom port ``` Use the `open_file` tool to load JSON + schema pairs, then query them with `get_node`, `get_schema_for_path`, etc. Each file gets a `file_id` for addressing. @@ -81,4 +81,4 @@ Available tools: `list_files`, `open_file`, `close_file`, `get_file_info`, `get_ ## MacOS -https://support.apple.com/de-de/guide/mac-help/mh40616/mac \ No newline at end of file +https://support.apple.com/de-de/guide/mac-help/mh40616/mac diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index 6555bc52..bcb43095 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -40,8 +40,8 @@ public AppService() { this.fileSessionManager = new FileSessionManager(); this.settingsController = new SettingsControllerImpl(); - this.mcpController = new McpController(fileSessionManager, settingsController, this); this.recentFilesManager = new RecentFilesManager(); + this.mcpController = new McpController(fileSessionManager, settingsController, this); startMcpServer(); } diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index b9ae5b7c..75030de6 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -520,6 +520,10 @@ private JsonNode buildCandidateNode(Object value) { return JsonNodeFactory.instance.numberNode((Double) value); } + if (value instanceof Number) + { + return JsonNodeFactory.instance.numberNode(((Number) value).doubleValue()); + } return JsonNodeFactory.instance.textNode(value.toString()); } diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java index 921107ef..d902d5a2 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -8,8 +8,9 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Properties; @@ -37,6 +38,7 @@ public record RecentFile(File jsonFile, File schemaFile) {} private final List recentFiles; private final List changeListeners; + private final Object lock = new Object(); public RecentFilesManager() { @@ -56,28 +58,47 @@ public void addRecentFile(final File jsonFile, final File schemaFile) { return; } - recentFiles.removeIf(rf -> rf.jsonFile().equals(jsonFile)); - recentFiles.add(0, new RecentFile(jsonFile, schemaFile)); - while (recentFiles.size() > MAX_RECENT_FILES) + final List listeners; + synchronized (lock) { - recentFiles.remove(recentFiles.size() - 1); + recentFiles.removeIf(rf -> rf.jsonFile().equals(jsonFile)); + recentFiles.add(0, new RecentFile(jsonFile, schemaFile)); + while (recentFiles.size() > MAX_RECENT_FILES) + { + recentFiles.remove(recentFiles.size() - 1); + } + save(); + listeners = new ArrayList<>(changeListeners); + } + for (final Runnable listener : listeners) + { + listener.run(); } - save(); - notifyChangeListeners(); } - /** Returns an unmodifiable view of recent files, newest first. */ + /** Returns a snapshot of recent files, newest first. */ public List getRecentFiles() { - return Collections.unmodifiableList(recentFiles); + synchronized (lock) + { + return new ArrayList<>(recentFiles); + } } /** Clears all recent files and persists the empty list. */ public void clear() { - recentFiles.clear(); - save(); - notifyChangeListeners(); + final List listeners; + synchronized (lock) + { + recentFiles.clear(); + save(); + listeners = new ArrayList<>(changeListeners); + } + for (final Runnable listener : listeners) + { + listener.run(); + } } /** @@ -86,14 +107,9 @@ public void clear() */ public void addChangeListener(final Runnable listener) { - changeListeners.add(listener); - } - - private void notifyChangeListeners() - { - for (final Runnable listener : changeListeners) + synchronized (lock) { - listener.run(); + changeListeners.add(listener); } } @@ -135,7 +151,11 @@ private void save() final File dir = RECENT_FILES_PATH.getParentFile(); if (dir != null && !dir.exists()) { - dir.mkdirs(); + if (!dir.mkdirs() && !dir.exists()) + { + logger.error("Cannot create directory for recent files: {}", dir); + return; + } } final Properties props = new Properties(); for (int i = 0; i < recentFiles.size(); i++) @@ -144,13 +164,33 @@ private void save() props.setProperty(String.format(KEY_JSON, i), rf.jsonFile().getAbsolutePath()); props.setProperty(String.format(KEY_SCHEMA, i), rf.schemaFile().getAbsolutePath()); } - try (FileOutputStream out = new FileOutputStream(RECENT_FILES_PATH)) + final File tempFile = new File(RECENT_FILES_PATH.getParentFile(), RECENT_FILES_PATH.getName() + ".tmp"); + try (FileOutputStream out = new FileOutputStream(tempFile)) { props.store(out, null); } catch (IOException e) { logger.error("Could not save recent files to {}", RECENT_FILES_PATH, e); + return; + } + try + { + Files.move(tempFile.toPath(), RECENT_FILES_PATH.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + catch (IOException e) + { + // ATOMIC_MOVE not supported on this filesystem — fall back to direct write + logger.warn("Atomic rename failed, falling back to direct write: {}", e.getMessage()); + try (FileOutputStream out = new FileOutputStream(RECENT_FILES_PATH)) + { + props.store(out, null); + } + catch (IOException ex) + { + logger.error("Could not save recent files to {}", RECENT_FILES_PATH, ex); + } } } } diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java index 233bdc67..4f225ad1 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -175,5 +175,4 @@ private static int[] parseSemver(final String version) return result; } - } diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index 2195bbd8..0721f8b1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java @@ -174,6 +174,15 @@ private void setRootSchema(JsonSchema rootSchema) this.rootSchema = rootSchema; } + /** + * Sends a model state event to all registered observers. + * + * In normal GUI mode this is called via {@link javafx.application.Platform#runLater} to stay + * on the JavaFX Application Thread. In headless mode (no JavaFX toolkit), the + * {@link IllegalStateException} from {@code Platform.runLater} is caught by callers and this + * method is invoked directly on the worker thread instead. This is safe because in headless + * mode no JavaFX-bound observers are registered with the {@link com.daniel.jsoneditor.model.statemachine.EventSender}. + */ public void sendEvent(Event state) { eventSender.sendEvent(state); @@ -245,6 +254,10 @@ private JsonNode buildCandidateNode(Object value) { return JsonNodeFactory.instance.numberNode((Double) value); } + if (value instanceof Number) + { + return JsonNodeFactory.instance.numberNode(((Number) value).doubleValue()); + } return JsonNodeFactory.instance.textNode(value.toString()); } diff --git a/src/main/java/com/daniel/jsoneditor/model/json/schema/SchemaHelper.java b/src/main/java/com/daniel/jsoneditor/model/json/schema/SchemaHelper.java index 9d31c247..3ece0e34 100644 --- a/src/main/java/com/daniel/jsoneditor/model/json/schema/SchemaHelper.java +++ b/src/main/java/com/daniel/jsoneditor/model/json/schema/SchemaHelper.java @@ -33,6 +33,10 @@ public static JsonSchema resolveJsonRefsInSchema(JsonSchema root) */ public static List validateJsonWithSchema(JsonNode json, JsonSchema schema) { + if (schema == null) + { + return Collections.emptyList(); + } final Set messages = schema.validate(json); final List errors = new ArrayList<>(); for (final ValidationMessage message : messages) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index e52d0778..f3d70941 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -60,7 +60,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java index 6b625a68..c6069ff2 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -59,7 +59,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java index 25b18883..527045ed 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -55,7 +55,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final ObjectNode content = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index 745fefd4..8e17cda7 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -57,7 +57,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java index ff2b7d40..af82b5fb 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -61,7 +61,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final String referencingKey = arguments.path("referencing_key").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java index 447f9c19..c0fe617c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -58,7 +58,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final List objects = model.getReferenceableObjects(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index 640db35e..ab45bc47 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -58,7 +58,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReadableModel model = resolveModel(arguments); if (model == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "Unknown file_id"); + return unknownFileIdError(id); } final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java index d9ee82e1..940c832e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java @@ -16,6 +16,8 @@ */ public final class McpArgumentValidator { + // V202012 is used intentionally: MCP tool input schemas use simple type declarations + // (type, properties, required) that are fully compatible with this spec version. private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012); private McpArgumentValidator() { /* utility */ } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index 5f883b69..58b4b15c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -16,6 +16,9 @@ public abstract class ReadOnlyMcpTool extends McpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + /** JSON-RPC error code for invalid parameters (unknown file_id). */ + private static final int JSONRPC_INVALID_PARAMS = -32602; + protected final FileSessionManager sessionManager; protected ReadOnlyMcpTool(final FileSessionManager sessionManager) @@ -27,6 +30,10 @@ protected ReadOnlyMcpTool(final FileSessionManager sessionManager) this.sessionManager = sessionManager; } + /** + * Resolves the target model from the {@code file_id} argument. + * Returns {@code null} if the {@code file_id} is absent or unknown. + */ protected ReadableModel resolveModel(final JsonNode arguments) { final String fileId = arguments.path("file_id").asText(null); @@ -37,6 +44,15 @@ protected ReadableModel resolveModel(final JsonNode arguments) final EditorSession session = sessionManager.getSession(fileId); return session != null ? session.model() : null; } + + /** + * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. + * All per-file tools should call this when {@link #resolveModel(JsonNode)} returns {@code null}. + */ + protected static String unknownFileIdError(final JsonNode id) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id"); + } protected static void addFileIdProperty(final ObjectNode properties) { diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index 6991e62a..3ad6fcf3 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -53,8 +53,18 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) } final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); - final JsonNode json = reader.getJsonFromFile(jsonFile); - final JsonSchema schema = reader.getSchemaFromFileResolvingRefs(schemaFile); + final JsonNode json; + final JsonSchema schema; + try + { + json = reader.getJsonFromFile(jsonFile); + schema = reader.getSchemaFromFileResolvingRefs(schemaFile); + } + catch (Exception e) + { + logger.error("Failed to parse JSON or schema files: {} / {}", jsonPath, schemaPath, e); + return new OpenFileResult(null, "Failed to parse files: " + e.getMessage()); + } if (json == null || schema == null) { diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index bdccd2dc..33744000 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -98,7 +98,7 @@ private void registerMacOsReopenHandler() catch (Exception e) { // Not on macOS or AWT desktop not available — ignore silently - logger.debug("Could not register macOS reopen handler: {}", e.getMessage()); + logger.debug("Could not register macOS reopen handler", e); } } @@ -124,7 +124,7 @@ private void registerDockMenu() } catch (Exception e) { - logger.debug("Could not register dock menu: {}", e.getMessage()); + logger.debug("Could not register dock menu", e); } } @@ -164,7 +164,7 @@ private void rebuildDockMenu(final Taskbar taskbar) } catch (Exception e) { - logger.debug("Could not rebuild dock menu: {}", e.getMessage()); + logger.debug("Could not rebuild dock menu", e); } } diff --git a/src/test/java/com/daniel/jsoneditor/controller/settings/RecentFilesManagerTest.java b/src/test/java/com/daniel/jsoneditor/controller/settings/RecentFilesManagerTest.java new file mode 100644 index 00000000..cc77c236 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/controller/settings/RecentFilesManagerTest.java @@ -0,0 +1,116 @@ +package com.daniel.jsoneditor.controller.settings; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class RecentFilesManagerTest +{ + @Test + void testConcurrentAddRecentFile() throws InterruptedException + { + final RecentFilesManager manager = new RecentFilesManager(); + final int threadCount = 10; + final CountDownLatch latch = new CountDownLatch(threadCount); + final AtomicReference caught = new AtomicReference<>(); + + final List threads = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) + { + final int index = i; + final Thread t = new Thread(() -> + { + try + { + manager.addRecentFile( + new File("/tmp/test" + index + ".json"), + new File("/tmp/schema" + index + ".json") + ); + } + catch (Throwable e) + { + caught.compareAndSet(null, e); + } + finally + { + latch.countDown(); + } + }); + threads.add(t); + } + threads.forEach(Thread::start); + latch.await(); + + assertNull(caught.get(), "Concurrent addRecentFile threw: " + caught.get()); + assertNotNull(manager.getRecentFiles()); + } + + @Test + void testConcurrentReadWhileWrite() throws InterruptedException + { + final RecentFilesManager manager = new RecentFilesManager(); + final AtomicReference caught = new AtomicReference<>(); + final CountDownLatch writerDone = new CountDownLatch(1); + final CountDownLatch readerDone = new CountDownLatch(1); + + final Thread writer = new Thread(() -> + { + try + { + for (int i = 0; i < 50; i++) + { + manager.addRecentFile( + new File("/tmp/write" + i + ".json"), + new File("/tmp/schema" + i + ".json") + ); + } + } + catch (Throwable e) + { + caught.compareAndSet(null, e); + } + finally + { + writerDone.countDown(); + } + }, "writer"); + + final Thread reader = new Thread(() -> + { + try + { + for (int i = 0; i < 200; i++) + { + final List list = manager.getRecentFiles(); + assertNotNull(list); + for (final RecentFilesManager.RecentFile ignored : list) + { + // iterate to trigger potential ConcurrentModificationException + } + } + } + catch (Throwable e) + { + caught.compareAndSet(null, e); + } + finally + { + readerDone.countDown(); + } + }, "reader"); + + writer.start(); + reader.start(); + writerDone.await(); + readerDone.await(); + + assertNull(caught.get(), "Concurrent read/write threw: " + caught.get()); + } +} From bf6b8c840f4f70dae902f612c8997cbe98118032 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 10:35:19 +0200 Subject: [PATCH 14/24] fix(core): QA pass - error distinction, null handling, style --- .../jsoneditor/controller/AppService.java | 15 ++++ .../jsoneditor/controller/AppWindow.java | 1 + .../controller/impl/ControllerImpl.java | 9 +- .../settings/RecentFilesManager.java | 2 +- .../controller/settings/UpdateService.java | 1 - .../jsoneditor/model/mcp/CloseFileTool.java | 34 ++++---- .../jsoneditor/model/mcp/ListFilesTool.java | 1 - .../jsoneditor/model/mcp/OpenFileTool.java | 3 +- .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 2 +- .../jsoneditor/model/mcp/ShowGuiTool.java | 23 ++++-- .../model/sessions/CloseFileResult.java | 14 ++++ .../model/sessions/EditorSession.java | 12 +-- .../model/sessions/FileSessionManager.java | 18 ++-- .../daniel/jsoneditor/view/JFXLauncher.java | 82 ++++++++++++------- .../sessions/FileSessionManagerTest.java | 9 +- 15 files changed, 148 insertions(+), 78 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/sessions/CloseFileResult.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index bcb43095..d47e7ad9 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -74,6 +74,11 @@ private void startMcpServer() */ public AppWindow createWindow() { + if (shuttingDown.get()) + { + logger.info("Cannot create window — application is shutting down"); + return null; + } final AppWindow window = new AppWindow(this); windows.add(window); window.setOnClose(() -> onWindowClosed(window)); @@ -87,6 +92,10 @@ public AppWindow createWindow() public void openFileInNewWindow(final File jsonFile, final File schemaFile) { final AppWindow window = createWindow(); + if (window == null) + { + return; + } window.getController().jsonAndSchemaSelected(jsonFile, schemaFile, null); } @@ -136,6 +145,12 @@ public int getWindowCount() return windows.size(); } + /** Returns true if the application is shutting down. */ + public boolean isShuttingDown() + { + return shuttingDown.get(); + } + /** * Shuts down all shared services. Called when the application exits. * Safe to call multiple times – only the first invocation performs work. diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index d279cf3a..1a1d6841 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -16,6 +16,7 @@ public class AppWindow private final Stage stage; + /** Creates a new editor window wired to the given AppService. */ public AppWindow(final AppService appService) { this.stage = new Stage(); diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 75030de6..36044a37 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -8,6 +8,7 @@ import java.util.Set; import com.daniel.jsoneditor.controller.AppService; +import com.daniel.jsoneditor.controller.AppWindow; import com.daniel.jsoneditor.controller.Controller; import com.daniel.jsoneditor.controller.impl.commands.CommandManager; import com.daniel.jsoneditor.controller.impl.commands.CommandManagerImpl; @@ -457,7 +458,11 @@ private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onS @Override public void openNewJson() { - appService.createWindow(); + final AppWindow window = appService.createWindow(); + if (window == null) + { + logger.warn("Could not create new window — application is shutting down"); + } } @Override @@ -477,6 +482,8 @@ public void setValueAtPath(String path, Object value) final JsonNodeWithPath parentNodeWithPath = readableModel.getNodeForPath(parentPath); if (parentNodeWithPath == null || !parentNodeWithPath.getNode().isObject()) { + logger.debug("Cannot set value: parent at {} is null or not an object", parentPath); + view.showToast(Toasts.VALUE_VALIDATION_FAILED_TOAST); return; } final ObjectNode candidateParent = parentNodeWithPath.getNode().deepCopy(); diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java index d902d5a2..36e00ce7 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -61,7 +61,7 @@ public void addRecentFile(final File jsonFile, final File schemaFile) final List listeners; synchronized (lock) { - recentFiles.removeIf(rf -> rf.jsonFile().equals(jsonFile)); + recentFiles.removeIf((final RecentFile rf) -> rf.jsonFile().equals(jsonFile)); recentFiles.add(0, new RecentFile(jsonFile, schemaFile)); while (recentFiles.size() > MAX_RECENT_FILES) { diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java index 4f225ad1..701af679 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/UpdateService.java @@ -174,5 +174,4 @@ private static int[] parseSemver(final String version) } return result; } - } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java index 033642eb..4fb591b9 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -1,5 +1,6 @@ package com.daniel.jsoneditor.model.mcp; +import com.daniel.jsoneditor.model.sessions.CloseFileResult; import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; @@ -11,24 +12,24 @@ class CloseFileTool extends ReadOnlyMcpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public CloseFileTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "close_file"; } - + @Override public String getDescription() { return "Close a previously opened file session. Cannot close GUI-owned sessions."; } - + @Override public ObjectNode getInputSchema() { @@ -36,7 +37,7 @@ public ObjectNode getInputSchema() addFileIdProperty(props); return props; } - + @Override public ArrayNode getRequiredInputProperties() { @@ -44,25 +45,28 @@ public ArrayNode getRequiredInputProperties() addFileIdRequired(arr); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String fileId = arguments.path("file_id").asText(""); - - final boolean closed = sessionManager.closeFile(fileId); - if (!closed) + + final CloseFileResult closeResult = sessionManager.closeFile(fileId); + if (closeResult == CloseFileResult.NOT_FOUND) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, - "Cannot close session: not found or GUI-owned"); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Unknown file_id: " + fileId); } - + if (closeResult == CloseFileResult.GUI_OWNED) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Session is GUI-owned and cannot be closed via MCP"); + } + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.put("success", true); result.put("file_id", fileId); - + return McpToolRegistry.createToolResult(id, result); } } - - diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java index 89081e1f..d860435f 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java @@ -55,4 +55,3 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr } } - diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java index d1937f3d..9c2d739a 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -66,7 +66,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final OpenFileResult openResult = sessionManager.openFile(jsonPath, schemaPath); if (!openResult.success()) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, openResult.error()); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, openResult.error()); } final ObjectNode result = OBJECT_MAPPER.createObjectNode(); @@ -77,4 +77,3 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr return McpToolRegistry.createToolResult(id, result); } } - diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index 58b4b15c..cfca29a1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -17,7 +17,7 @@ public abstract class ReadOnlyMcpTool extends McpTool private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); /** JSON-RPC error code for invalid parameters (unknown file_id). */ - private static final int JSONRPC_INVALID_PARAMS = -32602; + protected static final int JSONRPC_INVALID_PARAMS = -32602; protected final FileSessionManager sessionManager; diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java index 07ab2bde..06b3e30a 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -9,8 +9,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - - /** * MCP tool that opens a new GUI window on demand. @@ -20,41 +18,48 @@ class ShowGuiTool extends McpTool { private static final Logger logger = LoggerFactory.getLogger(ShowGuiTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + private static final int JSONRPC_INVALID_PARAMS = -32602; + private final AppService appService; - + public ShowGuiTool(final AppService appService) { this.appService = appService; } - + @Override public String getName() { return "show_gui"; } - + @Override public String getDescription() { return "Opens a new JSON Editor GUI window. Use when running in headless mode to show the editor interface."; } - + @Override public ObjectNode getInputSchema() { return OBJECT_MAPPER.createObjectNode(); } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + if (appService.isShuttingDown()) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Cannot open window — application is shutting down"); + } + Platform.runLater(() -> { appService.createWindow(); logger.info("GUI window opened via MCP tool"); }); - + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.put("status", "queued"); result.put("note", "Window creation requested. The window will appear shortly."); diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/CloseFileResult.java b/src/main/java/com/daniel/jsoneditor/model/sessions/CloseFileResult.java new file mode 100644 index 00000000..1ab06c2b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/CloseFileResult.java @@ -0,0 +1,14 @@ +package com.daniel.jsoneditor.model.sessions; + +/** + * Result of a {@link FileSessionManager#closeFile(String)} operation. + */ +public enum CloseFileResult +{ + /** Session was closed successfully. */ + CLOSED, + /** No session found for the given ID. */ + NOT_FOUND, + /** Session exists but is GUI-owned and cannot be closed via MCP. */ + GUI_OWNED +} diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java b/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java index e8d15ac2..75854b7b 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/EditorSession.java @@ -6,13 +6,13 @@ /** - * Represents a single open file session with its model and metadata. + * Represents an active editor session. * - * @param id unique session identifier - * @param model the model for this session - * @param jsonFile the JSON file being edited - * @param schemaFile the schema file used for validation - * @param guiOwned true if this session is owned by the GUI (cannot be closed via MCP) + * @param id unique session identifier (never null) + * @param model the readable model for this session (never null) + * @param jsonFile the JSON file being edited (may be null for unsaved/new documents) + * @param schemaFile the schema file (may be null if no schema) + * @param guiOwned true if this session is owned by a GUI window (cannot be closed via MCP) */ public record EditorSession(String id, ReadableModel model, File jsonFile, File schemaFile, boolean guiOwned) { diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index 3ad6fcf3..43a106ea 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -125,7 +125,7 @@ public String registerGuiSession(final ReadableModel model, final File jsonFile, */ public void unregisterGuiSession(final String sessionId) { - sessions.computeIfPresent(sessionId, (key, session) -> + sessions.computeIfPresent(sessionId, (final String key, final EditorSession session) -> { if (session.guiOwned()) { @@ -140,26 +140,30 @@ public void unregisterGuiSession(final String sessionId) * Closes a headless session. Refuses to close GUI-owned sessions. * * @param sessionId the session to close - * @return true if closed, false if not found or GUI-owned + * @return {@link CloseFileResult#CLOSED} if closed, {@link CloseFileResult#NOT_FOUND} if not found, + * {@link CloseFileResult#GUI_OWNED} if the session is GUI-owned */ - public boolean closeFile(final String sessionId) + public CloseFileResult closeFile(final String sessionId) { - final boolean[] closed = {false}; - sessions.computeIfPresent(sessionId, (key, session) -> + final CloseFileResult[] result = {CloseFileResult.NOT_FOUND}; + sessions.computeIfPresent(sessionId, (final String key, final EditorSession session) -> { if (session.guiOwned()) { logger.warn("Cannot close GUI-owned session {} via MCP", sessionId); + result[0] = CloseFileResult.GUI_OWNED; return session; // keep it } logger.info("Closed file session {}", sessionId); - closed[0] = true; + result[0] = CloseFileResult.CLOSED; return null; // removes the entry }); - return closed[0]; + return result[0]; } /** + * Returns the session for the given ID, or {@code null} if not found. + * * @param sessionId the session ID * @return the session or null if not found */ diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index 33744000..81dae329 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.view; import com.daniel.jsoneditor.controller.AppService; +import com.daniel.jsoneditor.controller.AppWindow; import com.daniel.jsoneditor.controller.settings.RecentFilesManager; import javafx.application.Application; import javafx.application.Platform; @@ -13,9 +14,12 @@ import java.awt.MenuItem; import java.awt.PopupMenu; import java.awt.Taskbar; +import java.awt.desktop.AppReopenedEvent; import java.awt.desktop.AppReopenedListener; +import java.awt.event.ActionEvent; import java.io.File; import java.util.List; +import javax.swing.SwingUtilities; /** @@ -64,7 +68,11 @@ public void start(final Stage stage) } else { - appService.createWindow(); + final AppWindow window = appService.createWindow(); + if (window == null) + { + logger.error("Failed to create initial window — application is shutting down"); + } } // macOS: reopen app when user clicks dock icon with no windows open @@ -84,13 +92,17 @@ private void registerMacOsReopenHandler() { if (Desktop.isDesktopSupported()) { - Desktop.getDesktop().addAppEventListener((AppReopenedListener) event -> + Desktop.getDesktop().addAppEventListener((AppReopenedListener) (final AppReopenedEvent event) -> Platform.runLater(() -> { if (appService.getWindowCount() == 0) { logger.info("macOS reopen event — opening new window"); - appService.createWindow(); + final AppWindow window = appService.createWindow(); + if (window == null) + { + logger.warn("Could not create window on dock reopen — application is shutting down"); + } } })); } @@ -134,38 +146,48 @@ private void registerDockMenu() */ private void rebuildDockMenu(final Taskbar taskbar) { - try + SwingUtilities.invokeLater(() -> { - final PopupMenu menu = new PopupMenu(); - - final MenuItem newWindowItem = new MenuItem("New Window"); - newWindowItem.addActionListener(evt -> Platform.runLater(() -> appService.createWindow())); - menu.add(newWindowItem); - - final List recentFiles = - appService.getRecentFilesManager().getRecentFiles(); - if (!recentFiles.isEmpty()) + try { - menu.addSeparator(); - final Menu recentMenu = new Menu("Recent Projects"); - for (final RecentFilesManager.RecentFile rf : recentFiles) + final PopupMenu menu = new PopupMenu(); + + final MenuItem newWindowItem = new MenuItem("New Window"); + newWindowItem.addActionListener((final ActionEvent evt) -> Platform.runLater(() -> { - final File jsonFile = rf.jsonFile(); - final File schemaFile = rf.schemaFile(); - final MenuItem item = new MenuItem(jsonFile.getName()); - item.addActionListener(evt -> - Platform.runLater(() -> appService.openFileInNewWindow(jsonFile, schemaFile))); - recentMenu.add(item); + final AppWindow window = appService.createWindow(); + if (window == null) + { + logger.warn("Could not create window from dock menu — application is shutting down"); + } + })); + menu.add(newWindowItem); + + final List recentFiles = + appService.getRecentFilesManager().getRecentFiles(); + if (!recentFiles.isEmpty()) + { + menu.addSeparator(); + final Menu recentMenu = new Menu("Recent Projects"); + for (final RecentFilesManager.RecentFile rf : recentFiles) + { + final File jsonFile = rf.jsonFile(); + final File schemaFile = rf.schemaFile(); + final MenuItem item = new MenuItem(jsonFile.getName()); + item.addActionListener((final ActionEvent evt) -> + Platform.runLater(() -> appService.openFileInNewWindow(jsonFile, schemaFile))); + recentMenu.add(item); + } + menu.add(recentMenu); } - menu.add(recentMenu); - } - taskbar.setMenu(menu); - } - catch (Exception e) - { - logger.debug("Could not rebuild dock menu", e); - } + taskbar.setMenu(menu); + } + catch (Exception e) + { + logger.debug("Could not rebuild dock menu", e); + } + }); } @Override diff --git a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java index a8b5d6cd..421fc415 100644 --- a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; +import com.daniel.jsoneditor.model.sessions.CloseFileResult; public class FileSessionManagerTest @@ -88,8 +89,8 @@ void testCloseFileRemovesSession() assertTrue(openResult.success(), "Precondition: session must open successfully"); final String id = openResult.sessionId(); - final boolean closed = sessionManager.closeFile(id); - assertTrue(closed, "closeFile must return true for a valid headless session"); + final CloseFileResult closeResult = sessionManager.closeFile(id); + assertEquals(CloseFileResult.CLOSED, closeResult, "closeFile must return CLOSED for a valid headless session"); assertNull(sessionManager.getSession(id), "getSession must return null after close"); assertTrue(sessionManager.listSessions().isEmpty(), "listSessions must be empty after close"); @@ -111,8 +112,8 @@ void testCannotCloseGuiSession() assertTrue(sessionManager.getSession(guiId).guiOwned(), "GUI session must be marked guiOwned"); // Attempting to close the GUI session via closeFile must fail - final boolean closed = sessionManager.closeFile(guiId); - assertFalse(closed, "closeFile must return false for a GUI-owned session"); + final CloseFileResult closeResult = sessionManager.closeFile(guiId); + assertEquals(CloseFileResult.GUI_OWNED, closeResult, "closeFile must return GUI_OWNED for a GUI-owned session"); assertNotNull(sessionManager.getSession(guiId), "GUI session must still exist after failed close"); } From 207b755551f6ba47a7cb1a72c6c7aebd52a88c80 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 13:09:34 +0200 Subject: [PATCH 15/24] chore(core): extract JsonNodeHelper, distinct MCP errors, cleanup --- .../controller/impl/ControllerImpl.java | 34 +------ .../jsoneditor/model/impl/ModelImpl.java | 29 +----- .../jsoneditor/model/json/JsonNodeHelper.java | 49 ++++++++++ .../model/mcp/FindReferencesToTool.java | 7 +- .../jsoneditor/model/mcp/GetExamplesTool.java | 7 +- .../jsoneditor/model/mcp/GetFileInfoTool.java | 7 +- .../jsoneditor/model/mcp/GetNodeTool.java | 7 +- .../mcp/GetReferenceableInstancesTool.java | 7 +- .../mcp/GetReferenceableObjectsTool.java | 7 +- .../model/mcp/GetSchemaForPathTool.java | 7 +- .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 25 +++++- .../model/mcp/ReadOnlyMcpTool.java.bak | 89 +++++++++++++++++++ .../model/json/JsonNodeHelperTest.java | 89 +++++++++++++++++++ .../model/mcp/McpServerIntegrationTest.java | 24 +++++ 14 files changed, 306 insertions(+), 82 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/json/JsonNodeHelper.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak create mode 100644 src/test/java/com/daniel/jsoneditor/model/json/JsonNodeHelperTest.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 36044a37..2df115bc 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -25,13 +25,13 @@ import com.daniel.jsoneditor.model.diff.DiffEntry; import com.daniel.jsoneditor.model.diff.JsonDiffer; import com.daniel.jsoneditor.model.json.JsonNodeWithPath; +import com.daniel.jsoneditor.model.json.JsonNodeHelper; import com.daniel.jsoneditor.model.json.schema.SchemaHelper; import com.daniel.jsoneditor.model.json.schema.paths.PathHelper; import com.daniel.jsoneditor.model.observe.Observer; import com.daniel.jsoneditor.model.observe.Subject; import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.daniel.jsoneditor.model.settings.Settings; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import com.daniel.jsoneditor.model.statemachine.impl.Event; import com.daniel.jsoneditor.model.statemachine.impl.EventEnum; @@ -493,7 +493,7 @@ public void setValueAtPath(String path, Object value) } else { - candidateParent.set(propertyName, buildCandidateNode(value)); + candidateParent.set(propertyName, JsonNodeHelper.toJsonNode(value)); } final JsonSchema parentSchema = readableModel.getSubschemaForPath(parentPath); if (parentSchema != null && !SchemaHelper.validateJsonWithSchema(candidateParent, parentSchema).isEmpty()) @@ -504,35 +504,7 @@ public void setValueAtPath(String path, Object value) commandManager.executeCommand(commandFactory.setValueAtNodeCommand(parentPath, propertyName, value)); } - /** Wraps a raw Java value into a JsonNode for pre-write schema validation. */ - private JsonNode buildCandidateNode(Object value) - { - if (value == null) - { - return JsonNodeFactory.instance.nullNode(); - } - if (value instanceof Boolean) - { - return JsonNodeFactory.instance.booleanNode((Boolean) value); - } - if (value instanceof Integer) - { - return JsonNodeFactory.instance.numberNode((Integer) value); - } - if (value instanceof Long) - { - return JsonNodeFactory.instance.numberNode((Long) value); - } - if (value instanceof Double) - { - return JsonNodeFactory.instance.numberNode((Double) value); - } - if (value instanceof Number) - { - return JsonNodeFactory.instance.numberNode(((Number) value).doubleValue()); - } - return JsonNodeFactory.instance.textNode(value.toString()); - } + @Override public void overrideNodeAtPath(String path, JsonNode node) diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index 0721f8b1..f6a7b2c4 100644 --- a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java @@ -20,7 +20,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.daniel.jsoneditor.model.json.JsonNodeHelper; import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; @@ -221,7 +221,7 @@ public void setValueAtPath(String parentPath, String propertyName, Object value) } else { - candidateParent.set(propertyName, buildCandidateNode(value)); + candidateParent.set(propertyName, JsonNodeHelper.toJsonNode(value)); } final JsonSchema parentSchema = getSubschemaForPath(parentPath); if (parentSchema != null) @@ -236,30 +236,7 @@ public void setValueAtPath(String parentPath, String propertyName, Object value) parentNodeWithPath.setProperty(propertyName, value); } - private JsonNode buildCandidateNode(Object value) - { - if (value instanceof Boolean) - { - return JsonNodeFactory.instance.booleanNode((Boolean) value); - } - if (value instanceof Integer) - { - return JsonNodeFactory.instance.numberNode((Integer) value); - } - if (value instanceof Long) - { - return JsonNodeFactory.instance.numberNode((Long) value); - } - if (value instanceof Double) - { - return JsonNodeFactory.instance.numberNode((Double) value); - } - if (value instanceof Number) - { - return JsonNodeFactory.instance.numberNode(((Number) value).doubleValue()); - } - return JsonNodeFactory.instance.textNode(value.toString()); - } + @Override public JsonSchema getRootSchema() diff --git a/src/main/java/com/daniel/jsoneditor/model/json/JsonNodeHelper.java b/src/main/java/com/daniel/jsoneditor/model/json/JsonNodeHelper.java new file mode 100644 index 00000000..c0d080ad --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/json/JsonNodeHelper.java @@ -0,0 +1,49 @@ +package com.daniel.jsoneditor.model.json; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; + + +/** + * Utility class for converting Java values into Jackson {@link JsonNode} instances. + */ +public final class JsonNodeHelper +{ + private JsonNodeHelper() + { + // utility class + } + + /** + * Converts a Java value to a Jackson JsonNode. + * Handles null, Boolean, Integer, Long, Double, Number (fallback), and toString for everything else. + */ + public static JsonNode toJsonNode(final Object value) + { + if (value == null) + { + return JsonNodeFactory.instance.nullNode(); + } + if (value instanceof Boolean) + { + return JsonNodeFactory.instance.booleanNode((Boolean) value); + } + if (value instanceof Integer) + { + return JsonNodeFactory.instance.numberNode((Integer) value); + } + if (value instanceof Long) + { + return JsonNodeFactory.instance.numberNode((Long) value); + } + if (value instanceof Double) + { + return JsonNodeFactory.instance.numberNode((Double) value); + } + if (value instanceof Number) + { + return JsonNodeFactory.instance.numberNode(((Number) value).doubleValue()); + } + return JsonNodeFactory.instance.textNode(value.toString()); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index f3d70941..f6266ac6 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -57,11 +57,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java index c6069ff2..7b839062 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -56,11 +56,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java index 527045ed..95b7d0e3 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -52,11 +52,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final ObjectNode content = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index 8e17cda7..fff97f38 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -54,11 +54,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java index af82b5fb..1b99470c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -58,11 +58,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final String referencingKey = arguments.path("referencing_key").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java index c0fe617c..d257290f 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -55,11 +55,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final List objects = model.getReferenceableObjects(); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index ab45bc47..f912b64b 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -55,11 +55,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final ReadableModel model = resolveModel(arguments); - if (model == null) + final String error = validateFileId(arguments, id); + if (error != null) { - return unknownFileIdError(id); + return error; } + final ReadableModel model = resolveModel(arguments); final String path = arguments.path("path").asText(""); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index cfca29a1..e6ac668f 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -46,13 +46,30 @@ protected ReadableModel resolveModel(final JsonNode arguments) } /** - * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. - * All per-file tools should call this when {@link #resolveModel(JsonNode)} returns {@code null}. + * Validates that the {@code file_id} argument is present and refers to a known session. + * Returns a JSON-RPC error response string if validation fails, or {@code null} if valid. + * Tools should call this at the start of {@code execute()} and return early if non-null. */ - protected static String unknownFileIdError(final JsonNode id) + protected String validateFileId(final JsonNode arguments, final JsonNode id) { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id"); + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null || fileId.isEmpty()) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "file_id argument is required"); + } + if (sessionManager.getSession(fileId) == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id: " + fileId); + } + return null; } + + /** + * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. + * @deprecated Use {@link #validateFileId(JsonNode, JsonNode)} instead for distinct error messages. + */ + @Deprecated + protected static void addFileIdProperty(final ObjectNode properties) { diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak new file mode 100644 index 00000000..5808633d --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak @@ -0,0 +1,89 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.sessions.EditorSession; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + + +/** + * Base class for read-only MCP tools. Resolves the target model from a file_id argument. + */ +public abstract class ReadOnlyMcpTool extends McpTool +{ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** JSON-RPC error code for invalid parameters (unknown file_id). */ + protected static final int JSONRPC_INVALID_PARAMS = -32602; + + protected final FileSessionManager sessionManager; + + protected ReadOnlyMcpTool(final FileSessionManager sessionManager) + { + if (sessionManager == null) + { + throw new IllegalArgumentException("sessionManager cannot be null"); + } + this.sessionManager = sessionManager; + } + + /** + * Resolves the target model from the {@code file_id} argument. + * Returns {@code null} if the {@code file_id} is absent or unknown. + */ + protected ReadableModel resolveModel(final JsonNode arguments) + { + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null) + { + return null; + } + final EditorSession session = sessionManager.getSession(fileId); + return session != null ? session.model() : null; + } + + /** + * Validates that the {@code file_id} argument is present and refers to a known session. + * Returns a JSON-RPC error response string if validation fails, or {@code null} if valid. + * Tools should call this at the start of {@code execute()} and return early if non-null. + */ + protected String validateFileId(final JsonNode arguments, final JsonNode id) + { + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null || fileId.isEmpty()) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "file_id argument is required"); + } + if (sessionManager.getSession(fileId) == null) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id: " + fileId); + } + return null; + } + + /** + * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. + * @deprecated Use {@link #validateFileId(JsonNode, JsonNode)} instead for distinct error messages. + */ + @Deprecated + protected static String unknownFileIdError(final JsonNode id) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id"); + } + + protected static void addFileIdProperty(final ObjectNode properties) + { + final ObjectNode fileIdProp = OBJECT_MAPPER.createObjectNode(); + fileIdProp.put("type", "string"); + fileIdProp.put("description", "Session ID of the file to operate on (from list_files or open_file)"); + properties.set("file_id", fileIdProp); + } + + protected static void addFileIdRequired(final ArrayNode required) + { + required.add("file_id"); + } +} diff --git a/src/test/java/com/daniel/jsoneditor/model/json/JsonNodeHelperTest.java b/src/test/java/com/daniel/jsoneditor/model/json/JsonNodeHelperTest.java new file mode 100644 index 00000000..be763ef7 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/json/JsonNodeHelperTest.java @@ -0,0 +1,89 @@ +package com.daniel.jsoneditor.model.json; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.NumericNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + + +class JsonNodeHelperTest +{ + @Test + void testNullReturnsNullNode() + { + final JsonNode result = JsonNodeHelper.toJsonNode(null); + assertInstanceOf(NullNode.class, result, "Expected NullNode for null input"); + } + + @Test + void testBooleanReturnsBoolean() + { + final JsonNode trueResult = JsonNodeHelper.toJsonNode(true); + assertInstanceOf(BooleanNode.class, trueResult); + assertTrue(trueResult.booleanValue()); + + final JsonNode falseResult = JsonNodeHelper.toJsonNode(false); + assertInstanceOf(BooleanNode.class, falseResult); + assertFalse(falseResult.booleanValue()); + } + + @Test + void testIntegerReturnsNumber() + { + final JsonNode result = JsonNodeHelper.toJsonNode(42); + assertInstanceOf(NumericNode.class, result); + assertEquals(42, result.intValue()); + } + + @Test + void testLongReturnsNumber() + { + final JsonNode result = JsonNodeHelper.toJsonNode(123456789L); + assertInstanceOf(NumericNode.class, result); + assertEquals(123456789L, result.longValue()); + } + + @Test + void testDoubleReturnsNumber() + { + final JsonNode result = JsonNodeHelper.toJsonNode(3.14); + assertInstanceOf(NumericNode.class, result); + assertEquals(3.14, result.doubleValue(), 0.0001); + } + + @Test + void testFloatReturnsNumber() + { + final JsonNode result = JsonNodeHelper.toJsonNode(2.5f); + assertInstanceOf(NumericNode.class, result); + assertEquals(2.5, result.doubleValue(), 0.0001); + } + + @Test + void testStringReturnsText() + { + final JsonNode result = JsonNodeHelper.toJsonNode("hello"); + assertInstanceOf(TextNode.class, result); + assertEquals("hello", result.textValue()); + } + + @Test + void testUnknownObjectReturnsToString() + { + final Object obj = new Object() + { + @Override + public String toString() + { + return "custom-object"; + } + }; + final JsonNode result = JsonNodeHelper.toJsonNode(obj); + assertInstanceOf(TextNode.class, result); + assertEquals("custom-object", result.textValue()); + } +} diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java index 3e7ef360..b5ea2965 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java @@ -227,6 +227,30 @@ void testOpenInvalidFile() throws Exception assertNotNull(result.get("error"), "Expected error when opening non-existent files"); } + @Test + void testGetNodeWithEmptyFileIdArgument() throws Exception + { + final JsonNode result = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", "") + .put("path", "")); + assertNotNull(result.get("error"), "Expected error when file_id is empty string"); + final String message = result.path("error").path("message").asText(); + assertTrue(message.contains("file_id argument is required"), + "Expected 'file_id argument is required' for empty file_id, got: " + message); + } + + @Test + void testGetNodeWithNonExistentFileId() throws Exception + { + final JsonNode result = callTool("get_node", OBJECT_MAPPER.createObjectNode() + .put("file_id", "nonexistent123") + .put("path", "")); + assertNotNull(result.get("error"), "Expected error when file_id is unknown"); + final String message = result.path("error").path("message").asText(); + assertTrue(message.contains("nonexistent123"), + "Expected file_id to be included in error message, got: " + message); + } + // ── helpers ────────────────────────────────────────────────────────────── private JsonNode sendJsonRpc(final String method) throws Exception From 1692670f5b3dd05e500b14c45ff177709ebfd60d Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 13:11:18 +0200 Subject: [PATCH 16/24] chore: remove accidental .bak file --- .gitignore | 2 + .../model/mcp/ReadOnlyMcpTool.java.bak | 89 ------------------- 2 files changed, 2 insertions(+), 89 deletions(-) delete mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak diff --git a/.gitignore b/.gitignore index ef13446d..ea2e3885 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ out/ ### Mac OS ### .DS_Store .java-version + +*.bak diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak deleted file mode 100644 index 5808633d..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java.bak +++ /dev/null @@ -1,89 +0,0 @@ -package com.daniel.jsoneditor.model.mcp; - -import com.daniel.jsoneditor.model.ReadableModel; -import com.daniel.jsoneditor.model.sessions.EditorSession; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; - - -/** - * Base class for read-only MCP tools. Resolves the target model from a file_id argument. - */ -public abstract class ReadOnlyMcpTool extends McpTool -{ - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - /** JSON-RPC error code for invalid parameters (unknown file_id). */ - protected static final int JSONRPC_INVALID_PARAMS = -32602; - - protected final FileSessionManager sessionManager; - - protected ReadOnlyMcpTool(final FileSessionManager sessionManager) - { - if (sessionManager == null) - { - throw new IllegalArgumentException("sessionManager cannot be null"); - } - this.sessionManager = sessionManager; - } - - /** - * Resolves the target model from the {@code file_id} argument. - * Returns {@code null} if the {@code file_id} is absent or unknown. - */ - protected ReadableModel resolveModel(final JsonNode arguments) - { - final String fileId = arguments.path("file_id").asText(null); - if (fileId == null) - { - return null; - } - final EditorSession session = sessionManager.getSession(fileId); - return session != null ? session.model() : null; - } - - /** - * Validates that the {@code file_id} argument is present and refers to a known session. - * Returns a JSON-RPC error response string if validation fails, or {@code null} if valid. - * Tools should call this at the start of {@code execute()} and return early if non-null. - */ - protected String validateFileId(final JsonNode arguments, final JsonNode id) - { - final String fileId = arguments.path("file_id").asText(null); - if (fileId == null || fileId.isEmpty()) - { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "file_id argument is required"); - } - if (sessionManager.getSession(fileId) == null) - { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id: " + fileId); - } - return null; - } - - /** - * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. - * @deprecated Use {@link #validateFileId(JsonNode, JsonNode)} instead for distinct error messages. - */ - @Deprecated - protected static String unknownFileIdError(final JsonNode id) - { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id"); - } - - protected static void addFileIdProperty(final ObjectNode properties) - { - final ObjectNode fileIdProp = OBJECT_MAPPER.createObjectNode(); - fileIdProp.put("type", "string"); - fileIdProp.put("description", "Session ID of the file to operate on (from list_files or open_file)"); - properties.set("file_id", fileIdProp); - } - - protected static void addFileIdRequired(final ArrayNode required) - { - required.add("file_id"); - } -} From f38a4421d4a30efb2d51a758ee2ff1a97d71b992 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 13:40:47 +0200 Subject: [PATCH 17/24] fix(mcp): remove stray @Deprecated, move constant to base class --- .../java/com/daniel/jsoneditor/model/mcp/McpTool.java | 3 +++ .../daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java | 10 ---------- .../com/daniel/jsoneditor/model/mcp/ShowGuiTool.java | 1 - 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java index f9dd2033..0ae6120e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java @@ -12,6 +12,9 @@ */ public abstract class McpTool { + /** JSON-RPC error code for invalid method parameters. */ + protected static final int JSONRPC_INVALID_PARAMS = -32602; + /** * @return unique tool name (e.g., "get_node", "set_node") */ diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index e6ac668f..277e7993 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -16,9 +16,6 @@ public abstract class ReadOnlyMcpTool extends McpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - /** JSON-RPC error code for invalid parameters (unknown file_id). */ - protected static final int JSONRPC_INVALID_PARAMS = -32602; - protected final FileSessionManager sessionManager; protected ReadOnlyMcpTool(final FileSessionManager sessionManager) @@ -64,13 +61,6 @@ protected String validateFileId(final JsonNode arguments, final JsonNode id) return null; } - /** - * Returns a standard JSON-RPC error response for an unknown or missing {@code file_id}. - * @deprecated Use {@link #validateFileId(JsonNode, JsonNode)} instead for distinct error messages. - */ - @Deprecated - - protected static void addFileIdProperty(final ObjectNode properties) { final ObjectNode fileIdProp = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java index 06b3e30a..2e569086 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -18,7 +18,6 @@ class ShowGuiTool extends McpTool { private static final Logger logger = LoggerFactory.getLogger(ShowGuiTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final int JSONRPC_INVALID_PARAMS = -32602; private final AppService appService; From 7099a59a1a6ab1e891a3cf36fcdd3668960ee3b8 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 13:47:06 +0200 Subject: [PATCH 18/24] chore(mcp): replace hardcoded error codes with constant --- src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java | 2 +- .../jsoneditor/model/mcp/GetReferenceableInstancesTool.java | 2 +- .../com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index fff97f38..78a52f0d 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -66,7 +66,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final JsonNodeWithPath node = model.getNodeForPath(path); if (node == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "No node found at path: " + path); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No node found at path: " + path); } final ObjectNode result = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java index 1b99470c..8e78fb7e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -70,7 +70,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); if (refObject == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "No referenceable object found with key: " + referencingKey); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No referenceable object found with key: " + referencingKey); } final List instances = model.getReferenceableObjectInstances(refObject); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index f912b64b..8192c194 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -67,7 +67,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr final JsonSchema schema = model.getSubschemaForPath(path); if (schema == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "No schema found for path: " + path); + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No schema found for path: " + path); } final ObjectNode out = OBJECT_MAPPER.createObjectNode(); From 874bac49e9384671481234af8be4ffdad1bd2656 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 14:08:16 +0200 Subject: [PATCH 19/24] chore: remove double blank lines and trailing whitespace --- .../controller/impl/ControllerImpl.java | 148 +++++++++--------- .../jsoneditor/model/impl/ModelImpl.java | 147 +++++++++-------- 2 files changed, 143 insertions(+), 152 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 2df115bc..f23266a8 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -53,35 +53,34 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - public class ControllerImpl implements Controller, Observer { private static final Logger logger = LoggerFactory.getLogger(ControllerImpl.class); - + private final WritableModel model; - + private final ReadableModel readableModel; - + private final View view; - + private final List subjects; - + private final SettingsController settingsController; - + private final CommandManager commandManager; - + private final CommandFactory commandFactory; - + private final McpController mcpController; - + private final FileSessionManager fileSessionManager; - + private final AppService appService; - + private String guiSessionId; - + private boolean updateCheckDone; - + public ControllerImpl(final WritableModel model, final ReadableModel readableModel, final Stage stage, final AppService appService) { this.appService = appService; @@ -95,11 +94,11 @@ public ControllerImpl(final WritableModel model, final ReadableModel readableMod this.subjects = new ArrayList<>(); this.view = new ViewImpl(readableModel, this, stage); this.view.observe(this.readableModel.getForObservation()); - + // Set up callback for unsaved changes notifications from CommandManager this.commandManager.setUnsavedChangesCallback(this::updateWindowTitle); } - + /** * Updates the window title with given unsaved changes count. * This method is called by the CommandManager callback. @@ -108,45 +107,44 @@ private void updateWindowTitle(final int unsavedChangesCount) { view.updateWindowTitle(unsavedChangesCount); } - + @Override public SettingsController getSettingsController() { return settingsController; } - + @Override public McpController getMcpController() { return mcpController; } - + @Override public CommandManager getCommandManager() { return commandManager; } - - + @Override public void update() { } - + @Override public void observe(Subject subjectToObserve) { subjects.add(subjectToObserve); subjectToObserve.registerObserver(this); - + } - + @Override public void launchFinished() { model.sendEvent(new Event(EventEnum.READ_JSON_AND_SCHEMA)); } - + @Override public void checkForUpdate() { @@ -163,7 +161,7 @@ public void checkForUpdate() } })); } - + @Override public void checkForUpdateSilently() { @@ -180,14 +178,12 @@ public void checkForUpdateSilently() } }); } - + private static String buildUpdateAvailableMessage(final String latestVersion) { return "Update available: v" + latestVersion + " — visit GitHub to download"; } - - - + @Override public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsFile) { @@ -215,15 +211,15 @@ public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsF } guiSessionId = fileSessionManager.registerGuiSession(readableModel, jsonFile, schemaFile); }); - + } else { view.selectJsonAndSchema(); } - + } - + @Override public void moveItemToIndex(JsonNodeWithPath newParent, JsonNodeWithPath item, int index) { @@ -234,17 +230,17 @@ public void moveItemToIndex(JsonNodeWithPath newParent, JsonNodeWithPath item, i // cross-parent moves not implemented (only reorder inside same array) commandManager.executeCommand(commandFactory.moveItemCommand(item.getPath(), index)); } - + @Override public String resolveVariablesInJson(String text) { Set variables = VariableHelper.findVariables(text); - + if (!variables.isEmpty()) { VariableReplacementDialog dialog = new VariableReplacementDialog(variables); Map replacements = dialog.showAndWait().orElse(null); - + if (replacements != null) { return VariableHelper.replaceVariables(text, replacements); @@ -252,7 +248,7 @@ public String resolveVariablesInJson(String text) } return text; } - + @Override public void importAtNode(String path, String content) { @@ -263,7 +259,7 @@ public void importAtNode(String path, String content) final JsonNode contentNode = jsonReader.getNodeFromString(content); final JsonNode mergedNode = JsonNodeMerger.createMergedNode(readableModel, existingNodeAtPath, contentNode); final JsonSchema schemaAtPath = readableModel.getSubschemaForPath(path); - + if (mergedNode != null && SchemaHelper.validateJsonWithSchema(mergedNode, schemaAtPath).isEmpty()) { commandManager.executeCommand(commandFactory.setNodeCommand(path, mergedNode)); @@ -292,7 +288,7 @@ public void importAtNode(String path, String content) logger.error("Unexpected error during import at path {}: {}", path, e.getMessage(), e); } } - + @Override public void exportNode(String path) { @@ -307,7 +303,7 @@ public void exportNode(String path) exportJsonNode(filename, nodeWithPath.getNode()); } } - + @Override public void exportNodeWithDependencies(String path) { @@ -321,7 +317,7 @@ public void exportNodeWithDependencies(String path) { pathsToExport.add(selectedNode); } - + String fileWithEnding = readableModel.getCurrentJSONFile().getName(); int lastDotIndex = fileWithEnding.lastIndexOf("."); String fileWithoutEnding = (lastDotIndex != -1) ? fileWithEnding.substring(0, lastDotIndex) : fileWithEnding; @@ -329,7 +325,7 @@ public void exportNodeWithDependencies(String path) exportJsonNode(filename, readableModel.getExportStructureForNodes(pathsToExport)); } } - + private void exportJsonNode(String exportFilename, JsonNode node) { File directory = readableModel.getCurrentJSONFile().getParentFile(); @@ -337,9 +333,9 @@ private void exportJsonNode(String exportFilename, JsonNode node) JsonFileReaderAndWriter writer = new JsonFileReaderAndWriterImpl(); writer.writeJsonToFile(node, exportFile); model.sendEvent(new Event(EventEnum.EXPORT_SUCCESSFUL)); - + } - + @Override public void removeNodes(List paths) { @@ -349,13 +345,13 @@ public void removeNodes(List paths) } commandManager.executeCommand(commandFactory.removeNodesCommand(paths)); } - + @Override public void addNewNodeToArray(String path) { commandManager.executeCommand(commandFactory.addNodeToArrayCommand(path)); } - + @Override public void createNewReferenceableObjectNodeWithKey(String pathOfReferenceableObject, String key) { @@ -365,7 +361,7 @@ public void createNewReferenceableObjectNodeWithKey(String pathOfReferenceableOb } commandManager.executeCommand(commandFactory.createReferenceableObjectCommand(pathOfReferenceableObject, key)); } - + @Override public void sortArray(String path) { @@ -375,7 +371,7 @@ public void sortArray(String path) } commandManager.executeCommand(commandFactory.sortArrayCommand(path)); } - + @Override public void reorderArray(String path, List newIndices) { @@ -385,7 +381,7 @@ public void reorderArray(String path, List newIndices) } commandManager.executeCommand(commandFactory.reorderArrayCommand(path, newIndices)); } - + @Override public void duplicateArrayNode(String path) { @@ -395,7 +391,7 @@ public void duplicateArrayNode(String path) } commandManager.executeCommand(commandFactory.duplicateArrayItemCommand(path)); } - + @Override public void duplicateReferenceableObjectForLinking(String referencePath, String pathToDuplicate) { @@ -405,12 +401,12 @@ public void duplicateReferenceableObjectForLinking(String referencePath, String } commandManager.executeCommand(commandFactory.duplicateReferenceAndLinkCommand(referencePath, pathToDuplicate)); } - + @Override public void saveToFile() { final ValidationResult validationResult = ReferenceValidator.validateReferences(readableModel); - + if (!validationResult.isValid()) { for (ValidationError error : validationResult.getErrors()) @@ -419,13 +415,13 @@ public void saveToFile() } return; } - + final JsonFileReaderAndWriter jsonWriter = new JsonFileReaderAndWriterImpl(); jsonWriter.writeJsonToFile(readableModel.getRootJson(), readableModel.getCurrentJSONFile()); commandManager.markAsSaved(); // Mark current state as saved model.sendEvent(new Event(EventEnum.SAVING_SUCCESSFUL)); } - + @Override public void refreshFromDisk() { @@ -436,13 +432,13 @@ public void refreshFromDisk() model.resetRootNode(json); }); } - + @Override public String searchForNode(String path, String value) { return readableModel.searchForNode(path, value); } - + private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onSuccess) { if (SchemaHelper.validateJsonWithSchema(json, schema).isEmpty()) @@ -454,7 +450,7 @@ private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onS view.cantValidateJson(); } } - + @Override public void openNewJson() { @@ -464,19 +460,19 @@ public void openNewJson() logger.warn("Could not create new window — application is shutting down"); } } - + @Override public void generateJson() { - + } - + @Override public void setValueAtPath(String path, Object value) { final String parentPath = PathHelper.getParentPath(path); final String propertyName = PathHelper.getLastPathSegment(path); - + // Validate by building a candidate parent object with the change applied, then checking it against the parent schema. // This way we check for both correct format and correct structure (required properties etc) final JsonNodeWithPath parentNodeWithPath = readableModel.getNodeForPath(parentPath); @@ -504,8 +500,6 @@ public void setValueAtPath(String path, Object value) commandManager.executeCommand(commandFactory.setValueAtNodeCommand(parentPath, propertyName, value)); } - - @Override public void overrideNodeAtPath(String path, JsonNode node) { @@ -515,7 +509,7 @@ public void overrideNodeAtPath(String path, JsonNode node) } commandManager.executeCommand(commandFactory.setNodeCommand(path, node)); } - + @Override public void copyToClipboard(String path) { @@ -533,7 +527,7 @@ public void copyToClipboard(String path) logger.error("Failed to copy to clipboard, " + path + " is not a valid path"); } } - + @Override public void pasteFromClipboardReplacingChild(String pathToInsert) { @@ -554,7 +548,7 @@ public void pasteFromClipboardReplacingChild(String pathToInsert) { commandManager.executeCommand(commandFactory.setNodeCommand(pathToInsert, jsonNode)); view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); - + } else if (itemToInsertAt.isArray() && SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(pathToInsert + "/0")).isEmpty()) @@ -583,9 +577,9 @@ else if (itemToInsertAt.isArray() && SchemaHelper.validateJsonWithSchema(jsonNod logger.error("Unexpected error during paste at path {}: {}", pathToInsert, e.getMessage(), e); } } - + } - + @Override public void pasteFromClipboardIntoParent(String parentPath) { @@ -629,44 +623,44 @@ public void pasteFromClipboardIntoParent(String parentPath) logger.error("Unexpected error during paste into parent {}: {}", parentPath, e.getMessage(), e); } } - + } - + @Override public void undo() { commandManager.undo(); } - + @Override public void redo() { commandManager.redo(); } - + @Override public List calculateJsonDiff() { final JsonFileReaderAndWriter jsonReader = new JsonFileReaderAndWriterImpl(); final JsonNode savedJson = jsonReader.getJsonFromFile(readableModel.getCurrentJSONFile()); - + if (savedJson == null) { logger.error("Failed to read saved JSON from file: {}", readableModel.getCurrentJSONFile()); return new ArrayList<>(); } - + final JsonNode currentJson = readableModel.getRootJson(); - + if (currentJson == null) { logger.error("Current JSON in model is null"); return new ArrayList<>(); } - + return JsonDiffer.calculateDiff(savedJson, currentJson, readableModel); } - + @Override public void shutdown() { diff --git a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index f6a7b2c4..ebaf8199 100644 --- a/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java +++ b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java @@ -45,69 +45,68 @@ import java.util.stream.Collectors; import java.util.stream.StreamSupport; - public class ModelImpl implements ReadableModel, WritableModelInternal { private static final Logger logger = LoggerFactory.getLogger(ModelImpl.class); - + private static final String NUMBER_REGEX = "-?\\d+(\\.\\d+)?"; - + private final EventSender eventSender; - + private File jsonFile; - + private File schemaFile; - + private JsonNode rootJson; - + private JsonSchema rootSchema; - + private Settings settings; - + private final GitBlameIntegration gitBlameIntegration = new GitBlameIntegration(); - + public ModelImpl(EventSender eventSender) { this.eventSender = eventSender; this.settings = new Settings(null, null); } - + @Override public CommandFactory getCommandFactory() { return new CommandFactory(this); } - + @Override public File getCurrentJSONFile() { return jsonFile; } - + @Override public File getCurrentSchemaFile() { return schemaFile; } - + @Override public JsonNode getRootJson() { return rootJson; } - + @Override public Event getLatestEvent() { return eventSender.getState(); } - + @Override public Subject getForObservation() { return eventSender; } - + @Override public void jsonAndSchemaSuccessfullyValidated(File jsonFile, File schemaFile, JsonNode json, JsonSchema schema) { @@ -117,7 +116,7 @@ public void jsonAndSchemaSuccessfullyValidated(File jsonFile, File schemaFile, J setRootSchema(schema); sendEvent(new Event(EventEnum.MAIN_EDITOR)); } - + @Override public void resetRootNode(JsonNode jsonNode) { @@ -126,19 +125,19 @@ public void resetRootNode(JsonNode jsonNode) sendEvent(new Event(EventEnum.RELOADED_JSON_FROM_DISK, "")); sendEvent(new Event(EventEnum.RESET_SUCCESSFUL)); } - + @Override public String searchForNode(String path, String value) { return NodeSearcher.findPathWithValue(getRootJson(), path, value); } - + @Override public void setSettings(Settings settings) { this.settings = settings; } - + public void setCurrentJSONFile(File json) { this.jsonFile = json; @@ -158,22 +157,22 @@ public void setCurrentJSONFile(File json) }); } } - + private void setCurrentSchemaFile(File schema) { this.schemaFile = schema; } - + private void setRootJson(JsonNode rootJson) { this.rootJson = rootJson; } - + private void setRootSchema(JsonSchema rootSchema) { this.rootSchema = rootSchema; } - + /** * Sends a model state event to all registered observers. * @@ -187,14 +186,14 @@ public void sendEvent(Event state) { eventSender.sendEvent(state); } - + @Override public void moveItemToIndex(JsonNodeWithPath item, int index) { JsonNodeWithPath parent = getNodeForPath(PathHelper.getParentPath(item.getPath())); ArrayNode arrayNode = (ArrayNode) parent.getNode(); JsonNode itemNode = item.getNode(); - + for (int i = 0; i < arrayNode.size(); i++) { if (arrayNode.get(i).equals(itemNode)) @@ -205,7 +204,7 @@ public void moveItemToIndex(JsonNodeWithPath item, int index) } } } - + @Override public void setValueAtPath(String parentPath, String propertyName, Object value) { @@ -236,20 +235,18 @@ public void setValueAtPath(String parentPath, String propertyName, Object value) parentNodeWithPath.setProperty(propertyName, value); } - - @Override public JsonSchema getRootSchema() { return rootSchema; } - + @Override public Settings getSettings() { return settings; } - + @Override public JsonNodeWithPath getNodeForPath(String path) { @@ -260,13 +257,13 @@ public JsonNodeWithPath getNodeForPath(String path) } return new JsonNodeWithPath(getRootJson().at(path), path); } - + @Override public JsonNode getExportStructureForNodes(List paths) { return NodeStructureDelegate.getExportStructureForNodes(this, paths); } - + @Override public List getDependentPaths(JsonNodeWithPath node) { @@ -275,7 +272,7 @@ public List getDependentPaths(JsonNodeWithPath node) collectReferencesRecursively(node, referencedNodes); return referencedNodes; } - + @Override public List getReferenceableObjects() { @@ -294,7 +291,7 @@ public List getReferenceableObjects() } return referenceableObjects; } - + @Override public List getReferenceableObjectInstances() { @@ -305,13 +302,13 @@ public List getReferenceableObjectInstances() } return items; } - + @Override public List getReferenceableObjectInstances(ReferenceableObject object) { return ReferenceHelper.getReferenceableObjectInstances(this, object); } - + @Override public List getInstancesOfReferenceableObjectAtPath(String referenceableObjectPath) { @@ -324,7 +321,7 @@ public List getInstancesOfReferenceableObjectAtPath } return new ArrayList<>(); } - + private void collectReferencesRecursively(JsonNodeWithPath node, List referencedNodes) { String referencePath = ReferenceHelper.resolveReference(node, this); @@ -363,9 +360,9 @@ else if (node.isArray()) } } } - + } - + @Override public List getStringExamplesForPath(String path) { @@ -393,7 +390,7 @@ public List getStringExamplesForPath(String path) } return Collections.emptyList(); } - + @Override public void sortArray(String path) { @@ -465,7 +462,7 @@ public void sortArray(String path) arrayNode.removeAll(); arrayNode.addAll(items); } - + @Override public List getAllowedStringValuesForPath(String path) { @@ -493,7 +490,7 @@ public List getAllowedStringValuesForPath(String path) } return Collections.emptyList(); } - + public boolean canAddMoreItems(String path) { final JsonSchema jsonSchema = getSubschemaForPath(path); @@ -518,7 +515,7 @@ public boolean canAddMoreItems(String path) // either the node doesn't exist or the node is not an array return false; } - + @Override public ReferenceToObject getReferenceToObject(String path) { @@ -532,32 +529,32 @@ public ReferenceToObject getReferenceToObject(String path) } return null; } - + @Override public ReferenceableObject getReferenceableObject(String path) { return ReferenceHelper.getReferenceableObjectOfPath(this, path); } - + @Override public NodeGraph getJsonAsGraph(String path, Set allowedEdgeNames) { return NodeGraphCreator.createGraph(this, path, allowedEdgeNames); } - + @Override public List getReferencesToObjectForPath(String path) { return ReferenceHelper.getReferencesOfObject(this, path); } - + @Override public int addNodeToArray(String selectedPath) { JsonNode newItem = makeArrayNode(selectedPath); return addNodeToArray(selectedPath, newItem); } - + @Override public JsonNode makeArrayNode(String selectedPath) { @@ -565,7 +562,7 @@ public JsonNode makeArrayNode(String selectedPath) final JsonNode itemsSchema = jsonSchema != null ? jsonSchema.getSchemaNode() : null; return NodeGenerator.generateNodeFromSchema(itemsSchema); } - + @Override public int addNodeToArray(String arrayPath, JsonNode nodeToAdd) { @@ -576,17 +573,17 @@ public int addNodeToArray(String arrayPath, JsonNode nodeToAdd) int indexOfNewItem = parent.getNode().size(); ((ArrayNode) parent.getNode()).add(nodeToAdd); return indexOfNewItem; - + } return -1; } - + @Override public void duplicateArrayItem(String pathToItemToDuplicate) { duplicateItem(pathToItemToDuplicate); } - + @Override public void duplicateNodeAndLink(String referencePath, String pathToItemToDuplicate) { @@ -595,7 +592,7 @@ public void duplicateNodeAndLink(String referencePath, String pathToItemToDuplic { return; } - + // Retrieve the parent node of the reference JsonNodeWithPath referencingNode = getNodeForPath(referencePath); if (referencingNode == null || !referencingNode.isObject()) @@ -603,42 +600,42 @@ public void duplicateNodeAndLink(String referencePath, String pathToItemToDuplic return; } ReferenceToObject reference = getReferenceToObject(referencePath); - + String clonedPath = duplicateItem(pathToItemToDuplicate); if (clonedPath == null) { return; } - + JsonNodeWithPath clonedNode = getNodeForPath(clonedPath); - + String newKeyName = ReferenceHelper.getReferenceableObjectOfPath(this, clonedPath).getKeyOfInstance(clonedNode.getNode()); - + //set the objectKey of the reference to the key of the object ReferenceHelper.setObjectKeyOfInstance(this, reference, referencePath, newKeyName); } - + private String duplicateItem(String pathToItemToDuplicate) { JsonNodeWithPath itemToDuplicate = getNodeForPath(pathToItemToDuplicate); - + JsonNodeWithPath parentArray = getNodeForPath(PathHelper.getParentPath(pathToItemToDuplicate)); - + if (parentArray == null || !parentArray.getNode().isArray()) { return null; } ArrayNode arrayNode = (ArrayNode) parentArray.getNode(); - + // Get the index of the item to be cloned so that we can insert the next item at that index + 1 int indexToClone = Integer.parseInt(SchemaHelper.getLastPathSegment(pathToItemToDuplicate)); - + JsonNode clonedNode = itemToDuplicate.getNode().deepCopy(); // Insert the cloned item at indexToClone + 1 arrayNode.insert(indexToClone + 1, clonedNode); - + String clonedPath = PathHelper.getParentPath(itemToDuplicate.getPath()) + "/" + (indexToClone + 1); - + ReferenceableObject object = ReferenceHelper.getReferenceableObjectOfPath(this, clonedPath); if (object != null) { @@ -656,7 +653,7 @@ private String duplicateItem(String pathToItemToDuplicate) } return clonedPath; } - + @Override public void removeNodes(List paths) { @@ -664,7 +661,7 @@ public void removeNodes(List paths) { return; } - + // dirty hack: remove the last item first to avoid removing unintended items // this will break once we add sorting for (int i = paths.size() - 1; i >= 0; i--) @@ -672,7 +669,7 @@ public void removeNodes(List paths) removeOrSetNode(paths.get(i), null); } } - + /** * changes the JSON structure by setting the defined content at the defined path. If you want to notify the UI of these changes, set * notifyUI to true. @@ -723,7 +720,7 @@ else if (parentNode.isArray()) } } } - + @Override public ReferenceableObjectInstance getReferenceableObjectInstanceWithKey(ReferenceableObject object, String key) { @@ -740,13 +737,13 @@ public ReferenceableObjectInstance getReferenceableObjectInstanceWithKey(Referen } return null; } - + @Override public ReferenceableObject getReferenceableObjectByReferencingKey(String referencingKey) { return ReferenceHelper.getReferenceableObject(this, referencingKey); } - + @Override public void setNode(String path, JsonNode content) { @@ -903,7 +900,7 @@ private JsonNode resolveRef(JsonNode nodeWithRef) } return nodeWithRef; } - + @Override public String getIdentifier(String pathOfParentNode, JsonNode childNode) { @@ -917,13 +914,13 @@ public String getIdentifier(String pathOfParentNode, JsonNode childNode) } return null; } - + @Override public GitBlameInfo getBlameForPath(String jsonPath) { return gitBlameIntegration.getBlameForPath(jsonPath); } - + @Override public boolean isGitBlameAvailable() { From 939024bf06ca6166c460cadbaec470dfbf466447 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 14:53:56 +0200 Subject: [PATCH 20/24] fix(core): final QA pass - port arg, TOCTOU fix, test DRY, style --- .../jsoneditor/controller/AppService.java | 21 ++- .../impl/JsonFileReaderAndWriterImpl.java | 22 +-- .../controller/mcp/McpController.java | 26 ++-- .../settings/RecentFilesManager.java | 1 + .../jsoneditor/model/mcp/CloseFileTool.java | 8 +- .../model/mcp/FindReferencesToTool.java | 33 +++-- .../jsoneditor/model/mcp/GetExamplesTool.java | 31 ++--- .../jsoneditor/model/mcp/GetFileInfoTool.java | 31 ++--- .../jsoneditor/model/mcp/GetNodeTool.java | 27 ++-- .../mcp/GetReferenceableInstancesTool.java | 29 ++-- .../mcp/GetReferenceableObjectsTool.java | 27 ++-- .../model/mcp/GetSchemaForPathTool.java | 25 ++-- .../jsoneditor/model/mcp/ListFilesTool.java | 15 +- .../jsoneditor/model/mcp/McpToolRegistry.java | 35 +++-- .../jsoneditor/model/mcp/OpenFileTool.java | 25 ++-- .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 52 ++++--- .../jsoneditor/model/mcp/ShowGuiTool.java | 13 +- .../model/sessions/FileSessionManager.java | 28 ++-- .../daniel/jsoneditor/util/VersionUtil.java | 6 +- .../daniel/jsoneditor/view/JFXLauncher.java | 36 ++++- .../view/impl/jfx/dialogs/SettingsDialog.java | 2 +- .../mcp/McpMultiFileIntegrationTest.java | 111 +-------------- .../model/mcp/McpServerIntegrationTest.java | 112 ++------------- .../jsoneditor/model/mcp/McpTestBase.java | 131 ++++++++++++++++++ .../sessions/FileSessionManagerTest.java | 1 - 25 files changed, 408 insertions(+), 440 deletions(-) create mode 100644 src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index d47e7ad9..7b83b9d9 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -36,27 +36,40 @@ public class AppService private final List windows = new CopyOnWriteArrayList<>(); private final AtomicBoolean shuttingDown = new AtomicBoolean(false); + /** Creates the service using the port configured in settings. */ public AppService() + { + this(0); + } + + /** + * Creates the service, overriding the MCP server port when {@code portOverride > 0}. + * + * @param portOverride port to use for the MCP server, or {@code 0} to use the settings value + */ + public AppService(final int portOverride) { this.fileSessionManager = new FileSessionManager(); this.settingsController = new SettingsControllerImpl(); this.recentFilesManager = new RecentFilesManager(); this.mcpController = new McpController(fileSessionManager, settingsController, this); - startMcpServer(); + startMcpServer(portOverride); } /** * Starts the MCP server if enabled in settings. + * Uses {@code portOverride} when positive; otherwise falls back to the settings port. * Called automatically during construction so the server is available before any window opens. */ - private void startMcpServer() + private void startMcpServer(final int portOverride) { if (!settingsController.isMcpServerEnabled()) { logger.info("MCP server disabled in settings, skipping auto-start"); return; } - mcpController.startMcpServer(); + final int port = portOverride > 0 ? portOverride : settingsController.getMcpServerPort(); + mcpController.startMcpServer(port); if (mcpController.isMcpServerRunning()) { logger.info("MCP server started on port {}", mcpController.getMcpServerPort()); @@ -70,7 +83,7 @@ private void startMcpServer() /** * Creates a new editor window. * - * @return the new window + * @return the new {@link AppWindow}, or {@code null} if the application is shutting down */ public AppWindow createWindow() { diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java index 94d353f9..30cbc4d2 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java @@ -18,21 +18,21 @@ public class JsonFileReaderAndWriterImpl implements JsonFileReaderAndWriter { private static final Logger logger = LoggerFactory.getLogger(JsonFileReaderAndWriterImpl.class); - + private final ObjectMapper regularMapper; - + private final ObjectMapper mapperIgnoringUnknownProperties; - + public JsonFileReaderAndWriterImpl() { this.regularMapper = new ObjectMapper(); - + this.mapperIgnoringUnknownProperties = new ObjectMapper(); this.mapperIgnoringUnknownProperties.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } - - + + @Override public JsonNode getJsonFromFile(File file) { @@ -46,7 +46,7 @@ public JsonNode getJsonFromFile(File file) } return null; } - + @Override public JsonNode getNodeFromString(String content) throws JsonProcessingException { @@ -54,10 +54,10 @@ public JsonNode getNodeFromString(String content) throws JsonProcessingException { throw new IllegalArgumentException("Content cannot be null"); } - + return regularMapper.readTree(content); } - + @Override public T getJsonFromFile(File file, Class classOfObject, boolean ignoreUnknownProperties) { @@ -78,13 +78,13 @@ public T getJsonFromFile(File file, Class classOfObject, boolean ignoreUn } return null; } - + @Override public JsonSchema getSchemaFromFileResolvingRefs(File file) { return SchemaHelper.resolveJsonRefsInSchema(CustomSchemaFactory.makeCustomFactory().getSchema(getJsonFromFile(file))); } - + @Override public boolean writeJsonToFile(JsonNode json, File file) { diff --git a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java index 56484353..8e0537a8 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -9,15 +9,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - public class McpController { private static final Logger logger = LoggerFactory.getLogger(McpController.class); - + private final JsonEditorMcpServer mcpServer; - + private final SettingsController settingsController; - + /** * Creates the MCP controller. Pass appService for GUI integration (ShowGuiTool), * or null for standalone/headless mode. @@ -28,26 +27,27 @@ public McpController(final FileSessionManager sessionManager, final SettingsCont this.mcpServer = new JsonEditorMcpServer(sessionManager, appService); this.settingsController = settingsController; } - + /** - * Starts the MCP server. + * Starts the MCP server on the given port. + * + * @param port the port to listen on */ - public void startMcpServer() + public void startMcpServer(final int port) { if (!mcpServer.isRunning()) { try { - mcpServer.start(settingsController.getMcpServerPort()); + mcpServer.start(port); } catch (IOException e) { - logger.error("Failed to start MCP server on port {}: {}", - settingsController.getMcpServerPort(), e.getMessage()); + logger.error("Failed to start MCP server on port {}: {}", port, e.getMessage()); } } } - + /** * Stops the MCP server if it's running. */ @@ -55,7 +55,7 @@ public void stopMcpServer() { mcpServer.stop(); } - + /** * Checks if the MCP server is currently running. * @@ -65,7 +65,7 @@ public boolean isMcpServerRunning() { return mcpServer.isRunning(); } - + /** * Gets the current port the server is running on or configured to use. * diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java index 36e00ce7..1419cace 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -172,6 +172,7 @@ private void save() catch (IOException e) { logger.error("Could not save recent files to {}", RECENT_FILES_PATH, e); + tempFile.delete(); return; } try diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java index 4fb591b9..7b79e381 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -8,7 +8,6 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; - class CloseFileTool extends ReadOnlyMcpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -49,7 +48,12 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String fileId = arguments.path("file_id").asText(""); + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null || fileId.isEmpty()) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "file_id argument is required"); + } final CloseFileResult closeResult = sessionManager.closeFile(fileId); if (closeResult == CloseFileResult.NOT_FOUND) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index f6266ac6..dc07a20c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -13,29 +13,28 @@ import java.util.List; - class FindReferencesToTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(FindReferencesToTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public FindReferencesToTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "find_references_to"; } - + @Override public String getDescription() { return "Find all references pointing to a referenceable object instance at a given path"; } - + @Override public ObjectNode getInputSchema() { @@ -53,23 +52,23 @@ public ArrayNode getRequiredInputProperties() arr.add("path"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final String path = arguments.path("path").asText(""); - + final List references = model.getReferencesToObjectForPath(path); - + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + if (references != null) { for (final ReferenceToObjectInstance ref : references) @@ -79,7 +78,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr refNode.put("key", ref.getKey()); refNode.put("display_name", ref.getFancyName()); refNode.put("referencing_key", ref.getReference().getObjectReferencingKey()); - + final String remarks = ref.getRemarks(); if (remarks != null) { @@ -89,11 +88,11 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr { refNode.putNull("remarks"); } - + result.add(refNode); } } - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java index 7b839062..8b31994e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -12,29 +12,28 @@ import java.util.List; - class GetExamplesTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetExamplesTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetExamplesTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_examples"; } - + @Override public String getDescription() { return "Get example values for a JSON path"; } - + @Override public ObjectNode getInputSchema() { @@ -52,38 +51,38 @@ public ArrayNode getRequiredInputProperties() arr.add("path"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final String path = arguments.path("path").asText(""); - + final List examples = model.getStringExamplesForPath(path); final List allowedValues = model.getAllowedStringValuesForPath(path); - + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - + final ArrayNode examplesArray = OBJECT_MAPPER.createArrayNode(); if (examples != null) { examples.forEach(examplesArray::add); } result.set("examples", examplesArray); - + final ArrayNode allowedArray = OBJECT_MAPPER.createArrayNode(); if (allowedValues != null) { allowedValues.forEach(allowedArray::add); } result.set("allowed_values", allowedArray); - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java index 95b7d0e3..ac94dfca 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -10,29 +10,28 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - class GetFileInfoTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetFileInfoTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetFileInfoTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_file_info"; } - + @Override public String getDescription() { return "Get information about an open JSON file and schema"; } - + @Override public ObjectNode getInputSchema() { @@ -40,7 +39,7 @@ public ObjectNode getInputSchema() addFileIdProperty(props); return props; } - + @Override public ArrayNode getRequiredInputProperties() { @@ -48,19 +47,19 @@ public ArrayNode getRequiredInputProperties() addFileIdRequired(arr); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final ObjectNode content = OBJECT_MAPPER.createObjectNode(); - + if (model.getCurrentJSONFile() != null) { content.put("file_path", model.getCurrentJSONFile().getAbsolutePath()); @@ -71,7 +70,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr content.putNull("file_path"); content.putNull("file_name"); } - + if (model.getCurrentSchemaFile() != null) { content.put("schema_path", model.getCurrentSchemaFile().getAbsolutePath()); @@ -80,9 +79,9 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr { content.putNull("schema_path"); } - + content.put("has_content", model.getRootJson() != null); - + return McpToolRegistry.createToolResult(id, content); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index 78a52f0d..a3a78f1e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -11,29 +11,28 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - class GetNodeTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetNodeTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetNodeTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_node"; } - + @Override public String getDescription() { return "Get a JSON node at a specific path"; } - + @Override public ObjectNode getInputSchema() { @@ -50,32 +49,32 @@ public ArrayNode getRequiredInputProperties() arr.add("path"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final String path = arguments.path("path").asText(""); - + final JsonNodeWithPath node = model.getNodeForPath(path); if (node == null) { return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No node found at path: " + path); } - + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.put("path", node.getPath()); result.put("display_name", node.getDisplayName()); result.set("value", node.getNode()); result.put("is_array", node.isArray()); result.put("is_object", node.getNode().isObject()); - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java index 8e78fb7e..a46bafc1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -14,29 +14,28 @@ import java.util.List; - class GetReferenceableInstancesTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetReferenceableInstancesTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetReferenceableInstancesTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_referenceable_instances"; } - + @Override public String getDescription() { return "Get all instances of a referenceable object type"; } - + @Override public ObjectNode getInputSchema() { @@ -54,28 +53,28 @@ public ArrayNode getRequiredInputProperties() arr.add("referencing_key"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final String referencingKey = arguments.path("referencing_key").asText(""); - + final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); if (refObject == null) { return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No referenceable object found with key: " + referencingKey); } - + final List instances = model.getReferenceableObjectInstances(refObject); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + if (instances != null) { for (final ReferenceableObjectInstance instance : instances) @@ -87,7 +86,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr result.add(instNode); } } - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java index d257290f..3515e833 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -13,29 +13,28 @@ import java.util.List; - class GetReferenceableObjectsTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetReferenceableObjectsTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetReferenceableObjectsTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_referenceable_objects"; } - + @Override public String getDescription() { return "List all referenceable object types defined in the schema"; } - + @Override public ObjectNode getInputSchema() { @@ -43,7 +42,7 @@ public ObjectNode getInputSchema() addFileIdProperty(props); return props; } - + @Override public ArrayNode getRequiredInputProperties() { @@ -51,20 +50,20 @@ public ArrayNode getRequiredInputProperties() addFileIdRequired(arr); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final List objects = model.getReferenceableObjects(); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + if (objects != null) { for (final ReferenceableObject obj : objects) @@ -76,7 +75,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr result.add(objNode); } } - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java index 8192c194..27ef500d 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -11,29 +11,28 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - class GetSchemaForPathTool extends ReadOnlyMcpTool { private static final Logger logger = LoggerFactory.getLogger(GetSchemaForPathTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public GetSchemaForPathTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "get_schema_for_path"; } - + @Override public String getDescription() { return "Get the JSON schema definition for a specific path"; } - + @Override public ObjectNode getInputSchema() { @@ -51,25 +50,25 @@ public ArrayNode getRequiredInputProperties() arr.add("path"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String error = validateFileId(arguments, id); - if (error != null) + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) { - return error; + return resolved.error(); } - final ReadableModel model = resolveModel(arguments); - + final ReadableModel model = resolved.model(); + final String path = arguments.path("path").asText(""); - + final JsonSchema schema = model.getSubschemaForPath(path); if (schema == null) { return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "No schema found for path: " + path); } - + final ObjectNode out = OBJECT_MAPPER.createObjectNode(); out.set("schema", OBJECT_MAPPER.readTree(schema.getSchemaNode().toString())); return McpToolRegistry.createToolResult(id, out); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java index d860435f..016715b1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java @@ -8,39 +8,38 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; - class ListFilesTool extends ReadOnlyMcpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public ListFilesTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "list_files"; } - + @Override public String getDescription() { return "List all currently open file sessions with their IDs and paths"; } - + @Override public ObjectNode getInputSchema() { return OBJECT_MAPPER.createObjectNode(); } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + for (final EditorSession session : sessionManager.listSessions()) { final ObjectNode entry = OBJECT_MAPPER.createObjectNode(); @@ -50,7 +49,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr entry.put("gui_owned", session.guiOwned()); result.add(entry); } - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java index 59d4d8a8..6e1be953 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -1,7 +1,7 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.daniel.jsoneditor.controller.AppService; +import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -10,9 +10,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; import java.util.ArrayList; - +import java.util.List; /** * Registry of all available MCP tools for the JSON Editor. @@ -22,9 +21,9 @@ public class McpToolRegistry { private static final Logger logger = LoggerFactory.getLogger(McpToolRegistry.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + private final List tools; - + /** * Create registry with all available tools. Pass null for headless mode. When * appService is provided, the show_gui tool is registered for opening GUI @@ -52,7 +51,7 @@ public McpToolRegistry(final FileSessionManager sessionManager, final AppService } this.tools = List.copyOf(toolList); } - + /** * @return list of all registered tools */ @@ -60,7 +59,7 @@ public List getTools() { return tools; } - + /** * Find tool by name. * @@ -78,7 +77,7 @@ public McpTool getTool(final String name) } return null; } - + /** * Create JSON array of tool definitions for tools/list response. * @@ -87,21 +86,21 @@ public McpTool getTool(final String name) public ArrayNode getToolDefinitions() { final ArrayNode toolsArray = OBJECT_MAPPER.createArrayNode(); - + for (final McpTool tool : tools) { final ObjectNode toolDef = OBJECT_MAPPER.createObjectNode(); toolDef.put("name", tool.getName()); toolDef.put("description", tool.getDescription()); - + toolDef.set("inputSchema", buildInputSchema(tool)); - + toolsArray.add(toolDef); } - + return toolsArray; } - + /** * Build complete input schema for a tool including type, properties, required, and additionalProperties. * This is the canonical schema used both for tools/list and for validation. @@ -112,16 +111,16 @@ public static ObjectNode buildInputSchema(final McpTool tool) schema.put("type", "object"); schema.set("properties", tool.getInputSchema()); schema.put("additionalProperties", false); - + final ArrayNode required = tool.getRequiredInputProperties(); if (required != null && !required.isEmpty()) { schema.set("required", required); } - + return schema; } - + /** * Create a tool result where content contains a single text element with JSON payload. * The payload is serialized as a JSON string in the "text" field per MCP specification. @@ -136,14 +135,14 @@ protected static String createToolResult(final JsonNode id, final JsonNode paylo textContent.put("text", jsonText); contentArray.add(textContent); result.set("content", contentArray); - + final ObjectNode response = OBJECT_MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.set("id", id); response.set("result", result); return OBJECT_MAPPER.writeValueAsString(response); } - + protected static ObjectNode createSchemaWithProperty(final String propName, final String propType, final String description) { final ObjectNode props = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java index 9c2d739a..83bb2516 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -8,46 +8,45 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; - class OpenFileTool extends ReadOnlyMcpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + public OpenFileTool(final FileSessionManager sessionManager) { super(sessionManager); } - + @Override public String getName() { return "open_file"; } - + @Override public String getDescription() { return "Open a JSON file with its schema for reading. Returns a file_id to use with other tools."; } - + @Override public ObjectNode getInputSchema() { final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - + final ObjectNode jsonPathProp = OBJECT_MAPPER.createObjectNode(); jsonPathProp.put("type", "string"); jsonPathProp.put("description", "Absolute path to the JSON file"); props.set("json_path", jsonPathProp); - + final ObjectNode schemaPathProp = OBJECT_MAPPER.createObjectNode(); schemaPathProp.put("type", "string"); schemaPathProp.put("description", "Absolute path to the JSON schema file"); props.set("schema_path", schemaPathProp); - + return props; } - + @Override public ArrayNode getRequiredInputProperties() { @@ -56,24 +55,24 @@ public ArrayNode getRequiredInputProperties() arr.add("schema_path"); return arr; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String jsonPath = arguments.path("json_path").asText(""); final String schemaPath = arguments.path("schema_path").asText(""); - + final OpenFileResult openResult = sessionManager.openFile(jsonPath, schemaPath); if (!openResult.success()) { return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, openResult.error()); } - + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.put("file_id", openResult.sessionId()); result.put("json_path", jsonPath); result.put("schema_path", schemaPath); - + return McpToolRegistry.createToolResult(id, result); } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index 277e7993..2f060306 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -8,16 +8,21 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; - /** * Base class for read-only MCP tools. Resolves the target model from a file_id argument. */ public abstract class ReadOnlyMcpTool extends McpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + protected final FileSessionManager sessionManager; - + + /** + * Holds either a resolved {@link ReadableModel} on success, or a pre-built JSON-RPC + * error response string on failure. Exactly one field is non-null. + */ + record ResolveResult(ReadableModel model, String error) {} + protected ReadOnlyMcpTool(final FileSessionManager sessionManager) { if (sessionManager == null) @@ -26,39 +31,32 @@ protected ReadOnlyMcpTool(final FileSessionManager sessionManager) } this.sessionManager = sessionManager; } - - /** - * Resolves the target model from the {@code file_id} argument. - * Returns {@code null} if the {@code file_id} is absent or unknown. - */ - protected ReadableModel resolveModel(final JsonNode arguments) - { - final String fileId = arguments.path("file_id").asText(null); - if (fileId == null) - { - return null; - } - final EditorSession session = sessionManager.getSession(fileId); - return session != null ? session.model() : null; - } /** - * Validates that the {@code file_id} argument is present and refers to a known session. - * Returns a JSON-RPC error response string if validation fails, or {@code null} if valid. - * Tools should call this at the start of {@code execute()} and return early if non-null. + * Atomically resolves the {@code file_id} argument to a {@link ReadableModel}. + *

+ * Returns a {@link ResolveResult} where either {@link ResolveResult#model()} is non-null + * (success) or {@link ResolveResult#error()} is non-null (failure). Tools should call + * this at the start of {@code execute()} and return {@link ResolveResult#error()} + * immediately when non-null, eliminating the TOCTOU window between validation and lookup. */ - protected String validateFileId(final JsonNode arguments, final JsonNode id) + protected ResolveResult resolveFileSession(final JsonNode arguments, final JsonNode id) { final String fileId = arguments.path("file_id").asText(null); if (fileId == null || fileId.isEmpty()) { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "file_id argument is required"); + return new ResolveResult(null, + JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "file_id argument is required")); } - if (sessionManager.getSession(fileId) == null) + final EditorSession session = sessionManager.getSession(fileId); + if (session == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, "Unknown file_id: " + fileId); + return new ResolveResult(null, + JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Unknown file_id: " + fileId)); } - return null; + return new ResolveResult(session.model(), null); } protected static void addFileIdProperty(final ObjectNode properties) @@ -68,7 +66,7 @@ protected static void addFileIdProperty(final ObjectNode properties) fileIdProp.put("description", "Session ID of the file to operate on (from list_files or open_file)"); properties.set("file_id", fileIdProp); } - + protected static void addFileIdRequired(final ArrayNode required) { required.add("file_id"); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java index 2e569086..6281540b 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; import com.daniel.jsoneditor.controller.AppService; +import com.daniel.jsoneditor.controller.AppWindow; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -9,7 +10,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - /** * MCP tool that opens a new GUI window on demand. * Useful when the app runs in headless mode and an AI agent or user wants to see the editor. @@ -55,8 +55,15 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr Platform.runLater(() -> { - appService.createWindow(); - logger.info("GUI window opened via MCP tool"); + final AppWindow window = appService.createWindow(); + if (window != null) + { + logger.info("GUI window opened via MCP tool"); + } + else + { + logger.warn("GUI window creation failed — application may be shutting down"); + } }); final ObjectNode result = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java index 43a106ea..263c1886 100644 --- a/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -16,8 +16,6 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - /** * Manages multiple open file sessions. Used by both GUI and MCP server. @@ -26,9 +24,9 @@ public class FileSessionManager { private static final Logger logger = LoggerFactory.getLogger(FileSessionManager.class); - + private final Map sessions = new ConcurrentHashMap<>(); - + /** * Opens a JSON file with its schema and creates a new headless session. * @@ -40,7 +38,7 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) { final File jsonFile = new File(jsonPath); final File schemaFile = new File(schemaPath); - + if (!jsonFile.exists()) { logger.error("JSON file does not exist: {}", jsonPath); @@ -51,7 +49,7 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) logger.error("Schema file does not exist: {}", schemaPath); return new OpenFileResult(null, "Schema file does not exist: " + schemaPath); } - + final JsonFileReaderAndWriterImpl reader = new JsonFileReaderAndWriterImpl(); final JsonNode json; final JsonSchema schema; @@ -65,7 +63,7 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) logger.error("Failed to parse JSON or schema files: {} / {}", jsonPath, schemaPath, e); return new OpenFileResult(null, "Failed to parse files: " + e.getMessage()); } - + if (json == null || schema == null) { logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); @@ -79,10 +77,10 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) logger.error("JSON does not validate against schema: {} / {}", jsonPath, schemaPath); return new OpenFileResult(null, "JSON does not validate against schema: " + errorDetails); } - + final ModelImpl model = new ModelImpl(new EventSenderImpl()); model.jsonAndSchemaSuccessfullyValidated(jsonFile, schemaFile, json, schema); - + EditorSession session; String sessionId; do @@ -91,11 +89,11 @@ public OpenFileResult openFile(final String jsonPath, final String schemaPath) session = new EditorSession(sessionId, model, jsonFile, schemaFile, false); } while (sessions.putIfAbsent(sessionId, session) != null); - + logger.info("Opened file session {} for {}", sessionId, jsonPath); return new OpenFileResult(sessionId, null); } - + /** * Registers an existing GUI model as a session. Protected from MCP close. * @@ -117,7 +115,7 @@ public String registerGuiSession(final ReadableModel model, final File jsonFile, logger.info("Registered GUI session {} for {}", sessionId, jsonFile != null ? jsonFile.getAbsolutePath() : "null"); return sessionId; } - + /** * Unregisters a GUI session (called when GUI closes a file). * @@ -135,7 +133,7 @@ public void unregisterGuiSession(final String sessionId) return session; }); } - + /** * Closes a headless session. Refuses to close GUI-owned sessions. * @@ -160,7 +158,7 @@ public CloseFileResult closeFile(final String sessionId) }); return result[0]; } - + /** * Returns the session for the given ID, or {@code null} if not found. * @@ -171,7 +169,7 @@ public EditorSession getSession(final String sessionId) { return sessions.get(sessionId); } - + /** * @return list of all active sessions */ diff --git a/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java b/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java index 46e70da1..1a71df7f 100644 --- a/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java +++ b/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java @@ -14,7 +14,7 @@ public final class VersionUtil { private static final Logger logger = LoggerFactory.getLogger(VersionUtil.class); private static final String VERSION; - + static { String loadedVersion = "unknown"; @@ -37,12 +37,12 @@ public final class VersionUtil } VERSION = loadedVersion; } - + private VersionUtil() { throw new UnsupportedOperationException("Utility class"); } - + /** * @return the application version (e.g., "0.16.1") */ diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index 81dae329..ed0884dd 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -40,6 +40,33 @@ public static void launchJFXApplication(final String[] args) launch(args); } + /** + * Parses {@code --port N} from the launch arguments. + * Returns the port number if valid, or {@code 0} if not specified (use settings default). + */ + private int parsePortOverride(final List args) + { + final int portIdx = args.indexOf("--port"); + if (portIdx >= 0 && portIdx + 1 < args.size()) + { + try + { + final int port = Integer.parseInt(args.get(portIdx + 1)); + if (port > 0 && port <= 65535) + { + return port; + } + logger.warn("Invalid --port value '{}': must be 1-65535, using settings default", + args.get(portIdx + 1)); + } + catch (final NumberFormatException e) + { + logger.warn("Invalid --port argument '{}', using settings default", args.get(portIdx + 1)); + } + } + return 0; + } + @Override public void start(final Stage stage) { @@ -47,12 +74,13 @@ public void start(final Stage stage) Platform.setImplicitExit(false); stage.close(); - // Core service starts MCP server immediately - appService = new AppService(); - - // Parse launch arguments + // Parse launch arguments before creating AppService so port override can be applied final List args = getParameters().getRaw(); final boolean headless = args.contains("--headless"); + final int portOverride = parsePortOverride(args); + + // Core service starts MCP server immediately + appService = new AppService(portOverride); if (headless) { diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/SettingsDialog.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/SettingsDialog.java index c378c9f0..9865a9ad 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/SettingsDialog.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/SettingsDialog.java @@ -239,7 +239,7 @@ private void toggleMcpServer() else { settingsController.setMcpServerPort(tmpMcpServerPort); - mcpController.startMcpServer(); + mcpController.startMcpServer(tmpMcpServerPort); } updateMcpStatusLabel(); updateMcpToggleButton(); diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java index 9fc8c482..261ffd82 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java @@ -1,32 +1,16 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; import java.util.HashSet; import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; -public class McpMultiFileIntegrationTest +public class McpMultiFileIntegrationTest extends McpTestBase { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final String JSON_COMPANY = "{\"company\":\"Acme\",\"employees\":[{\"name\":\"Alice\",\"role\":\"dev\"}," + "{\"name\":\"Bob\",\"role\":\"qa\"}],\"config\":{\"version\":2,\"darkMode\":true}}"; @@ -61,38 +45,6 @@ public class McpMultiFileIntegrationTest + "\"count\":{\"type\":\"integer\"}" + "}}"; - private final AtomicInteger requestIdCounter = new AtomicInteger(1); - - private JsonEditorMcpServer server; - private HttpClient httpClient; - private String baseUrl; - - @BeforeEach - void setUp() throws Exception - { - final int port; - try (final ServerSocket socket = new ServerSocket(0)) - { - port = socket.getLocalPort(); - } - final FileSessionManager sessionManager = new FileSessionManager(); - server = new JsonEditorMcpServer(sessionManager, null); - server.start(port); - httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build(); - baseUrl = "http://127.0.0.1:" + port; - } - - @AfterEach - void tearDown() - { - if (server != null) - { - server.stop(); - } - } - @Test void testMultipleFilesOpenSimultaneously() throws Exception { @@ -258,9 +210,9 @@ void testToolCallWithInvalidFileId() throws Exception @Test void testOpenFileValidationRejection() throws Exception { - final Path jsonFile = createTempFile("mcp-invalid-", ".json", + final java.nio.file.Path jsonFile = createTempFile("mcp-invalid-", ".json", "{\"active\":\"not-a-boolean\",\"count\":\"not-a-number\"}"); - final Path schemaFile = createTempFile("mcp-invalid-schema-", ".json", SCHEMA_FLAGS); + final java.nio.file.Path schemaFile = createTempFile("mcp-invalid-schema-", ".json", SCHEMA_FLAGS); final JsonNode result = callTool("open_file", OBJECT_MAPPER.createObjectNode() .put("json_path", jsonFile.toString()) @@ -291,61 +243,4 @@ void testCloseWhileReading() throws Exception assertNotNull(getNodeResult.get("error"), "Expected JSON-RPC error when accessing a closed session"); } - - // ── helpers ─────────────────────────────────────────────────────────────── - - private String openFile(final String jsonContent, final String schemaContent) throws Exception - { - final Path jsonFile = createTempFile("mcp-multi-", ".json", jsonContent); - final Path schemaFile = createTempFile("mcp-multi-schema-", ".json", schemaContent); - final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() - .put("json_path", jsonFile.toString()) - .put("schema_path", schemaFile.toString())); - assertNull(openResult.get("error"), "Expected no error from open_file"); - final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); - assertFalse(fileId.isEmpty(), "Expected non-empty file_id"); - return fileId; - } - - private JsonNode callTool(final String toolName, final ObjectNode arguments) throws Exception - { - final ObjectNode params = OBJECT_MAPPER.createObjectNode(); - params.put("name", toolName); - params.set("arguments", arguments); - - final ObjectNode request = OBJECT_MAPPER.createObjectNode(); - request.put("jsonrpc", "2.0"); - request.put("id", requestIdCounter.getAndIncrement()); - request.put("method", "tools/call"); - request.set("params", params); - return sendRequest(request); - } - - private JsonNode sendRequest(final ObjectNode requestNode) throws Exception - { - final String body = OBJECT_MAPPER.writeValueAsString(requestNode); - final HttpResponse response = httpClient.send( - HttpRequest.newBuilder() - .uri(URI.create(baseUrl + "/")) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .header("Content-Type", "application/json") - .timeout(Duration.ofSeconds(10)) - .build(), - HttpResponse.BodyHandlers.ofString()); - return OBJECT_MAPPER.readTree(response.body()); - } - - private JsonNode parseToolResultPayload(final JsonNode rpcResponse) throws Exception - { - final String text = rpcResponse.path("result").path("content").get(0).path("text").asText(); - return OBJECT_MAPPER.readTree(text); - } - - private Path createTempFile(final String prefix, final String suffix, final String content) throws Exception - { - final Path tempFile = Files.createTempFile(prefix, suffix); - Files.writeString(tempFile, content); - tempFile.toFile().deleteOnExit(); - return tempFile; - } } diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java index b5ea2965..02700482 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java @@ -1,74 +1,27 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.sessions.FileSessionManager; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.net.ServerSocket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; -public class McpServerIntegrationTest +public class McpServerIntegrationTest extends McpTestBase { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private final AtomicInteger requestIdCounter = new AtomicInteger(1); - - private JsonEditorMcpServer server; - private HttpClient httpClient; - private String baseUrl; - - @BeforeEach - void setUp() throws Exception - { - final int port; - try (final ServerSocket socket = new ServerSocket(0)) - { - port = socket.getLocalPort(); - } - final FileSessionManager sessionManager = new FileSessionManager(); - server = new JsonEditorMcpServer(sessionManager, null); - server.start(port); - httpClient = HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(5)) - .build(); - baseUrl = "http://127.0.0.1:" + port; - } - - @AfterEach - void tearDown() - { - if (server != null) - { - server.stop(); - } - } - @Test void testHealthEndpoint() throws Exception { - final HttpResponse response = httpClient.send( - HttpRequest.newBuilder() - .uri(URI.create(baseUrl + "/health")) + final java.net.http.HttpResponse response = httpClient.send( + java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(baseUrl + "/health")) .GET() - .timeout(Duration.ofSeconds(5)) + .timeout(java.time.Duration.ofSeconds(5)) .build(), - HttpResponse.BodyHandlers.ofString()); + java.net.http.HttpResponse.BodyHandlers.ofString()); assertEquals(200, response.statusCode()); final JsonNode body = OBJECT_MAPPER.readTree(response.body()); @@ -253,60 +206,13 @@ void testGetNodeWithNonExistentFileId() throws Exception // ── helpers ────────────────────────────────────────────────────────────── - private JsonNode sendJsonRpc(final String method) throws Exception - { - final ObjectNode request = OBJECT_MAPPER.createObjectNode(); - request.put("jsonrpc", "2.0"); - request.put("id", requestIdCounter.getAndIncrement()); - request.put("method", method); - return sendRequest(request); - } - - private JsonNode callTool(final String toolName, final ObjectNode arguments) throws Exception - { - final ObjectNode params = OBJECT_MAPPER.createObjectNode(); - params.put("name", toolName); - params.set("arguments", arguments); - - final ObjectNode request = OBJECT_MAPPER.createObjectNode(); - request.put("jsonrpc", "2.0"); - request.put("id", requestIdCounter.getAndIncrement()); - request.put("method", "tools/call"); - request.set("params", params); - return sendRequest(request); - } - - private JsonNode sendRequest(final ObjectNode requestNode) throws Exception - { - final String body = OBJECT_MAPPER.writeValueAsString(requestNode); - final HttpResponse response = httpClient.send( - HttpRequest.newBuilder() - .uri(URI.create(baseUrl + "/")) - .POST(HttpRequest.BodyPublishers.ofString(body)) - .header("Content-Type", "application/json") - .timeout(Duration.ofSeconds(10)) - .build(), - HttpResponse.BodyHandlers.ofString()); - return OBJECT_MAPPER.readTree(response.body()); - } - - private JsonNode parseToolResultPayload(final JsonNode rpcResponse) throws Exception - { - final String text = rpcResponse.path("result").path("content").get(0).path("text").asText(); - return OBJECT_MAPPER.readTree(text); - } - private Path createTempJsonFile() throws Exception { - final Path tempFile = Files.createTempFile("mcp-test-", ".json"); - Files.writeString(tempFile, "{\"name\":\"test\",\"value\":42}"); - tempFile.toFile().deleteOnExit(); - return tempFile; + return createTempFile("mcp-test-", ".json", "{\"name\":\"test\",\"value\":42}"); } private Path createTempSchemaFile() throws Exception { - final Path tempFile = Files.createTempFile("mcp-schema-", ".json"); final String schema = "{" + "\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + "\"type\":\"object\"," @@ -315,8 +221,6 @@ private Path createTempSchemaFile() throws Exception + "\"value\":{\"type\":\"number\"}" + "}" + "}"; - Files.writeString(tempFile, schema); - tempFile.toFile().deleteOnExit(); - return tempFile; + return createTempFile("mcp-schema-", ".json", schema); } } diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java new file mode 100644 index 00000000..d425e526 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java @@ -0,0 +1,131 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.sessions.FileSessionManager; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import java.net.ServerSocket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + + +/** + * Base class for MCP server integration tests. + * Starts a fresh server on a random port before each test and stops it after. + */ +abstract class McpTestBase +{ + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + protected final AtomicInteger requestIdCounter = new AtomicInteger(1); + + protected JsonEditorMcpServer server; + protected HttpClient httpClient; + protected String baseUrl; + + @BeforeEach + void setUp() throws Exception + { + final int port; + try (final ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + final FileSessionManager sessionManager = new FileSessionManager(); + server = new JsonEditorMcpServer(sessionManager, null); + server.start(port); + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + baseUrl = "http://127.0.0.1:" + port; + } + + @AfterEach + void tearDown() + { + if (server != null) + { + server.stop(); + } + } + + protected JsonNode sendJsonRpc(final String method) throws Exception + { + final ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", requestIdCounter.getAndIncrement()); + request.put("method", method); + return sendRequest(request); + } + + protected JsonNode callTool(final String toolName, final ObjectNode arguments) throws Exception + { + final ObjectNode params = OBJECT_MAPPER.createObjectNode(); + params.put("name", toolName); + params.set("arguments", arguments); + + final ObjectNode request = OBJECT_MAPPER.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", requestIdCounter.getAndIncrement()); + request.put("method", "tools/call"); + request.set("params", params); + return sendRequest(request); + } + + protected JsonNode sendRequest(final ObjectNode requestNode) throws Exception + { + final String body = OBJECT_MAPPER.writeValueAsString(requestNode); + final HttpResponse response = httpClient.send( + HttpRequest.newBuilder() + .uri(URI.create(baseUrl + "/")) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(10)) + .build(), + HttpResponse.BodyHandlers.ofString()); + return OBJECT_MAPPER.readTree(response.body()); + } + + protected JsonNode parseToolResultPayload(final JsonNode rpcResponse) throws Exception + { + final String text = rpcResponse.path("result").path("content").get(0).path("text").asText(); + return OBJECT_MAPPER.readTree(text); + } + + protected Path createTempFile(final String prefix, final String suffix, final String content) throws Exception + { + final Path tempFile = Files.createTempFile(prefix, suffix); + Files.writeString(tempFile, content); + tempFile.toFile().deleteOnExit(); + return tempFile; + } + + /** + * Opens a file via MCP open_file tool and returns the assigned file_id. + * Asserts that no error occurs and that the returned file_id is non-empty. + */ + protected String openFile(final String jsonContent, final String schemaContent) throws Exception + { + final Path jsonFile = createTempFile("mcp-test-", ".json", jsonContent); + final Path schemaFile = createTempFile("mcp-schema-", ".json", schemaContent); + final JsonNode openResult = callTool("open_file", OBJECT_MAPPER.createObjectNode() + .put("json_path", jsonFile.toString()) + .put("schema_path", schemaFile.toString())); + assertNull(openResult.get("error"), "Expected no error from open_file"); + final String fileId = parseToolResultPayload(openResult).path("file_id").asText(); + assertFalse(fileId.isEmpty(), "Expected non-empty file_id"); + return fileId; + } + +} diff --git a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java index 421fc415..60a081cc 100644 --- a/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -15,7 +15,6 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; -import com.daniel.jsoneditor.model.sessions.CloseFileResult; public class FileSessionManagerTest From 34bc9550a9d4362df411e503784b58b3c54948ee Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 15:10:10 +0200 Subject: [PATCH 21/24] chore: cosmetic fixes - stale docs, test stability, DRY --- AGENTS.md | 2 +- .../impl/JsonFileReaderAndWriterImpl.java | 2 - .../jsoneditor/model/mcp/CloseFileTool.java | 2 +- .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 3 +- .../jsoneditor/model/mcp/McpTestBase.java | 43 +++++++++++++++---- 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0c704f35..4379848b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,7 +104,7 @@ Headless: JFXLauncher --headless → FileSessionManager → JsonEditorMcpServe Tools are registered in `McpToolRegistry` (`model/mcp/`). All per-file tools require a `file_id` argument. Base classes: -- `ReadOnlyMcpTool` – holds `FileSessionManager`, provides `resolveModel(arguments)` helper +- `ReadOnlyMcpTool` – holds `FileSessionManager`, provides `resolveFileSession(arguments, id)` helper - `WriteMcpTool` – extends `ReadOnlyMcpTool` (currently no write tools registered) Session management tools (extend `ReadOnlyMcpTool`, no `file_id` needed): diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java index 30cbc4d2..aa464025 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/json/impl/JsonFileReaderAndWriterImpl.java @@ -31,8 +31,6 @@ public JsonFileReaderAndWriterImpl() this.mapperIgnoringUnknownProperties.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } - - @Override public JsonNode getJsonFromFile(File file) { diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java index 7b79e381..339b22bb 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -52,7 +52,7 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr if (fileId == null || fileId.isEmpty()) { return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, - "file_id argument is required"); + FILE_ID_REQUIRED_MESSAGE); } final CloseFileResult closeResult = sessionManager.closeFile(fileId); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index 2f060306..f751d760 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -14,6 +14,7 @@ public abstract class ReadOnlyMcpTool extends McpTool { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + protected static final String FILE_ID_REQUIRED_MESSAGE = "file_id argument is required"; protected final FileSessionManager sessionManager; @@ -47,7 +48,7 @@ protected ResolveResult resolveFileSession(final JsonNode arguments, final JsonN { return new ResolveResult(null, JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, - "file_id argument is required")); + FILE_ID_REQUIRED_MESSAGE)); } final EditorSession session = sessionManager.getSession(fileId); if (session == null) diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java index d425e526..2cac4ba5 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java @@ -15,6 +15,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -30,6 +32,8 @@ abstract class McpTestBase protected final AtomicInteger requestIdCounter = new AtomicInteger(1); + private final List tempFiles = new ArrayList<>(); + protected JsonEditorMcpServer server; protected HttpClient httpClient; protected String baseUrl; @@ -37,14 +41,28 @@ abstract class McpTestBase @BeforeEach void setUp() throws Exception { - final int port; - try (final ServerSocket socket = new ServerSocket(0)) - { - port = socket.getLocalPort(); - } + int port = 0; final FileSessionManager sessionManager = new FileSessionManager(); server = new JsonEditorMcpServer(sessionManager, null); - server.start(port); + for (int attempt = 0; attempt < 3; attempt++) + { + try (final ServerSocket socket = new ServerSocket(0)) + { + port = socket.getLocalPort(); + } + try + { + server.start(port); + break; + } + catch (Exception e) + { + if (attempt == 2) + { + throw e; + } + } + } httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); @@ -52,12 +70,21 @@ void setUp() throws Exception } @AfterEach - void tearDown() + void tearDown() throws Exception { if (server != null) { server.stop(); } + if (httpClient != null) + { + httpClient.close(); + } + for (final Path tempFile : tempFiles) + { + Files.deleteIfExists(tempFile); + } + tempFiles.clear(); } protected JsonNode sendJsonRpc(final String method) throws Exception @@ -107,7 +134,7 @@ protected Path createTempFile(final String prefix, final String suffix, final St { final Path tempFile = Files.createTempFile(prefix, suffix); Files.writeString(tempFile, content); - tempFile.toFile().deleteOnExit(); + tempFiles.add(tempFile); return tempFile; } From 2d9442bfe2b7ab0baf7888ca27e761902cab05ee Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 15:25:41 +0200 Subject: [PATCH 22/24] chore: final cosmetic cleanup --- AGENTS.md | 3 +-- src/main/java/com/daniel/jsoneditor/controller/AppService.java | 1 - src/main/java/com/daniel/jsoneditor/controller/AppWindow.java | 1 - src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java | 1 - .../editorwindow/components/graph/JsonPlacementStrategy.java | 1 - 5 files changed, 1 insertion(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4379848b..e51a4ace 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,8 +105,7 @@ Tools are registered in `McpToolRegistry` (`model/mcp/`). All per-file tools req Base classes: - `ReadOnlyMcpTool` – holds `FileSessionManager`, provides `resolveFileSession(arguments, id)` helper -- `WriteMcpTool` – extends `ReadOnlyMcpTool` (currently no write tools registered) - + Session management tools (extend `ReadOnlyMcpTool`, no `file_id` needed): - `ListFilesTool` – list all open sessions - `OpenFileTool` – open a JSON + schema file pair, returns `file_id` diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppService.java b/src/main/java/com/daniel/jsoneditor/controller/AppService.java index 7b83b9d9..3ede2365 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppService.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -14,7 +14,6 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicBoolean; - /** * Central application service that owns shared state across all editor windows. * Starts the MCP server immediately so external clients (e.g. OpenCode) can connect diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index 1a1d6841..51bbd3e8 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -5,7 +5,6 @@ import com.daniel.jsoneditor.model.statemachine.impl.EventSenderImpl; import javafx.stage.Stage; - /** * Encapsulates a single app window with its own Model, Controller, View, and Stage. * Created by AppService, one per open file. diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index ed0884dd..0a6fe853 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -21,7 +21,6 @@ import java.util.List; import javax.swing.SwingUtilities; - /** * JavaFX entry point. Initializes the platform, creates the AppService (which starts * the MCP server immediately), and optionally opens a GUI window. diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/editorwindow/components/graph/JsonPlacementStrategy.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/editorwindow/components/graph/JsonPlacementStrategy.java index 6f7ba822..97b7280c 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/editorwindow/components/graph/JsonPlacementStrategy.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/editorwindow/components/graph/JsonPlacementStrategy.java @@ -1,7 +1,6 @@ package com.daniel.jsoneditor.view.impl.jfx.impl.scenes.impl.editor.components.editorwindow.components.graph; import java.util.*; -import java.util.stream.Collectors; import com.brunomnsilva.smartgraph.graph.Digraph; import com.brunomnsilva.smartgraph.graph.Edge; From 30feba05b2a4a8c1dd2ee12cdd172004692f6102 Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 15:44:30 +0200 Subject: [PATCH 23/24] fix: eliminate zombie process, port TOCTOU, file_id validation DRY --- .../jsoneditor/model/mcp/CloseFileTool.java | 7 ++--- .../model/mcp/JsonEditorMcpServer.java | 13 +++++---- .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 28 +++++++++++++++---- .../daniel/jsoneditor/view/JFXLauncher.java | 7 +++++ .../jsoneditor/model/mcp/McpTestBase.java | 24 ++-------------- 5 files changed, 42 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java index 339b22bb..e4a03286 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -48,11 +48,10 @@ public ArrayNode getRequiredInputProperties() @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - final String fileId = arguments.path("file_id").asText(null); - if (fileId == null || fileId.isEmpty()) + final String fileId = getValidatedFileId(arguments); + if (fileId == null) { - return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, - FILE_ID_REQUIRED_MESSAGE); + return fileIdRequiredError(id); } final CloseFileResult closeResult = sessionManager.closeFile(fileId); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index cd3a9461..863ae54c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -17,6 +17,7 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; /** @@ -72,7 +73,7 @@ public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppSer * Starts the MCP server on the specified port. * * @param port - * the port to listen on (localhost only, 1024-65535) + * the port to listen on (localhost only); use 0 to let the OS assign a free port (1024-65535 for explicit ports) * * @throws IOException * if the server cannot be started @@ -81,9 +82,9 @@ public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppSer */ public synchronized void start(final int port) throws IOException { - if (port < 1024 || port > 65535) + if (port < 0 || (port > 0 && port < 1024) || port > 65535) { - throw new IllegalArgumentException("Port must be between 1024 and 65535"); + throw new IllegalArgumentException("Port must be 0 (OS assigns) or between 1024 and 65535"); } if (running) @@ -92,17 +93,17 @@ public synchronized void start(final int port) throws IOException stop(); } - this.port = port; server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); + this.port = (port == 0) ? server.getAddress().getPort() : port; server.createContext("/", this::handleRequest); - server.setExecutor(java.util.concurrent.Executors.newCachedThreadPool(r -> { + server.setExecutor(Executors.newCachedThreadPool(r -> { Thread t = new Thread(r); t.setDaemon(true); return t; })); server.start(); running = true; - logger.info("MCP Server started on http://127.0.0.1:{}", port); + logger.info("MCP Server started on http://127.0.0.1:{}", this.port); } /** diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java index f751d760..bc787e4b 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -43,12 +43,10 @@ protected ReadOnlyMcpTool(final FileSessionManager sessionManager) */ protected ResolveResult resolveFileSession(final JsonNode arguments, final JsonNode id) { - final String fileId = arguments.path("file_id").asText(null); - if (fileId == null || fileId.isEmpty()) + final String fileId = getValidatedFileId(arguments); + if (fileId == null) { - return new ResolveResult(null, - JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, - FILE_ID_REQUIRED_MESSAGE)); + return new ResolveResult(null, fileIdRequiredError(id)); } final EditorSession session = sessionManager.getSession(fileId); if (session == null) @@ -60,6 +58,26 @@ protected ResolveResult resolveFileSession(final JsonNode arguments, final JsonN return new ResolveResult(session.model(), null); } + /** + * Returns the file_id string from arguments if present and non-empty, or null if missing/empty. + * Callers should return {@link #fileIdRequiredError(JsonNode)} when this returns null. + */ + protected String getValidatedFileId(final JsonNode arguments) + { + final String fileId = arguments.path("file_id").asText(null); + if (fileId == null || fileId.isEmpty()) + { + return null; + } + return fileId; + } + + /** Builds a JSON-RPC error response for a missing or empty file_id argument. */ + protected String fileIdRequiredError(final JsonNode id) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, FILE_ID_REQUIRED_MESSAGE); + } + protected static void addFileIdProperty(final ObjectNode properties) { final ObjectNode fileIdProp = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index 0a6fe853..29e0468c 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -92,6 +92,13 @@ public void start(final Stage stage) { logger.info("Started in headless mode — MCP server is disabled in settings, no GUI window."); } + if (headless && !appService.getMcpController().isMcpServerRunning()) + { + logger.error("Headless mode with MCP disabled — nothing to do. Exiting."); + appService.shutdown(); + Platform.exit(); + return; + } } else { diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java index 2cac4ba5..6d2075c8 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import java.net.ServerSocket; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -41,32 +40,13 @@ abstract class McpTestBase @BeforeEach void setUp() throws Exception { - int port = 0; final FileSessionManager sessionManager = new FileSessionManager(); server = new JsonEditorMcpServer(sessionManager, null); - for (int attempt = 0; attempt < 3; attempt++) - { - try (final ServerSocket socket = new ServerSocket(0)) - { - port = socket.getLocalPort(); - } - try - { - server.start(port); - break; - } - catch (Exception e) - { - if (attempt == 2) - { - throw e; - } - } - } + server.start(0); httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); - baseUrl = "http://127.0.0.1:" + port; + baseUrl = "http://127.0.0.1:" + server.getPort(); } @AfterEach From d04975a8cccd268a28fee35ba138c72e735d1e1b Mon Sep 17 00:00:00 2001 From: Daniel Kispert <34270661+DanielKispert@users.noreply.github.com> Date: Tue, 12 May 2026 16:20:08 +0200 Subject: [PATCH 24/24] chore: simplify redundant condition, strip trailing whitespace --- .../jsoneditor/controller/AppWindow.java | 10 +-- .../model/mcp/JsonEditorMcpServer.java | 82 +++++++++---------- .../daniel/jsoneditor/view/JFXLauncher.java | 2 +- .../jsoneditor/model/mcp/McpTestBase.java | 1 - 4 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java index 51bbd3e8..91bd04fb 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java +++ b/src/main/java/com/daniel/jsoneditor/controller/AppWindow.java @@ -12,9 +12,9 @@ public class AppWindow { private final Controller controller; - + private final Stage stage; - + /** Creates a new editor window wired to the given AppService. */ public AppWindow(final AppService appService) { @@ -23,7 +23,7 @@ public AppWindow(final AppService appService) final ModelImpl model = new ModelImpl(new EventSenderImpl()); this.controller = new ControllerImpl(model, model, stage, appService); } - + /** * Sets up close behavior: shuts down this window's controller. * @@ -40,13 +40,13 @@ public void setOnClose(final Runnable onClose) } }); } - + /** Returns this window's controller. */ public Controller getController() { return controller; } - + /** Returns this window's stage. */ public Stage getStage() { diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index 863ae54c..0dedbc48 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -28,36 +28,36 @@ public class JsonEditorMcpServer { private static final Logger logger = LoggerFactory.getLogger(JsonEditorMcpServer.class); - + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - + private static final String PROTOCOL_VERSION = "2024-11-05"; - + private static final String SERVER_NAME = "json-editor"; - + private static final String SERVER_VERSION = VersionUtil.getVersion(); - + public static final int DEFAULT_PORT = 4500; - + private static final int HTTP_OK = 200; private static final int HTTP_BAD_REQUEST = 400; private static final int HTTP_METHOD_NOT_ALLOWED = 405; private static final int HTTP_INTERNAL_ERROR = 500; - + private static final int JSONRPC_PARSE_ERROR = -32700; private static final int JSONRPC_INVALID_REQUEST = -32600; private static final int JSONRPC_METHOD_NOT_FOUND = -32601; private static final int JSONRPC_INVALID_PARAMS = -32602; private static final int JSONRPC_INTERNAL_ERROR = -32603; - + private final McpToolRegistry toolRegistry; - + private HttpServer server; - + private int port; - + private volatile boolean running; - + /** Creates MCP server backed by a FileSessionManager for multi-file support. */ public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppService appService) { @@ -68,7 +68,7 @@ public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppSer this.toolRegistry = new McpToolRegistry(sessionManager, appService); this.running = false; } - + /** * Starts the MCP server on the specified port. * @@ -86,13 +86,13 @@ public synchronized void start(final int port) throws IOException { throw new IllegalArgumentException("Port must be 0 (OS assigns) or between 1024 and 65535"); } - + if (running) { logger.warn("MCP Server already running on port {}, stopping first", this.port); stop(); } - + server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0); this.port = (port == 0) ? server.getAddress().getPort() : port; server.createContext("/", this::handleRequest); @@ -105,7 +105,7 @@ public synchronized void start(final int port) throws IOException running = true; logger.info("MCP Server started on http://127.0.0.1:{}", this.port); } - + /** * Stops the MCP server gracefully. */ @@ -119,21 +119,21 @@ public synchronized void stop() logger.info("MCP Server stopped"); } } - + public boolean isRunning() { return running; } - + public int getPort() { return port; } - + private void handleRequest(final HttpExchange exchange) throws IOException { final String path = exchange.getRequestURI().getPath(); - + if ("/health".equals(path)) { handleHealthCheck(exchange); @@ -143,13 +143,13 @@ private void handleRequest(final HttpExchange exchange) throws IOException handleMcpRequest(exchange); } } - + private void handleHealthCheck(final HttpExchange exchange) throws IOException { final String response = "{\"status\":\"ok\",\"service\":\"json-editor-mcp\"}"; sendJsonResponse(exchange, HTTP_OK, response); } - + private void handleMcpRequest(final HttpExchange exchange) throws IOException { if (!"POST".equals(exchange.getRequestMethod())) @@ -157,17 +157,17 @@ private void handleMcpRequest(final HttpExchange exchange) throws IOException sendJsonResponse(exchange, HTTP_METHOD_NOT_ALLOWED, createErrorResponse(null, JSONRPC_INVALID_REQUEST, "Method not allowed")); return; } - + String requestBody = null; try (InputStream is = exchange.getRequestBody()) { requestBody = new String(is.readAllBytes(), StandardCharsets.UTF_8); final JsonNode request = OBJECT_MAPPER.readTree(requestBody); - + final String method = request.path("method").asText(); final JsonNode params = request.path("params"); final JsonNode id = request.path("id"); - + final String response = processMethod(method, params, id); sendJsonResponse(exchange, HTTP_OK, response); } @@ -177,7 +177,7 @@ private void handleMcpRequest(final HttpExchange exchange) throws IOException sendJsonResponse(exchange, HTTP_BAD_REQUEST, createErrorResponse(null, JSONRPC_PARSE_ERROR, "Parse error")); } } - + private String processMethod(final String method, final JsonNode params, final JsonNode id) throws JsonProcessingException { return switch (method) @@ -188,39 +188,39 @@ private String processMethod(final String method, final JsonNode params, final J default -> createErrorResponse(id, JSONRPC_METHOD_NOT_FOUND, "Method not found: " + method); }; } - + private String handleInitialize(final JsonNode id) throws JsonProcessingException { final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.put("protocolVersion", PROTOCOL_VERSION); - + final ObjectNode capabilities = OBJECT_MAPPER.createObjectNode(); final ObjectNode tools = OBJECT_MAPPER.createObjectNode(); capabilities.set("tools", tools); result.set("capabilities", capabilities); - + final ObjectNode serverInfo = OBJECT_MAPPER.createObjectNode(); serverInfo.put("name", SERVER_NAME); serverInfo.put("version", SERVER_VERSION); result.set("serverInfo", serverInfo); - + return createSuccessResponse(id, result); } - + private String handleToolsList(final JsonNode id) throws JsonProcessingException { final ObjectNode result = OBJECT_MAPPER.createObjectNode(); result.set("tools", toolRegistry.getToolDefinitions()); return createSuccessResponse(id, result); } - + private String handleToolsCall(final JsonNode params, final JsonNode id) throws JsonProcessingException { final String toolName = params.path("name").asText(); final JsonNode arguments = params.path("arguments"); - + logger.info("MCP tool called: {} with arguments: {}", toolName, arguments); - + final McpTool tool = toolRegistry.getTool(toolName); if (tool == null) { @@ -239,7 +239,7 @@ private String handleToolsCall(final JsonNode params, final JsonNode id) throws logger.warn("Tool {} validation failed: {}", toolName, e.getMessage()); return createErrorResponse(id, JSONRPC_INVALID_PARAMS, e.getMessage()); } - + final String result = tool.execute(arguments, id); logger.debug("Tool {} completed successfully", toolName); return result; @@ -253,7 +253,7 @@ private String createSuccessResponse(final JsonNode id, final JsonNode result) t response.set("result", result); return OBJECT_MAPPER.writeValueAsString(response); } - + private String createErrorResponse(final JsonNode id, final int code, final String message) { @@ -262,12 +262,12 @@ private String createErrorResponse(final JsonNode id, final int code, final Stri final ObjectNode response = OBJECT_MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.set("id", id); - + final ObjectNode error = OBJECT_MAPPER.createObjectNode(); error.put("code", code); error.put("message", message); response.set("error", error); - + return OBJECT_MAPPER.writeValueAsString(response); } catch (JsonProcessingException e) @@ -283,12 +283,12 @@ public static String createErrorResponseStatic(final JsonNode id, final int code final ObjectNode response = OBJECT_MAPPER.createObjectNode(); response.put("jsonrpc", "2.0"); response.set("id", id); - + final ObjectNode error = OBJECT_MAPPER.createObjectNode(); error.put("code", code); error.put("message", message); response.set("error", error); - + return OBJECT_MAPPER.writeValueAsString(response); } catch (JsonProcessingException e) @@ -296,7 +296,7 @@ public static String createErrorResponseStatic(final JsonNode id, final int code return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}}"; } } - + private void sendJsonResponse(final HttpExchange exchange, final int statusCode, final String response) throws IOException { exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); diff --git a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java index 29e0468c..3b466461 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -92,7 +92,7 @@ public void start(final Stage stage) { logger.info("Started in headless mode — MCP server is disabled in settings, no GUI window."); } - if (headless && !appService.getMcpController().isMcpServerRunning()) + if (!appService.getMcpController().isMcpServerRunning()) { logger.error("Headless mode with MCP disabled — nothing to do. Exiting."); appService.shutdown(); diff --git a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java index 6d2075c8..637efc38 100644 --- a/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java @@ -20,7 +20,6 @@ import static org.junit.jupiter.api.Assertions.*; - /** * Base class for MCP server integration tests. * Starts a fresh server on a random port before each test and stops it after.