UNPKG

@ldavis9000aws/mcp-project-memory

Version:

Enhanced memory system for software development projects with persistent context across sessions

651 lines (650 loc) 30.9 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'; // 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; // The ProjectMemoryManager class extends the original concept with software development specific features class ProjectMemoryManager { async loadGraph() { try { const data = await fs.readFile(MEMORY_FILE_PATH, "utf-8"); return JSON.parse(data); } catch (error) { if (error instanceof Error && 'code' in error && error.code === "ENOENT") { return { entities: [], relations: [] }; } throw error; } } async saveGraph(graph) { await fs.writeFile(MEMORY_FILE_PATH, JSON.stringify(graph, null, 2)); } async createEntities(entities) { const graph = await this.loadGraph(); const now = new Date().toISOString(); // Process entities to ensure observations have timestamps const processedEntities = entities.map(entity => { const newEntity = { ...entity }; newEntity.observations = entity.observations.map((obs) => { if (typeof obs === 'string') { return { content: obs, timestamp: now }; } else if ('timestamp' in obs) { return obs; } else { // Handle other cases return { content: String(obs), timestamp: now }; } }); return newEntity; }); // Filter out entities that already exist const newEntities = processedEntities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name)); graph.entities.push(...newEntities); await this.saveGraph(graph); 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)); graph.relations.push(...newRelations); await this.saveGraph(graph); return newRelations; } async addObservations(observations) { const graph = await this.loadGraph(); const now = new Date().toISOString(); 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`); } // Convert string contents to Observation objects with timestamps const newObservations = o.contents.map(content => ({ content, timestamp: now })); // Filter out duplicate observations by content const uniqueNewObservations = newObservations.filter(newObs => !entity.observations.some(existingObs => existingObs.content === newObs.content)); entity.observations.push(...uniqueNewObservations); return { entityName: o.entityName, addedObservations: uniqueNewObservations }; }); await this.saveGraph(graph); 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.content)); } }); 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 this.loadGraph(); } async searchNodes(query) { const graph = await this.loadGraph(); // Filter entities based on case-insensitive search const filteredEntities = graph.entities.filter(e => e.name.toLowerCase().includes(query.toLowerCase()) || e.entityType.toLowerCase().includes(query.toLowerCase()) || e.observations.some(o => o.content.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)); return { entities: filteredEntities, relations: filteredRelations, }; } async openNodes(names) { const graph = await this.loadGraph(); // Filter entities by name 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)); return { entities: filteredEntities, relations: filteredRelations, }; } // New methods for software development context async getRelatedEntities(entityName, depth = 1) { const graph = await this.loadGraph(); const relatedEntities = new Set([entityName]); let collectEntities = new Set([entityName]); // Traverse the graph to the specified depth for (let i = 0; i < depth; i++) { const currentDepthEntities = Array.from(collectEntities); collectEntities = new Set(); for (const entity of currentDepthEntities) { // Find all relations where this entity is either source or target const connectedRelations = graph.relations.filter(r => r.from === entity || r.to === entity); // Add connected entities to our sets for (const relation of connectedRelations) { const connected = relation.from === entity ? relation.to : relation.from; if (!relatedEntities.has(connected)) { relatedEntities.add(connected); collectEntities.add(connected); } } } } // Filter entities and relations const filteredEntities = graph.entities.filter(e => relatedEntities.has(e.name)); const filteredRelations = graph.relations.filter(r => relatedEntities.has(r.from) && relatedEntities.has(r.to)); return { entities: filteredEntities, relations: filteredRelations }; } async findDevelopmentHistory(entity, entityType, timeframe) { // Get the entity and related entities const graph = await this.getRelatedEntities(entity, 2); // Filter by entity type if provided if (entityType) { graph.entities = graph.entities.filter(e => e.entityType === entityType); } // Filter by timeframe if provided if (timeframe) { const now = new Date(); let startDate; // Parse timeframe string switch (timeframe) { case 'last_day': startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); break; case 'last_week': startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); break; case 'last_month': startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); break; default: // Try to parse as date range 'YYYY-MM-DD:YYYY-MM-DD' const rangeParts = timeframe.split(':'); if (rangeParts.length === 2) { startDate = new Date(rangeParts[0]); const endDate = new Date(rangeParts[1]); // Filter observations by date range graph.entities = graph.entities.map(entity => ({ ...entity, observations: entity.observations.filter(obs => { const obsDate = new Date(obs.timestamp); return obsDate >= startDate && obsDate <= endDate; }) })); // Keep only entities with observations graph.entities = graph.entities.filter(e => e.observations.length > 0); return graph; } // Invalid timeframe format throw new Error(`Invalid timeframe: ${timeframe}`); } // Filter observations by start date graph.entities = graph.entities.map(entity => ({ ...entity, observations: entity.observations.filter(obs => new Date(obs.timestamp) >= startDate) })); // Keep only entities with observations graph.entities = graph.entities.filter(e => e.observations.length > 0); } // Sort observations by timestamp (newest first) graph.entities.forEach(entity => { entity.observations.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); }); return graph; } async recordIssue(issue) { const graph = await this.loadGraph(); const now = new Date().toISOString(); // Create unique issue name const issueName = `Issue_${Date.now()}`; // Create observations array const observations = [ { content: `Description: ${issue.description}`, timestamp: now }, { content: `Status: ${issue.status}`, timestamp: now } ]; // Add optional fields if (issue.errorMessage) { observations.push({ content: `Error message: ${issue.errorMessage}`, timestamp: now }); } if (issue.stackTrace) { observations.push({ content: `Stack trace: ${issue.stackTrace}`, timestamp: now }); } // Create issue entity const issueEntity = { name: issueName, entityType: "Issue", observations }; // Find component entity const component = graph.entities.find(e => e.name === issue.component); if (!component) { throw new Error(`Component ${issue.component} not found`); } // Create issue entity await this.createEntities([issueEntity]); // Create relation between component and issue await this.createRelations([{ from: issue.component, to: issueName, relationType: "affected_by" }]); return issueEntity; } async getProjectOverview(projectName) { const graph = await this.loadGraph(); // Find the project entity const projectEntity = graph.entities.find(e => e.name === projectName && e.entityType === "Project"); if (!projectEntity) { throw new Error(`Project ${projectName} not found`); } // Find all entities and relations connected to the project const relatedEntities = new Set([projectName]); const processedEntities = new Set(); const queue = [projectName]; // Breadth-first search to find all connected entities while (queue.length > 0) { const currentEntity = queue.shift(); if (processedEntities.has(currentEntity)) { continue; } processedEntities.add(currentEntity); // Find all direct relations const directRelations = graph.relations.filter(r => r.from === currentEntity || r.to === currentEntity); for (const relation of directRelations) { const connectedEntity = relation.from === currentEntity ? relation.to : relation.from; relatedEntities.add(connectedEntity); if (!processedEntities.has(connectedEntity)) { queue.push(connectedEntity); } } } // Filter entities and relations const projectEntities = graph.entities.filter(e => relatedEntities.has(e.name)); const projectRelations = graph.relations.filter(r => relatedEntities.has(r.from) && relatedEntities.has(r.to)); // Group entities by type for summary const summary = { components: projectEntities.filter(e => e.entityType === "Component").length, technologies: projectEntities.filter(e => e.entityType === "Technology").length, issues: projectEntities.filter(e => e.entityType === "Issue").length, decisions: projectEntities.filter(e => e.entityType === "Decision").length }; // Add summary as an observation to the project entity const projectIndex = projectEntities.findIndex(e => e.name === projectName); if (projectIndex !== -1) { projectEntities[projectIndex].observations.push({ content: `Project summary: ${summary.components} components, ${summary.technologies} technologies, ${summary.issues} issues, ${summary.decisions} decisions`, timestamp: new Date().toISOString() }); } return { entities: projectEntities, relations: projectRelations }; } } const projectMemoryManager = new ProjectMemoryManager(); // Server setup const server = new Server({ name: "project-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 for project components, technologies, or issues", inputSchema: { type: "object", properties: { entities: { type: "array", items: { type: "object", properties: { name: { type: "string", description: "The name of the entity (use PascalCase or snake_case for consistency)" }, entityType: { type: "string", description: "The type of the entity (Project, Component, Technology, Issue, Decision, etc.)" }, 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 (contains, uses, depends_on, affected_by, resolved_by, led_to)", 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 (contains, uses, depends_on, affected_by, resolved_by, led_to)" }, }, 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 (implementation details, notes, etc.)" }, }, 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" }, }, 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"], }, }, { name: "find_development_history", description: "Find development history for a specific component or issue", inputSchema: { type: "object", properties: { entity: { type: "string", description: "Entity name to find history for" }, entityType: { type: "string", description: "Optional: Filter by entity type (Component, Issue, Decision, etc.)" }, timeframe: { type: "string", description: "Optional: Timeframe like 'last_day', 'last_week', 'last_month', or date range 'YYYY-MM-DD:YYYY-MM-DD'" } }, required: ["entity"], }, }, { name: "record_issue", description: "Record a new issue or error with detailed information", inputSchema: { type: "object", properties: { component: { type: "string", description: "The component where the issue occurs" }, description: { type: "string", description: "Description of the issue" }, errorMessage: { type: "string", description: "Optional: Error message" }, stackTrace: { type: "string", description: "Optional: Stack trace" }, status: { type: "string", description: "Status of the issue (e.g., 'Open', 'In Progress', 'Resolved')" } }, required: ["component", "description", "status"], }, }, { name: "get_project_overview", description: "Get an overview of the entire project structure", inputSchema: { type: "object", properties: { projectName: { type: "string", description: "The name of the project entity to get an overview for" } }, required: ["projectName"], }, }, { name: "get_related_entities", description: "Get entities related to a specific entity with a specified depth", inputSchema: { type: "object", properties: { entityName: { type: "string", description: "The name of the entity to find related entities for" }, depth: { type: "number", description: "Optional: How many relation hops to traverse (default: 1)" } }, required: ["entityName"], }, } ], }; }); server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; if (!args) { throw new Error(`No arguments provided for tool: ${name}`); } try { switch (name) { case "create_entities": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.createEntities(args.entities), null, 2) }] }; case "create_relations": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.createRelations(args.relations), null, 2) }] }; case "add_observations": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.addObservations(args.observations), null, 2) }] }; case "delete_entities": await projectMemoryManager.deleteEntities(args.entityNames); return { content: [{ type: "text", text: "Entities deleted successfully" }] }; case "delete_observations": await projectMemoryManager.deleteObservations(args.deletions); return { content: [{ type: "text", text: "Observations deleted successfully" }] }; case "delete_relations": await projectMemoryManager.deleteRelations(args.relations); return { content: [{ type: "text", text: "Relations deleted successfully" }] }; case "read_graph": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.readGraph(), null, 2) }] }; case "search_nodes": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.searchNodes(args.query), null, 2) }] }; case "open_nodes": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.openNodes(args.names), null, 2) }] }; case "find_development_history": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.findDevelopmentHistory(args.entity, args.entityType, args.timeframe), null, 2) }] }; case "record_issue": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.recordIssue(args), null, 2) }] }; case "get_project_overview": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.getProjectOverview(args.projectName), null, 2) }] }; case "get_related_entities": return { content: [{ type: "text", text: JSON.stringify(await projectMemoryManager.getRelatedEntities(args.entityName, args.depth), null, 2) }] }; default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { if (error instanceof Error) { return { content: [{ type: "text", text: `Error: ${error.message}` }] }; } return { content: [{ type: "text", text: `Unknown error occurred` }] }; } }); async function main() { // Check if memory file exists, create an empty one if not try { await fs.access(MEMORY_FILE_PATH); } catch (error) { console.log(`Memory file not found at ${MEMORY_FILE_PATH}, creating empty file`); await fs.writeFile(MEMORY_FILE_PATH, JSON.stringify({ entities: [], relations: [] }, null, 2)); } const transport = new StdioServerTransport(); await server.connect(transport); console.error("Project Memory MCP Server running on stdio"); } main().catch((error) => { console.error("Fatal error in main():", error); process.exit(1); });