@ldavis9000aws/mcp-project-memory
Version:
Enhanced memory system for software development projects with persistent context across sessions
651 lines (650 loc) • 30.9 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';
// 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);
});