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/AGENTS.md b/AGENTS.md index 3a400975..e51a4ace 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,18 +83,52 @@ 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 +Headless: JFXLauncher --headless → 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 `resolveFileSession(arguments, id)` helper + +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. +### Headless Mode +Run the MCP server without the GUI (no JavaFX window). Start with: +```bash +./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 locally +./gradlew run # run GUI locally +./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`. @@ -105,6 +139,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/README.md b/README.md index a9da7dbc..9d32cf2c 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,27 @@ 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 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. + +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 -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/build.gradle b/build.gradle index 71757695..10a4c25f 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' @@ -80,3 +76,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 new file mode 100644 index 00000000..3ede2365 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/AppService.java @@ -0,0 +1,179 @@ +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; + +/** + * Central application service that owns shared state across all editor windows. + * 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 +{ + 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); + + /** 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(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(final int portOverride) + { + if (!settingsController.isMcpServerEnabled()) + { + logger.info("MCP server disabled in settings, skipping auto-start"); + return; + } + final int port = portOverride > 0 ? portOverride : settingsController.getMcpServerPort(); + mcpController.startMcpServer(port); + if (mcpController.isMcpServerRunning()) + { + logger.info("MCP server started on port {}", mcpController.getMcpServerPort()); + } + else + { + logger.error("MCP server failed to start — check port availability"); + } + } + + /** + * Creates a new editor window. + * + * @return the new {@link AppWindow}, or {@code null} if the application is shutting down + */ + 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)); + 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(); + if (window == null) + { + return; + } + window.getController().jsonAndSchemaSelected(jsonFile, schemaFile, null); + } + + /** + * 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.", 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(); + } + + /** 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. + */ + 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 new file mode 100644 index 00000000..91bd04fb --- /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; + + /** Creates a new editor window wired to the given AppService. */ + 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.setOnHiding(event -> + { + controller.shutdown(); + if (onClose != null) + { + onClose.run(); + } + }); + } + + /** 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/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 86b3a5ec..f23266a8 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,8 @@ import java.util.Map; 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; @@ -17,25 +19,25 @@ 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; 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.ObjectNode; import com.daniel.jsoneditor.model.statemachine.impl.Event; import com.daniel.jsoneditor.model.statemachine.impl.EventEnum; import com.daniel.jsoneditor.model.validation.ReferenceValidator; import com.daniel.jsoneditor.model.validation.ValidationError; import com.daniel.jsoneditor.model.validation.ValidationResult; -import com.daniel.jsoneditor.model.validation.ModelValidationException; import com.daniel.jsoneditor.view.View; import com.daniel.jsoneditor.view.impl.ViewImpl; import com.daniel.jsoneditor.view.impl.jfx.dialogs.VariableReplacementDialog; @@ -51,32 +53,40 @@ 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(WritableModel model, ReadableModel readableModel, Stage stage) + + public ControllerImpl(final WritableModel model, final ReadableModel readableModel, final Stage stage, final 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; @@ -84,12 +94,11 @@ 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); - + // 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. @@ -98,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() { @@ -153,7 +161,7 @@ public void checkForUpdate() } })); } - + @Override public void checkForUpdateSilently() { @@ -170,12 +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) { @@ -196,16 +204,22 @@ 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); + } + guiSessionId = fileSessionManager.registerGuiSession(readableModel, jsonFile, schemaFile); }); - + } else { view.selectJsonAndSchema(); } - + } - + @Override public void moveItemToIndex(JsonNodeWithPath newParent, JsonNodeWithPath item, int index) { @@ -216,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); @@ -234,7 +248,7 @@ public String resolveVariablesInJson(String text) } return text; } - + @Override public void importAtNode(String path, String content) { @@ -244,20 +258,19 @@ public void importAtNode(String path, String content) final JsonNodeWithPath existingNodeAtPath = readableModel.getNodeForPath(path == null ? "" : path); final JsonNode contentNode = jsonReader.getNodeFromString(content); final JsonNode mergedNode = JsonNodeMerger.createMergedNode(readableModel, existingNodeAtPath, contentNode); - if (mergedNode == null) + final JsonSchema schemaAtPath = readableModel.getSubschemaForPath(path); + + if (mergedNode != null && SchemaHelper.validateJsonWithSchema(mergedNode, schemaAtPath).isEmpty()) + { + commandManager.executeCommand(commandFactory.setNodeCommand(path, mergedNode)); + view.showToast(Toasts.IMPORT_SUCCESSFUL_TOAST); + logger.info("Successfully imported JSON at path: {}", path); + } + else { view.showToast(Toasts.IMPORT_VALIDATION_FAILED_TOAST); - logger.warn("Import merge returned null for path: {}", path); - return; + logger.warn("Import validation failed for path: {}", path); } - commandManager.executeCommand(commandFactory.setNodeCommand(path, mergedNode)); - view.showToast(Toasts.IMPORT_SUCCESSFUL_TOAST); - logger.info("Successfully imported JSON at path: {}", path); - } - catch (ModelValidationException e) - { - view.showToast(Toasts.IMPORT_VALIDATION_FAILED_TOAST); - logger.warn("Import validation failed for path {}: {}", path, e.getMessage()); } catch (JsonProcessingException e) { @@ -275,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) { @@ -290,7 +303,7 @@ public void exportNode(String path) exportJsonNode(filename, nodeWithPath.getNode()); } } - + @Override public void exportNodeWithDependencies(String path) { @@ -304,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; @@ -312,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(); @@ -320,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) { @@ -332,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) { @@ -348,7 +361,7 @@ public void createNewReferenceableObjectNodeWithKey(String pathOfReferenceableOb } commandManager.executeCommand(commandFactory.createReferenceableObjectCommand(pathOfReferenceableObject, key)); } - + @Override public void sortArray(String path) { @@ -358,7 +371,7 @@ public void sortArray(String path) } commandManager.executeCommand(commandFactory.sortArrayCommand(path)); } - + @Override public void reorderArray(String path, List newIndices) { @@ -368,7 +381,7 @@ public void reorderArray(String path, List newIndices) } commandManager.executeCommand(commandFactory.reorderArrayCommand(path, newIndices)); } - + @Override public void duplicateArrayNode(String path) { @@ -378,7 +391,7 @@ public void duplicateArrayNode(String path) } commandManager.executeCommand(commandFactory.duplicateArrayItemCommand(path)); } - + @Override public void duplicateReferenceableObjectForLinking(String referencePath, String pathToDuplicate) { @@ -388,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()) @@ -402,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() { @@ -419,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()) @@ -437,33 +450,54 @@ private void handleJsonValidation(JsonNode json, JsonSchema schema, Runnable onS view.cantValidateJson(); } } - + @Override public void openNewJson() { - launchFinished(); + final AppWindow window = appService.createWindow(); + if (window == null) + { + 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); - try + + // 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); + 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(); + if (value == null) { - commandManager.executeCommand(commandFactory.setValueAtNodeCommand(parentPath, propertyName, value)); + candidateParent.remove(propertyName); } - catch (ModelValidationException e) + else + { + candidateParent.set(propertyName, JsonNodeHelper.toJsonNode(value)); + } + final JsonSchema parentSchema = readableModel.getSubschemaForPath(parentPath); + if (parentSchema != null && !SchemaHelper.validateJsonWithSchema(candidateParent, parentSchema).isEmpty()) { view.showToast(Toasts.VALUE_VALIDATION_FAILED_TOAST); - logger.debug("Value validation rejected at {}: {}", path, e.getMessage()); + return; } + commandManager.executeCommand(commandFactory.setValueAtNodeCommand(parentPath, propertyName, value)); } @Override @@ -475,7 +509,7 @@ public void overrideNodeAtPath(String path, JsonNode node) } commandManager.executeCommand(commandFactory.setNodeCommand(path, node)); } - + @Override public void copyToClipboard(String path) { @@ -493,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) { @@ -510,31 +544,21 @@ public void pasteFromClipboardReplacingChild(String pathToInsert) view.showToast(Toasts.ERROR_TOAST); return; } - try + if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(pathToInsert)).isEmpty()) { commandManager.executeCommand(commandFactory.setNodeCommand(pathToInsert, jsonNode)); view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); + } - catch (ModelValidationException e) + else if (itemToInsertAt.isArray() && SchemaHelper.validateJsonWithSchema(jsonNode, + readableModel.getSubschemaForPath(pathToInsert + "/0")).isEmpty()) { - // Try as array item if direct replacement fails and target is array - if (itemToInsertAt.isArray()) - { - try - { - commandManager.executeCommand(commandFactory.setNodeCommand( - itemToInsertAt.getPath() + "/" + itemToInsertAt.getNode().size(), jsonNode)); - view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); - } - catch (ModelValidationException e2) - { - view.showToast(Toasts.ERROR_TOAST); - } - } - else - { - view.showToast(Toasts.ERROR_TOAST); - } + commandManager.executeCommand(commandFactory.setNodeCommand(itemToInsertAt.getPath() + "/" + itemToInsertAt.getNode().size(), jsonNode)); + view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); + } + else + { + view.showToast(Toasts.ERROR_TOAST); } } catch (JsonProcessingException e) @@ -553,9 +577,9 @@ public void pasteFromClipboardReplacingChild(String pathToInsert) logger.error("Unexpected error during paste at path {}: {}", pathToInsert, e.getMessage(), e); } } - + } - + @Override public void pasteFromClipboardIntoParent(String parentPath) { @@ -572,14 +596,16 @@ public void pasteFromClipboardIntoParent(String parentPath) try { final JsonNode jsonNode = new JsonFileReaderAndWriterImpl().getNodeFromString(jsonString); - final int arraySize = parentNode.getNode().size(); - commandManager.executeCommand(commandFactory.setNodeCommand(parentNode.getPath() + "/" + arraySize, jsonNode)); - view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); - } - catch (ModelValidationException e) - { - view.showToast(Toasts.ERROR_TOAST); - logger.debug("Paste validation rejected at {}: {}", parentPath, e.getMessage()); + if (SchemaHelper.validateJsonWithSchema(jsonNode, readableModel.getSubschemaForPath(parentPath)).isEmpty()) + { + final int arraySize = parentNode.getNode().size(); + commandManager.executeCommand(commandFactory.setNodeCommand(parentNode.getPath() + "/" + arraySize, jsonNode)); + view.showToast(Toasts.PASTED_FROM_CLIPBOARD_TOAST); + } + else + { + view.showToast(Toasts.ERROR_TOAST); + } } catch (JsonProcessingException e) { @@ -597,48 +623,51 @@ 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() { - logger.info("Shutting down application"); - mcpController.stopMcpServer(); + logger.info("Shutting down editor window"); + if (guiSessionId != null) + { + fileSessionManager.unregisterGuiSession(guiSessionId); + } } } 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..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 @@ -18,21 +18,19 @@ 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 +44,7 @@ public JsonNode getJsonFromFile(File file) } return null; } - + @Override public JsonNode getNodeFromString(String content) throws JsonProcessingException { @@ -54,10 +52,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 +76,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 e823606c..8e0537a8 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -2,45 +2,52 @@ import java.io.IOException; +import com.daniel.jsoneditor.controller.AppService; 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; public class McpController { private static final Logger logger = LoggerFactory.getLogger(McpController.class); - + private final JsonEditorMcpServer mcpServer; - + private final SettingsController settingsController; - - public McpController(final WritableModel writableModel, 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(writableModel); + 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. */ @@ -48,7 +55,7 @@ public void stopMcpServer() { mcpServer.stop(); } - + /** * Checks if the MCP server is currently running. * @@ -58,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 new file mode 100644 index 00000000..1419cace --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/RecentFilesManager.java @@ -0,0 +1,197 @@ +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.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +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 File RECENT_FILES_PATH = new File( + System.getProperty("user.home") + "/.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; + private final Object lock = new Object(); + + 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; + } + final List listeners; + synchronized (lock) + { + recentFiles.removeIf((final RecentFile 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(); + } + } + + /** Returns a snapshot of recent files, newest first. */ + public List getRecentFiles() + { + synchronized (lock) + { + return new ArrayList<>(recentFiles); + } + } + + /** Clears all recent files and persists the empty list. */ + public void clear() + { + final List listeners; + synchronized (lock) + { + recentFiles.clear(); + save(); + listeners = new ArrayList<>(changeListeners); + } + for (final Runnable listener : listeners) + { + listener.run(); + } + } + + /** + * 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) + { + synchronized (lock) + { + changeListeners.add(listener); + } + } + + private void load() + { + final Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(RECENT_FILES_PATH)) + { + 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_PATH, 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 File dir = RECENT_FILES_PATH.getParentFile(); + if (dir != null && !dir.exists()) + { + 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++) + { + 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()); + } + 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); + tempFile.delete(); + 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 ce5274ec..701af679 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,13 +164,14 @@ 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/impl/ModelImpl.java b/src/main/java/com/daniel/jsoneditor/model/impl/ModelImpl.java index 77a2b135..ebaf8199 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; @@ -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 null; + 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; @@ -146,38 +145,55 @@ 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)); + } }); } } - + 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. + * + * 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); } - + @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)) @@ -188,7 +204,7 @@ public void moveItemToIndex(JsonNodeWithPath item, int index) } } } - + @Override public void setValueAtPath(String parentPath, String propertyName, Object value) { @@ -204,7 +220,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) @@ -219,39 +235,18 @@ 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); - } - return JsonNodeFactory.instance.textNode(value.toString()); - } - @Override public JsonSchema getRootSchema() { return rootSchema; } - + @Override public Settings getSettings() { return settings; } - + @Override public JsonNodeWithPath getNodeForPath(String path) { @@ -262,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) { @@ -277,7 +272,7 @@ public List getDependentPaths(JsonNodeWithPath node) collectReferencesRecursively(node, referencedNodes); return referencedNodes; } - + @Override public List getReferenceableObjects() { @@ -296,7 +291,7 @@ public List getReferenceableObjects() } return referenceableObjects; } - + @Override public List getReferenceableObjectInstances() { @@ -307,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) { @@ -326,7 +321,7 @@ public List getInstancesOfReferenceableObjectAtPath } return new ArrayList<>(); } - + private void collectReferencesRecursively(JsonNodeWithPath node, List referencedNodes) { String referencePath = ReferenceHelper.resolveReference(node, this); @@ -365,9 +360,9 @@ else if (node.isArray()) } } } - + } - + @Override public List getStringExamplesForPath(String path) { @@ -395,7 +390,7 @@ public List getStringExamplesForPath(String path) } return Collections.emptyList(); } - + @Override public void sortArray(String path) { @@ -467,7 +462,7 @@ public void sortArray(String path) arrayNode.removeAll(); arrayNode.addAll(items); } - + @Override public List getAllowedStringValuesForPath(String path) { @@ -495,7 +490,7 @@ public List getAllowedStringValuesForPath(String path) } return Collections.emptyList(); } - + public boolean canAddMoreItems(String path) { final JsonSchema jsonSchema = getSubschemaForPath(path); @@ -520,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) { @@ -534,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) { @@ -567,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) { @@ -578,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) { @@ -597,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()) @@ -605,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) { @@ -658,7 +653,7 @@ private String duplicateItem(String pathToItemToDuplicate) } return clonedPath; } - + @Override public void removeNodes(List paths) { @@ -666,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--) @@ -674,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. @@ -725,7 +720,7 @@ else if (parentNode.isArray()) } } } - + @Override public ReferenceableObjectInstance getReferenceableObjectInstanceWithKey(ReferenceableObject object, String key) { @@ -742,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) { @@ -905,7 +900,7 @@ private JsonNode resolveRef(JsonNode nodeWithRef) } return nodeWithRef; } - + @Override public String getIdentifier(String pathOfParentNode, JsonNode childNode) { @@ -919,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() { 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/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/CloseFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java new file mode 100644 index 00000000..e4a03286 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/CloseFileTool.java @@ -0,0 +1,75 @@ +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; +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 = getValidatedFileId(arguments); + if (fileId == null) + { + return fileIdRequiredError(id); + } + + final CloseFileResult closeResult = sessionManager.closeFile(fileId); + if (closeResult == CloseFileResult.NOT_FOUND) + { + 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/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java index ce05c1a5..dc07a20c 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; @@ -12,53 +13,62 @@ 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 ReadableModel model) + + public FindReferencesToTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + 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) @@ -68,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) { @@ -78,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 c15bca7a..8b31994e 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; @@ -11,67 +12,77 @@ 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 ReadableModel model) + + public GetExamplesTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + 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 03e87a8c..ac94dfca 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -1,47 +1,65 @@ 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; - 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 public String getName() { return "get_file_info"; } - + @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 var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + final ReadableModel model = resolved.model(); + final ObjectNode content = OBJECT_MAPPER.createObjectNode(); - + if (model.getCurrentJSONFile() != null) { content.put("file_path", model.getCurrentJSONFile().getAbsolutePath()); @@ -52,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()); @@ -61,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 23efb597..a3a78f1e 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; @@ -10,61 +11,70 @@ 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 ReadableModel model) + + public GetNodeTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + 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, -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(); 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 99134e27..a46bafc1 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; @@ -13,58 +14,67 @@ 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 ReadableModel model) + + public GetReferenceableInstancesTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + 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, -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); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + if (instances != null) { for (final ReferenceableObjectInstance instance : instances) @@ -76,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 78c50246..3515e833 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; @@ -12,41 +13,57 @@ 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 ReadableModel model) + + public GetReferenceableObjectsTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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 var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + final ReadableModel model = resolved.model(); + final List objects = model.getReferenceableObjects(); final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - + if (objects != null) { for (final ReferenceableObject obj : objects) @@ -58,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 8c4be675..27ef500d 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; @@ -10,54 +11,64 @@ 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 ReadableModel model) + + public GetSchemaForPathTool(final FileSessionManager sessionManager) { - super(model); + 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() { - 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; } - + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { + final var resolved = resolveFileSession(arguments, id); + if (resolved.error() != null) + { + return resolved.error(); + } + 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, -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(); 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/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index 5951768f..0dedbc48 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -1,7 +1,8 @@ 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.daniel.jsoneditor.controller.AppService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -16,6 +17,7 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; /** @@ -26,58 +28,52 @@ 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 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. - * - * @param writableModel for read and write operations (passed to tools when enabled) - */ - public JsonEditorMcpServer(final WritableModel writableModel) + + /** Creates MCP server backed by a FileSessionManager for multi-file support. */ + public JsonEditorMcpServer(final FileSessionManager sessionManager, final AppService appService) { - 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, appService); this.running = false; } - + /** * 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 @@ -86,30 +82,30 @@ public JsonEditorMcpServer(final WritableModel writableModel) */ 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) { logger.warn("MCP Server already running on port {}, stopping first", this.port); 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); } - + /** * Stops the MCP server gracefully. */ @@ -123,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); @@ -147,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())) @@ -161,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); } @@ -181,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) @@ -192,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) { @@ -243,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; @@ -257,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) { @@ -266,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) @@ -287,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) @@ -300,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/model/mcp/ListFilesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java new file mode 100644 index 00000000..016715b1 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ListFilesTool.java @@ -0,0 +1,56 @@ +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/McpArgumentValidator.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java index 1eb702c8..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,7 +16,9 @@ */ public final class McpArgumentValidator { - private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + // 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/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/McpToolRegistry.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java index 672ed902..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,6 +1,7 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.WritableModel; +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; @@ -9,9 +10,9 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; import java.util.List; - /** * Registry of all available MCP tools for the JSON Editor. * Add/remove tools here to control which operations are exposed via MCP. @@ -20,27 +21,37 @@ 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 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. 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 WritableModel model) + public McpToolRegistry(final FileSessionManager sessionManager, final AppService appService) { - 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) - ); + 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); } - + /** * @return list of all registered tools */ @@ -48,7 +59,7 @@ public List getTools() { return tools; } - + /** * Find tool by name. * @@ -66,7 +77,7 @@ public McpTool getTool(final String name) } return null; } - + /** * Create JSON array of tool definitions for tools/list response. * @@ -75,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. @@ -100,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. @@ -124,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 new file mode 100644 index 00000000..83bb2516 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/OpenFileTool.java @@ -0,0 +1,78 @@ +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; +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 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 4f80f408..bc787e4b 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,93 @@ 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; - - protected ReadOnlyMcpTool(final ReadableModel model) + 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; + + /** + * 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 (model == null) + if (sessionManager == null) { - throw new IllegalArgumentException("model cannot be null"); + throw new IllegalArgumentException("sessionManager cannot be null"); } - this.model = model; + this.sessionManager = sessionManager; + } + + /** + * 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 ResolveResult resolveFileSession(final JsonNode arguments, final JsonNode id) + { + final String fileId = getValidatedFileId(arguments); + if (fileId == null) + { + return new ResolveResult(null, fileIdRequiredError(id)); + } + final EditorSession session = sessionManager.getSession(fileId); + if (session == null) + { + return new ResolveResult(null, + JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Unknown file_id: " + fileId)); + } + 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(); + 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 deleted file mode 100644 index 7c456e22..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.daniel.jsoneditor.model.mcp; - -import com.daniel.jsoneditor.model.WritableModel; -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; - - -/** - * Example write tool that sets a value at a specific path. - * Uncomment in McpToolRegistry to enable. - */ -class SetNodeTool extends WriteMcpTool -{ - private static final Logger logger = LoggerFactory.getLogger(SetNodeTool.class); - - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - public SetNodeTool(final WritableModel model) - { - super(model); - } - - @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(); - - 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(); - arr.add("path"); - arr.add("property"); - arr.add("value"); - return arr; - } - - @Override - public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - 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..6281540b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ShowGuiTool.java @@ -0,0 +1,74 @@ +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; +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 + { + if (appService.isShuttingDown()) + { + return JsonEditorMcpServer.createErrorResponseStatic(id, JSONRPC_INVALID_PARAMS, + "Cannot open window — application is shutting down"); + } + + Platform.runLater(() -> + { + 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(); + 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/mcp/WriteMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java deleted file mode 100644 index c2aad5b9..00000000 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.daniel.jsoneditor.model.mcp; - -import com.daniel.jsoneditor.model.WritableModel; - - -/** - * 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 -{ - protected final WritableModel model; - - protected WriteMcpTool(final WritableModel model) - { - if (model == null) - { - throw new IllegalArgumentException("model cannot be null"); - } - this.model = model; - } -} 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 new file mode 100644 index 00000000..75854b7b --- /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 an active editor session. + * + * @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 new file mode 100644 index 00000000..263c1886 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/sessions/FileSessionManager.java @@ -0,0 +1,185 @@ +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.json.schema.SchemaHelper; +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 result with sessionId on success, or error message on failure + */ + 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); + return new OpenFileResult(null, "JSON file does not exist: " + jsonPath); + } + if (!schemaFile.exists()) + { + 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; + 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) + { + logger.error("Failed to load JSON or schema from files: {} / {}", jsonPath, schemaPath); + return new OpenFileResult(null, "Failed to parse JSON or schema files: " + jsonPath + " / " + schemaPath); + } + + 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 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 + { + 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 new OpenFileResult(sessionId, null); + } + + /** + * 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) + { + 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; + } + + /** + * Unregisters a GUI session (called when GUI closes a file). + * + * @param sessionId the session to unregister + */ + public void unregisterGuiSession(final String sessionId) + { + sessions.computeIfPresent(sessionId, (final String key, final EditorSession session) -> + { + if (session.guiOwned()) + { + logger.info("Unregistered GUI session {}", sessionId); + return null; // removes the entry + } + return session; + }); + } + + /** + * Closes a headless session. Refuses to close GUI-owned sessions. + * + * @param sessionId the session to close + * @return {@link CloseFileResult#CLOSED} if closed, {@link CloseFileResult#NOT_FOUND} if not found, + * {@link CloseFileResult#GUI_OWNED} if the session is GUI-owned + */ + public CloseFileResult closeFile(final String sessionId) + { + 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); + result[0] = CloseFileResult.CLOSED; + return null; // removes the entry + }); + 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 + */ + public EditorSession getSession(final String sessionId) + { + return sessions.get(sessionId); + } + + /** + * @return list of all active sessions + */ + public List listSessions() + { + return new ArrayList<>(sessions.values()); + } + + private String generateUniqueId(final String prefix) + { + return prefix + UUID.randomUUID().toString().substring(0, 8); + } +} 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/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 43ec7f81..3b466461 100644 --- a/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java +++ b/src/main/java/com/daniel/jsoneditor/view/JFXLauncher.java @@ -1,36 +1,235 @@ 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 com.daniel.jsoneditor.controller.AppWindow; +import com.daniel.jsoneditor.controller.settings.RecentFilesManager; import javafx.application.Application; import javafx.application.Platform; import javafx.stage.Stage; -import com.daniel.jsoneditor.controller.impl.ControllerImpl; +import org.slf4j.Logger; +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.AppReopenedEvent; +import java.awt.desktop.AppReopenedListener; +import java.awt.event.ActionEvent; +import java.io.File; +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. + *

+ * 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); } - - + + /** + * 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(Stage stage) + public void start(final Stage stage) + { + // Keep JavaFX runtime alive even without windows + Platform.setImplicitExit(false); + stage.close(); + + // 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) + { + 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."); + } + if (!appService.getMcpController().isMcpServerRunning()) + { + logger.error("Headless mode with MCP disabled — nothing to do. Exiting."); + appService.shutdown(); + Platform.exit(); + return; + } + } + else + { + 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 + 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. + */ + private void registerMacOsReopenHandler() + { + try + { + if (Desktop.isDesktopSupported()) + { + Desktop.getDesktop().addAppEventListener((AppReopenedListener) (final AppReopenedEvent event) -> + Platform.runLater(() -> + { + if (appService.getWindowCount() == 0) + { + logger.info("macOS reopen event — opening new window"); + final AppWindow window = appService.createWindow(); + if (window == null) + { + logger.warn("Could not create window on dock reopen — application is shutting down"); + } + } + })); + } + } + catch (Exception e) + { + // Not on macOS or AWT desktop not available — ignore silently + logger.debug("Could not register macOS reopen handler", e); + } + } + + /** + * 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); + } + } + + /** + * 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) { - stage.setTitle("JSON Editor"); - EventSender eventSender = new EventSenderImpl(); - ModelImpl model = new ModelImpl(eventSender); - Controller controller = new ControllerImpl(model, model, stage); - - stage.setOnCloseRequest(event -> { - controller.shutdown(); - Platform.exit(); + SwingUtilities.invokeLater(() -> + { + try + { + final PopupMenu menu = new PopupMenu(); + + final MenuItem newWindowItem = new MenuItem("New Window"); + newWindowItem.addActionListener((final ActionEvent evt) -> Platform.runLater(() -> + { + 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); + } + + taskbar.setMenu(menu); + } + catch (Exception e) + { + logger.debug("Could not rebuild dock menu", e); + } }); } + + @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/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/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; 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 + + diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties index 2804593a..8fa71c16 100644 --- a/src/main/resources/version.properties +++ b/src/main/resources/version.properties @@ -1 +1 @@ -version=0.18.0 +version=0.19.0 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()); + } +} 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/McpMultiFileIntegrationTest.java b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java new file mode 100644 index 00000000..261ffd82 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpMultiFileIntegrationTest.java @@ -0,0 +1,246 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + + +public class McpMultiFileIntegrationTest extends McpTestBase +{ + 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\"}" + + "}}"; + + @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"); + } + + @Test + void testOpenFileValidationRejection() throws Exception + { + final java.nio.file.Path jsonFile = createTempFile("mcp-invalid-", ".json", + "{\"active\":\"not-a-boolean\",\"count\":\"not-a-number\"}"); + 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()) + .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"); + } +} 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..02700482 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpServerIntegrationTest.java @@ -0,0 +1,226 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + + +public class McpServerIntegrationTest extends McpTestBase +{ + @Test + void testHealthEndpoint() throws Exception + { + final java.net.http.HttpResponse response = httpClient.send( + java.net.http.HttpRequest.newBuilder() + .uri(java.net.URI.create(baseUrl + "/health")) + .GET() + .timeout(java.time.Duration.ofSeconds(5)) + .build(), + java.net.http.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"); + } + + @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 Path createTempJsonFile() throws Exception + { + return createTempFile("mcp-test-", ".json", "{\"name\":\"test\",\"value\":42}"); + } + + private Path createTempSchemaFile() throws Exception + { + final String schema = "{" + + "\"$schema\":\"http://json-schema.org/draft-07/schema#\"," + + "\"type\":\"object\"," + + "\"properties\":{" + + "\"name\":{\"type\":\"string\"}," + + "\"value\":{\"type\":\"number\"}" + + "}" + + "}"; + 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..637efc38 --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/mcp/McpTestBase.java @@ -0,0 +1,137 @@ +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.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.*; + +/** + * 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); + + private final List tempFiles = new ArrayList<>(); + + protected JsonEditorMcpServer server; + protected HttpClient httpClient; + protected String baseUrl; + + @BeforeEach + void setUp() throws Exception + { + final FileSessionManager sessionManager = new FileSessionManager(); + server = new JsonEditorMcpServer(sessionManager, null); + server.start(0); + httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + baseUrl = "http://127.0.0.1:" + server.getPort(); + } + + @AfterEach + 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 + { + 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); + tempFiles.add(tempFile); + 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 new file mode 100644 index 00000000..60a081cc --- /dev/null +++ b/src/test/java/com/daniel/jsoneditor/model/sessions/FileSessionManagerTest.java @@ -0,0 +1,260 @@ +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 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); + 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(); + + 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"); + + 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 OpenFileResult openResult = sessionManager.openFile(jsonFile.toString(), schemaFile.toString()); + assertTrue(openResult.success(), "Precondition: session must open successfully"); + final String id = openResult.sessionId(); + + 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"); + } + + @Test + void testCannotCloseGuiSession() + { + // Open a headless session to get a valid ReadableModel instance + 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) + 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 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"); + } + + @Test + void testUnregisterGuiSession() + { + 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( + 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 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"); + 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 OpenFileResult openResult = sessionManager.openFile( + jsonFiles.get(index).toString(), schemaFiles.get(index).toString()); + if (!openResult.success()) + { + errorCount.incrementAndGet(); + } + else + { + synchronized (openedIds) + { + openedIds.add(openResult.sessionId()); + } + sessionManager.closeFile(openResult.sessionId()); + } + } + 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"); + } + + @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"); + } +}