onerios-mcp-server
Version:
OneriosMCP server providing memory, backlog management, file operations, and utility functions for enhanced AI assistant capabilities
560 lines (559 loc) • 24 kB
JavaScript
;
/**
* Memory Module - Knowledge Graph-based persistent memory for MCP server
*
* This module provides persistent memory functionality using a knowledge graph structure
* with entities, relations, and observations. Based on the official MCP memory server
* implementation with adaptations for our modular architecture.
*
* @see https://github.com/modelcontextprotocol/servers/tree/main/src/memory
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.getTools = getTools;
exports.handleTool = handleTool;
const zod_1 = require("zod");
const fs_1 = require("fs");
const path_1 = require("path");
// ===== ZOD VALIDATION SCHEMAS =====
const EntitySchema = zod_1.z.object({
name: zod_1.z.string().min(1, 'Entity name cannot be empty'),
entityType: zod_1.z.string().min(1, 'Entity type cannot be empty'),
observations: zod_1.z.array(zod_1.z.string()).default([])
});
const RelationSchema = zod_1.z.object({
from: zod_1.z.string().min(1, 'Relation source cannot be empty'),
to: zod_1.z.string().min(1, 'Relation target cannot be empty'),
relationType: zod_1.z.string().min(1, 'Relation type cannot be empty')
});
const CreateEntitiesSchema = zod_1.z.object({
entities: zod_1.z.array(EntitySchema).min(1, 'Must provide at least one entity')
});
const CreateRelationsSchema = zod_1.z.object({
relations: zod_1.z.array(RelationSchema).min(1, 'Must provide at least one relation')
});
const AddObservationsSchema = zod_1.z.object({
observations: zod_1.z.array(zod_1.z.object({
entityName: zod_1.z.string().min(1, 'Entity name cannot be empty'),
contents: zod_1.z.array(zod_1.z.string().min(1, 'Observation content cannot be empty')).min(1, 'Must provide at least one observation')
})).min(1, 'Must provide at least one observation entry')
});
const DeleteEntitiesSchema = zod_1.z.object({
entityNames: zod_1.z.array(zod_1.z.string().min(1, 'Entity name cannot be empty')).min(1, 'Must provide at least one entity name')
});
const DeleteObservationsSchema = zod_1.z.object({
deletions: zod_1.z.array(zod_1.z.object({
entityName: zod_1.z.string().min(1, 'Entity name cannot be empty'),
observations: zod_1.z.array(zod_1.z.string().min(1, 'Observation cannot be empty')).min(1, 'Must provide at least one observation')
})).min(1, 'Must provide at least one deletion entry')
});
const DeleteRelationsSchema = zod_1.z.object({
relations: zod_1.z.array(RelationSchema).min(1, 'Must provide at least one relation')
});
const SearchNodesSchema = zod_1.z.object({
query: zod_1.z.string().min(1, 'Search query cannot be empty')
});
const OpenNodesSchema = zod_1.z.object({
names: zod_1.z.array(zod_1.z.string().min(1, 'Entity name cannot be empty')).min(1, 'Must provide at least one entity name')
});
// ===== STORAGE CONFIGURATION =====
// Determine memory file path with environment variable support
const defaultMemoryPath = (0, path_1.resolve)(__dirname, '..', '..', 'memory.json');
function getMemoryFilePath() {
return process.env.MEMORY_FILE_PATH
? (0, path_1.isAbsolute)(process.env.MEMORY_FILE_PATH)
? process.env.MEMORY_FILE_PATH
: (0, path_1.resolve)(__dirname, '..', '..', process.env.MEMORY_FILE_PATH)
: defaultMemoryPath;
}
// ===== KNOWLEDGE GRAPH MANAGER =====
/**
* KnowledgeGraphManager handles all operations on the knowledge graph
* including persistence, CRUD operations, and search functionality
*/
class KnowledgeGraphManager {
constructor() {
this.graphCache = null;
this.lastModified = 0;
}
/**
* Load the knowledge graph from persistent storage
* Uses NDJSON format where each line is a JSON object with type field
*/
async loadGraph() {
try {
// Check if we need to reload from disk
const memoryFilePath = getMemoryFilePath();
const stats = await fs_1.promises.stat(memoryFilePath);
if (this.graphCache && stats.mtime.getTime() <= this.lastModified) {
return this.graphCache;
}
const data = await fs_1.promises.readFile(memoryFilePath, 'utf-8');
const lines = data.split('\n').filter(line => line.trim() !== '');
const graph = { entities: [], relations: [] };
for (const line of lines) {
try {
const item = JSON.parse(line);
if (item.type === 'entity') {
const { type, ...entity } = item;
graph.entities.push(entity);
}
else if (item.type === 'relation') {
const { type, ...relation } = item;
graph.relations.push(relation);
}
}
catch (parseError) {
console.error('Failed to parse memory file line:', line, parseError);
// Continue processing other lines
}
}
this.graphCache = graph;
this.lastModified = stats.mtime.getTime();
return graph;
}
catch (error) {
// Handle file not found - return empty graph
if (error?.code === 'ENOENT') {
const emptyGraph = { entities: [], relations: [] };
this.graphCache = emptyGraph;
this.lastModified = Date.now();
return emptyGraph;
}
throw new Error(`Failed to load knowledge graph: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Save the knowledge graph to persistent storage
* Uses atomic write with temporary file to prevent corruption
*/
async saveGraph(graph) {
try {
const lines = [
...graph.entities.map(entity => JSON.stringify({ type: 'entity', ...entity })),
...graph.relations.map(relation => JSON.stringify({ type: 'relation', ...relation }))
];
const content = lines.join('\n');
const memoryFilePath = getMemoryFilePath();
const tempPath = `${memoryFilePath}.tmp`;
// Atomic write: write to temp file then rename
await fs_1.promises.writeFile(tempPath, content, 'utf-8');
await fs_1.promises.rename(tempPath, memoryFilePath);
// Update cache
this.graphCache = graph;
this.lastModified = Date.now();
}
catch (error) {
throw new Error(`Failed to save knowledge graph: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* Create new entities in the knowledge graph
* Filters out entities with names that already exist
*/
async createEntities(entities) {
const graph = await this.loadGraph();
const existingNames = new Set(graph.entities.map(e => e.name));
const newEntities = entities.filter(entity => !existingNames.has(entity.name));
graph.entities.push(...newEntities);
await this.saveGraph(graph);
return newEntities;
}
/**
* Create new relations between entities
* Filters out relations that already exist
*/
async createRelations(relations) {
const graph = await this.loadGraph();
const newRelations = relations.filter(relation => !graph.relations.some(existing => existing.from === relation.from &&
existing.to === relation.to &&
existing.relationType === relation.relationType));
graph.relations.push(...newRelations);
await this.saveGraph(graph);
return newRelations;
}
/**
* Add observations to existing entities
* Returns the observations that were actually added (excludes duplicates)
*/
async addObservations(observations) {
const graph = await this.loadGraph();
const results = observations.map(obs => {
const entity = graph.entities.find(e => e.name === obs.entityName);
if (!entity) {
throw new Error(`Entity with name '${obs.entityName}' not found`);
}
const newObservations = obs.contents.filter(content => !entity.observations.includes(content));
entity.observations.push(...newObservations);
return { entityName: obs.entityName, addedObservations: newObservations };
});
await this.saveGraph(graph);
return results;
}
/**
* Delete entities and cascade delete their relations
*/
async deleteEntities(entityNames) {
const graph = await this.loadGraph();
const nameSet = new Set(entityNames);
// Remove entities
graph.entities = graph.entities.filter(entity => !nameSet.has(entity.name));
// Cascade delete relations
graph.relations = graph.relations.filter(relation => !nameSet.has(relation.from) && !nameSet.has(relation.to));
await this.saveGraph(graph);
}
/**
* Delete specific observations from entities
*/
async deleteObservations(deletions) {
const graph = await this.loadGraph();
for (const deletion of deletions) {
const entity = graph.entities.find(e => e.name === deletion.entityName);
if (entity) {
entity.observations = entity.observations.filter(obs => !deletion.observations.includes(obs));
}
}
await this.saveGraph(graph);
}
/**
* Delete specific relations from the graph
*/
async deleteRelations(relations) {
const graph = await this.loadGraph();
graph.relations = graph.relations.filter(existing => !relations.some(deleteRel => existing.from === deleteRel.from &&
existing.to === deleteRel.to &&
existing.relationType === deleteRel.relationType));
await this.saveGraph(graph);
}
/**
* Read the entire knowledge graph
*/
async readGraph() {
return this.loadGraph();
}
/**
* Search for nodes based on query string
* Searches entity names, types, and observation content
*/
async searchNodes(query) {
const graph = await this.loadGraph();
const queryLower = query.toLowerCase();
// Find matching entities
const matchingEntities = graph.entities.filter(entity => entity.name.toLowerCase().includes(queryLower) ||
entity.entityType.toLowerCase().includes(queryLower) ||
entity.observations.some(obs => obs.toLowerCase().includes(queryLower)));
// Get names of matching entities for relation filtering
const matchingEntityNames = new Set(matchingEntities.map(e => e.name));
// Find relations that involve any matching entity (either as from or to)
const matchingRelations = graph.relations.filter(relation => matchingEntityNames.has(relation.from) || matchingEntityNames.has(relation.to));
return {
entities: matchingEntities,
relations: matchingRelations
};
}
/**
* Open specific nodes by their names
* Returns the requested entities and relations between them
*/
async openNodes(names) {
const graph = await this.loadGraph();
const nameSet = new Set(names);
// Find requested entities
const requestedEntities = graph.entities.filter(entity => nameSet.has(entity.name));
// Find relations between requested entities
const relatedRelations = graph.relations.filter(relation => nameSet.has(relation.from) && nameSet.has(relation.to));
return {
entities: requestedEntities,
relations: relatedRelations
};
}
}
// Create singleton instance
const knowledgeGraphManager = new KnowledgeGraphManager();
// ===== MCP TOOLS EXPORT =====
/**
* Get all memory-related MCP tools
*/
function getTools() {
return [
{
name: 'memory_create_entities',
description: 'Create multiple new entities in the knowledge graph memory',
inputSchema: {
type: 'object',
properties: {
entities: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Unique identifier for the entity' },
entityType: { type: 'string', description: 'Type/classification of the entity (e.g., person, organization, event)' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'Array of discrete facts/observations about this entity'
}
},
required: ['name', 'entityType', 'observations']
},
description: 'Array of entities to create'
}
},
required: ['entities']
}
},
{
name: 'memory_create_relations',
description: 'Create multiple new relations between entities in the knowledge graph',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'Source entity name' },
to: { type: 'string', description: 'Target entity name' },
relationType: { type: 'string', description: 'Type of relationship in active voice (e.g., works_at, located_in)' }
},
required: ['from', 'to', 'relationType']
},
description: 'Array of relations to create'
}
},
required: ['relations']
}
},
{
name: 'memory_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: 'Name of the entity to add observations to' },
contents: {
type: 'array',
items: { type: 'string' },
description: 'Array of observation contents to add'
}
},
required: ['entityName', 'contents']
},
description: 'Array of observation sets to add'
}
},
required: ['observations']
}
},
{
name: 'memory_delete_entities',
description: 'Delete entities and their associated relations from the knowledge graph',
inputSchema: {
type: 'object',
properties: {
entityNames: {
type: 'array',
items: { type: 'string' },
description: 'Array of entity names to delete'
}
},
required: ['entityNames']
}
},
{
name: 'memory_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: 'Name of the entity to remove observations from' },
observations: {
type: 'array',
items: { type: 'string' },
description: 'Array of observations to remove'
}
},
required: ['entityName', 'observations']
},
description: 'Array of observation deletions to perform'
}
},
required: ['deletions']
}
},
{
name: 'memory_delete_relations',
description: 'Delete specific relations from the knowledge graph',
inputSchema: {
type: 'object',
properties: {
relations: {
type: 'array',
items: {
type: 'object',
properties: {
from: { type: 'string', description: 'Source entity name' },
to: { type: 'string', description: 'Target entity name' },
relationType: { type: 'string', description: 'Type of relationship' }
},
required: ['from', 'to', 'relationType']
},
description: 'Array of relations to delete'
}
},
required: ['relations']
}
},
{
name: 'memory_read_graph',
description: 'Read the entire knowledge graph from memory',
inputSchema: {
type: 'object',
properties: {}
}
},
{
name: 'memory_search_nodes',
description: 'Search for nodes in the knowledge graph based on a query',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query to match against entity names, types, and observations' }
},
required: ['query']
}
},
{
name: 'memory_open_nodes',
description: 'Open specific nodes in the knowledge graph by their names',
inputSchema: {
type: 'object',
properties: {
names: {
type: 'array',
items: { type: 'string' },
description: 'Array of entity names to retrieve'
}
},
required: ['names']
}
}
];
}
/**
* Handle memory tool invocations
*/
async function handleTool(name, args) {
try {
switch (name) {
case 'memory_create_entities': {
const validated = CreateEntitiesSchema.parse(args);
const createdEntities = await knowledgeGraphManager.createEntities(validated.entities);
return {
content: [{
type: 'text',
text: `Created ${createdEntities.length} entities:\n${JSON.stringify(createdEntities, null, 2)}`
}]
};
}
case 'memory_create_relations': {
const validated = CreateRelationsSchema.parse(args);
const createdRelations = await knowledgeGraphManager.createRelations(validated.relations);
return {
content: [{
type: 'text',
text: `Created ${createdRelations.length} relations:\n${JSON.stringify(createdRelations, null, 2)}`
}]
};
}
case 'memory_add_observations': {
const validated = AddObservationsSchema.parse(args);
const results = await knowledgeGraphManager.addObservations(validated.observations);
return {
content: [{
type: 'text',
text: `Added observations:\n${JSON.stringify(results, null, 2)}`
}]
};
}
case 'memory_delete_entities': {
const validated = DeleteEntitiesSchema.parse(args);
await knowledgeGraphManager.deleteEntities(validated.entityNames);
return {
content: [{
type: 'text',
text: `Successfully deleted ${validated.entityNames.length} entities: ${validated.entityNames.join(', ')}`
}]
};
}
case 'memory_delete_observations': {
const validated = DeleteObservationsSchema.parse(args);
await knowledgeGraphManager.deleteObservations(validated.deletions);
return {
content: [{
type: 'text',
text: `Successfully deleted observations from ${validated.deletions.length} entities`
}]
};
}
case 'memory_delete_relations': {
const validated = DeleteRelationsSchema.parse(args);
await knowledgeGraphManager.deleteRelations(validated.relations);
return {
content: [{
type: 'text',
text: `Successfully deleted ${validated.relations.length} relations`
}]
};
}
case 'memory_read_graph': {
const graph = await knowledgeGraphManager.readGraph();
return {
content: [{
type: 'text',
text: `Knowledge Graph:\n${JSON.stringify(graph, null, 2)}`
}]
};
}
case 'memory_search_nodes': {
const validated = SearchNodesSchema.parse(args);
const results = await knowledgeGraphManager.searchNodes(validated.query);
return {
content: [{
type: 'text',
text: `Search results for "${validated.query}":\n${JSON.stringify(results, null, 2)}`
}]
};
}
case 'memory_open_nodes': {
const validated = OpenNodesSchema.parse(args);
const results = await knowledgeGraphManager.openNodes(validated.names);
return {
content: [{
type: 'text',
text: `Nodes "${validated.names.join(', ')}":\n${JSON.stringify(results, null, 2)}`
}]
};
}
default:
throw new Error(`Unknown memory tool: ${name}`);
}
}
catch (error) {
if (error instanceof zod_1.z.ZodError) {
throw new Error(`Invalid arguments: ${error.errors.map(e => e.message).join(', ')}`);
}
throw error;
}
}