diff --git a/modules/GephiMcp/pom.xml b/modules/GephiMcp/pom.xml
new file mode 100644
index 000000000..ff09cccbd
--- /dev/null
+++ b/modules/GephiMcp/pom.xml
@@ -0,0 +1,142 @@
+
+
+ 4.0.0
+
+
+ org.gephi
+ gephi-plugin-parent
+ 0.11.1
+
+
+ org.gephi.plugins
+ gephi-mcp
+ 1.2.3
+ nbm
+
+ Gephi AI (MCP)
+ HTTP API for remote Gephi control via MCP (Model Context Protocol)
+ https://github.com/MattArtzAnthro/gephi-ai
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+
+ Matt Artz
+ https://www.mattartz.me
+
+
+
+
+
+
+ org.gephi
+ graph-api
+
+
+ org.gephi
+ project-api
+
+
+ org.gephi
+ layout-api
+
+
+ org.gephi
+ io-exporter-api
+
+
+ org.gephi
+ io-importer-api
+
+
+ org.gephi
+ statistics-api
+
+
+ org.gephi
+ utils-longtask
+
+
+ org.gephi
+ appearance-api
+
+
+ org.gephi
+ preview-api
+
+
+ org.gephi
+ filters-api
+
+
+ org.gephi
+ visualization-api
+
+
+
+
+ org.netbeans.api
+ org-openide-util
+
+
+ org.netbeans.api
+ org-openide-util-lookup
+
+
+ org.netbeans.api
+ org-openide-modules
+
+
+ org.netbeans.api
+ org-openide-nodes
+
+
+
+
+ org.nanohttpd
+ nanohttpd
+ 2.3.1
+
+
+ com.google.code.gson
+ gson
+ 2.10.1
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.2
+ test
+
+
+
+
+
+
+ org.apache.netbeans.utilities
+ nbm-maven-plugin
+ true
+
+ Apache 2.0
+ Matt Artz
+ https://www.mattartz.me
+ https://github.com/MattArtzAnthro/gephi-ai
+ https://github.com/MattArtzAnthro/gephi-ai
+
+ org.gephi.plugins.mcp.api
+
+
+
+
+
+
diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java
new file mode 100644
index 000000000..ade7022e1
--- /dev/null
+++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/Installer.java
@@ -0,0 +1,97 @@
+package org.gephi.plugins.mcp;
+
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.gephi.plugins.mcp.api.GephiAPIServer;
+import org.openide.modules.ModuleInstall;
+
+public class Installer extends ModuleInstall {
+
+ private static final Logger LOGGER = Logger.getLogger(Installer.class.getName());
+ private static final int DEFAULT_PORT = 8080;
+ private GephiAPIServer server;
+
+ @Override
+ public void restored() {
+ LOGGER.info("########## Gephi MCP Plugin: restored() called ##########");
+ System.out.println("########## Gephi MCP Plugin: restored() called ##########");
+
+ // Start server in a new thread to not block module loading
+ Thread serverThread = new Thread(() -> {
+ try {
+ Thread.sleep(2000); // Wait for Gephi to fully initialize
+ startServer();
+ } catch (Exception e) {
+ LOGGER.log(Level.SEVERE, "Failed to start MCP server", e);
+ System.err.println("Failed to start MCP server: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }, "MCP-Server-Starter");
+ serverThread.setDaemon(true);
+ serverThread.start();
+ }
+
+ @Override
+ public void close() {
+ LOGGER.info("########## Gephi MCP Plugin: close() called ##########");
+ stopServer();
+ }
+
+ @Override
+ public boolean closing() {
+ LOGGER.info("########## Gephi MCP Plugin: closing() called ##########");
+ stopServer();
+ return true;
+ }
+
+ private void startServer() {
+ try {
+ int port = Integer.getInteger("gephi.mcp.port", DEFAULT_PORT);
+ LOGGER.info("########## Starting MCP server on port " + port + " ##########");
+ System.out.println("########## Starting MCP server on port " + port + " ##########");
+
+ server = new GephiAPIServer(port);
+ server.startServer();
+
+ LOGGER.info("===========================================");
+ LOGGER.info(" Gephi MCP Plugin Active");
+ LOGGER.info(" API: http://127.0.0.1:" + port);
+ LOGGER.info("===========================================");
+ System.out.println("===========================================");
+ System.out.println(" Gephi MCP Plugin Active");
+ System.out.println(" API: http://127.0.0.1:" + port);
+ System.out.println("===========================================");
+ } catch (Exception e) {
+ LOGGER.log(Level.SEVERE, "Failed to start MCP server", e);
+ System.err.println("Failed to start MCP server: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ private void stopServer() {
+ final GephiAPIServer s = server;
+ server = null;
+ if (s == null) return;
+ // Stop on a daemon watchdog so a slow/stuck socket close can never block
+ // Gephi's shutdown (which runs on the EDT). Wait at most 3s, then continue
+ // regardless; the daemon threads can't keep the JVM alive.
+ Thread stopper = new Thread(() -> {
+ try {
+ s.stopServer();
+ LOGGER.info("########## MCP server stopped ##########");
+ } catch (Exception e) {
+ LOGGER.log(Level.WARNING, "Error stopping server", e);
+ }
+ }, "MCP-Server-Stopper");
+ stopper.setDaemon(true);
+ stopper.start();
+ try {
+ stopper.join(3000);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ if (stopper.isAlive()) {
+ LOGGER.warning("MCP server stop exceeded 3s; continuing Gephi shutdown");
+ }
+ }
+}
diff --git a/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java
new file mode 100644
index 000000000..22cbabdaf
--- /dev/null
+++ b/modules/GephiMcp/src/main/java/org/gephi/plugins/mcp/api/GephiAPIServer.java
@@ -0,0 +1,672 @@
+package org.gephi.plugins.mcp.api;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import fi.iki.elonen.NanoHTTPD;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.gephi.plugins.mcp.service.GephiControlService;
+
+public class GephiAPIServer extends NanoHTTPD {
+
+ private static final Logger LOGGER = Logger.getLogger(GephiAPIServer.class.getName());
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+ private final GephiControlService service;
+
+ public GephiAPIServer(int port) {
+ super("127.0.0.1", port);
+ this.service = GephiControlService.getInstance();
+ }
+
+ @Override
+ public Response serve(IHTTPSession session) {
+ String uri = session.getUri();
+ Method method = session.getMethod();
+
+ LOGGER.info("MCP API: " + method + " " + uri);
+
+ // Defense against DNS-rebinding: the API is reachable only from local
+ // processes (the MCP server is a local Python process, not a browser).
+ // Reject any request whose Host header points at a non-local name, which
+ // is how a malicious web page would try to reach 127.0.0.1 via a rebound
+ // hostname. Requests with no Host header (e.g. raw curl) are allowed.
+ if (!isLocalHost(session)) {
+ JsonObject error = new JsonObject();
+ error.addProperty("success", false);
+ error.addProperty("error", "Forbidden: requests must target localhost");
+ return newFixedLengthResponse(Response.Status.FORBIDDEN, "application/json", GSON.toJson(error));
+ }
+
+ if (Method.OPTIONS.equals(method)) {
+ return newFixedLengthResponse(Response.Status.OK, "text/plain", "");
+ }
+
+ try {
+ JsonObject requestBody = null;
+ if (Method.POST.equals(method) || Method.PUT.equals(method)) {
+ Map files = new HashMap<>();
+ session.parseBody(files);
+ String body = files.get("postData");
+ if (body != null && !body.isEmpty()) {
+ requestBody = JsonParser.parseString(body).getAsJsonObject();
+ }
+ }
+
+ JsonObject result = routeRequest(uri, method, session.getParms(), requestBody);
+
+ Response.Status status = result.has("success") && result.get("success").getAsBoolean()
+ ? Response.Status.OK : Response.Status.BAD_REQUEST;
+
+ return newFixedLengthResponse(status, "application/json", GSON.toJson(result));
+
+ } catch (Exception e) {
+ LOGGER.log(Level.WARNING, "API error", e);
+ JsonObject error = new JsonObject();
+ error.addProperty("success", false);
+ error.addProperty("error", e.getMessage());
+ return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, "application/json", GSON.toJson(error));
+ }
+ }
+
+ /** True when the request's Host header is absent or resolves to the loopback interface. */
+ private boolean isLocalHost(IHTTPSession session) {
+ return isLoopbackHost(session.getHeaders().get("host"));
+ }
+
+ /**
+ * True when {@code host} (a raw HTTP Host header value, possibly null or with a
+ * port and/or IPv6 brackets) names the loopback interface. A null/empty header is
+ * allowed (non-browser clients like the MCP server may omit it); any non-loopback
+ * name is rejected, which is what blocks DNS-rebinding attacks from a web page.
+ * Package-private and static so it can be unit-tested without a live server.
+ */
+ static boolean isLoopbackHost(String host) {
+ if (host == null || host.isEmpty()) return true;
+ String name = host;
+ int colon = name.lastIndexOf(':');
+ if (colon > -1 && name.indexOf(']') < colon) name = name.substring(0, colon);
+ name = name.replace("[", "").replace("]", "").trim().toLowerCase();
+ return name.equals("127.0.0.1") || name.equals("localhost") || name.equals("::1");
+ }
+
+ @SuppressWarnings("unchecked")
+ private JsonObject routeRequest(String uri, Method method, Map params, JsonObject body) {
+
+ // ─── Health ──────────────────────────────────────────────────
+
+ if ("/health".equals(uri) || "/".equals(uri)) {
+ JsonObject result = new JsonObject();
+ result.addProperty("success", true);
+ result.addProperty("service", "Gephi MCP API");
+ result.addProperty("version", "1.2.3");
+ result.addProperty("status", "running");
+ // "busy" here (persistently) means Gephi is wedged and needs a restart.
+ result.addProperty("graph_lock", service.graphLockProbe());
+ result.add("graph_lock_stats", service.graphLockStats());
+ return result;
+ }
+
+ // ─── View / camera (teaching mode) ───────────────────────────
+
+ if ("/view/focus".equals(uri) && Method.POST.equals(method)) {
+ String mode = body != null && body.has("mode") ? body.get("mode").getAsString() : "graph";
+ String id = body != null && body.has("id") ? body.get("id").getAsString() : null;
+ String source = body != null && body.has("source") ? body.get("source").getAsString() : null;
+ String target = body != null && body.has("target") ? body.get("target").getAsString() : null;
+ Double x = body != null && body.has("x") ? body.get("x").getAsDouble() : null;
+ Double y = body != null && body.has("y") ? body.get("y").getAsDouble() : null;
+ Double w = body != null && body.has("w") ? body.get("w").getAsDouble() : null;
+ Double h = body != null && body.has("h") ? body.get("h").getAsDouble() : null;
+ Double zoom = body != null && body.has("zoom") ? body.get("zoom").getAsDouble() : null;
+ java.util.List select = null;
+ if (body != null && body.has("select")) {
+ select = new java.util.ArrayList<>();
+ for (com.google.gson.JsonElement el : body.get("select").getAsJsonArray()) {
+ select.add(el.getAsString());
+ }
+ }
+ return service.focusView(mode, id, source, target, x, y, w, h, zoom, select);
+ }
+
+ // ─── Project ─────────────────────────────────────────────────
+
+ if ("/project/new".equals(uri) && Method.POST.equals(method)) {
+ String name = body != null && body.has("name") ? body.get("name").getAsString() : "New Project";
+ return service.createProject(name);
+ }
+
+ if ("/project/open".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("file")) return errorResult("Missing 'file' parameter");
+ return service.openProject(body.get("file").getAsString());
+ }
+
+ if ("/project/save".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("file")) return errorResult("Missing 'file' parameter");
+ return service.saveProject(body.get("file").getAsString());
+ }
+
+ if ("/project/info".equals(uri) && Method.GET.equals(method)) {
+ return service.getProjectInfo();
+ }
+
+ // ─── Workspace ───────────────────────────────────────────────
+
+ if ("/workspace/new".equals(uri) && Method.POST.equals(method)) {
+ return service.newWorkspace();
+ }
+
+ if ("/workspace/list".equals(uri) && Method.GET.equals(method)) {
+ return service.listWorkspaces();
+ }
+
+ if ("/workspace/switch".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("index")) return errorResult("Missing 'index'");
+ return service.switchWorkspace(body.get("index").getAsInt());
+ }
+
+ if ("/workspace/delete".equals(uri) && Method.DELETE.equals(method)) {
+ int index = params.containsKey("index") ? Integer.parseInt(params.get("index")) : -1;
+ if (index < 0 && body != null && body.has("index")) index = body.get("index").getAsInt();
+ if (index < 0) return errorResult("Missing 'index'");
+ return service.deleteWorkspace(index);
+ }
+
+ if ("/workspace/duplicate".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("index")) return errorResult("Missing 'index'");
+ return service.duplicateWorkspace(body.get("index").getAsInt());
+ }
+
+ if ("/workspace/rename".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("index") || !body.has("name")) return errorResult("Missing 'index' or 'name'");
+ return service.renameWorkspace(body.get("index").getAsInt(), body.get("name").getAsString());
+ }
+
+ // ─── Nodes ───────────────────────────────────────────────────
+
+ if ("/graph/node/add".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("id")) return errorResult("Missing 'id' parameter");
+ String id = body.get("id").getAsString();
+ String label = body.has("label") ? body.get("label").getAsString() : null;
+ Map attrs = null;
+ if (body.has("attributes") && body.get("attributes").isJsonObject()) {
+ attrs = GSON.fromJson(body.get("attributes"), Map.class);
+ }
+ return service.addNode(id, label, attrs);
+ }
+
+ if ("/graph/nodes/add".equals(uri) && Method.POST.equals(method)) {
+ if (body == null || !body.has("nodes")) return errorResult("Missing 'nodes' array");
+ List