diff --git a/build.gradle b/build.gradle index 37c08f1..5ca3abe 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,9 @@ dependencies { 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' testImplementation 'org.testfx:testfx-core:4.0.18' diff --git a/src/main/java/com/daniel/jsoneditor/controller/Controller.java b/src/main/java/com/daniel/jsoneditor/controller/Controller.java index c3dfd54..15468d2 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/Controller.java +++ b/src/main/java/com/daniel/jsoneditor/controller/Controller.java @@ -1,22 +1,22 @@ package com.daniel.jsoneditor.controller; +import java.io.File; +import java.util.List; + import com.daniel.jsoneditor.controller.impl.commands.CommandManager; +import com.daniel.jsoneditor.controller.mcp.McpController; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.model.diff.DiffEntry; import com.daniel.jsoneditor.model.json.JsonNodeWithPath; import com.fasterxml.jackson.databind.JsonNode; -import java.io.File; -import java.util.List; - public interface Controller { - /* - * Other controller methods for nested controllers - */ SettingsController getSettingsController(); + McpController getMcpController(); + /** * Gets the command manager for accessing command history * @return the command manager instance @@ -101,4 +101,5 @@ public interface Controller */ List calculateJsonDiff(); + } 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 2a6de97..fb9cd14 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -1,6 +1,7 @@ package com.daniel.jsoneditor.controller.impl; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -13,6 +14,7 @@ import com.daniel.jsoneditor.controller.impl.json.VariableHelper; import com.daniel.jsoneditor.controller.impl.json.impl.JsonFileReaderAndWriterImpl; import com.daniel.jsoneditor.controller.impl.json.impl.JsonNodeMerger; +import com.daniel.jsoneditor.controller.mcp.McpController; import com.daniel.jsoneditor.controller.settings.SettingsController; import com.daniel.jsoneditor.controller.settings.impl.SettingsControllerImpl; import com.daniel.jsoneditor.model.ReadableModel; @@ -64,6 +66,8 @@ public class ControllerImpl implements Controller, Observer private final CommandFactory commandFactory; + private final McpController mcpController; + public ControllerImpl(WritableModel model, ReadableModel readableModel, Stage stage) { this.settingsController = new SettingsControllerImpl(); @@ -74,6 +78,7 @@ 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); @@ -94,6 +99,12 @@ public SettingsController getSettingsController() return settingsController; } + @Override + public McpController getMcpController() + { + return mcpController; + } + @Override public CommandManager getCommandManager() { @@ -125,7 +136,7 @@ public void jsonAndSchemaSelected(File jsonFile, File schemaFile, File settingsF { if (jsonFile != null && schemaFile != null) { - // grab json from files and validate + // grab Json from files and validate JsonFileReaderAndWriter reader = new JsonFileReaderAndWriterImpl(); JsonNode json = reader.getJsonFromFile(jsonFile); JsonSchema schema = reader.getSchemaFromFileResolvingRefs(schemaFile); 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 c2b92d1..94d353f 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 @@ -12,14 +12,23 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class JsonFileReaderAndWriterImpl implements JsonFileReaderAndWriter { - private final ObjectMapper mapper; + private static final Logger logger = LoggerFactory.getLogger(JsonFileReaderAndWriterImpl.class); + + private final ObjectMapper regularMapper; + + private final ObjectMapper mapperIgnoringUnknownProperties; public JsonFileReaderAndWriterImpl() { - this.mapper = new ObjectMapper(); + this.regularMapper = new ObjectMapper(); + + this.mapperIgnoringUnknownProperties = new ObjectMapper(); + this.mapperIgnoringUnknownProperties.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); } @@ -29,11 +38,11 @@ public JsonNode getJsonFromFile(File file) { try { - return mapper.readTree(file); + return regularMapper.readTree(file); } catch (IOException e) { - e.printStackTrace(); + logger.error("Failed to read JSON from file: {}", file.getAbsolutePath(), e); } return null; } @@ -46,8 +55,7 @@ public JsonNode getNodeFromString(String content) throws JsonProcessingException throw new IllegalArgumentException("Content cannot be null"); } - final ObjectMapper objectMapper = new ObjectMapper(); - return objectMapper.readTree(content); + return regularMapper.readTree(content); } @Override @@ -57,18 +65,16 @@ public T getJsonFromFile(File file, Class classOfObject, boolean ignoreUn { if (ignoreUnknownProperties) { - ObjectMapper newMapper = new ObjectMapper(); - newMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - return newMapper.readValue(file, classOfObject); + return mapperIgnoringUnknownProperties.readValue(file, classOfObject); } else { - return mapper.readValue(file, classOfObject); + return regularMapper.readValue(file, classOfObject); } } catch (IOException e) { - e.printStackTrace(); + logger.error("Failed to read JSON from file {} as {}: {}", file.getAbsolutePath(), classOfObject.getSimpleName(), e.getMessage(), e); } return null; } @@ -82,10 +88,9 @@ public JsonSchema getSchemaFromFileResolvingRefs(File file) @Override public boolean writeJsonToFile(JsonNode json, File file) { - ObjectMapper mapper = new ObjectMapper(); try { - mapper.writer(new JsonPrettyPrinter()).writeValue(file, json); + regularMapper.writer(new JsonPrettyPrinter()).writeValue(file, json); } catch (IOException e) { diff --git a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java new file mode 100644 index 0000000..e823606 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -0,0 +1,71 @@ +package com.daniel.jsoneditor.controller.mcp; + +import java.io.IOException; + +import com.daniel.jsoneditor.controller.settings.SettingsController; +import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; +import 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) + { + this.mcpServer = new JsonEditorMcpServer(writableModel); + this.settingsController = settingsController; + } + + /** + * Starts the MCP server. + */ + public void startMcpServer() + { + if (!mcpServer.isRunning()) + { + try + { + mcpServer.start(settingsController.getMcpServerPort()); + } + catch (IOException e) + { + logger.error("Failed to start MCP server on port {}: {}", + settingsController.getMcpServerPort(), e.getMessage()); + } + } + } + + /** + * Stops the MCP server if it's running. + */ + public void stopMcpServer() + { + mcpServer.stop(); + } + + /** + * Checks if the MCP server is currently running. + * + * @return true if the server is running + */ + public boolean isMcpServerRunning() + { + return mcpServer.isRunning(); + } + + /** + * Gets the current port the server is running on or configured to use. + * + * @return the MCP server port + */ + public int getMcpServerPort() + { + return mcpServer.isRunning() ? mcpServer.getPort() : settingsController.getMcpServerPort(); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/SettingsController.java b/src/main/java/com/daniel/jsoneditor/controller/settings/SettingsController.java index 08ae3eb..9ca3231 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/SettingsController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/SettingsController.java @@ -37,4 +37,12 @@ public interface SettingsController void setLogGraphRequests(boolean logGraphRequests); boolean isLogGraphRequests(); + + void setMcpServerEnabled(boolean enabled); + + boolean isMcpServerEnabled(); + + void setMcpServerPort(int port); + + int getMcpServerPort(); } diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/impl/PropertyFileKeys.java b/src/main/java/com/daniel/jsoneditor/controller/settings/impl/PropertyFileKeys.java index 00114fb..fb8671b 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/impl/PropertyFileKeys.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/impl/PropertyFileKeys.java @@ -23,4 +23,8 @@ public class PropertyFileKeys public final static String PROPERTY_DEBUG_MODE = "debug_mode"; public final static String PROPERTY_LOG_GRAPH_REQUESTS = "log_graph_requests"; + + public final static String PROPERTY_MCP_SERVER_ENABLED = "autostart_mcp_server"; + + public final static String PROPERTY_MCP_SERVER_PORT = "mcp_server_port"; } diff --git a/src/main/java/com/daniel/jsoneditor/controller/settings/impl/SettingsControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/settings/impl/SettingsControllerImpl.java index 776090f..6946d8a 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/settings/impl/SettingsControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/settings/impl/SettingsControllerImpl.java @@ -4,6 +4,7 @@ import java.util.Properties; import com.daniel.jsoneditor.controller.settings.SettingsController; +import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; public class SettingsControllerImpl implements SettingsController @@ -173,4 +174,42 @@ public boolean isLogGraphRequests() { return "true".equalsIgnoreCase(properties.getProperty(PropertyFileKeys.PROPERTY_LOG_GRAPH_REQUESTS)); } + + @Override + public void setMcpServerEnabled(boolean enabled) + { + properties.setProperty(PropertyFileKeys.PROPERTY_MCP_SERVER_ENABLED, enabled ? "true" : "false"); + PropertiesFileHelper.writePropertiesToFile(properties); + } + + @Override + public boolean isMcpServerEnabled() + { + return "true".equalsIgnoreCase(properties.getProperty(PropertyFileKeys.PROPERTY_MCP_SERVER_ENABLED)); + } + + @Override + public void setMcpServerPort(int port) + { + properties.setProperty(PropertyFileKeys.PROPERTY_MCP_SERVER_PORT, String.valueOf(port)); + PropertiesFileHelper.writePropertiesToFile(properties); + } + + @Override + public int getMcpServerPort() + { + final String portStr = properties.getProperty(PropertyFileKeys.PROPERTY_MCP_SERVER_PORT); + if (portStr == null) + { + return JsonEditorMcpServer.DEFAULT_PORT; + } + try + { + return Integer.parseInt(portStr); + } + catch (NumberFormatException e) + { + return JsonEditorMcpServer.DEFAULT_PORT; + } + } } diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java new file mode 100644 index 0000000..d317405 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java @@ -0,0 +1,88 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.json.schema.reference.ReferenceToObjectInstance; +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; + +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) + { + super(model); + } + + @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", + "JSON path to a referenceable object instance to find references to"); + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("path"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + 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) + { + final ObjectNode refNode = OBJECT_MAPPER.createObjectNode(); + refNode.put("path", ref.getPath()); + 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) + { + refNode.put("remarks", remarks); + } + else + { + 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 new file mode 100644 index 0000000..b0c1bbb --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -0,0 +1,77 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +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; + +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) + { + super(model); + } + + @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"); + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("path"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + 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 new file mode 100644 index 0000000..03e87a8 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -0,0 +1,69 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +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 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) + { + super(model); + } + + @Override + public String getName() + { + return "get_file_info"; + } + + @Override + public String getDescription() + { + return "Get information about the currently open JSON file and schema"; + } + + @Override + public ObjectNode getInputSchema() + { + return OBJECT_MAPPER.createObjectNode(); + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final ObjectNode content = OBJECT_MAPPER.createObjectNode(); + + if (model.getCurrentJSONFile() != null) + { + content.put("file_path", model.getCurrentJSONFile().getAbsolutePath()); + content.put("file_name", model.getCurrentJSONFile().getName()); + } + else + { + content.putNull("file_path"); + content.putNull("file_name"); + } + + if (model.getCurrentSchemaFile() != null) + { + content.put("schema_path", model.getCurrentSchemaFile().getAbsolutePath()); + } + else + { + 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 new file mode 100644 index 0000000..b25ac11 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -0,0 +1,70 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.json.JsonNodeWithPath; +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 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) + { + super(model); + } + + @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., /root/child)"); + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("path"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + 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); + } + + 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 new file mode 100644 index 0000000..99134e2 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -0,0 +1,82 @@ +package com.daniel.jsoneditor.model.mcp; + +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.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; + +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) + { + super(model); + } + + @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", + "The referencing key of the referenceable object type"); + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("referencing_key"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + 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); + } + + final List instances = model.getReferenceableObjectInstances(refObject); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + if (instances != null) + { + for (final ReferenceableObjectInstance instance : instances) + { + final ObjectNode instNode = OBJECT_MAPPER.createObjectNode(); + instNode.put("path", instance.getPath()); + instNode.put("key", instance.getKey()); + instNode.put("display_name", instance.getFancyName()); + 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 new file mode 100644 index 0000000..78c5024 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -0,0 +1,64 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObject; +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; + +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) + { + super(model); + } + + @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(); + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final List objects = model.getReferenceableObjects(); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + if (objects != null) + { + for (final ReferenceableObject obj : objects) + { + final ObjectNode objNode = OBJECT_MAPPER.createObjectNode(); + objNode.put("path", obj.getPath()); + objNode.put("referencing_key", obj.getReferencingKey()); + objNode.put("key_property", obj.getKey()); + 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 new file mode 100644 index 0000000..56b3db9 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -0,0 +1,65 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +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 com.networknt.schema.JsonSchema; +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) + { + super(model); + } + + @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"); + } + + @Override + public ArrayNode getRequiredInputProperties() + { + final ArrayNode arr = OBJECT_MAPPER.createArrayNode(); + arr.add("path"); + return arr; + } + + @Override + public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + 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); + } + + 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 new file mode 100644 index 0000000..5718603 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -0,0 +1,310 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.WritableModel; +import com.daniel.jsoneditor.util.VersionUtil; +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 com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; + + +/** + * MCP (Model Context Protocol) Server for the JSON Editor. + * Provides read-only and (optionally) write access to the current editor session via HTTP JSON-RPC. + * Listens only on localhost for security. + */ +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 = 3000; + + 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) + { + if (writableModel == null) + { + throw new IllegalArgumentException("writableModel cannot be null"); + } + this.toolRegistry = new McpToolRegistry(writableModel); + this.running = false; + } + + /** + * Starts the MCP server on the specified port. + * + * @param port + * the port to listen on (localhost only, 1024-65535) + * + * @throws IOException + * if the server cannot be started + * @throws IllegalArgumentException + * if port is invalid + */ + public synchronized void start(final int port) throws IOException + { + if (port < 1024 || port > 65535) + { + throw new IllegalArgumentException("Port must be 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); + server.createContext("/", this::handleRequest); + server.start(); + running = true; + logger.info("MCP Server started on http://127.0.0.1:{}", port); + } + + /** + * Stops the MCP server gracefully. + */ + public synchronized void stop() + { + if (server != null) + { + server.stop(0); + server = null; + running = false; + 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); + } + else + { + 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())) + { + 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); + } + catch (JsonProcessingException e) + { + logger.error("Invalid JSON in request: {}", requestBody, e); + 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) + { + case "initialize" -> handleInitialize(id); + case "tools/list" -> handleToolsList(id); + case "tools/call" -> handleToolsCall(params, id); + 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) + { + logger.warn("Unknown tool requested: {}", toolName); + return createErrorResponse(id, JSONRPC_INVALID_PARAMS, "Unknown tool: " + toolName); + } + + // Validate arguments against complete input schema (including required properties) + final ObjectNode inputSchema = McpToolRegistry.buildInputSchema(tool); + try + { + McpArgumentValidator.validate(inputSchema, arguments); + } + catch (ValidationException e) + { + 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; + } + + private String createSuccessResponse(final JsonNode id, final JsonNode result) throws JsonProcessingException + { + final ObjectNode response = OBJECT_MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.set("id", id); + response.set("result", result); + return OBJECT_MAPPER.writeValueAsString(response); + } + + + private String createErrorResponse(final JsonNode id, final int code, final String message) + { + try + { + 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) + { + return "{\"jsonrpc\":\"2.0\",\"error\":{\"code\":-32603,\"message\":\"Internal error\"}}"; + } + } + + public static String createErrorResponseStatic(final JsonNode id, final int code, final String message) + { + try + { + 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) + { + 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"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + final byte[] responseBytes = response.getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(statusCode, responseBytes.length); + try (OutputStream os = exchange.getResponseBody()) + { + os.write(responseBytes); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java new file mode 100644 index 0000000..1eb702c --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java @@ -0,0 +1,57 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Validator for MCP tool arguments using JSON Schema validation. + * Accepts a complete JSON Schema object (with properties, required, etc.) + * and validates arguments against it using the networknt library. + */ +public final class McpArgumentValidator +{ + private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + + private McpArgumentValidator() { /* utility */ } + + /** + * Validate arguments against a complete JSON Schema. + * + * @param schemaNode Complete JSON Schema (type=object, properties, required, etc.) + * @param arguments JsonNode with actual arguments + * @throws ValidationException if validation fails with all error messages + */ + public static void validate(final JsonNode schemaNode, final JsonNode arguments) throws ValidationException + { + if (schemaNode == null || schemaNode.isEmpty()) + { + return; + } + + if (arguments == null || arguments.isMissingNode()) + { + if (schemaNode.has("required") && schemaNode.get("required").size() > 0) + { + throw new ValidationException("Missing required parameters"); + } + return; + } + + final JsonSchema schema = SCHEMA_FACTORY.getSchema(schemaNode); + final Set errors = schema.validate(arguments); + + if (!errors.isEmpty()) + { + final String errorMessage = errors.stream() + .map(ValidationMessage::getMessage) + .collect(Collectors.joining("; ")); + throw new ValidationException(errorMessage); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java new file mode 100644 index 0000000..f9dd203 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java @@ -0,0 +1,55 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + + +/** + * Abstract base class for MCP (Model Context Protocol) tools. + * Tools can be read-only or write operations on the JSON Editor model. + */ +public abstract class McpTool +{ + /** + * @return unique tool name (e.g., "get_node", "set_node") + */ + public abstract String getName(); + + /** + * @return human-readable description of what the tool does + */ + public abstract String getDescription(); + + /** + * @return JSON Schema "properties" object for input parameters (ObjectNode) + */ + public abstract ObjectNode getInputSchema(); + + /** + * Optional: return an output schema describing the tool result (may be null) + */ + public ObjectNode getOutputSchema() + { + return null; + } + + /** + * Optional: return a JSON array of required input property names (may be null) + */ + public ArrayNode getRequiredInputProperties() + { + return null; + } + + /** + * Execute the tool with given arguments. + * + * @param arguments tool arguments from JSON-RPC call + * @param id JSON-RPC request id + * @return JSON-RPC result string (formatted via createToolResult) + * @throws JsonProcessingException if JSON serialization fails + */ + public abstract String execute(JsonNode arguments, JsonNode id) throws JsonProcessingException; +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java new file mode 100644 index 0000000..672ed90 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -0,0 +1,144 @@ +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; + +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. + */ +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. + */ + public McpToolRegistry(final WritableModel model) + { + 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) + ); + } + + /** + * @return list of all registered tools + */ + public List getTools() + { + return tools; + } + + /** + * Find tool by name. + * + * @param name tool name + * @return tool or null if not found + */ + public McpTool getTool(final String name) + { + for (final McpTool tool : tools) + { + if (tool.getName().equals(name)) + { + return tool; + } + } + return null; + } + + /** + * Create JSON array of tool definitions for tools/list response. + * + * @return ArrayNode with tool definitions + */ + 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. + */ + public static ObjectNode buildInputSchema(final McpTool tool) + { + final ObjectNode schema = OBJECT_MAPPER.createObjectNode(); + 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. + */ + protected static String createToolResult(final JsonNode id, final JsonNode payload) throws JsonProcessingException + { + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + final ArrayNode contentArray = OBJECT_MAPPER.createArrayNode(); + final ObjectNode textContent = OBJECT_MAPPER.createObjectNode(); + textContent.put("type", "text"); + final String jsonText = OBJECT_MAPPER.writeValueAsString(payload == null ? OBJECT_MAPPER.nullNode() : payload); + 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(); + final ObjectNode prop = OBJECT_MAPPER.createObjectNode(); + prop.put("type", propType); + prop.put("description", description); + props.set(propName, prop); + return props; + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java new file mode 100644 index 0000000..4f80f40 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java @@ -0,0 +1,22 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; + + +/** + * Base class for read-only MCP tools that only query the model state. + * These tools cannot modify the JSON document. + */ +public abstract class ReadOnlyMcpTool extends McpTool +{ + protected final ReadableModel model; + + protected ReadOnlyMcpTool(final ReadableModel model) + { + if (model == null) + { + throw new IllegalArgumentException("model cannot be null"); + } + this.model = model; + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java new file mode 100644 index 0000000..7c456e2 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java @@ -0,0 +1,119 @@ +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/ValidationException.java b/src/main/java/com/daniel/jsoneditor/model/mcp/ValidationException.java new file mode 100644 index 0000000..6a0d969 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/ValidationException.java @@ -0,0 +1,12 @@ +package com.daniel.jsoneditor.model.mcp; + +/** + * Simple exception used to indicate validation failures for MCP tool arguments. + */ +public class ValidationException extends Exception +{ + public ValidationException(final String message) + { + super(message); + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java new file mode 100644 index 0000000..c2aad5b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java @@ -0,0 +1,22 @@ +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/util/VersionUtil.java b/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java new file mode 100644 index 0000000..46e70da --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/util/VersionUtil.java @@ -0,0 +1,53 @@ +package com.daniel.jsoneditor.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +/** + * Utility class for accessing application version information from version.properties. + */ +public final class VersionUtil +{ + private static final Logger logger = LoggerFactory.getLogger(VersionUtil.class); + private static final String VERSION; + + static + { + String loadedVersion = "unknown"; + try (InputStream is = VersionUtil.class.getClassLoader().getResourceAsStream("version.properties")) + { + if (is != null) + { + final Properties props = new Properties(); + props.load(is); + loadedVersion = props.getProperty("version", "unknown"); + } + else + { + logger.warn("version.properties not found in classpath"); + } + } + catch (IOException e) + { + logger.error("Failed to load version from version.properties", e); + } + VERSION = loadedVersion; + } + + private VersionUtil() + { + throw new UnsupportedOperationException("Utility class"); + } + + /** + * @return the application version (e.g., "0.16.1") + */ + public static String getVersion() + { + return VERSION; + } +} 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 6ea119a..600b76a 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/ViewImpl.java @@ -52,6 +52,11 @@ public void update() break; case MAIN_EDITOR: uiHandler.showMainEditor(); + //autostart if enabled + if (controller.getSettingsController().isMcpServerEnabled()) + { + controller.getMcpController().startMcpServer(); + } break; case RESET_SUCCESSFUL: showToast(Toasts.REFRESH_SUCCESSFUL_TOAST); diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/AboutDialog.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/AboutDialog.java index c2afc80..4ff1f12 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/AboutDialog.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/dialogs/AboutDialog.java @@ -1,34 +1,15 @@ package com.daniel.jsoneditor.view.impl.jfx.dialogs; -import java.io.IOException; -import java.io.InputStream; -import java.util.Properties; - -import javafx.scene.control.Alert; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.daniel.jsoneditor.util.VersionUtil; public class AboutDialog extends ThemedAlert { - private static final Logger logger = LoggerFactory.getLogger(AboutDialog.class); - public AboutDialog() { super(AlertType.INFORMATION); - Properties versionProperties = new Properties(); - InputStream versionFile = getClass().getClassLoader().getResourceAsStream("version.properties"); - try - { - versionProperties.load(versionFile); - } - catch (IOException e) - { - logger.error("IOException on trying to load version property", e); - } - String versionNumber = versionProperties.getProperty("version"); setTitle("About"); setHeaderText("JSON Editor"); - setContentText("Version " + versionNumber); + setContentText("Version " + VersionUtil.getVersion()); } } 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 6181b79..f2a719a 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 @@ -1,11 +1,15 @@ package com.daniel.jsoneditor.view.impl.jfx.dialogs; +import com.daniel.jsoneditor.controller.mcp.McpController; import com.daniel.jsoneditor.controller.settings.SettingsController; import javafx.geometry.Insets; +import javafx.scene.control.Button; import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; +import javafx.scene.control.Spinner; +import javafx.scene.control.SpinnerValueFactory; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; import javafx.scene.layout.HBox; @@ -25,18 +29,31 @@ public class SettingsDialog extends ThemedDialog private String tmpClusterShape; + private boolean tmpMcpServerEnabled; + + private int tmpMcpServerPort; + private final SettingsController settingsController; - public SettingsDialog(SettingsController controller) + private final McpController mcpController; + + private Label mcpStatusLabel; + + private Button mcpToggleButton; + + public SettingsDialog(SettingsController settingsController, McpController mcpController) { super(); - this.settingsController = controller; + this.settingsController = settingsController; + this.mcpController = mcpController; this.tmpHideEmptyColumns = settingsController.hideEmptyColumns(); this.tmpRenameReferences = settingsController.renameReferencesWhenRenamingObject(); this.tmpDebugMode = settingsController.isDebugMode(); this.tmpLogGraphRequests = settingsController.isLogGraphRequests(); this.tmpClusterShape = settingsController.getClusterShape(); + this.tmpMcpServerEnabled = settingsController.isMcpServerEnabled(); + this.tmpMcpServerPort = settingsController.getMcpServerPort(); setTitle("Settings"); getDialogPane().getButtonTypes().setAll(new ButtonType("Save", ButtonType.OK.getButtonData()), ButtonType.CANCEL); @@ -57,6 +74,8 @@ public SettingsDialog(SettingsController controller) settingsController.setDebugMode(tmpDebugMode); settingsController.setLogGraphRequests(tmpLogGraphRequests); settingsController.setClusterShape(tmpClusterShape); + settingsController.setMcpServerEnabled(tmpMcpServerEnabled); + settingsController.setMcpServerPort(tmpMcpServerPort); } return null; }); @@ -82,7 +101,14 @@ private TabPane createTabPane() VBox debugBox = new VBox(createDebugToastsSetting(), createLogGraphRequestsSettings()); debugTab.setContent(debugBox); - tabs.getTabs().addAll(automationTab, displayTab, debugTab); + // MCP Tab + Tab mcpTab = new Tab("MCP"); + VBox mcpBox = new VBox(10); + mcpBox.setPadding(new Insets(10)); + mcpBox.getChildren().addAll(createMcpEnabledSetting(), createMcpPortSetting(), createMcpServerControls()); + mcpTab.setContent(mcpBox); + + tabs.getTabs().addAll(automationTab, displayTab, debugTab, mcpTab); return tabs; } @@ -130,4 +156,85 @@ private HBox createClusterShapeSettings() box.getChildren().addAll(title, clusterShapeComboBox); return box; } + + private CheckBox createMcpEnabledSetting() + { + CheckBox mcpEnabledCheckBox = new CheckBox("Enable MCP Server on startup"); + mcpEnabledCheckBox.setSelected(tmpMcpServerEnabled); + mcpEnabledCheckBox.selectedProperty().addListener((observable, oldValue, newValue) -> tmpMcpServerEnabled = newValue); + return mcpEnabledCheckBox; + } + + private HBox createMcpPortSetting() + { + HBox box = new HBox(10); + Label title = new Label("MCP Server Port:"); + Spinner portSpinner = new Spinner<>(); + portSpinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(1024, 65535, tmpMcpServerPort)); + portSpinner.setEditable(true); + portSpinner.setPrefWidth(100); + portSpinner.valueProperty().addListener((observable, oldValue, newValue) -> tmpMcpServerPort = newValue); + box.getChildren().addAll(title, portSpinner); + return box; + } + + private VBox createMcpServerControls() + { + VBox box = new VBox(10); + box.setPadding(new Insets(10, 0, 0, 0)); + + mcpStatusLabel = new Label(); + updateMcpStatusLabel(); + + mcpToggleButton = new Button(); + updateMcpToggleButton(); + mcpToggleButton.setOnAction(e -> toggleMcpServer()); + + HBox controlsBox = new HBox(15); + controlsBox.getChildren().addAll(mcpToggleButton, mcpStatusLabel); + + box.getChildren().add(controlsBox); + return box; + } + + private void toggleMcpServer() + { + if (mcpController.isMcpServerRunning()) + { + mcpController.stopMcpServer(); + } + else + { + settingsController.setMcpServerPort(tmpMcpServerPort); + mcpController.startMcpServer(); + } + updateMcpStatusLabel(); + updateMcpToggleButton(); + } + + private void updateMcpStatusLabel() + { + if (mcpController.isMcpServerRunning()) + { + mcpStatusLabel.setText("Server running on port " + mcpController.getMcpServerPort()); + mcpStatusLabel.setStyle("-fx-text-fill: green;"); + } + else + { + mcpStatusLabel.setText("Server stopped"); + mcpStatusLabel.setStyle("-fx-text-fill: gray;"); + } + } + + private void updateMcpToggleButton() + { + if (mcpController.isMcpServerRunning()) + { + mcpToggleButton.setText("Stop Server"); + } + else + { + mcpToggleButton.setText("Start Server"); + } + } } diff --git a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java index c0c996d..3a2415a 100644 --- a/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java +++ b/src/main/java/com/daniel/jsoneditor/view/impl/jfx/impl/scenes/impl/editor/components/menubar/JsonEditorMenuBar.java @@ -36,7 +36,7 @@ public JsonEditorMenuBar(ReadableModel model, Controller controller, EditorWindo refreshItem.setAccelerator(new KeyCodeCombination(KeyCode.R, KeyCombination.SHORTCUT_DOWN)); MenuItem settingsItem = new MenuItem("Settings..."); settingsItem.setOnAction(event -> { - SettingsDialog dialog = new SettingsDialog(controller.getSettingsController()); + SettingsDialog dialog = new SettingsDialog(controller.getSettingsController(), controller.getMcpController()); dialog.showAndWait(); // Trigger settings change handling using existing infrastructure manager.handleSettingsChanged(); diff --git a/src/main/resources/version.properties b/src/main/resources/version.properties index e813e4f..b811b26 100644 --- a/src/main/resources/version.properties +++ b/src/main/resources/version.properties @@ -1 +1 @@ -version=0.16.0 \ No newline at end of file +version=0.16.1 \ No newline at end of file