@adarsh6938/mcp-knowledge-graph-semantic
Version:
Private MCP Server for semantic knowledge graph with persistent memory
761 lines • 35.4 kB
JavaScript
#!/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