Skip to main content
Glama
unified-memory-store-handler.ts17.3 kB
/** * Unified Memory Store Handler * Single responsibility: Orchestrate memory creation + relation creation in one operation * THE IMPLEMENTOR'S RULE: Build exactly what's specified - memory store with immediate relations * FIXED: Proper transactional safety with single transaction scope */ import { McpMemoryHandler, McpRelationHandler } from '../mcp-handlers'; import { LocalIdResolver } from './services'; import { generateCompactId } from '../../id_generator'; import { DIContainer } from '../../container/di-container'; import { getLimitsConfig } from '../../config'; import { MCPValidationError, MCPDatabaseError, MCPOperationError, MCPErrorCodes, detectNeo4jError } from '../../infrastructure/errors'; export interface MemoryDefinition { name: string; memoryType: string; localId?: string; observations: string[]; metadata?: Record<string, any>; } export interface RelationDefinition { from: string; to: string; type: string; strength?: number; source?: "agent" | "user" | "system"; } export interface StoreOptions { validateReferences?: boolean; allowDuplicateRelations?: boolean; transactional?: boolean; maxMemories?: number; maxRelations?: number; } export interface MemoryStoreRequest { memories: MemoryDefinition[]; relations?: RelationDefinition[]; options?: StoreOptions; } export interface ConnectionResult { from: string; to: string; type: string; strength: number; source: string; } export interface MemoryStoreResponse { success: boolean; created: string[]; connected: ConnectionResult[]; localIdMap?: Record<string, string>; errors?: string[]; warnings?: string[]; limits: { memoriesLimit: number; relationsLimit: number; }; _meta: { database: string; operation: "store"; timestamp: string; }; } export class UnifiedMemoryStoreHandler { private memoryHandler: McpMemoryHandler; private relationHandler: McpRelationHandler; private localIdResolver: LocalIdResolver; constructor(memoryHandler: McpMemoryHandler, relationHandler: McpRelationHandler) { this.memoryHandler = memoryHandler; this.relationHandler = relationHandler; this.localIdResolver = new LocalIdResolver(); } async handleMemoryStore(request: MemoryStoreRequest): Promise<MemoryStoreResponse> { const options = this.applyDefaultOptions(request.options); // ZERO-FALLBACK FIX: Use proper transactional scope if (options.transactional) { return this.handleTransactionalStore(request, options); } else { // Non-transactional mode (legacy compatibility) return this.handleNonTransactionalStore(request, options); } } /** * TRANSACTIONAL STORE - Single transaction for entire operation * Zero-fallback: All or nothing */ private async handleTransactionalStore( request: MemoryStoreRequest, options: Required<StoreOptions> ): Promise<MemoryStoreResponse> { const container = DIContainer.getInstance(); const sessionFactory = container.getSessionFactory(); const session = sessionFactory.createSession(); const tx = session.beginTransaction(); try { // Step 1: Validate request this.validateStoreRequest(request, options); // Step 2: Create memories in transaction const createdMemories = await this.createMemoriesInTransaction( tx, request.memories ); // Step 3: Build localId → realId mapping const localIdMapping = this.localIdResolver.buildMapping( request.memories.map((mem, index) => ({ localId: mem.localId, id: createdMemories[index] })) ); // Step 4: Create relations in same transaction const connectionResults = await this.createRelationsInTransaction( tx, request.relations || [], localIdMapping ); // Step 5: Commit transaction await tx.commit(); // Step 6: Build successful response return this.buildSuccessResponse( createdMemories, connectionResults, localIdMapping, options ); } catch (error) { // ZERO-FALLBACK: Rollback everything on any error await tx.rollback(); // Detect and throw specific Neo4j errors const neo4jError = detectNeo4jError(error); if (neo4jError) { return this.buildErrorResponse(neo4jError, options); } return this.buildErrorResponse(error, options); } finally { await session.close(); } } /** * Create memories within transaction */ private async createMemoriesInTransaction( tx: any, memories: MemoryDefinition[] ): Promise<string[]> { const createdIds: string[] = []; for (const memory of memories) { const memoryId = generateCompactId(); // Create memory node const createMemoryQuery = ` CREATE (m:Memory { id: $id, name: $name, memoryType: $memoryType, metadata: $metadata, createdAt: $createdAt, modifiedAt: $modifiedAt, lastAccessed: $lastAccessed }) RETURN m.id as id `; const timestamp = new Date().toISOString(); await tx.run(createMemoryQuery, { id: memoryId, name: memory.name, memoryType: memory.memoryType, metadata: JSON.stringify(memory.metadata || {}), createdAt: timestamp, modifiedAt: timestamp, lastAccessed: timestamp }); // Create observations for (const observation of memory.observations) { const obsId = generateCompactId(); const createObsQuery = ` MATCH (m:Memory {id: $memoryId}) CREATE (o:Observation { id: $obsId, content: $content, createdAt: $timestamp }) CREATE (m)-[:HAS_OBSERVATION]->(o) `; await tx.run(createObsQuery, { memoryId, obsId, content: observation, timestamp: new Date().toISOString() }); } createdIds.push(memoryId); } return createdIds; } /** * Create relations within transaction */ private async createRelationsInTransaction( tx: any, relations: RelationDefinition[], localIdMapping: Map<string, string> ): Promise<ConnectionResult[]> { const connectionResults: ConnectionResult[] = []; for (const relation of relations) { const fromId = this.resolveId(relation.from, localIdMapping); const toId = this.resolveId(relation.to, localIdMapping); const createRelationQuery = ` MATCH (from:Memory {id: $fromId}), (to:Memory {id: $toId}) CREATE (from)-[:RELATES_TO { relationType: $relationType, strength: $strength, source: $source, createdAt: $createdAt }]->(to) RETURN from.id as fromId, to.id as toId `; const result = await tx.run(createRelationQuery, { fromId, toId, relationType: relation.type, strength: relation.strength || 0.5, source: relation.source || 'agent', createdAt: new Date().toISOString() }); if (result.records.length === 0) { throw new MCPDatabaseError( `Failed to create relation: ${fromId} → ${toId} (${relation.type}). ` + `One or both memories do not exist.`, MCPErrorCodes.MEMORY_NOT_FOUND, { fromId, toId, relationType: relation.type } ); } connectionResults.push({ from: fromId, to: toId, type: relation.type, strength: relation.strength || 0.5, source: relation.source || 'agent' }); } return connectionResults; } /** * NON-TRANSACTIONAL STORE - Legacy mode */ private async handleNonTransactionalStore( request: MemoryStoreRequest, options: Required<StoreOptions> ): Promise<MemoryStoreResponse> { try { // Step 1: Validate request this.validateStoreRequest(request, options); // Step 2: Create memories first const createdMemories = await this.createMemories(request.memories); // Step 3: Build localId → realId mapping const localIdMapping = this.localIdResolver.buildMapping( request.memories.map((mem, index) => ({ localId: mem.localId, id: createdMemories[index] })) ); // Step 4: Create relations using resolved IDs const connectionResults = await this.createRelations( request.relations || [], localIdMapping ); // Step 5: Build successful response return this.buildSuccessResponse( createdMemories, connectionResults, localIdMapping, options ); } catch (error) { // Non-transactional: return error but don't rollback return this.buildErrorResponse(error, options); } } /** * Create all memories using existing McpMemoryHandler */ private async createMemories(memories: MemoryDefinition[]): Promise<string[]> { const memoryRequests = memories.map(mem => ({ name: mem.name, memoryType: mem.memoryType, metadata: mem.metadata || {}, observations: mem.observations || [] })); const result = await this.memoryHandler.handleMemoryManage({ operation: 'create', memories: memoryRequests }); // Extract created IDs - zero-fallback architecture demands success if (!result.success) { throw new MCPOperationError( `Memory creation failed: ${result.results.map((r: any) => r.error).filter(Boolean).join('; ')}`, MCPErrorCodes.DATABASE_OPERATION_FAILED, { errors: result.results.filter((r: any) => r.error) } ); } return result.results .filter((r: any) => r.status === 'created') .map((r: any) => r.id); } /** * Create relations using existing McpRelationHandler with resolved IDs */ private async createRelations( relations: RelationDefinition[], localIdMapping: Map<string, string> ): Promise<ConnectionResult[]> { if (relations.length === 0) { return []; } // Resolve localIds to realIds - fail fast on errors let resolvedRelations: any[]; try { resolvedRelations = relations.map(rel => ({ fromId: this.resolveId(rel.from, localIdMapping), toId: this.resolveId(rel.to, localIdMapping), relationType: rel.type, strength: rel.strength || 0.5, source: rel.source || 'agent' })); } catch (error) { throw new MCPValidationError( `Local ID resolution failed: ${error instanceof Error ? error.message : String(error)}`, MCPErrorCodes.INVALID_LOCAL_ID, { originalError: error instanceof Error ? error.message : String(error) } ); } const result = await this.relationHandler.handleRelationManage({ operation: 'create', relations: resolvedRelations }); // ZERO-FALLBACK: Report failed relations immediately const failedRelations = result.results.filter((r: any) => r.status === 'failed'); if (failedRelations.length > 0) { const errors = failedRelations.map((r: any) => `${r.fromId} → ${r.toId} (${r.relationType}): ${r.error || 'Unknown error'}` ); throw new MCPOperationError( `Relation creation failed: ${errors.join('; ')}`, MCPErrorCodes.DATABASE_OPERATION_FAILED, { failedRelations } ); } // Convert to ConnectionResult format return result.results .filter((r: any) => r.status === 'created') .map((r: any) => ({ from: r.fromId, to: r.toId, type: r.relationType, strength: resolvedRelations.find(rel => rel.fromId === r.fromId && rel.toId === r.toId && rel.relationType === r.relationType )?.strength || 0.5, source: resolvedRelations.find(rel => rel.fromId === r.fromId && rel.toId === r.toId && rel.relationType === r.relationType )?.source || 'agent' })); } /** * Resolve ID - either localId to realId mapping or existing memory ID */ private resolveId(id: string, localIdMapping: Map<string, string>): string { const realId = localIdMapping.get(id); return realId || id; // If not a localId, assume it's an existing memory ID } /** * Validate store request against limits and rules */ private validateStoreRequest(request: MemoryStoreRequest, options: StoreOptions): void { // Validate memory count if (request.memories.length === 0) { throw new MCPValidationError( 'memories array cannot be empty', MCPErrorCodes.EMPTY_ARRAY ); } if (request.memories.length > options.maxMemories!) { throw new MCPValidationError( `Too many memories: ${request.memories.length} > ${options.maxMemories}`, MCPErrorCodes.INVALID_MEMORY_COUNT, { requested: request.memories.length, limit: options.maxMemories } ); } // Validate relation count if (request.relations && request.relations.length > options.maxRelations!) { throw new MCPValidationError( `Too many relations: ${request.relations.length} > ${options.maxRelations}`, MCPErrorCodes.INVALID_RELATION_COUNT, { requested: request.relations.length, limit: options.maxRelations } ); } // Validate local IDs this.localIdResolver.validateLocalIds(request.memories); // Validate individual memories for (const memory of request.memories) { if (!memory.name?.trim()) { throw new MCPValidationError( 'Memory name cannot be empty', MCPErrorCodes.INVALID_NAME ); } if (!memory.memoryType?.trim()) { throw new MCPValidationError( 'Memory type cannot be empty', MCPErrorCodes.INVALID_TYPE ); } if (!memory.observations || memory.observations.length === 0) { throw new MCPValidationError( `Memory "${memory.name}" must have at least one observation`, MCPErrorCodes.EMPTY_ARRAY, { memoryName: memory.name } ); } } // Validate relations if (request.relations) { for (const relation of request.relations) { if (!relation.from?.trim() || !relation.to?.trim()) { throw new MCPValidationError( 'Relation from/to IDs cannot be empty', MCPErrorCodes.VALIDATION_FAILED, { from: relation.from, to: relation.to } ); } if (!relation.type?.trim()) { throw new MCPValidationError( 'Relation type cannot be empty', MCPErrorCodes.INVALID_TYPE ); } if (relation.strength !== undefined && (relation.strength < 0 || relation.strength > 1)) { throw new MCPValidationError( `Relation strength must be between 0.0 and 1.0, got: ${relation.strength}`, MCPErrorCodes.INVALID_STRENGTH, { providedStrength: relation.strength } ); } } } } /** * Apply default options with configurable limits */ private applyDefaultOptions(options?: StoreOptions): Required<StoreOptions> { const limits = getLimitsConfig(); return { validateReferences: options?.validateReferences ?? true, allowDuplicateRelations: options?.allowDuplicateRelations ?? false, transactional: options?.transactional ?? true, maxMemories: options?.maxMemories ?? limits.maxMemoriesPerOperation, maxRelations: options?.maxRelations ?? limits.maxRelationsPerOperation }; } /** * Build successful response */ private buildSuccessResponse( createdIds: string[], connections: ConnectionResult[], localIdMapping: Map<string, string>, options: Required<StoreOptions> ): MemoryStoreResponse { const currentDb = DIContainer.getInstance().getCurrentDatabase(); return { success: true, created: createdIds, connected: connections, localIdMap: this.localIdResolver.mappingToResponse(localIdMapping), limits: { memoriesLimit: options.maxMemories, relationsLimit: options.maxRelations }, _meta: { database: currentDb.database, operation: "store", timestamp: new Date().toISOString() } }; } /** * Build error response */ private buildErrorResponse(error: unknown, options: Required<StoreOptions>): MemoryStoreResponse { const errorMessage = error instanceof Error ? error.message : String(error); const currentDb = DIContainer.getInstance().getCurrentDatabase(); return { success: false, created: [], connected: [], errors: [errorMessage], limits: { memoriesLimit: options.maxMemories, relationsLimit: options.maxRelations }, _meta: { database: currentDb.database, operation: "store", timestamp: new Date().toISOString() } }; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/sylweriusz/mcp-neo4j-memory-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server