From 71158c8f82ebe2bf31e6df2a1b165bcf70c636a8 Mon Sep 17 00:00:00 2001 From: Daniel Kispert Date: Thu, 5 Feb 2026 22:27:33 +0100 Subject: [PATCH 1/5] first add of mcp server --- build.gradle | 3 + .../jsoneditor/controller/Controller.java | 8 +- .../controller/impl/ControllerImpl.java | 13 +- .../impl/JsonFileReaderAndWriterImpl.java | 31 +- .../controller/mcp/McpController.java | 72 +++ .../settings/SettingsController.java | 8 + .../settings/impl/PropertyFileKeys.java | 4 + .../settings/impl/SettingsControllerImpl.java | 39 ++ .../model/mcp/JsonEditorMcpServer.java | 467 ++++++++++++++++++ .../daniel/jsoneditor/util/VersionUtil.java | 53 ++ .../daniel/jsoneditor/view/impl/ViewImpl.java | 5 + .../view/impl/jfx/dialogs/AboutDialog.java | 23 +- .../view/impl/jfx/dialogs/SettingsDialog.java | 113 ++++- .../components/menubar/JsonEditorMenuBar.java | 2 +- src/main/resources/version.properties | 2 +- 15 files changed, 799 insertions(+), 44 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java create mode 100644 src/main/java/com/daniel/jsoneditor/util/VersionUtil.java 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..c8d2388 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/Controller.java +++ b/src/main/java/com/daniel/jsoneditor/controller/Controller.java @@ -1,10 +1,10 @@ package com.daniel.jsoneditor.controller; 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; @@ -12,11 +12,10 @@ 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 +100,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..4ae5a87 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(readableModel, 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..00a2806 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -0,0 +1,72 @@ +package com.daniel.jsoneditor.controller.mcp; + +import com.daniel.jsoneditor.controller.settings.SettingsController; +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +public class McpController +{ + private static final Logger logger = LoggerFactory.getLogger(McpController.class); + + private final JsonEditorMcpServer mcpServer; + + private final SettingsController settingsController; + + public McpController(final ReadableModel model, final SettingsController settingsController) + { + this.mcpServer = new JsonEditorMcpServer(model); + this.settingsController = settingsController; + } + + /** + * Starts the MCP server. + * Logs errors but does not throw exceptions. + */ + 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/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java new file mode 100644 index 0000000..2be91ae --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -0,0 +1,467 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.json.JsonNodeWithPath; +import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObject; +import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObjectInstance; +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.ArrayNode; +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; +import java.util.List; + + +/** + * MCP (Model Context Protocol) Server for the JSON Editor. + * Provides read-only 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 final ReadableModel model; + + private HttpServer server; + + private int port; + + private volatile boolean running; + + public JsonEditorMcpServer(final ReadableModel model) + { + if (model == null) + { + throw new IllegalArgumentException("model cannot be null"); + } + this.model = model; + 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, 200, response); + } + + private void handleMcpRequest(final HttpExchange exchange) throws IOException + { + if (!"POST".equals(exchange.getRequestMethod())) + { + sendJsonResponse(exchange, 405, createErrorResponse(null, -32600, "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, 200, response); + } + catch (JsonProcessingException e) + { + logger.error("Invalid JSON in request: {}", requestBody, e); + sendJsonResponse(exchange, 400, createErrorResponse(null, -32700, "Parse error")); + } + catch (Exception e) + { + logger.error("Error processing MCP request", e); + sendJsonResponse(exchange, 500, createErrorResponse(null, -32603, "Internal 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, -32601, "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 ArrayNode tools = OBJECT_MAPPER.createArrayNode(); + + tools.add(createToolDefinition("get_current_file", "Get information about the currently open JSON file", + OBJECT_MAPPER.createObjectNode())); + + tools.add(createToolDefinition("get_node", "Get a JSON node at a specific path", + createSchemaWithProperty("path", "string", "JSON path (e.g., /root/child)"))); + + tools.add(createToolDefinition("get_referenceable_objects", "List all referenceable object types defined in the schema", + OBJECT_MAPPER.createObjectNode())); + + tools.add(createToolDefinition("get_referenceable_instances", "Get all instances of a referenceable object type", + createSchemaWithProperty("referencing_key", "string", "The referencing key of the referenceable object type"))); + + tools.add(createToolDefinition("get_examples", "Get example values for a JSON path", + createSchemaWithProperty("path", "string", "JSON path to get examples for"))); + + tools.add(createToolDefinition("get_schema_for_path", "Get the JSON schema definition for a specific path", + createSchemaWithProperty("path", "string", "JSON path to get schema for"))); + + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + result.set("tools", tools); + 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"); + + return switch (toolName) + { + case "get_current_file" -> executeGetCurrentFile(id); + case "get_node" -> executeGetNode(arguments, id); + case "get_referenceable_objects" -> executeGetReferenceableObjects(id); + case "get_referenceable_instances" -> executeGetReferenceableInstances(arguments, id); + case "get_examples" -> executeGetExamples(arguments, id); + case "get_schema_for_path" -> executeGetSchemaForPath(arguments, id); + default -> createErrorResponse(id, -32602, "Unknown tool: " + toolName); + }; + } + + private String executeGetCurrentFile(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 createToolResult(id, content.toString()); + } + + private String executeGetNode(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + + if (path.isEmpty()) + { + return createToolResult(id, "Error: path parameter is required"); + } + + final JsonNodeWithPath node = model.getNodeForPath(path); + if (node == null) + { + return createToolResult(id, "Error: 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 createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); + } + + private String executeGetReferenceableObjects(final JsonNode id) throws JsonProcessingException + { + final List objects = model.getReferenceableObjects(); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + 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 createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); + } + + private String executeGetReferenceableInstances(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String referencingKey = arguments.path("referencing_key").asText(""); + + if (referencingKey.isEmpty()) + { + return createToolResult(id, "Error: referencing_key parameter is required"); + } + + final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); + if (refObject == null) + { + return createToolResult(id, "Error: No referenceable object found with key: " + referencingKey); + } + + final List instances = model.getReferenceableObjectInstances(refObject); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + 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 createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); + } + + private String executeGetExamples(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + + if (path.isEmpty()) + { + return createToolResult(id, "Error: path parameter is required"); + } + + final List examples = model.getStringExamplesForPath(path); + final List allowedValues = model.getAllowedStringValuesForPath(path); + + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + + final ArrayNode examplesArray = OBJECT_MAPPER.createArrayNode(); + examples.forEach(examplesArray::add); + result.set("examples", examplesArray); + + final ArrayNode allowedArray = OBJECT_MAPPER.createArrayNode(); + allowedValues.forEach(allowedArray::add); + result.set("allowed_values", allowedArray); + + return createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); + } + + private String executeGetSchemaForPath(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + + if (path.isEmpty()) + { + return createToolResult(id, "Error: path parameter is required"); + } + + final var schema = model.getSubschemaForPath(path); + if (schema == null) + { + return createToolResult(id, "Error: No schema found for path: " + path); + } + + return createToolResult(id, schema.getSchemaNode().toString()); + } + + private ObjectNode createToolDefinition(final String name, final String description, final ObjectNode inputSchema) + { + final ObjectNode tool = OBJECT_MAPPER.createObjectNode(); + tool.put("name", name); + tool.put("description", description); + + final ObjectNode schema = OBJECT_MAPPER.createObjectNode(); + schema.put("type", "object"); + schema.set("properties", inputSchema); + tool.set("inputSchema", schema); + + return tool; + } + + private 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; + } + + 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 createToolResult(final JsonNode id, final String content) throws JsonProcessingException + { + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + final ArrayNode contentArray = OBJECT_MAPPER.createArrayNode(); + final ObjectNode textContent = OBJECT_MAPPER.createObjectNode(); + textContent.put("type", "text"); + textContent.put("text", content); + contentArray.add(textContent); + result.set("content", contentArray); + + return createSuccessResponse(id, result); + } + + 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\"}}"; + } + } + + 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/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 From 70e5d60c623f881fb060730a23ccffaaf1445395 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Feb 2026 01:17:42 +0100 Subject: [PATCH 2/5] refactor --- .../controller/impl/ControllerImpl.java | 2 +- .../controller/mcp/McpController.java | 11 +- .../model/mcp/GetCurrentFileTool.java | 69 +++++ .../jsoneditor/model/mcp/GetExamplesTool.java | 81 ++++++ .../jsoneditor/model/mcp/GetNodeTool.java | 73 +++++ .../mcp/GetReferenceableInstancesTool.java | 88 ++++++ .../mcp/GetReferenceableObjectsTool.java | 74 +++++ .../model/mcp/GetSchemaForPathTool.java | 66 +++++ .../model/mcp/JsonEditorMcpServer.java | 273 +++--------------- .../model/mcp/McpArgumentValidator.java | 65 +++++ .../daniel/jsoneditor/model/mcp/McpTool.java | 38 +++ .../jsoneditor/model/mcp/McpToolRegistry.java | 121 ++++++++ .../jsoneditor/model/mcp/ReadOnlyMcpTool.java | 22 ++ .../jsoneditor/model/mcp/SetNodeTool.java | 117 ++++++++ .../model/mcp/ValidationException.java | 12 + .../jsoneditor/model/mcp/WriteMcpTool.java | 22 ++ 16 files changed, 899 insertions(+), 235 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ReadOnlyMcpTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/ValidationException.java create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/WriteMcpTool.java diff --git a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java index 4ae5a87..fb9cd14 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java +++ b/src/main/java/com/daniel/jsoneditor/controller/impl/ControllerImpl.java @@ -78,7 +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(readableModel, settingsController); + this.mcpController = new McpController(model, settingsController); // Set up callback for unsaved changes notifications from CommandManager this.commandManager.setUnsavedChangesCallback(this::updateWindowTitle); 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 00a2806..e823606 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java +++ b/src/main/java/com/daniel/jsoneditor/controller/mcp/McpController.java @@ -1,13 +1,13 @@ package com.daniel.jsoneditor.controller.mcp; +import java.io.IOException; + import com.daniel.jsoneditor.controller.settings.SettingsController; -import com.daniel.jsoneditor.model.ReadableModel; +import com.daniel.jsoneditor.model.WritableModel; import com.daniel.jsoneditor.model.mcp.JsonEditorMcpServer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; - public class McpController { private static final Logger logger = LoggerFactory.getLogger(McpController.class); @@ -16,15 +16,14 @@ public class McpController private final SettingsController settingsController; - public McpController(final ReadableModel model, final SettingsController settingsController) + public McpController(final WritableModel writableModel, final SettingsController settingsController) { - this.mcpServer = new JsonEditorMcpServer(model); + this.mcpServer = new JsonEditorMcpServer(writableModel); this.settingsController = settingsController; } /** * Starts the MCP server. - * Logs errors but does not throw exceptions. */ public void startMcpServer() { diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java new file mode 100644 index 0000000..d87495b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.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 GetCurrentFileTool extends ReadOnlyMcpTool +{ + private static final Logger logger = LoggerFactory.getLogger(GetCurrentFileTool.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + public GetCurrentFileTool(final ReadableModel model) + { + super(model); + } + + @Override + public String getName() + { + return "get_current_file"; + } + + @Override + public String getDescription() + { + return "Get information about the currently open JSON file"; + } + + @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.toString()); + } +} 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..3c4f56b --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -0,0 +1,81 @@ +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 String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + if (path.isEmpty()) + { + return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); + } + + try + { + 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, OBJECT_MAPPER.writeValueAsString(result)); + } + catch (Exception e) + { + logger.error("Error executing get_examples for path: {}", path, e); + return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get examples for path: %s", path)); + } + } +} 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..236e07d --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -0,0 +1,73 @@ +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.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 String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + if (path.isEmpty()) + { + return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); + } + + try + { + final JsonNodeWithPath node = model.getNodeForPath(path); + if (node == null) + { + return McpToolRegistry.createToolResult(id, String.format("Error: No node found at path: %s", 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, OBJECT_MAPPER.writeValueAsString(result)); + } + catch (Exception e) + { + logger.error("Error executing get_node for path: {}", path, e); + return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get node at path: %s", path)); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java new file mode 100644 index 0000000..0231936 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.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.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 String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String referencingKey = arguments.path("referencing_key").asText(""); + if (referencingKey.isEmpty()) + { + return McpToolRegistry.createToolResult(id, "Error: referencing_key parameter is required"); + } + + try + { + final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); + if (refObject == null) + { + return McpToolRegistry.createToolResult(id, + String.format("Error: No referenceable object found with key: %s", 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, OBJECT_MAPPER.writeValueAsString(result)); + } + catch (Exception e) + { + logger.error("Error executing get_referenceable_instances for key: {}", referencingKey, e); + return McpToolRegistry.createToolResult(id, + String.format("Error: Failed to retrieve instances for key: %s", referencingKey)); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java new file mode 100644 index 0000000..ecbba36 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -0,0 +1,74 @@ +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 + { + try + { + final List objects = model.getReferenceableObjects(); + if (objects == null) + { + return McpToolRegistry.createToolResult(id, "[]"); + } + + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + 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, OBJECT_MAPPER.writeValueAsString(result)); + } + catch (Exception e) + { + logger.error("Error executing get_referenceable_objects", e); + return McpToolRegistry.createToolResult(id, "Error: Failed to retrieve referenceable objects"); + } + } +} 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..aa469a3 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -0,0 +1,66 @@ +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 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 String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + if (path.isEmpty()) + { + return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); + } + + try + { + final JsonSchema schema = model.getSubschemaForPath(path); + if (schema == null) + { + return McpToolRegistry.createToolResult(id, String.format("Error: No schema found for path: %s", path)); + } + + return McpToolRegistry.createToolResult(id, schema.getSchemaNode().toString()); + } + catch (Exception e) + { + logger.error("Error executing get_schema_for_path for path: {}", path, e); + return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get schema for path: %s", path)); + } + } +} diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java index 2be91ae..58ec52f 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -1,14 +1,10 @@ package com.daniel.jsoneditor.model.mcp; -import com.daniel.jsoneditor.model.ReadableModel; -import com.daniel.jsoneditor.model.json.JsonNodeWithPath; -import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObject; -import com.daniel.jsoneditor.model.json.schema.reference.ReferenceableObjectInstance; +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.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; @@ -20,12 +16,11 @@ import java.io.OutputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; -import java.util.List; /** * MCP (Model Context Protocol) Server for the JSON Editor. - * Provides read-only access to the current editor session via HTTP JSON-RPC. + * 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 @@ -42,7 +37,18 @@ public class JsonEditorMcpServer public static final int DEFAULT_PORT = 3000; - private final ReadableModel model; + 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; @@ -50,13 +56,20 @@ public class JsonEditorMcpServer private volatile boolean running; - public JsonEditorMcpServer(final ReadableModel model) + /** + * 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 (model == null) + if (writableModel == null) { - throw new IllegalArgumentException("model cannot be null"); + throw new IllegalArgumentException("writableModel cannot be null"); } - this.model = model; + this.toolRegistry = new McpToolRegistry(writableModel); this.running = false; } @@ -133,14 +146,14 @@ private void handleRequest(final HttpExchange exchange) throws IOException private void handleHealthCheck(final HttpExchange exchange) throws IOException { final String response = "{\"status\":\"ok\",\"service\":\"json-editor-mcp\"}"; - sendJsonResponse(exchange, 200, response); + sendJsonResponse(exchange, HTTP_OK, response); } private void handleMcpRequest(final HttpExchange exchange) throws IOException { if (!"POST".equals(exchange.getRequestMethod())) { - sendJsonResponse(exchange, 405, createErrorResponse(null, -32600, "Method not allowed")); + sendJsonResponse(exchange, HTTP_METHOD_NOT_ALLOWED, createErrorResponse(null, JSONRPC_INVALID_REQUEST, "Method not allowed")); return; } @@ -155,17 +168,12 @@ private void handleMcpRequest(final HttpExchange exchange) throws IOException final JsonNode id = request.path("id"); final String response = processMethod(method, params, id); - sendJsonResponse(exchange, 200, response); + sendJsonResponse(exchange, HTTP_OK, response); } catch (JsonProcessingException e) { logger.error("Invalid JSON in request: {}", requestBody, e); - sendJsonResponse(exchange, 400, createErrorResponse(null, -32700, "Parse error")); - } - catch (Exception e) - { - logger.error("Error processing MCP request", e); - sendJsonResponse(exchange, 500, createErrorResponse(null, -32603, "Internal error")); + sendJsonResponse(exchange, HTTP_BAD_REQUEST, createErrorResponse(null, JSONRPC_PARSE_ERROR, "Parse error")); } } @@ -176,7 +184,7 @@ private String processMethod(final String method, final JsonNode params, final J case "initialize" -> handleInitialize(id); case "tools/list" -> handleToolsList(id); case "tools/call" -> handleToolsCall(params, id); - default -> createErrorResponse(id, -32601, "Method not found: " + method); + default -> createErrorResponse(id, JSONRPC_METHOD_NOT_FOUND, "Method not found: " + method); }; } @@ -200,28 +208,8 @@ private String handleInitialize(final JsonNode id) throws JsonProcessingExceptio private String handleToolsList(final JsonNode id) throws JsonProcessingException { - final ArrayNode tools = OBJECT_MAPPER.createArrayNode(); - - tools.add(createToolDefinition("get_current_file", "Get information about the currently open JSON file", - OBJECT_MAPPER.createObjectNode())); - - tools.add(createToolDefinition("get_node", "Get a JSON node at a specific path", - createSchemaWithProperty("path", "string", "JSON path (e.g., /root/child)"))); - - tools.add(createToolDefinition("get_referenceable_objects", "List all referenceable object types defined in the schema", - OBJECT_MAPPER.createObjectNode())); - - tools.add(createToolDefinition("get_referenceable_instances", "Get all instances of a referenceable object type", - createSchemaWithProperty("referencing_key", "string", "The referencing key of the referenceable object type"))); - - tools.add(createToolDefinition("get_examples", "Get example values for a JSON path", - createSchemaWithProperty("path", "string", "JSON path to get examples for"))); - - tools.add(createToolDefinition("get_schema_for_path", "Get the JSON schema definition for a specific path", - createSchemaWithProperty("path", "string", "JSON path to get schema for"))); - final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - result.set("tools", tools); + result.set("tools", toolRegistry.getToolDefinitions()); return createSuccessResponse(id, result); } @@ -230,186 +218,27 @@ private String handleToolsCall(final JsonNode params, final JsonNode id) throws final String toolName = params.path("name").asText(); final JsonNode arguments = params.path("arguments"); - return switch (toolName) - { - case "get_current_file" -> executeGetCurrentFile(id); - case "get_node" -> executeGetNode(arguments, id); - case "get_referenceable_objects" -> executeGetReferenceableObjects(id); - case "get_referenceable_instances" -> executeGetReferenceableInstances(arguments, id); - case "get_examples" -> executeGetExamples(arguments, id); - case "get_schema_for_path" -> executeGetSchemaForPath(arguments, id); - default -> createErrorResponse(id, -32602, "Unknown tool: " + toolName); - }; - } - - private String executeGetCurrentFile(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 createToolResult(id, content.toString()); - } - - private String executeGetNode(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - final String path = arguments.path("path").asText(""); - - if (path.isEmpty()) - { - return createToolResult(id, "Error: path parameter is required"); - } - - final JsonNodeWithPath node = model.getNodeForPath(path); - if (node == null) - { - return createToolResult(id, "Error: 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 createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); - } - - private String executeGetReferenceableObjects(final JsonNode id) throws JsonProcessingException - { - final List objects = model.getReferenceableObjects(); - final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - - 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 createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); - } - - private String executeGetReferenceableInstances(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - final String referencingKey = arguments.path("referencing_key").asText(""); - - if (referencingKey.isEmpty()) - { - return createToolResult(id, "Error: referencing_key parameter is required"); - } - - final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); - if (refObject == null) - { - return createToolResult(id, "Error: No referenceable object found with key: " + referencingKey); - } - - final List instances = model.getReferenceableObjectInstances(refObject); - final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - - for (final ReferenceableObjectInstance instance : instances) + final McpTool tool = toolRegistry.getTool(toolName); + if (tool == null) { - 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 createErrorResponse(id, JSONRPC_INVALID_PARAMS, "Unknown tool: " + toolName); } - - return createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); - } - - private String executeGetExamples(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - final String path = arguments.path("path").asText(""); - - if (path.isEmpty()) - { - return createToolResult(id, "Error: path parameter is required"); - } - - final List examples = model.getStringExamplesForPath(path); - final List allowedValues = model.getAllowedStringValuesForPath(path); - - final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - - final ArrayNode examplesArray = OBJECT_MAPPER.createArrayNode(); - examples.forEach(examplesArray::add); - result.set("examples", examplesArray); - - final ArrayNode allowedArray = OBJECT_MAPPER.createArrayNode(); - allowedValues.forEach(allowedArray::add); - result.set("allowed_values", allowedArray); - - return createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); - } - - private String executeGetSchemaForPath(final JsonNode arguments, final JsonNode id) throws JsonProcessingException - { - final String path = arguments.path("path").asText(""); - - if (path.isEmpty()) + + // Lightweight validation: check types of provided arguments against tool.getInputSchema() properties + final ObjectNode inputSchemaProps = tool.getInputSchema(); + try { - return createToolResult(id, "Error: path parameter is required"); + McpArgumentValidator.validate(inputSchemaProps, arguments); } - - final var schema = model.getSubschemaForPath(path); - if (schema == null) + catch (ValidationException e) { - return createToolResult(id, "Error: No schema found for path: " + path); + // Validation failed in a known way -> return invalid params error + return createErrorResponse(id, JSONRPC_INVALID_PARAMS, e.getMessage()); } - return createToolResult(id, schema.getSchemaNode().toString()); - } - - private ObjectNode createToolDefinition(final String name, final String description, final ObjectNode inputSchema) - { - final ObjectNode tool = OBJECT_MAPPER.createObjectNode(); - tool.put("name", name); - tool.put("description", description); - - final ObjectNode schema = OBJECT_MAPPER.createObjectNode(); - schema.put("type", "object"); - schema.set("properties", inputSchema); - tool.set("inputSchema", schema); - - return tool; + return tool.execute(arguments, id); } - - private 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; - } - + private String createSuccessResponse(final JsonNode id, final JsonNode result) throws JsonProcessingException { final ObjectNode response = OBJECT_MAPPER.createObjectNode(); @@ -419,19 +248,7 @@ private String createSuccessResponse(final JsonNode id, final JsonNode result) t return OBJECT_MAPPER.writeValueAsString(response); } - private String createToolResult(final JsonNode id, final String content) throws JsonProcessingException - { - final ObjectNode result = OBJECT_MAPPER.createObjectNode(); - final ArrayNode contentArray = OBJECT_MAPPER.createArrayNode(); - final ObjectNode textContent = OBJECT_MAPPER.createObjectNode(); - textContent.put("type", "text"); - textContent.put("text", content); - contentArray.add(textContent); - result.set("content", contentArray); - - return createSuccessResponse(id, result); - } - + private String createErrorResponse(final JsonNode id, final int code, final String message) { try 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..fbb1a9e --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java @@ -0,0 +1,65 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +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; + +/** + * Lightweight validator for MCP tool arguments based on the tool's input schema. + * Uses networknt JsonSchema validation library already present in the project. + */ +public final class McpArgumentValidator +{ + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); + + private McpArgumentValidator() { /* utility */ } + + /** + * Validate arguments against input schema using JSON Schema validation. + * + * @param inputSchema ObjectNode representing the inputSchema with properties + * @param arguments JsonNode with actual arguments + * @throws ValidationException if validation fails + */ + public static void validate(final ObjectNode inputSchema, final JsonNode arguments) throws ValidationException + { + if (inputSchema == null || inputSchema.isEmpty()) + { + return; + } + + if (arguments == null || arguments.isMissingNode() || arguments.isNull()) + { + return; + } + + final ObjectNode schemaNode = buildValidationSchema(inputSchema); + 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("Invalid parameters: " + errorMessage); + } + } + + private static ObjectNode buildValidationSchema(final ObjectNode inputSchema) + { + final ObjectNode schemaNode = MAPPER.createObjectNode(); + schemaNode.put("type", "object"); + schemaNode.set("properties", inputSchema); + schemaNode.put("additionalProperties", false); + return schemaNode; + } +} 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..f2db273 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java @@ -0,0 +1,38 @@ +package com.daniel.jsoneditor.model.mcp; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +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 for input parameters (ObjectNode with properties) + */ + public abstract ObjectNode getInputSchema(); + + /** + * 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..8d5a7b8 --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -0,0 +1,121 @@ +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 GetCurrentFileTool(model), + new GetNodeTool(model), + new GetSchemaForPathTool(model), + new GetExamplesTool(model), + new GetReferenceableObjectsTool(model), + new GetReferenceableInstancesTool(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()); + + final ObjectNode schema = OBJECT_MAPPER.createObjectNode(); + schema.put("type", "object"); + schema.set("properties", tool.getInputSchema()); + toolDef.set("inputSchema", schema); + + toolsArray.add(toolDef); + } + + return toolsArray; + } + + protected static String createToolResult(final JsonNode id, final String content) throws JsonProcessingException + { + final ObjectNode result = OBJECT_MAPPER.createObjectNode(); + final ArrayNode contentArray = OBJECT_MAPPER.createArrayNode(); + final ObjectNode textContent = OBJECT_MAPPER.createObjectNode(); + textContent.put("type", "text"); + textContent.put("text", content); + 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..d800e8f --- /dev/null +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java @@ -0,0 +1,117 @@ +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.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 String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException + { + final String path = arguments.path("path").asText(""); + final String property = arguments.path("property").asText(""); + + if (path.isEmpty() || property.isEmpty()) + { + return McpToolRegistry.createToolResult(id, "Error: path and property parameters are required"); + } + + final JsonNode valueNode = arguments.path("value"); + if (valueNode.isMissingNode()) + { + return McpToolRegistry.createToolResult(id, "Error: value parameter is required"); + } + + try + { + 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 McpToolRegistry.createToolResult(id, "Error: value must be string, number, boolean, or null"); + } + + 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, OBJECT_MAPPER.writeValueAsString(result)); + } + catch (Exception e) + { + logger.error("Error executing set_node for path: {}, property: {}", path, property, e); + return McpToolRegistry.createToolResult(id, + String.format("Error: 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; + } +} From 52b9fa364af4ecc18b4043a6d8dab3abba32ceef Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Feb 2026 02:45:07 +0100 Subject: [PATCH 3/5] more reworkings --- .../model/mcp/GetCurrentFileTool.java | 2 +- .../jsoneditor/model/mcp/GetExamplesTool.java | 61 ++++++++------- .../jsoneditor/model/mcp/GetNodeTool.java | 57 ++++++++------ .../mcp/GetReferenceableInstancesTool.java | 66 +++++++++-------- .../mcp/GetReferenceableObjectsTool.java | 34 +++++---- .../model/mcp/GetSchemaForPathTool.java | 41 +++++----- .../model/mcp/JsonEditorMcpServer.java | 28 ++++++- .../model/mcp/McpArgumentValidator.java | 36 ++++----- .../daniel/jsoneditor/model/mcp/McpTool.java | 19 ++++- .../jsoneditor/model/mcp/McpToolRegistry.java | 49 +++++++++--- .../jsoneditor/model/mcp/SetNodeTool.java | 74 +++++++++++-------- 11 files changed, 284 insertions(+), 183 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java index d87495b..2dabd67 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java @@ -64,6 +64,6 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr content.put("has_content", model.getRootJson() != null); - return McpToolRegistry.createToolResult(id, content.toString()); + return McpToolRegistry.createToolResult(id, content); } } 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 3c4f56b..43cc04e 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -39,43 +39,48 @@ 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 ObjectNode getOutputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("examples", OBJECT_MAPPER.createArrayNode()); + props.set("allowed_values", OBJECT_MAPPER.createArrayNode()); + return props; + } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String path = arguments.path("path").asText(""); - if (path.isEmpty()) - { - return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); - } - try + 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) { - 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, OBJECT_MAPPER.writeValueAsString(result)); + examples.forEach(examplesArray::add); } - catch (Exception e) + result.set("examples", examplesArray); + + final ArrayNode allowedArray = OBJECT_MAPPER.createArrayNode(); + if (allowedValues != null) { - logger.error("Error executing get_examples for path: {}", path, e); - return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get examples for path: %s", path)); + 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/GetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java index 236e07d..e61cd05 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -5,6 +5,7 @@ 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; @@ -37,37 +38,45 @@ 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 ObjectNode getOutputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); + props.set("display_name", McpToolRegistry.createSchemaWithProperty("display_name", "string", "")); + props.set("value", OBJECT_MAPPER.createObjectNode()); + props.set("is_array", McpToolRegistry.createSchemaWithProperty("is_array", "boolean", "")); + props.set("is_object", McpToolRegistry.createSchemaWithProperty("is_object", "boolean", "")); + return props; + } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String path = arguments.path("path").asText(""); - if (path.isEmpty()) - { - return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); - } - try + final JsonNodeWithPath node = model.getNodeForPath(path); + if (node == null) { - final JsonNodeWithPath node = model.getNodeForPath(path); - if (node == null) - { - return McpToolRegistry.createToolResult(id, String.format("Error: No node found at path: %s", 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, OBJECT_MAPPER.writeValueAsString(result)); - } - catch (Exception e) - { - logger.error("Error executing get_node for path: {}", path, e); - return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get node at path: %s", path)); + 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 index 0231936..98d9f5b 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -42,47 +42,53 @@ 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 ObjectNode getOutputSchema() + { + final ObjectNode item = OBJECT_MAPPER.createObjectNode(); + item.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); + item.set("key", McpToolRegistry.createSchemaWithProperty("key", "string", "")); + item.set("display_name", McpToolRegistry.createSchemaWithProperty("display_name", "string", "")); + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("items", item); + return props; + } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String referencingKey = arguments.path("referencing_key").asText(""); - if (referencingKey.isEmpty()) + + final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); + if (refObject == null) { - return McpToolRegistry.createToolResult(id, "Error: referencing_key parameter is required"); + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "No referenceable object found with key: " + referencingKey); } - try + final List instances = model.getReferenceableObjectInstances(refObject); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + if (instances != null) { - final ReferenceableObject refObject = model.getReferenceableObjectByReferencingKey(referencingKey); - if (refObject == null) - { - return McpToolRegistry.createToolResult(id, - String.format("Error: No referenceable object found with key: %s", referencingKey)); - } - - final List instances = model.getReferenceableObjectInstances(refObject); - final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - - if (instances != null) + for (final ReferenceableObjectInstance instance : instances) { - 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); - } + 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, OBJECT_MAPPER.writeValueAsString(result)); - } - catch (Exception e) - { - logger.error("Error executing get_referenceable_instances for key: {}", referencingKey, e); - return McpToolRegistry.createToolResult(id, - String.format("Error: Failed to retrieve instances for key: %s", referencingKey)); } + + 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 ecbba36..4196074 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -41,19 +41,26 @@ public ObjectNode getInputSchema() return OBJECT_MAPPER.createObjectNode(); } + @Override + public ObjectNode getOutputSchema() + { + final ObjectNode item = OBJECT_MAPPER.createObjectNode(); + item.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); + item.set("referencing_key", McpToolRegistry.createSchemaWithProperty("referencing_key", "string", "")); + item.set("key_property", McpToolRegistry.createSchemaWithProperty("key_property", "string", "")); + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("items", item); + return props; + } + @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { - try + final List objects = model.getReferenceableObjects(); + final ArrayNode result = OBJECT_MAPPER.createArrayNode(); + + if (objects != null) { - final List objects = model.getReferenceableObjects(); - if (objects == null) - { - return McpToolRegistry.createToolResult(id, "[]"); - } - - final ArrayNode result = OBJECT_MAPPER.createArrayNode(); - for (final ReferenceableObject obj : objects) { final ObjectNode objNode = OBJECT_MAPPER.createObjectNode(); @@ -62,13 +69,8 @@ public String execute(final JsonNode arguments, final JsonNode id) throws JsonPr objNode.put("key_property", obj.getKey()); result.add(objNode); } - - return McpToolRegistry.createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); - } - catch (Exception e) - { - logger.error("Error executing get_referenceable_objects", e); - return McpToolRegistry.createToolResult(id, "Error: Failed to retrieve referenceable objects"); } + + 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 aa469a3..02ef818 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -4,6 +4,7 @@ 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; @@ -37,30 +38,36 @@ 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 ObjectNode getOutputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("schema", OBJECT_MAPPER.createObjectNode()); + return props; + } @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { final String path = arguments.path("path").asText(""); - if (path.isEmpty()) - { - return McpToolRegistry.createToolResult(id, "Error: path parameter is required"); - } - try + final JsonSchema schema = model.getSubschemaForPath(path); + if (schema == null) { - final JsonSchema schema = model.getSubschemaForPath(path); - if (schema == null) - { - return McpToolRegistry.createToolResult(id, String.format("Error: No schema found for path: %s", path)); - } - - return McpToolRegistry.createToolResult(id, schema.getSchemaNode().toString()); - } - catch (Exception e) - { - logger.error("Error executing get_schema_for_path for path: {}", path, e); - return McpToolRegistry.createToolResult(id, String.format("Error: Failed to get schema for path: %s", path)); + 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 index 58ec52f..6164706 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -224,15 +224,14 @@ private String handleToolsCall(final JsonNode params, final JsonNode id) throws return createErrorResponse(id, JSONRPC_INVALID_PARAMS, "Unknown tool: " + toolName); } - // Lightweight validation: check types of provided arguments against tool.getInputSchema() properties - final ObjectNode inputSchemaProps = tool.getInputSchema(); + // Validate arguments against complete input schema (including required properties) + final ObjectNode inputSchema = McpToolRegistry.buildInputSchema(tool); try { - McpArgumentValidator.validate(inputSchemaProps, arguments); + McpArgumentValidator.validate(inputSchema, arguments); } catch (ValidationException e) { - // Validation failed in a known way -> return invalid params error return createErrorResponse(id, JSONRPC_INVALID_PARAMS, e.getMessage()); } @@ -269,6 +268,27 @@ private String createErrorResponse(final JsonNode id, final int code, final Stri 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 { 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 fbb1a9e..1eb702c 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpArgumentValidator.java @@ -1,8 +1,6 @@ package com.daniel.jsoneditor.model.mcp; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SpecVersion; @@ -12,36 +10,39 @@ import java.util.stream.Collectors; /** - * Lightweight validator for MCP tool arguments based on the tool's input schema. - * Uses networknt JsonSchema validation library already present in the project. + * 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 ObjectMapper MAPPER = new ObjectMapper(); private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7); private McpArgumentValidator() { /* utility */ } /** - * Validate arguments against input schema using JSON Schema validation. + * Validate arguments against a complete JSON Schema. * - * @param inputSchema ObjectNode representing the inputSchema with properties + * @param schemaNode Complete JSON Schema (type=object, properties, required, etc.) * @param arguments JsonNode with actual arguments - * @throws ValidationException if validation fails + * @throws ValidationException if validation fails with all error messages */ - public static void validate(final ObjectNode inputSchema, final JsonNode arguments) throws ValidationException + public static void validate(final JsonNode schemaNode, final JsonNode arguments) throws ValidationException { - if (inputSchema == null || inputSchema.isEmpty()) + if (schemaNode == null || schemaNode.isEmpty()) { return; } - if (arguments == null || arguments.isMissingNode() || arguments.isNull()) + if (arguments == null || arguments.isMissingNode()) { + if (schemaNode.has("required") && schemaNode.get("required").size() > 0) + { + throw new ValidationException("Missing required parameters"); + } return; } - final ObjectNode schemaNode = buildValidationSchema(inputSchema); final JsonSchema schema = SCHEMA_FACTORY.getSchema(schemaNode); final Set errors = schema.validate(arguments); @@ -50,16 +51,7 @@ public static void validate(final ObjectNode inputSchema, final JsonNode argumen final String errorMessage = errors.stream() .map(ValidationMessage::getMessage) .collect(Collectors.joining("; ")); - throw new ValidationException("Invalid parameters: " + errorMessage); + throw new ValidationException(errorMessage); } } - - private static ObjectNode buildValidationSchema(final ObjectNode inputSchema) - { - final ObjectNode schemaNode = MAPPER.createObjectNode(); - schemaNode.put("type", "object"); - schemaNode.set("properties", inputSchema); - schemaNode.put("additionalProperties", false); - return schemaNode; - } } 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 f2db273..f9dd203 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpTool.java @@ -2,6 +2,7 @@ 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; @@ -22,10 +23,26 @@ public abstract class McpTool public abstract String getDescription(); /** - * @return JSON Schema for input parameters (ObjectNode with properties) + * @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. * 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 8d5a7b8..5e03ec1 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -81,10 +81,17 @@ public ArrayNode getToolDefinitions() toolDef.put("name", tool.getName()); toolDef.put("description", tool.getDescription()); - final ObjectNode schema = OBJECT_MAPPER.createObjectNode(); - schema.put("type", "object"); - schema.set("properties", tool.getInputSchema()); - toolDef.set("inputSchema", schema); + toolDef.set("inputSchema", buildInputSchema(tool)); + + // include output schema if available + final ObjectNode outputSchema = tool.getOutputSchema(); + if (outputSchema != null) + { + final ObjectNode outWrap = OBJECT_MAPPER.createObjectNode(); + outWrap.put("type", "object"); + outWrap.set("properties", outputSchema); + toolDef.set("outputSchema", outWrap); + } toolsArray.add(toolDef); } @@ -92,14 +99,38 @@ public ArrayNode getToolDefinitions() return toolsArray; } - protected static String createToolResult(final JsonNode id, final String content) throws JsonProcessingException + /** + * 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 JSON payload element. + * The payload is placed under the "json" field so clients can inspect it directly. + */ + 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"); - textContent.put("text", content); - contentArray.add(textContent); + final ObjectNode jsonContent = OBJECT_MAPPER.createObjectNode(); + jsonContent.put("type", "json"); + jsonContent.set("json", payload == null ? OBJECT_MAPPER.nullNode() : payload); + contentArray.add(jsonContent); result.set("content", contentArray); final ObjectNode response = OBJECT_MAPPER.createObjectNode(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java index d800e8f..ac52c25 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java @@ -4,6 +4,7 @@ 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; @@ -16,6 +17,7 @@ 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) @@ -57,47 +59,57 @@ public ObjectNode getInputSchema() 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 ObjectNode getOutputSchema() + { + final ObjectNode props = OBJECT_MAPPER.createObjectNode(); + props.set("success", McpToolRegistry.createSchemaWithProperty("success", "boolean", "")); + props.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); + props.set("property", McpToolRegistry.createSchemaWithProperty("property", "string", "")); + return props; + } + @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"); - if (path.isEmpty() || property.isEmpty()) + final Object value; + if (valueNode.isTextual()) { - return McpToolRegistry.createToolResult(id, "Error: path and property parameters are required"); + value = valueNode.asText(); } - - final JsonNode valueNode = arguments.path("value"); - if (valueNode.isMissingNode()) + else if (valueNode.isNumber()) + { + value = valueNode.numberValue(); + } + else if (valueNode.isBoolean()) { - return McpToolRegistry.createToolResult(id, "Error: value parameter is required"); + value = valueNode.asBoolean(); + } + else if (valueNode.isNull()) + { + value = null; + } + else + { + return JsonEditorMcpServer.createErrorResponseStatic(id, -32602, "value must be string, number, boolean, or null"); } try { - 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 McpToolRegistry.createToolResult(id, "Error: value must be string, number, boolean, or null"); - } - model.setValueAtPath(path, property, value); final ObjectNode result = OBJECT_MAPPER.createObjectNode(); @@ -105,13 +117,13 @@ else if (valueNode.isNull()) result.put("path", path); result.put("property", property); - return McpToolRegistry.createToolResult(id, OBJECT_MAPPER.writeValueAsString(result)); + return McpToolRegistry.createToolResult(id, result); } catch (Exception e) { logger.error("Error executing set_node for path: {}, property: {}", path, property, e); - return McpToolRegistry.createToolResult(id, - String.format("Error: Failed to set value at path: %s, property: %s", path, property)); + return JsonEditorMcpServer.createErrorResponseStatic(id, -32603, + String.format("Failed to set value at path: %s, property: %s", path, property)); } } } From 5f9faa7e6347f389daaceae4a2ab50954786e17b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Feb 2026 02:54:41 +0100 Subject: [PATCH 4/5] bugfix --- .../java/com/daniel/jsoneditor/controller/Controller.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/daniel/jsoneditor/controller/Controller.java b/src/main/java/com/daniel/jsoneditor/controller/Controller.java index c8d2388..15468d2 100644 --- a/src/main/java/com/daniel/jsoneditor/controller/Controller.java +++ b/src/main/java/com/daniel/jsoneditor/controller/Controller.java @@ -1,13 +1,14 @@ 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 java.io.File; -import java.util.List; +import com.fasterxml.jackson.databind.JsonNode; public interface Controller From ed8ede47ef1b9576a31cc57eac8752910c3d057a Mon Sep 17 00:00:00 2001 From: Daniel Kispert Date: Sat, 7 Feb 2026 14:03:53 +0100 Subject: [PATCH 5/5] mcp adjustments --- .../model/mcp/FindReferencesToTool.java | 88 +++++++++++++++++++ .../jsoneditor/model/mcp/GetExamplesTool.java | 9 -- ...rentFileTool.java => GetFileInfoTool.java} | 10 +-- .../jsoneditor/model/mcp/GetNodeTool.java | 12 --- .../mcp/GetReferenceableInstancesTool.java | 12 --- .../mcp/GetReferenceableObjectsTool.java | 12 --- .../model/mcp/GetSchemaForPathTool.java | 8 -- .../model/mcp/JsonEditorMcpServer.java | 8 +- .../jsoneditor/model/mcp/McpToolRegistry.java | 28 +++--- .../jsoneditor/model/mcp/SetNodeTool.java | 10 --- 10 files changed, 110 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/daniel/jsoneditor/model/mcp/FindReferencesToTool.java rename src/main/java/com/daniel/jsoneditor/model/mcp/{GetCurrentFileTool.java => GetFileInfoTool.java} (91%) 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 index 43cc04e..b0c1bbb 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetExamplesTool.java @@ -48,15 +48,6 @@ public ArrayNode getRequiredInputProperties() return arr; } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("examples", OBJECT_MAPPER.createArrayNode()); - props.set("allowed_values", OBJECT_MAPPER.createArrayNode()); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java similarity index 91% rename from src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java rename to src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java index 2dabd67..03e87a8 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetCurrentFileTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetFileInfoTool.java @@ -9,12 +9,12 @@ import org.slf4j.LoggerFactory; -class GetCurrentFileTool extends ReadOnlyMcpTool +class GetFileInfoTool extends ReadOnlyMcpTool { - private static final Logger logger = LoggerFactory.getLogger(GetCurrentFileTool.class); + private static final Logger logger = LoggerFactory.getLogger(GetFileInfoTool.class); private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - public GetCurrentFileTool(final ReadableModel model) + public GetFileInfoTool(final ReadableModel model) { super(model); } @@ -22,13 +22,13 @@ public GetCurrentFileTool(final ReadableModel model) @Override public String getName() { - return "get_current_file"; + return "get_file_info"; } @Override public String getDescription() { - return "Get information about the currently open JSON file"; + return "Get information about the currently open JSON file and schema"; } @Override 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 e61cd05..b25ac11 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetNodeTool.java @@ -47,18 +47,6 @@ public ArrayNode getRequiredInputProperties() return arr; } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); - props.set("display_name", McpToolRegistry.createSchemaWithProperty("display_name", "string", "")); - props.set("value", OBJECT_MAPPER.createObjectNode()); - props.set("is_array", McpToolRegistry.createSchemaWithProperty("is_array", "boolean", "")); - props.set("is_object", McpToolRegistry.createSchemaWithProperty("is_object", "boolean", "")); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { 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 98d9f5b..99134e2 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableInstancesTool.java @@ -51,18 +51,6 @@ public ArrayNode getRequiredInputProperties() return arr; } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode item = OBJECT_MAPPER.createObjectNode(); - item.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); - item.set("key", McpToolRegistry.createSchemaWithProperty("key", "string", "")); - item.set("display_name", McpToolRegistry.createSchemaWithProperty("display_name", "string", "")); - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("items", item); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { 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 4196074..78c5024 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetReferenceableObjectsTool.java @@ -41,18 +41,6 @@ public ObjectNode getInputSchema() return OBJECT_MAPPER.createObjectNode(); } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode item = OBJECT_MAPPER.createObjectNode(); - item.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); - item.set("referencing_key", McpToolRegistry.createSchemaWithProperty("referencing_key", "string", "")); - item.set("key_property", McpToolRegistry.createSchemaWithProperty("key_property", "string", "")); - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("items", item); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { 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 02ef818..56b3db9 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/GetSchemaForPathTool.java @@ -47,14 +47,6 @@ public ArrayNode getRequiredInputProperties() return arr; } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("schema", OBJECT_MAPPER.createObjectNode()); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException { 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 6164706..5718603 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/JsonEditorMcpServer.java @@ -218,9 +218,12 @@ private String handleToolsCall(final JsonNode params, final JsonNode id) throws 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); } @@ -232,10 +235,13 @@ private String handleToolsCall(final JsonNode params, final JsonNode id) throws } catch (ValidationException e) { + logger.warn("Tool {} validation failed: {}", toolName, e.getMessage()); return createErrorResponse(id, JSONRPC_INVALID_PARAMS, e.getMessage()); } - return tool.execute(arguments, id); + 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 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 5e03ec1..672ed90 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/McpToolRegistry.java @@ -31,12 +31,13 @@ public class McpToolRegistry public McpToolRegistry(final WritableModel model) { this.tools = List.of( - new GetCurrentFileTool(model), + new GetFileInfoTool(model), new GetNodeTool(model), new GetSchemaForPathTool(model), new GetExamplesTool(model), new GetReferenceableObjectsTool(model), - new GetReferenceableInstancesTool(model) + new GetReferenceableInstancesTool(model), + new FindReferencesToTool(model) ); } @@ -82,16 +83,6 @@ public ArrayNode getToolDefinitions() toolDef.put("description", tool.getDescription()); toolDef.set("inputSchema", buildInputSchema(tool)); - - // include output schema if available - final ObjectNode outputSchema = tool.getOutputSchema(); - if (outputSchema != null) - { - final ObjectNode outWrap = OBJECT_MAPPER.createObjectNode(); - outWrap.put("type", "object"); - outWrap.set("properties", outputSchema); - toolDef.set("outputSchema", outWrap); - } toolsArray.add(toolDef); } @@ -120,17 +111,18 @@ public static ObjectNode buildInputSchema(final McpTool tool) } /** - * Create a tool result where content contains a single JSON payload element. - * The payload is placed under the "json" field so clients can inspect it directly. + * 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 jsonContent = OBJECT_MAPPER.createObjectNode(); - jsonContent.put("type", "json"); - jsonContent.set("json", payload == null ? OBJECT_MAPPER.nullNode() : payload); - contentArray.add(jsonContent); + 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(); diff --git a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java index ac52c25..7c456e2 100644 --- a/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java +++ b/src/main/java/com/daniel/jsoneditor/model/mcp/SetNodeTool.java @@ -69,16 +69,6 @@ public ArrayNode getRequiredInputProperties() return arr; } - @Override - public ObjectNode getOutputSchema() - { - final ObjectNode props = OBJECT_MAPPER.createObjectNode(); - props.set("success", McpToolRegistry.createSchemaWithProperty("success", "boolean", "")); - props.set("path", McpToolRegistry.createSchemaWithProperty("path", "string", "")); - props.set("property", McpToolRegistry.createSchemaWithProperty("property", "string", "")); - return props; - } - @Override public String execute(final JsonNode arguments, final JsonNode id) throws JsonProcessingException {