diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1fac4d5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +.kotlin + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a2b11dd --- /dev/null +++ b/pom.xml @@ -0,0 +1,79 @@ + + + 4.0.0 + + de.shahondin1624 + knowledge-graph + 1.0-SNAPSHOT + + + 25 + 25 + UTF-8 + 2026.01.4 + + + + + org.neo4j + neo4j + ${neo4j.version} + + + org.neo4j + neo4j-bolt + ${neo4j.version} + + + io.github.mcp-java + mcp-server-lib + 1.0.0 + compile + + + org.slf4j + slf4j-api + 2.0.9 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + + + de.shahondin1624.knowledgegraph.Main + + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/java/de/shahondin1624/knowledgegraph/DatabaseLauncher.java b/src/main/java/de/shahondin1624/knowledgegraph/DatabaseLauncher.java new file mode 100644 index 0000000..ddcb4e4 --- /dev/null +++ b/src/main/java/de/shahondin1624/knowledgegraph/DatabaseLauncher.java @@ -0,0 +1,135 @@ +package de.shahondin1624.knowledgegraph; + +import org.neo4j.configuration.GraphDatabaseSettings; +import org.neo4j.configuration.connectors.BoltConnector; +import org.neo4j.configuration.helpers.SocketAddress; +import org.neo4j.io.ByteUnit; +import org.neo4j.dbms.api.DatabaseManagementService; +import org.neo4j.dbms.api.DatabaseManagementServiceBuilder; +import org.neo4j.graphdb.GraphDatabaseService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.*; +import java.time.Duration; +import java.util.Collections; +import java.util.stream.Stream; + +import static org.neo4j.configuration.GraphDatabaseSettings.DEFAULT_DATABASE_NAME; + +public class DatabaseLauncher { + private static final Logger logger = LoggerFactory.getLogger(DatabaseLauncher.class); + private final Path databaseDirectory; + private GraphDatabaseService graphDb; + + public DatabaseLauncher() { + this(Paths.get(System.getProperty("user.home"), ".graphdb")); + } + + public DatabaseLauncher(final Path databaseDirectory) { + this.databaseDirectory = databaseDirectory; + } + + public void start() throws IOException, URISyntaxException { + logger.info("Starting database from directory: {}", databaseDirectory); + if (!Files.exists(databaseDirectory)) { + logger.info("Database directory does not exist, extracting template..."); + extractDatabaseTemplate(); + } + spinUpDatabase(); + } + + private void extractDatabaseTemplate() throws IOException, URISyntaxException { + final URL resourceUrl = getClass().getResource("/graphdb-template"); + if (resourceUrl == null) { + logger.warn("No database template found at /graphdb-template, creating empty directory"); + // If no template is provided, just create the directory + Files.createDirectories(databaseDirectory); + return; + } + + URI resourceUri = resourceUrl.toURI(); + logger.debug("Extracting database template from URI: {}", resourceUri); + + if ("jar".equals(resourceUri.getScheme())) { + try (final FileSystem fileSystem = FileSystems.newFileSystem(resourceUri, Collections.emptyMap())) { + final Path source = fileSystem.getPath("/graphdb-template"); + copyDirectory(source, databaseDirectory); + } + } else { + Path source = Paths.get(resourceUri); + copyDirectory(source, databaseDirectory); + } + logger.info("Database template extracted successfully to {}", databaseDirectory); + } + + private void copyDirectory(final Path source, final Path target) throws IOException { + try (final Stream stream = Files.walk(source)) { + stream.forEach(path -> { + try { + String relativePathStr = source.relativize(path).toString(); + Path destination = target.resolve(relativePathStr); + if (Files.isDirectory(path)) { + if (!Files.exists(destination)) { + Files.createDirectories(destination); + } + } else { + Files.copy(path, destination, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new RuntimeException("Failed to copy database template", e); + } + }); + } + } + + public void spinUpDatabase() { + logger.info("Spinning up Neo4j database..."); + final Path configFile = databaseDirectory.resolve("neo4j.conf"); + final DatabaseManagementServiceBuilder builder = new DatabaseManagementServiceBuilder(databaseDirectory); + + if (Files.exists(configFile)) { + logger.info("Loading database configuration from {}", configFile); + builder.loadPropertiesFromFile(configFile); + } else { + logger.info("No configuration file found, using programmatic defaults"); + configureProgrammatically(builder); + } + + final DatabaseManagementService managementService = builder.build(); + graphDb = managementService.database(DEFAULT_DATABASE_NAME); + GraphDatabaseProvider.setGraphDb(graphDb); + registerShutdownHook(managementService); + logger.info("Neo4j database '{}' is ready", DEFAULT_DATABASE_NAME); + } + + private void configureProgrammatically(final DatabaseManagementServiceBuilder builder) { + builder.setConfig(GraphDatabaseSettings.pagecache_memory, ByteUnit.mebiBytes(512)) + .setConfig(GraphDatabaseSettings.transaction_timeout, Duration.ofSeconds(60)) + .setConfig(GraphDatabaseSettings.preallocate_logical_logs, true) + .setConfig(BoltConnector.enabled, true) + .setConfig(BoltConnector.listen_address, new SocketAddress("0.0.0.0", 7687)) + .setConfig(BoltConnector.encryption_level, BoltConnector.EncryptionLevel.DISABLED) + .setConfig(GraphDatabaseSettings.auth_enabled, false); + } + + private void registerShutdownHook(final DatabaseManagementService managementService) { + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + logger.info("Shutting down Neo4j database..."); + managementService.shutdown(); + logger.info("Neo4j database shut down successfully"); + })); + } + + public GraphDatabaseService getGraphDb() { + return graphDb; + } + + public Path getDatabaseDirectory() { + return databaseDirectory; + } +} diff --git a/src/main/java/de/shahondin1624/knowledgegraph/GraphDatabaseProvider.java b/src/main/java/de/shahondin1624/knowledgegraph/GraphDatabaseProvider.java new file mode 100644 index 0000000..c262222 --- /dev/null +++ b/src/main/java/de/shahondin1624/knowledgegraph/GraphDatabaseProvider.java @@ -0,0 +1,25 @@ +package de.shahondin1624.knowledgegraph; + +import org.neo4j.graphdb.GraphDatabaseService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Static provider for the GraphDatabaseService to be accessed by MCP tools. + */ +public class GraphDatabaseProvider { + private static final Logger logger = LoggerFactory.getLogger(GraphDatabaseProvider.class); + private static GraphDatabaseService graphDb; + + public static synchronized void setGraphDb(final GraphDatabaseService db) { + logger.info("Registering GraphDatabaseService with provider"); + graphDb = db; + } + + public static synchronized GraphDatabaseService getGraphDb() { + if (graphDb == null) { + throw new IllegalStateException("GraphDatabaseService has not been initialized yet."); + } + return graphDb; + } +} diff --git a/src/main/java/de/shahondin1624/knowledgegraph/tooling/CreateNodeTool.java b/src/main/java/de/shahondin1624/knowledgegraph/tooling/CreateNodeTool.java new file mode 100644 index 0000000..a45b0c4 --- /dev/null +++ b/src/main/java/de/shahondin1624/knowledgegraph/tooling/CreateNodeTool.java @@ -0,0 +1,85 @@ +package de.shahondin1624.knowledgegraph.tooling; + +import de.shahondin1624.knowledgegraph.GraphDatabaseProvider; +import de.shahondin1624.knowledgegraph.util.DbHelper; +import io.modelcontextprotocol.spec.McpSchema; +import mcp.tools.DefaultMcpTool; +import mcp.tools.McpValidatedTool; +import mcp.tools.helper.SchemaBuilder; +import mcp.util.Result; +import org.neo4j.graphdb.Label; +import org.neo4j.graphdb.Node; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; +import java.util.Map; + +/** + * MCP tool for creating a new node with optional labels. + */ +@DefaultMcpTool +public class CreateNodeTool extends McpValidatedTool { + private static final Logger logger = LoggerFactory.getLogger(CreateNodeTool.class); + + @Override + public String name() { + return "create_node"; + } + + @Override + public String title() { + return "Create Node"; + } + + @Override + public String description() { + return "Creates a new node with the specified labels."; + } + + @Override + public McpSchema.JsonSchema inputSchema() { + return new SchemaBuilder() + .addProperty("labels", "array", "Optional list of labels for the node") + .build(); + } + + @Override + protected boolean isIdempotent() { + return false; + } + + @Override + @SuppressWarnings("unchecked") + public McpSchema.CallToolResult callValidated(McpSchema.CallToolRequest request, Map arguments) throws Exception { + List labelNames = (List) arguments.get("labels"); + logger.debug("Creating node with labels: {}", labelNames); + + final Result result = getStringExceptionResult(labelNames); + + if (result.isOk()) { + String elementId = result.unwrap(); + logger.info("Successfully created node with ID: {}", elementId); + return success("Node created with Element ID: " + elementId); + } else { + logger.error("Failed to create node: {}", result.err().unwrap().getMessage()); + return error("Failed to create node: " + result.err().unwrap().getMessage()); + } + } + + private static Result getStringExceptionResult(List labelNames) { + DbHelper dbHelper = new DbHelper(GraphDatabaseProvider.getGraphDb()); + return dbHelper.wrapInTx(tx -> { + Node node; + if (labelNames == null || labelNames.isEmpty()) { + node = tx.createNode(); + } else { + Label[] labels = labelNames.stream() + .map(Label::label) + .toArray(Label[]::new); + node = tx.createNode(labels); + } + return node.getElementId(); + }); + } +} diff --git a/src/main/java/de/shahondin1624/knowledgegraph/tooling/ExecuteCypherTool.java b/src/main/java/de/shahondin1624/knowledgegraph/tooling/ExecuteCypherTool.java new file mode 100644 index 0000000..216a112 --- /dev/null +++ b/src/main/java/de/shahondin1624/knowledgegraph/tooling/ExecuteCypherTool.java @@ -0,0 +1,84 @@ +package de.shahondin1624.knowledgegraph.tooling; + +import de.shahondin1624.knowledgegraph.GraphDatabaseProvider; +import de.shahondin1624.knowledgegraph.util.DbHelper; +import io.modelcontextprotocol.spec.McpSchema; +import mcp.tools.DefaultMcpTool; +import mcp.tools.McpValidatedTool; +import mcp.tools.helper.SchemaBuilder; +import org.neo4j.graphdb.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP tool for executing Cypher queries. + */ +@DefaultMcpTool +public class ExecuteCypherTool extends McpValidatedTool { + private static final Logger logger = LoggerFactory.getLogger(ExecuteCypherTool.class); + + @Override + public String name() { + return "execute_cypher"; + } + + @Override + public String title() { + return "Execute Cypher Query"; + } + + @Override + public String description() { + return "Executes a Cypher query on the Neo4j database and returns the result as a list of maps."; + } + + @Override + public McpSchema.JsonSchema inputSchema() { + return new SchemaBuilder() + .addProperty("query", "string", "The Cypher query to execute") + .addProperty("parameters", "object", "Optional query parameters") + .required("query") + .build(); + } + + @Override + protected boolean isReadOnly() { + return false; + } + + @Override + protected boolean isIdempotent() { + return false; + } + + @Override + @SuppressWarnings("unchecked") + public McpSchema.CallToolResult callValidated(McpSchema.CallToolRequest request, Map arguments) throws Exception { + final String query = (String) arguments.get("query"); + final Map parameters = (Map) arguments.get("parameters"); + logger.debug("Executing Cypher query: {} with parameters: {}", query, parameters); + + final DbHelper dbHelper = new DbHelper(GraphDatabaseProvider.getGraphDb()); + mcp.util.Result>, Exception> result = dbHelper.wrapInTx(tx -> { + Result neoResult = tx.execute(query, parameters != null ? parameters : Map.of()); + List> rows = new ArrayList<>(); + while (neoResult.hasNext()) { + rows.add(neoResult.next()); + } + return rows; + }); + + if (result.isOk()) { + List> rows = result.unwrap(); + logger.info("Successfully executed Cypher query, returned {} rows", rows.size()); + return success(rows.toString()); + } else { + logger.error("Cypher query execution failed: {}", result.err().unwrap().getMessage()); + return error("Query execution failed: " + result.err().unwrap().getMessage()); + } + } +} diff --git a/src/main/java/de/shahondin1624/knowledgegraph/tooling/FindNodesTool.java b/src/main/java/de/shahondin1624/knowledgegraph/tooling/FindNodesTool.java new file mode 100644 index 0000000..659fbad --- /dev/null +++ b/src/main/java/de/shahondin1624/knowledgegraph/tooling/FindNodesTool.java @@ -0,0 +1,91 @@ +package de.shahondin1624.knowledgegraph.tooling; + +import de.shahondin1624.knowledgegraph.GraphDatabaseProvider; +import de.shahondin1624.knowledgegraph.util.DbHelper; +import io.modelcontextprotocol.spec.McpSchema; +import mcp.tools.DefaultMcpTool; +import mcp.tools.McpValidatedTool; +import mcp.tools.helper.SchemaBuilder; +import org.neo4j.graphdb.Label; +import org.neo4j.graphdb.Node; +import org.neo4j.graphdb.ResourceIterator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP tool for finding nodes by label and property. + */ +@DefaultMcpTool +public class FindNodesTool extends McpValidatedTool { + private static final Logger logger = LoggerFactory.getLogger(FindNodesTool.class); + + @Override + public String name() { + return "find_nodes"; + } + + @Override + public String title() { + return "Find Nodes"; + } + + @Override + public String description() { + return "Finds nodes by label and a property key-value pair."; + } + + @Override + public McpSchema.JsonSchema inputSchema() { + return new SchemaBuilder() + .addProperty("label", "string", "The label of the nodes to find") + .addProperty("key", "string", "The property key to filter by") + .addProperty("value", "string", "The property value to filter by") + .required("label", "key", "value") + .build(); + } + + @Override + public McpSchema.CallToolResult callValidated(McpSchema.CallToolRequest request, Map arguments) throws Exception { + String labelName = (String) arguments.get("label"); + String key = (String) arguments.get("key"); + Object value = arguments.get("value"); + logger.debug("Finding nodes with label: {}, key: {}, value: {}", labelName, key, value); + + DbHelper dbHelper = new DbHelper(GraphDatabaseProvider.getGraphDb()); + mcp.util.Result>, Exception> result = dbHelper.wrapInTx(tx -> { + List> nodes = new ArrayList<>(); + try (ResourceIterator it = tx.findNodes(Label.label(labelName), key, value)) { + while (it.hasNext()) { + Node node = it.next(); + nodes.add(Map.of( + "elementId", node.getElementId(), + "labels", StreamSupportLabels(node.getLabels()), + "properties", node.getAllProperties() + )); + } + } + return nodes; + }); + + if (result.isOk()) { + List> nodes = result.unwrap(); + logger.info("Successfully found {} nodes", nodes.size()); + return success(nodes.toString()); + } else { + logger.error("Failed to find nodes: {}", result.err().unwrap().getMessage()); + return error("Failed to find nodes: " + result.err().unwrap().getMessage()); + } + } + + private List StreamSupportLabels(Iterable