Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e42287f
added check for update functionality
DanielKispert Apr 30, 2026
4254ae3
first step
DanielKispert Apr 30, 2026
00c7054
multi mcp to be tested
DanielKispert Apr 30, 2026
3454271
current progress
DanielKispert May 1, 2026
763794f
appwindow
DanielKispert May 1, 2026
f979003
fix: concurrency, null-safety, and consistency improvements
DanielKispert May 2, 2026
3a6ccfa
feature(mcp): headless-first architecture with always-on MCP server
DanielKispert May 3, 2026
ffd3424
chore(merge): merge main into feature/multi_editing (model validation)
DanielKispert May 3, 2026
87358e9
Merge remote-tracking branch 'origin/main' into feature/multi_editing
DanielKispert May 11, 2026
acdee63
chore(merge): merge main into feature/multi_editing
DanielKispert May 11, 2026
ed0178a
fix: adapt validateJsonWithSchema calls to List<String> return type
DanielKispert May 11, 2026
2be8ad3
chore: remove StandaloneMcpMain, add MCP integration tests
DanielKispert May 11, 2026
383d55f
feature: conditional exit behavior, dock menu with recent files
DanielKispert May 11, 2026
c2aa3bb
fix: address review findings (TOCTOU, sessions, recent files)
DanielKispert May 11, 2026
d737480
fix: update docs, improve error reporting, add edge-case tests
DanielKispert May 11, 2026
bb0913f
fix(core): consistency, null-safety, error handling, atomic writes
DanielKispert May 12, 2026
bf6b8c8
fix(core): QA pass - error distinction, null handling, style
DanielKispert May 12, 2026
207b755
chore(core): extract JsonNodeHelper, distinct MCP errors, cleanup
DanielKispert May 12, 2026
1692670
chore: remove accidental .bak file
DanielKispert May 12, 2026
f38a442
fix(mcp): remove stray @Deprecated, move constant to base class
DanielKispert May 12, 2026
7099a59
chore(mcp): replace hardcoded error codes with constant
DanielKispert May 12, 2026
874bac4
chore: remove double blank lines and trailing whitespace
DanielKispert May 12, 2026
939024b
fix(core): final QA pass - port arg, TOCTOU fix, test DRY, style
DanielKispert May 12, 2026
34bc955
chore: cosmetic fixes - stale docs, test stability, DRY
DanielKispert May 12, 2026
2d9442b
chore: final cosmetic cleanup
DanielKispert May 12, 2026
30feba0
fix: eliminate zombie process, port TOCTOU, file_id validation DRY
DanielKispert May 12, 2026
d04975a
chore: simplify redundant condition, strip trailing whitespace
DanielKispert May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,5 @@ out/
### Mac OS ###
.DS_Store
.java-version

*.bak
47 changes: 39 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.



21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
https://support.apple.com/de-de/guide/mac-help/mh40616/mac
19 changes: 15 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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"
}
179 changes: 179 additions & 0 deletions src/main/java/com/daniel/jsoneditor/controller/AppService.java
Original file line number Diff line number Diff line change
@@ -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<AppWindow> 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();
}
}
55 changes: 55 additions & 0 deletions src/main/java/com/daniel/jsoneditor/controller/AppWindow.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading