Elasticsearch Knowledge Graph for MCP
by j3k0
- legacy
#!/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 { Entity, Relation, KnowledgeGraph } from './types.js';
import { searchGraph, ScoredKnowledgeGraph } from './query-language.js';
// Define memory file path using environment variable with fallback
const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
// If MEMORY_FILE_PATH is just a filename, put it in the same directory as the script
const MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
? path.isAbsolute(process.env.MEMORY_FILE_PATH)
? process.env.MEMORY_FILE_PATH
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
: defaultMemoryPath;
// Helper function to format dates in YYYY-MM-DD format
function formatDate(date: Date = new Date()): string {
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
}
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
class KnowledgeGraphManager {
private async loadGraph(): Promise<KnowledgeGraph> {
try {
const data = await fs.readFile(MEMORY_FILE_PATH, "utf-8");
const lines = data.split("\n").filter(line => line.trim() !== "");
const graph = lines.reduce((graph: KnowledgeGraph, line) => {
try {
const item = JSON.parse(line);
if (item.type === "entity") graph.entities.push(item as Entity);
if (item.type === "relation") graph.relations.push(item as Relation);
} catch (error) {
console.error(`Error parsing line: ${line}`, error);
}
return graph;
}, { entities: [], relations: [] });
// Ensure all entities have date fields
const todayFormatted = formatDate();
graph.entities.forEach(entity => {
// Ensure the fields exist
if (!entity.lastWrite) entity.lastWrite = todayFormatted;
if (!entity.lastRead) entity.lastRead = todayFormatted;
});
return graph;
} catch (error) {
if (error instanceof Error && 'code' in error && (error as any).code === "ENOENT") {
return { entities: [], relations: [] };
}
throw error;
}
}
private async saveGraph(graph: KnowledgeGraph): Promise<void> {
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: Entity[]): Promise<Entity[]> {
const graph = await this.loadGraph();
const todayFormatted = formatDate();
const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name))
.map(entity => ({
...entity,
lastRead: todayFormatted,
lastWrite: todayFormatted,
isImportant: entity.isImportant || false // Default to false if not specified
}));
graph.entities.push(...newEntities);
await this.saveGraph(graph);
return newEntities;
}
async createRelations(relations: Relation[]): Promise<Relation[]> {
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
));
graph.relations.push(...newRelations);
await this.saveGraph(graph);
return newRelations;
}
async addObservations(observations: { entityName: string; contents: string[] }[]): Promise<{ entityName: string; addedObservations: string[] }[]> {
const graph = await this.loadGraph();
const todayFormatted = formatDate();
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));
if (newObservations.length > 0) {
entity.observations.push(...newObservations);
entity.lastWrite = todayFormatted;
}
return { entityName: o.entityName, addedObservations: newObservations };
});
await this.saveGraph(graph);
return results;
}
async deleteEntities(entityNames: string[]): Promise<void> {
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: { entityName: string; observations: string[] }[]): Promise<void> {
const graph = await this.loadGraph();
const todayFormatted = formatDate();
deletions.forEach(d => {
const entity = graph.entities.find(e => e.name === d.entityName);
if (entity) {
const originalLength = entity.observations.length;
entity.observations = entity.observations.filter(o => !d.observations.includes(o));
// Only update the date if observations were actually deleted
if (entity.observations.length < originalLength) {
entity.lastWrite = todayFormatted;
}
}
});
await this.saveGraph(graph);
}
async deleteRelations(relations: Relation[]): Promise<void> {
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(): Promise<KnowledgeGraph> {
return this.loadGraph();
}
/**
* Searches the knowledge graph with a structured query language.
*
* The query language supports:
* - type:value - Filter entities by type
* - name:value - Filter entities by name
* - +word - Require this term (AND logic)
* - -word - Exclude this term (NOT logic)
* - word1|word2|word3 - Match any of these terms (OR logic)
* - Any other text - Used for fuzzy matching
*
* Example: "type:person +programmer -manager frontend|backend|fullstack" searches for
* entities of type "person" that contain "programmer", don't contain "manager",
* and contain at least one of "frontend", "backend", or "fullstack".
*
* Results are sorted by relevance, with exact name matches ranked highest.
*
* @param query The search query string
* @returns A filtered knowledge graph containing matching entities and their relations
*/
async searchNodes(query: string): Promise<KnowledgeGraph> {
const graph = await this.loadGraph();
// Get the basic search results with scores
const searchResult = searchGraph(query, graph);
// Create a map of entity name to search score for quick lookup
// const searchScores = new Map<string, number>();
// searchResult.scoredEntities.forEach(scored => {
// searchScores.set(scored.entity.name, scored.score);
// });
// Find the maximum search score for normalization
const maxSearchScore = searchResult.scoredEntities.length > 0
? Math.max(...searchResult.scoredEntities.map(scored => scored.score))
: 1.0;
// Get all entities sorted by lastRead date (most recent first)
const entitiesByRecency = [...graph.entities]
.filter(e => e.lastRead) // Filter out entities without lastRead
.sort((a, b) => {
// Sort in descending order (newest first)
return new Date(b.lastRead!).getTime() - new Date(a.lastRead!).getTime();
});
// Get the 20 most recently accessed entities
const top20Recent = new Set(entitiesByRecency.slice(0, 20).map(e => e.name));
// Get the 10 most recently accessed entities (subset of top20)
const top10Recent = new Set(entitiesByRecency.slice(0, 10).map(e => e.name));
// Score the entities based on the criteria
const scoredEntities = searchResult.scoredEntities.map(scoredEntity => {
let score = 0;
// Score based on recency
if (top20Recent.has(scoredEntity.entity.name)) score += 1;
if (top10Recent.has(scoredEntity.entity.name)) score += 1;
// Score based on importance
if (scoredEntity.entity.isImportant) {
score += 1;
score *= 2; // Double the score for important entities
}
// Add normalized search score (0-1 range)
const searchScore = scoredEntity.score || 0;
score += searchScore / maxSearchScore;
return { entity: scoredEntity.entity, score };
});
// Sort by score (highest first) and take top 10
const topEntities = scoredEntities
.sort((a, b) => b.score - a.score)
.slice(0, 10)
.map(item => item.entity);
// Create a filtered graph with only the top entities
const filteredEntityNames = new Set(topEntities.map(e => e.name));
const filteredRelations = graph.relations.filter(r =>
filteredEntityNames.has(r.from) || filteredEntityNames.has(r.to)
);
const result: KnowledgeGraph = {
entities: topEntities,
relations: filteredRelations
};
// Update access dates for found entities
const todayFormatted = formatDate();
result.entities.forEach(foundEntity => {
// Find the actual entity in the original graph and update its access date
const originalEntity = graph.entities.find(e => e.name === foundEntity.name);
if (originalEntity) {
originalEntity.lastRead = todayFormatted;
}
});
// Save the updated access dates
await this.saveGraph(graph);
return result;
}
async openNodes(names: string[]): Promise<KnowledgeGraph> {
const graph = await this.loadGraph();
const todayFormatted = formatDate();
// Filter entities and update read dates
const filteredEntities = graph.entities.filter(e => {
if (names.includes(e.name)) {
// Update the lastRead whenever an entity is opened
e.lastRead = todayFormatted;
return true;
}
return false;
});
// Since we're modifying entities, we need to save the graph
await this.saveGraph(graph);
// Create a Set of filtered entity names for quick lookup
const filteredEntityNames = new Set(filteredEntities.map(e => e.name));
// Filter relations to include those where either from or to entity is in the filtered set
const filteredRelations = graph.relations.filter(r =>
filteredEntityNames.has(r.from) || filteredEntityNames.has(r.to)
);
const filteredGraph: KnowledgeGraph = {
entities: filteredEntities,
relations: filteredRelations,
};
return filteredGraph;
}
async setEntityImportance(entityNames: string[], isImportant: boolean): Promise<void> {
const graph = await this.loadGraph();
const todayFormatted = formatDate();
entityNames.forEach(name => {
const entity = graph.entities.find(e => e.name === name);
if (entity) {
entity.isImportant = isImportant;
entity.lastWrite = todayFormatted; // Update lastWrite since we're modifying the entity
}
});
await this.saveGraph(graph);
}
}
const knowledgeGraphManager = new KnowledgeGraphManager();
// The server instance and tools exposed to Claude
const server = new Server({
name: "memory-server",
version: "1.0.0",
}, {
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 the entire knowledge graph",
inputSchema: {
type: "object",
properties: {},
},
}, */
{
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. Supports a query language with these operators: 'type:value' to filter by entity type, 'name:value' to filter by entity name, '+word' to require a term (AND logic), '-word' to exclude a term (NOT logic). Any remaining text is used for fuzzy matching. Example: 'type:person +programmer -manager frontend|backend|fullstack' searches for entities of type 'person' that contain 'programmer', don't contain 'manager', and contain at least one of 'frontend', 'backend', or 'fullstack'."
},
},
required: ["query"],
},
},
{
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"],
},
},
],
};
});
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 as Entity[]), null, 2) }] };
case "create_relations":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.createRelations(args.relations as Relation[]), null, 2) }] };
case "add_observations":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.addObservations(args.observations as { entityName: string; contents: string[] }[]), null, 2) }] };
case "delete_entities":
await knowledgeGraphManager.deleteEntities(args.entityNames as string[]);
return { content: [{ type: "text", text: "Entities deleted successfully" }] };
case "delete_observations":
await knowledgeGraphManager.deleteObservations(args.deletions as { entityName: string; observations: string[] }[]);
return { content: [{ type: "text", text: "Observations deleted successfully" }] };
case "delete_relations":
await knowledgeGraphManager.deleteRelations(args.relations as Relation[]);
return { content: [{ type: "text", text: "Relations deleted successfully" }] };
case "read_graph":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.readGraph(), null, 2) }] };
case "search_nodes":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query as string), null, 2) }] };
case "open_nodes":
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names as string[]), null, 2) }] };
default:
throw new Error(`Unknown tool: ${name}`);
}
});
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Knowledge Graph MCP Server running on stdio");
}
main().catch((error) => {
console.error("Fatal error in main():", error);
process.exit(1);
});