index.ts•20.2 kB
#!/usr/bin/env node
/**
* @copyright 2025 Chris Bunting <cbuntingde@gmail.com>
* @license MIT
*
* Memory MCP Server - Implements three types of memory for vertical agents:
* - Short-term memory: retains details within a session
* - Long-term memory: stores demographics, contact details, and preferences
* - Episodic memory: connects past experiences with present conversations
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListResourcesRequestSchema,
ListToolsRequestSchema,
ReadResourceRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ErrorCode,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import * as fs from 'fs';
import * as path from 'path';
// Memory type definitions
interface ShortTermMemory {
sessionId: string;
data: Record<string, any>;
timestamp: number;
expiresAt: number;
}
interface LongTermMemory {
userId: string;
demographics?: {
age?: number;
location?: string;
occupation?: string;
};
contact?: {
email?: string;
phone?: string;
preferredContact?: string;
};
preferences?: {
seating?: string;
temperature?: string;
communicationStyle?: string;
interests?: string[];
};
lastUpdated: number;
}
interface EpisodicMemory {
id: string;
userId: string;
sessionId?: string;
event: string;
context: string;
outcome?: string;
sentiment?: 'positive' | 'negative' | 'neutral';
timestamp: number;
tags?: string[];
}
// Memory storage
class MemoryStore {
private shortTermMemory: Map<string, ShortTermMemory> = new Map();
private longTermMemory: Map<string, LongTermMemory> = new Map();
private episodicMemory: Map<string, EpisodicMemory> = new Map();
private dataDir: string;
constructor() {
this.dataDir = path.join(process.cwd(), 'memory-data');
this.ensureDataDirectory();
this.loadPersistedData();
}
private ensureDataDirectory(): void {
if (!fs.existsSync(this.dataDir)) {
fs.mkdirSync(this.dataDir, { recursive: true });
}
}
private loadPersistedData(): void {
try {
// Load long-term memory
const longTermPath = path.join(this.dataDir, 'long-term.json');
if (fs.existsSync(longTermPath)) {
const data = JSON.parse(fs.readFileSync(longTermPath, 'utf8'));
Object.entries(data).forEach(([userId, memory]: [string, any]) => {
this.longTermMemory.set(userId, memory);
});
}
// Load episodic memory
const episodicPath = path.join(this.dataDir, 'episodic.json');
if (fs.existsSync(episodicPath)) {
const data = JSON.parse(fs.readFileSync(episodicPath, 'utf8'));
Object.entries(data).forEach(([id, memory]: [string, any]) => {
this.episodicMemory.set(id, memory);
});
}
} catch (error) {
console.error('Error loading persisted data:', error);
}
}
private persistLongTermMemory(): void {
try {
const data = Object.fromEntries(this.longTermMemory);
fs.writeFileSync(
path.join(this.dataDir, 'long-term.json'),
JSON.stringify(data, null, 2)
);
} catch (error) {
console.error('Error persisting long-term memory:', error);
}
}
private persistEpisodicMemory(): void {
try {
const data = Object.fromEntries(this.episodicMemory);
fs.writeFileSync(
path.join(this.dataDir, 'episodic.json'),
JSON.stringify(data, null, 2)
);
} catch (error) {
console.error('Error persisting episodic memory:', error);
}
}
// Short-term memory operations
setShortTermMemory(sessionId: string, key: string, value: any, ttlMinutes: number = 30): void {
const expiresAt = Date.now() + (ttlMinutes * 60 * 1000);
const existing = this.shortTermMemory.get(sessionId);
if (existing && existing.expiresAt > Date.now()) {
existing.data[key] = value;
existing.expiresAt = expiresAt;
} else {
this.shortTermMemory.set(sessionId, {
sessionId,
data: { [key]: value },
timestamp: Date.now(),
expiresAt
});
}
}
getShortTermMemory(sessionId: string, key?: string): any {
const memory = this.shortTermMemory.get(sessionId);
if (!memory || memory.expiresAt <= Date.now()) {
this.shortTermMemory.delete(sessionId);
return null;
}
return key ? memory.data[key] : memory.data;
}
clearExpiredShortTermMemory(): void {
const now = Date.now();
for (const [sessionId, memory] of this.shortTermMemory.entries()) {
if (memory.expiresAt <= now) {
this.shortTermMemory.delete(sessionId);
}
}
}
// Long-term memory operations
setLongTermMemory(userId: string, data: Partial<LongTermMemory>): void {
const existing = this.longTermMemory.get(userId) || {
userId,
lastUpdated: Date.now()
};
const updated = {
...existing,
...data,
lastUpdated: Date.now()
};
this.longTermMemory.set(userId, updated);
this.persistLongTermMemory();
}
getLongTermMemory(userId: string): LongTermMemory | null {
return this.longTermMemory.get(userId) || null;
}
// Episodic memory operations
addEpisodicMemory(memory: Omit<EpisodicMemory, 'id' | 'timestamp'>): string {
const id = `episodic_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const episodicMemory: EpisodicMemory = {
...memory,
id,
timestamp: Date.now()
};
this.episodicMemory.set(id, episodicMemory);
this.persistEpisodicMemory();
return id;
}
getEpisodicMemory(userId: string, limit?: number): EpisodicMemory[] {
const memories = Array.from(this.episodicMemory.values())
.filter(memory => memory.userId === userId)
.sort((a, b) => b.timestamp - a.timestamp);
return limit ? memories.slice(0, limit) : memories;
}
searchEpisodicMemory(userId: string, query: string, limit?: number): EpisodicMemory[] {
const searchTerm = query.toLowerCase();
const memories = Array.from(this.episodicMemory.values())
.filter(memory =>
memory.userId === userId &&
(memory.event.toLowerCase().includes(searchTerm) ||
memory.context.toLowerCase().includes(searchTerm) ||
memory.outcome?.toLowerCase().includes(searchTerm) ||
memory.tags?.some(tag => tag.toLowerCase().includes(searchTerm)))
)
.sort((a, b) => b.timestamp - a.timestamp);
return limit ? memories.slice(0, limit) : memories;
}
}
// Initialize memory store
const memoryStore = new MemoryStore();
// Create MCP server
const server = new Server(
{
name: "memory-server",
version: "0.1.0",
},
{
capabilities: {
resources: {},
tools: {},
prompts: {},
},
}
);
// Resource handlers
server.setRequestHandler(ListResourcesRequestSchema, async () => {
memoryStore.clearExpiredShortTermMemory();
const resources = [];
// Add long-term memory resources
for (const [userId] of memoryStore['longTermMemory'].entries()) {
resources.push({
uri: `memory://long-term/${userId}`,
mimeType: "application/json",
name: `Long-term memory for ${userId}`,
description: "Demographics, contact details, and preferences"
});
}
// Add episodic memory resources
for (const [userId] of memoryStore['longTermMemory'].entries()) {
resources.push({
uri: `memory://episodic/${userId}`,
mimeType: "application/json",
name: `Episodic memory for ${userId}`,
description: "Past experiences and events"
});
}
return { resources };
});
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const url = new URL(request.params.uri);
const [type, userId] = url.pathname.replace(/^\//, '').split('/');
if (type === 'long-term') {
const memory = memoryStore.getLongTermMemory(userId);
if (!memory) {
throw new McpError(ErrorCode.InvalidRequest, `Long-term memory not found for user: ${userId}`);
}
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(memory, null, 2)
}]
};
} else if (type === 'episodic') {
const memories = memoryStore.getEpisodicMemory(userId);
return {
contents: [{
uri: request.params.uri,
mimeType: "application/json",
text: JSON.stringify(memories, null, 2)
}]
};
}
throw new McpError(ErrorCode.InvalidRequest, `Invalid memory resource type: ${type}`);
});
// Tool handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "set_short_term_memory",
description: "Store data in short-term memory for a session",
inputSchema: {
type: "object",
properties: {
sessionId: {
type: "string",
description: "Session identifier"
},
key: {
type: "string",
description: "Memory key"
},
value: {
description: "Memory value (any JSON-serializable data)"
},
ttlMinutes: {
type: "number",
description: "Time to live in minutes (default: 30)",
default: 30
}
},
required: ["sessionId", "key", "value"]
}
},
{
name: "get_short_term_memory",
description: "Retrieve data from short-term memory",
inputSchema: {
type: "object",
properties: {
sessionId: {
type: "string",
description: "Session identifier"
},
key: {
type: "string",
description: "Memory key (optional, returns all if not provided)"
}
},
required: ["sessionId"]
}
},
{
name: "set_long_term_memory",
description: "Store user demographics, contact details, or preferences",
inputSchema: {
type: "object",
properties: {
userId: {
type: "string",
description: "User identifier"
},
demographics: {
type: "object",
description: "User demographics (age, location, occupation, etc.)"
},
contact: {
type: "object",
description: "Contact information"
},
preferences: {
type: "object",
description: "User preferences and settings"
}
},
required: ["userId"]
}
},
{
name: "get_long_term_memory",
description: "Retrieve long-term memory for a user",
inputSchema: {
type: "object",
properties: {
userId: {
type: "string",
description: "User identifier"
}
},
required: ["userId"]
}
},
{
name: "add_episodic_memory",
description: "Add a new episodic memory (past experience or event)",
inputSchema: {
type: "object",
properties: {
userId: {
type: "string",
description: "User identifier"
},
sessionId: {
type: "string",
description: "Optional session identifier"
},
event: {
type: "string",
description: "Description of the event"
},
context: {
type: "string",
description: "Context surrounding the event"
},
outcome: {
type: "string",
description: "Outcome or resolution of the event"
},
sentiment: {
type: "string",
enum: ["positive", "negative", "neutral"],
description: "Sentiment of the experience"
},
tags: {
type: "array",
items: { type: "string" },
description: "Tags for categorizing the memory"
}
},
required: ["userId", "event", "context"]
}
},
{
name: "get_episodic_memory",
description: "Retrieve episodic memories for a user",
inputSchema: {
type: "object",
properties: {
userId: {
type: "string",
description: "User identifier"
},
limit: {
type: "number",
description: "Maximum number of memories to return"
}
},
required: ["userId"]
}
},
{
name: "search_episodic_memory",
description: "Search episodic memories by content",
inputSchema: {
type: "object",
properties: {
userId: {
type: "string",
description: "User identifier"
},
query: {
type: "string",
description: "Search query"
},
limit: {
type: "number",
description: "Maximum number of results"
}
},
required: ["userId", "query"]
}
}
]
};
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
switch (request.params.name) {
case "set_short_term_memory": {
const { sessionId, key, value, ttlMinutes = 30 } = request.params.arguments as any;
memoryStore.setShortTermMemory(sessionId, key, value, ttlMinutes);
return {
content: [{
type: "text",
text: `Stored short-term memory for session ${sessionId}: ${key}`
}]
};
}
case "get_short_term_memory": {
const { sessionId, key } = request.params.arguments as any;
const memory = memoryStore.getShortTermMemory(sessionId, key);
return {
content: [{
type: "text",
text: JSON.stringify(memory, null, 2)
}]
};
}
case "set_long_term_memory": {
const { userId, demographics, contact, preferences } = request.params.arguments as any;
memoryStore.setLongTermMemory(userId, { demographics, contact, preferences });
return {
content: [{
type: "text",
text: `Updated long-term memory for user ${userId}`
}]
};
}
case "get_long_term_memory": {
const { userId } = request.params.arguments as any;
const memory = memoryStore.getLongTermMemory(userId);
return {
content: [{
type: "text",
text: JSON.stringify(memory, null, 2)
}]
};
}
case "add_episodic_memory": {
const memoryData = request.params.arguments as any;
const id = memoryStore.addEpisodicMemory(memoryData);
return {
content: [{
type: "text",
text: `Added episodic memory with ID: ${id}`
}]
};
}
case "get_episodic_memory": {
const { userId, limit } = request.params.arguments as any;
const memories = memoryStore.getEpisodicMemory(userId, limit);
return {
content: [{
type: "text",
text: JSON.stringify(memories, null, 2)
}]
};
}
case "search_episodic_memory": {
const { userId, query, limit } = request.params.arguments as any;
const memories = memoryStore.searchEpisodicMemory(userId, query, limit);
return {
content: [{
type: "text",
text: JSON.stringify(memories, null, 2)
}]
};
}
default:
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}`);
}
});
// Prompt handlers
server.setRequestHandler(ListPromptsRequestSchema, async () => {
return {
prompts: [
{
name: "memory_summary",
description: "Generate a comprehensive memory summary for a user"
},
{
name: "personalization_insights",
description: "Get personalization insights based on user memories"
}
]
};
});
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name === "memory_summary") {
const userId = args?.userId as string;
if (!userId) {
throw new McpError(ErrorCode.InvalidParams, "userId is required for memory summary");
}
const longTerm = memoryStore.getLongTermMemory(userId);
const episodic = memoryStore.getEpisodicMemory(userId, 10);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Please provide a comprehensive memory summary for user ${userId}. Include:`
}
},
{
role: "user",
content: {
type: "text",
text: "1. Key demographic and preference information"
}
},
{
role: "user",
content: {
type: "text",
text: "2. Recent experiences and patterns"
}
},
{
role: "user",
content: {
type: "text",
text: "3. Insights for personalization and proactive support"
}
},
{
role: "user",
content: {
type: "resource",
resource: {
uri: `memory://long-term/${userId}`,
mimeType: "application/json",
text: JSON.stringify(longTerm, null, 2)
}
}
},
{
role: "user",
content: {
type: "resource",
resource: {
uri: `memory://episodic/${userId}`,
mimeType: "application/json",
text: JSON.stringify(episodic, null, 2)
}
}
}
]
};
} else if (name === "personalization_insights") {
const userId = args?.userId as string;
if (!userId) {
throw new McpError(ErrorCode.InvalidParams, "userId is required for personalization insights");
}
const longTerm = memoryStore.getLongTermMemory(userId);
const episodic = memoryStore.getEpisodicMemory(userId, 20);
return {
messages: [
{
role: "user",
content: {
type: "text",
text: `Based on the user's memory data, provide personalization insights for:`
}
},
{
role: "user",
content: {
type: "text",
text: "1. Preferred communication style and approach"
}
},
{
role: "user",
content: {
type: "text",
text: "2. Anticipated needs based on past experiences"
}
},
{
role: "user",
content: {
type: "text",
text: "3. Opportunities for proactive personalization"
}
},
{
role: "user",
content: {
type: "resource",
resource: {
uri: `memory://long-term/${userId}`,
mimeType: "application/json",
text: JSON.stringify(longTerm, null, 2)
}
}
},
{
role: "user",
content: {
type: "resource",
resource: {
uri: `memory://episodic/${userId}`,
mimeType: "application/json",
text: JSON.stringify(episodic, null, 2)
}
}
}
]
};
}
throw new McpError(ErrorCode.MethodNotFound, `Unknown prompt: ${name}`);
});
// Start the server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Memory MCP Server running on stdio");
}
main().catch((error) => {
console.error("Server error:", error);
process.exit(1);
});