UNPKG

@adarsh6938/mcp-knowledge-graph-semantic

Version:

Private MCP Server for semantic knowledge graph with persistent memory

761 lines 35.4 kB
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import minimist from 'minimist'; import { isAbsolute } from 'path'; import { pipeline } from '@xenova/transformers'; // Parse args and handle paths safely const argv = minimist(process.argv.slice(2)); // Check for memory path in command line args or environment variable let memoryPath = argv['memory-path'] || process.env.MEMORY_FILE_PATH; // If a custom path is provided, ensure it's absolute if (memoryPath && !isAbsolute(memoryPath)) { memoryPath = path.resolve(process.cwd(), memoryPath); } // Define the path to the JSONL file const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Use the custom path or default to the installation directory const MEMORY_FILE_PATH = memoryPath || path.join(__dirname, 'memory.jsonl'); // Create embeddings file path based on memory file name const getEmbeddingsFilePath = (memoryFilePath) => { const parsedPath = path.parse(memoryFilePath); return path.join(parsedPath.dir, `${parsedPath.name}_embeddings.json`); }; const EMBEDDINGS_FILE_PATH = getEmbeddingsFilePath(MEMORY_FILE_PATH); // Semantic Search Manager class SemanticSearchManager { constructor() { this.embedder = null; this.modelName = 'Xenova/all-MiniLM-L6-v2'; } async initializeEmbedder() { if (!this.embedder) { this.embedder = await pipeline('feature-extraction', this.modelName); } return this.embedder; } async generateEmbedding(text) { const embedder = await this.initializeEmbedder(); const output = await embedder(text, { pooling: 'mean', normalize: true }); return Array.from(output.data); } cosineSimilarity(a, b) { const dotProduct = a.reduce((sum, ai, i) => sum + ai * b[i], 0); const magnitudeA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0)); const magnitudeB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0)); return dotProduct / (magnitudeA * magnitudeB); } async loadSemanticIndex() { try { const data = await fs.readFile(EMBEDDINGS_FILE_PATH, 'utf-8'); return JSON.parse(data); } catch (error) { return { model: this.modelName, embeddings: [], version: 1 }; } } async saveSemanticIndex(index) { await fs.writeFile(EMBEDDINGS_FILE_PATH, JSON.stringify(index, null, 2)); } async buildSemanticIndex(entities) { const index = await this.loadSemanticIndex(); const newEmbeddings = []; for (const entity of entities) { // Create embedding for entity name + type const entityText = `${entity.name} ${entity.entityType}`; const existingEntityEmbedding = index.embeddings.find(e => e.entityName === entity.name && e.type === 'entity'); if (!existingEntityEmbedding) { const embedding = await this.generateEmbedding(entityText); newEmbeddings.push({ text: entityText, embedding, entityName: entity.name, type: 'entity', lastUpdated: new Date().toISOString() }); } // Create embeddings for observations for (const observation of entity.observations) { const existingObsEmbedding = index.embeddings.find(e => e.entityName === entity.name && e.type === 'observation' && e.text === observation); if (!existingObsEmbedding) { const embedding = await this.generateEmbedding(observation); newEmbeddings.push({ text: observation, embedding, entityName: entity.name, type: 'observation', lastUpdated: new Date().toISOString() }); } } } if (newEmbeddings.length > 0) { index.embeddings.push(...newEmbeddings); index.version += 1; await this.saveSemanticIndex(index); } } async semanticSearch(query, entities, threshold = 0.3, maxResults = 10) { const index = await this.loadSemanticIndex(); if (index.embeddings.length === 0) { await this.buildSemanticIndex(entities); return this.semanticSearch(query, entities, threshold, maxResults); } const queryEmbedding = await this.generateEmbedding(query); const results = []; for (const embeddingData of index.embeddings) { const similarity = this.cosineSimilarity(queryEmbedding, embeddingData.embedding); if (similarity >= threshold) { const entity = entities.find(e => e.name === embeddingData.entityName); if (entity) { results.push({ entity, similarity, matchedText: embeddingData.text, matchType: embeddingData.type }); } } } return results .sort((a, b) => b.similarity - a.similarity) .slice(0, maxResults); } async rebuildIndex(entities) { // Delete existing index and rebuild try { await fs.unlink(EMBEDDINGS_FILE_PATH); } catch (error) { // File doesn't exist, that's fine } await this.buildSemanticIndex(entities); } } // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph class KnowledgeGraphManager { constructor() { this.semanticSearch = new SemanticSearchManager(); } async loadGraph() { try { const data = await fs.readFile(MEMORY_FILE_PATH, "utf-8"); const lines = data.split("\n").filter(line => line.trim() !== ""); return lines.reduce((graph, line) => { const item = JSON.parse(line); if (item.type === "entity") graph.entities.push(item); if (item.type === "relation") graph.relations.push(item); return graph; }, { entities: [], relations: [] }); } catch (error) { if (error instanceof Error && 'code' in error && error.code === "ENOENT") { return { entities: [], relations: [] }; } throw error; } } async saveGraph(graph) { const lines = [ ...graph.entities.map(e => JSON.stringify({ type: "entity", ...e })), ...graph.relations.map(r => JSON.stringify({ type: "relation", ...r })), ]; await fs.writeFile(MEMORY_FILE_PATH, lines.join("\n")); } async createEntities(entities) { const graph = await this.loadGraph(); const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)) .map(e => ({ ...e, createdAt: new Date().toISOString(), version: e.version || 1 })); graph.entities.push(...newEntities); await this.saveGraph(graph); // Update semantic index for new entities if (newEntities.length > 0) { await this.semanticSearch.buildSemanticIndex(newEntities); } return newEntities; } async createRelations(relations) { const graph = await this.loadGraph(); const newRelations = relations.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from && existingRelation.to === r.to && existingRelation.relationType === r.relationType)).map(r => ({ ...r, createdAt: new Date().toISOString(), version: r.version || 1 })); graph.relations.push(...newRelations); await this.saveGraph(graph); return newRelations; } async addObservations(observations) { const graph = await this.loadGraph(); const results = observations.map(o => { const entity = graph.entities.find(e => e.name === o.entityName); if (!entity) { throw new Error(`Entity with name ${o.entityName} not found`); } const newObservations = o.contents.filter(content => !entity.observations.includes(content)); entity.observations.push(...newObservations); return { entityName: o.entityName, addedObservations: newObservations }; }); await this.saveGraph(graph); // Update semantic index for entities with new observations const updatedEntities = observations.map(o => graph.entities.find(e => e.name === o.entityName)).filter(Boolean); await this.semanticSearch.buildSemanticIndex(updatedEntities); return results; } async deleteEntities(entityNames) { const graph = await this.loadGraph(); graph.entities = graph.entities.filter(e => !entityNames.includes(e.name)); graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to)); await this.saveGraph(graph); } async deleteObservations(deletions) { const graph = await this.loadGraph(); deletions.forEach(d => { const entity = graph.entities.find(e => e.name === d.entityName); if (entity) { entity.observations = entity.observations.filter(o => !d.observations.includes(o)); } }); await this.saveGraph(graph); } async deleteRelations(relations) { const graph = await this.loadGraph(); graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.from === delRelation.from && r.to === delRelation.to && r.relationType === delRelation.relationType)); await this.saveGraph(graph); } async readGraph() { // Return a summary instead of full graph to avoid size issues const graph = await this.loadGraph(); // Limit to first 5 entities and their relations to prevent response size issues const limitedEntities = graph.entities.slice(0, 5); const limitedEntityNames = new Set(limitedEntities.map(e => e.name)); const limitedRelations = graph.relations.filter(r => limitedEntityNames.has(r.from) && limitedEntityNames.has(r.to)).slice(0, 10); return { entities: limitedEntities, relations: limitedRelations }; } async readGraphPaginated(page = 0, pageSize = 5) { const graph = await this.loadGraph(); const startIndex = page * pageSize; const endIndex = startIndex + pageSize; const paginatedEntities = graph.entities.slice(startIndex, endIndex); const paginatedEntityNames = new Set(paginatedEntities.map(e => e.name)); // Get relations involving these entities const relevantRelations = graph.relations.filter(r => paginatedEntityNames.has(r.from) || paginatedEntityNames.has(r.to)); return { entities: paginatedEntities, relations: relevantRelations, pagination: { page, pageSize, totalEntities: graph.entities.length, totalRelations: graph.relations.length, hasMore: endIndex < graph.entities.length } }; } // Very basic search function async searchNodes(query) { const graph = await this.loadGraph(); // Filter entities const filteredEntities = graph.entities.filter(e => e.name.toLowerCase().includes(query.toLowerCase()) || e.entityType.toLowerCase().includes(query.toLowerCase()) || e.observations.some(o => o.toLowerCase().includes(query.toLowerCase()))); // Create a Set of filtered entity names for quick lookup const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); // Filter relations to only include those between filtered entities const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); const filteredGraph = { entities: filteredEntities, relations: filteredRelations, }; return filteredGraph; } // Semantic search function async semanticSearchNodes(query, threshold = 0.3, maxResults = 10) { const graph = await this.loadGraph(); const results = await this.semanticSearch.semanticSearch(query, graph.entities, threshold, maxResults); // Create a filtered graph with the semantic search results const filteredEntities = results.map(r => r.entity); const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); return { results, graph: { entities: filteredEntities, relations: filteredRelations, } }; } // Hybrid search combining keyword and semantic search async hybridSearch(query, semanticWeight = 0.7, threshold = 0.3, maxResults = 10) { const graph = await this.loadGraph(); // Get semantic search results const semanticResults = await this.semanticSearch.semanticSearch(query, graph.entities, threshold, maxResults * 2); // Get keyword search results const keywordGraph = await this.searchNodes(query); // Combine and score results const combinedResults = new Map(); // Add semantic results semanticResults.forEach(result => { combinedResults.set(result.entity.name, result); }); // Boost entities that also match keyword search keywordGraph.entities.forEach(entity => { const existing = combinedResults.get(entity.name); if (existing) { // Boost similarity score for keyword matches existing.similarity = existing.similarity * semanticWeight + (1 - semanticWeight); existing.keywordMatch = true; } else { // Add keyword-only matches with lower similarity combinedResults.set(entity.name, { entity, similarity: 1 - semanticWeight, matchedText: entity.name, matchType: 'entity', keywordMatch: true }); } }); const results = Array.from(combinedResults.values()) .sort((a, b) => b.similarity - a.similarity) .slice(0, maxResults); const filteredEntities = results.map(r => r.entity); const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); return { results, graph: { entities: filteredEntities, relations: filteredRelations, } }; } // Rebuild semantic index async rebuildSemanticIndex() { const graph = await this.loadGraph(); await this.semanticSearch.rebuildIndex(graph.entities); } async openNodes(names) { const graph = await this.loadGraph(); // Filter entities const filteredEntities = graph.entities.filter(e => names.includes(e.name)); // Create a Set of filtered entity names for quick lookup const filteredEntityNames = new Set(filteredEntities.map(e => e.name)); // Filter relations to only include those between filtered entities const filteredRelations = graph.relations.filter(r => filteredEntityNames.has(r.from) && filteredEntityNames.has(r.to)); const filteredGraph = { entities: filteredEntities, relations: filteredRelations, }; return filteredGraph; } async updateEntities(entities) { const graph = await this.loadGraph(); const updatedEntities = entities.map(updateEntity => { const existingEntity = graph.entities.find(e => e.name === updateEntity.name); if (!existingEntity) { throw new Error(`Entity with name ${updateEntity.name} not found`); } return { ...existingEntity, ...updateEntity, version: existingEntity.version + 1, createdAt: new Date().toISOString() }; }); // Update entities in the graph updatedEntities.forEach(updatedEntity => { const index = graph.entities.findIndex(e => e.name === updatedEntity.name); if (index !== -1) { graph.entities[index] = updatedEntity; } }); await this.saveGraph(graph); return updatedEntities; } async updateRelations(relations) { const graph = await this.loadGraph(); const updatedRelations = relations.map(updateRelation => { const existingRelation = graph.relations.find(r => r.from === updateRelation.from && r.to === updateRelation.to && r.relationType === updateRelation.relationType); if (!existingRelation) { throw new Error(`Relation not found`); } return { ...existingRelation, ...updateRelation, version: existingRelation.version + 1, createdAt: new Date().toISOString() }; }); // Update relations in the graph updatedRelations.forEach(updatedRelation => { const index = graph.relations.findIndex(r => r.from === updatedRelation.from && r.to === updatedRelation.to && r.relationType === updatedRelation.relationType); if (index !== -1) { graph.relations[index] = updatedRelation; } }); await this.saveGraph(graph); return updatedRelations; } } const knowledgeGraphManager = new KnowledgeGraphManager(); // The server instance and tools exposed to Claude const server = new Server({ name: "@itseasy21/mcp-knowledge-graph", version: "1.0.7", }, { capabilities: { tools: {}, }, }); server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "create_entities", description: "Create multiple new entities in the knowledge graph", inputSchema: { type: "object", properties: { entities: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "The name of the entity" }, entityType: { type: "string", description: "The type of the entity" }, observations: { type: "array", items: { type: "string" }, description: "An array of observation contents associated with the entity" }, }, required: ["name", "entityType", "observations"], }, }, }, required: ["entities"], }, }, { name: "create_relations", description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", inputSchema: { type: "object", properties: { relations: { type: "array", items: { type: "object", properties: { from: { type: "string", description: "The name of the entity where the relation starts" }, to: { type: "string", description: "The name of the entity where the relation ends" }, relationType: { type: "string", description: "The type of the relation" }, }, required: ["from", "to", "relationType"], }, }, }, required: ["relations"], }, }, { name: "add_observations", description: "Add new observations to existing entities in the knowledge graph", inputSchema: { type: "object", properties: { observations: { type: "array", items: { type: "object", properties: { entityName: { type: "string", description: "The name of the entity to add the observations to" }, contents: { type: "array", items: { type: "string" }, description: "An array of observation contents to add" }, }, required: ["entityName", "contents"], }, }, }, required: ["observations"], }, }, { name: "delete_entities", description: "Delete multiple entities and their associated relations from the knowledge graph", inputSchema: { type: "object", properties: { entityNames: { type: "array", items: { type: "string" }, description: "An array of entity names to delete" }, }, required: ["entityNames"], }, }, { name: "delete_observations", description: "Delete specific observations from entities in the knowledge graph", inputSchema: { type: "object", properties: { deletions: { type: "array", items: { type: "object", properties: { entityName: { type: "string", description: "The name of the entity containing the observations" }, observations: { type: "array", items: { type: "string" }, description: "An array of observations to delete" }, }, required: ["entityName", "observations"], }, }, }, required: ["deletions"], }, }, { name: "delete_relations", description: "Delete multiple relations from the knowledge graph", inputSchema: { type: "object", properties: { relations: { type: "array", items: { type: "object", properties: { from: { type: "string", description: "The name of the entity where the relation starts" }, to: { type: "string", description: "The name of the entity where the relation ends" }, relationType: { type: "string", description: "The type of the relation" }, }, required: ["from", "to", "relationType"], }, description: "An array of relations to delete" }, }, required: ["relations"], }, }, { name: "read_graph", description: "Read a limited view of the knowledge graph (first 5 entities) to prevent response size issues. Use read_graph_paginated for full access.", inputSchema: { type: "object", properties: { random_string: { type: "string", description: "Dummy parameter for no-parameter tools" } }, required: ["random_string"], }, }, { name: "read_graph_paginated", description: "Read the knowledge graph with pagination to handle large datasets", inputSchema: { type: "object", properties: { page: { type: "number", description: "Page number (0-based), default 0", minimum: 0 }, pageSize: { type: "number", description: "Number of entities per page, default 5", minimum: 1, maximum: 20 } }, }, }, { name: "search_nodes", description: "Search for nodes in the knowledge graph based on a query", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query to match against entity names, types, and observation content" }, }, required: ["query"], }, }, { name: "semantic_search", description: "Search for nodes using semantic similarity based on meaning rather than exact keywords", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query to find semantically similar content" }, threshold: { type: "number", description: "Similarity threshold (0.0-1.0), default 0.3", minimum: 0, maximum: 1 }, maxResults: { type: "number", description: "Maximum number of results to return, default 10", minimum: 1 } }, required: ["query"], }, }, { name: "hybrid_search", description: "Search using both keyword matching and semantic similarity for comprehensive results", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query" }, semanticWeight: { type: "number", description: "Weight for semantic vs keyword search (0.0-1.0), default 0.7", minimum: 0, maximum: 1 }, threshold: { type: "number", description: "Semantic similarity threshold (0.0-1.0), default 0.3", minimum: 0, maximum: 1 }, maxResults: { type: "number", description: "Maximum number of results to return, default 10", minimum: 1 } }, required: ["query"], }, }, { name: "rebuild_semantic_index", description: "Rebuild the semantic search index for all entities and observations", inputSchema: { type: "object", properties: {}, }, }, { name: "open_nodes", description: "Open specific nodes in the knowledge graph by their names", inputSchema: { type: "object", properties: { names: { type: "array", items: { type: "string" }, description: "An array of entity names to retrieve", }, }, required: ["names"], }, }, { name: "update_entities", description: "Update multiple existing entities in the knowledge graph", inputSchema: { type: "object", properties: { entities: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "The name of the entity to update" }, entityType: { type: "string", description: "The updated type of the entity" }, observations: { type: "array", items: { type: "string" }, description: "The updated array of observation contents" }, }, required: ["name"], }, }, }, required: ["entities"], }, }, { name: "update_relations", description: "Update multiple existing relations in the knowledge graph", inputSchema: { type: "object", properties: { relations: { type: "array", items: { type: "object", properties: { from: { type: "string", description: "The name of the entity where the relation starts" }, to: { type: "string", description: "The name of the entity where the relation ends" }, relationType: { type: "string", description: "The type of the relation" }, }, required: ["from", "to", "relationType"], }, }, }, required: ["relations"], }, }, ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!args) { throw new Error(`No arguments provided for tool: ${name}`); } switch (name) { case "create_entities": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.createEntities(args.entities), null, 2) }] }; case "create_relations": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.createRelations(args.relations), null, 2) }] }; case "add_observations": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.addObservations(args.observations), null, 2) }] }; case "delete_entities": await knowledgeGraphManager.deleteEntities(args.entityNames); return { content: [{ type: "text", text: "Entities deleted successfully" }] }; case "delete_observations": await knowledgeGraphManager.deleteObservations(args.deletions); return { content: [{ type: "text", text: "Observations deleted successfully" }] }; case "delete_relations": await knowledgeGraphManager.deleteRelations(args.relations); return { content: [{ type: "text", text: "Relations deleted successfully" }] }; case "read_graph": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.readGraph(), null, 2) }] }; case "read_graph_paginated": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.readGraphPaginated(args.page, args.pageSize), null, 2) }] }; case "search_nodes": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query), null, 2) }] }; case "semantic_search": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.semanticSearchNodes(args.query, args.threshold, args.maxResults), null, 2) }] }; case "hybrid_search": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.hybridSearch(args.query, args.semanticWeight, args.threshold, args.maxResults), null, 2) }] }; case "rebuild_semantic_index": await knowledgeGraphManager.rebuildSemanticIndex(); return { content: [{ type: "text", text: "Semantic index rebuilt successfully" }] }; case "open_nodes": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names), null, 2) }] }; case "update_entities": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.updateEntities(args.entities), null, 2) }] }; case "update_relations": return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.updateRelations(args.relations), null, 2) }] }; default: throw new Error(`Unknown tool: ${name}`); } }); async function main() { const transport = new StdioServerTransport(); await server.connect(transport); } main().catch((error) => { process.exit(1); }); //# sourceMappingURL=index.js.map