Skip to main content
Glama

mcp-structured-memory

StorageManager.test.ts26.5 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { StorageManager } from './StorageManager.js' import { Memory } from '../types/memory.js' import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' // Mock the fs module vi.mock('fs/promises', () => ({ access: vi.fn(), mkdir: vi.fn(), readFile: vi.fn(), writeFile: vi.fn(), readdir: vi.fn() })) // Mock path and os modules for platform testing vi.mock('path', async () => { const actual = await vi.importActual('path') return { ...actual, join: vi.fn((...args: string[]) => args.join('/')), resolve: vi.fn((p: string) => p), basename: vi.fn((p: string, ext?: string) => { const parts = p.split('/') const filename = parts[parts.length - 1] if (ext && filename.endsWith(ext)) { return filename.slice(0, -ext.length) } return filename }) } }) vi.mock('os', () => ({ homedir: vi.fn(() => '/home/user'), platform: 'linux' })) // Mock gray-matter vi.mock('gray-matter', () => { const matterMock = vi.fn((content: string) => { // Simple mock implementation that extracts frontmatter-like content const lines = content.split('\n') if (lines[0] === '---') { const endIndex = lines.findIndex((line, i) => i > 0 && line === '---') if (endIndex > 0) { const frontmatterLines = lines.slice(1, endIndex) const data: any = {} frontmatterLines.forEach(line => { const [key, ...valueParts] = line.split(': ') if (key && valueParts.length > 0) { const value = valueParts.join(': ') // Handle arrays if (value.startsWith('[') && value.endsWith(']')) { data[key] = JSON.parse(value) } else if (value.startsWith('"') && value.endsWith('"')) { data[key] = value.slice(1, -1) } else { data[key] = value } } }) return { data, content: lines.slice(endIndex + 1).join('\n') } } } return { data: {}, content } }) matterMock.stringify = vi.fn((content: string, frontmatter: any) => { const frontmatterLines = Object.entries(frontmatter).map(([key, value]) => { if (Array.isArray(value)) { return `${key}: ${JSON.stringify(value)}` } else if (typeof value === 'string') { return `${key}: "${value}"` } else { return `${key}: ${value}` } }) return `---\n${frontmatterLines.join('\n')}\n---\n${content}` }) return { default: matterMock } }) describe('StorageManager', () => { let storageManager: StorageManager let mockFs: any let mockPath: any let mockOs: any beforeEach(() => { vi.clearAllMocks() mockFs = vi.mocked(fs) mockPath = vi.mocked(path) mockOs = vi.mocked(os) // Reset environment // Default OS platform Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) storageManager = new StorageManager() }) afterEach(() => { vi.restoreAllMocks() }) describe('Platform-specific Path Generation', () => { it('should generate macOS specific path', () => { Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) mockOs.homedir.mockReturnValue('/Users/testuser') void new StorageManager() expect(mockPath.join).toHaveBeenCalledWith( '/Users/testuser', 'Library', 'Application Support', 'mcp-structured-memory' ) }) it('should generate Windows specific path', () => { Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) mockOs.homedir.mockReturnValue('C:\\Users\\testuser') void new StorageManager() expect(mockPath.join).toHaveBeenCalledWith( 'C:\\Users\\testuser', 'AppData', 'Local', 'mcp-structured-memory' ) }) it('should generate Linux/default path', () => { Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) mockOs.homedir.mockReturnValue('/home/testuser') void new StorageManager() expect(mockPath.join).toHaveBeenCalledWith( '/home/testuser', '.local', 'share', 'mcp-structured-memory' ) }) }) describe('Directory Management', () => { it('should create storage directory if it does not exist', async () => { mockFs.access.mockRejectedValue(new Error('Directory does not exist')) mockFs.mkdir.mockResolvedValue(undefined) await storageManager.ensureStorageDirectory() expect(mockFs.mkdir).toHaveBeenCalledWith( expect.any(String), { recursive: true } ) expect(mockFs.mkdir).toHaveBeenCalledTimes(2) // Main dir + backups dir }) it('should not create directory if it already exists', async () => { mockFs.access.mockResolvedValue(undefined) await storageManager.ensureStorageDirectory() expect(mockFs.mkdir).not.toHaveBeenCalled() }) it('should create backups directory along with main directory', async () => { mockFs.access.mockRejectedValue(new Error('Directory does not exist')) mockFs.mkdir.mockResolvedValue(undefined) mockPath.join.mockImplementation((...args) => args.join('/')) await storageManager.ensureStorageDirectory() expect(mockFs.mkdir).toHaveBeenCalledTimes(2) expect(mockFs.mkdir).toHaveBeenNthCalledWith(2, expect.stringContaining('.backups'), { recursive: true } ) }) }) describe('Memory Creation and Writing', () => { const mockMemory: Memory = { metadata: { id: 'test-memory', created: '2025-01-01T00:00:00.000Z', updated: '2025-01-01T00:00:00.000Z', tags: ['test', 'memory'], status: 'active' }, content: '# Test Memory\n\nThis is test content.', filePath: '/test/path/test-memory.md' } beforeEach(() => { mockFs.access.mockResolvedValue(undefined) // Directory exists mockFs.writeFile.mockResolvedValue(undefined) mockFs.readFile.mockResolvedValue('existing content') vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-01-02T00:00:00.000Z') }) it('should write memory with correct frontmatter', async () => { await storageManager.writeMemory(mockMemory) expect(mockFs.writeFile).toHaveBeenCalledWith( expect.stringContaining('test-memory.md'), expect.any(String), 'utf-8' ) // Verify matter.stringify was called with correct parameters const matter = await import('gray-matter') expect(matter.default.stringify).toHaveBeenCalledWith( mockMemory.content, expect.objectContaining({ id: 'test-memory', tags: ['test', 'memory'], status: 'active', updated: '2025-01-02T00:00:00.000Z' }) ) }) it('should create backup before writing', async () => { const backupSpy = vi.spyOn(storageManager, 'createBackup') backupSpy.mockResolvedValue(undefined) await storageManager.writeMemory(mockMemory) expect(backupSpy).toHaveBeenCalledWith('test-memory') }) it('should ensure storage directory exists before writing', async () => { const ensureSpy = vi.spyOn(storageManager, 'ensureStorageDirectory') ensureSpy.mockResolvedValue(undefined) await storageManager.writeMemory(mockMemory) expect(ensureSpy).toHaveBeenCalled() }) it('should handle memory without status field', async () => { const memoryWithoutStatus = { ...mockMemory, metadata: { ...mockMemory.metadata, status: undefined } } await storageManager.writeMemory(memoryWithoutStatus) const [, content] = mockFs.writeFile.mock.calls[0] expect(content).not.toContain('status:') }) }) describe('Memory Reading', () => { const sampleFileContent = `--- id: "test-memory" created: "2025-01-01T00:00:00.000Z" updated: "2025-01-02T00:00:00.000Z" tags: ["test","memory"] status: active --- # Test Memory This is test content.` it('should read and parse memory correctly', async () => { mockFs.readFile.mockResolvedValue(sampleFileContent) const result = await storageManager.readMemory('test-memory') expect(result).not.toBeNull() expect(result?.metadata.id).toBe('test-memory') expect(result?.metadata.tags).toEqual(['test', 'memory']) expect(result?.metadata.status).toBe('active') expect(result?.content).toBe('# Test Memory\n\nThis is test content.') }) it('should return null for non-existent memory', async () => { const error = new Error('File not found') as NodeJS.ErrnoException error.code = 'ENOENT' mockFs.readFile.mockRejectedValue(error) const result = await storageManager.readMemory('non-existent') expect(result).toBeNull() }) it('should throw error for other file system errors', async () => { const error = new Error('Permission denied') mockFs.readFile.mockRejectedValue(error) await expect(storageManager.readMemory('test-memory')) .rejects.toThrow('Permission denied') }) it('should handle missing frontmatter fields with defaults', async () => { const contentWithoutFields = `--- id: "minimal-memory" --- # Minimal Memory Content here.` mockFs.readFile.mockResolvedValue(contentWithoutFields) vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-01-02T00:00:00.000Z') const result = await storageManager.readMemory('minimal-memory') expect(result?.metadata.created).toBe('2025-01-02T00:00:00.000Z') expect(result?.metadata.updated).toBe('2025-01-02T00:00:00.000Z') expect(result?.metadata.tags).toEqual([]) expect(result?.metadata.status).toBeUndefined() }) it('should use memory ID as fallback for missing ID in frontmatter', async () => { const contentWithoutId = `--- created: "2025-01-01T00:00:00.000Z" --- Content here.` mockFs.readFile.mockResolvedValue(contentWithoutId) const result = await storageManager.readMemory('fallback-id') expect(result?.metadata.id).toBe('fallback-id') }) }) describe('Memory Listing', () => { beforeEach(() => { mockFs.access.mockResolvedValue(undefined) // Directory exists }) it('should list all memory files and return summaries', async () => { const files = ['memory1.md', 'memory2.md', '.hidden.md', 'not-memory.txt'] mockFs.readdir.mockResolvedValue(files) const fileContents = [ `--- id: "memory1" created: "2025-01-01T00:00:00.000Z" updated: "2025-01-02T00:00:00.000Z" tags: ["tag1"] --- # Section 1 Content 1 ## Section 2 Content 2`, `--- id: "memory2" created: "2025-01-01T00:00:00.000Z" updated: "2025-01-01T12:00:00.000Z" tags: ["tag2"] status: inactive --- # Only Section Content here` ] mockFs.readFile .mockResolvedValueOnce(fileContents[0]) .mockResolvedValueOnce(fileContents[1]) mockPath.basename .mockReturnValueOnce('memory1') .mockReturnValueOnce('memory2') const result = await storageManager.listMemories() expect(result).toHaveLength(2) expect(result[0].id).toBe('memory1') // Should be first due to newer update date expect(result[0].sectionCount).toBe(2) expect(result[1].id).toBe('memory2') expect(result[1].sectionCount).toBe(1) expect(result[1].status).toBe('inactive') }) it('should filter out hidden files and non-markdown files', async () => { const files = ['.hidden.md', 'backup.txt', 'memory.md'] mockFs.readdir.mockResolvedValue(files) mockFs.readFile.mockResolvedValue(`--- id: "memory" --- Content`) mockPath.basename.mockReturnValue('memory') await storageManager.listMemories() expect(mockFs.readFile).toHaveBeenCalledTimes(1) }) it('should sort memories by updated date (newest first)', async () => { const files = ['old.md', 'new.md', 'middle.md'] mockFs.readdir.mockResolvedValue(files) const fileContents = [ `--- id: "old" updated: "2025-01-01T00:00:00.000Z" --- Content`, `--- id: "new" updated: "2025-01-03T00:00:00.000Z" --- Content`, `--- id: "middle" updated: "2025-01-02T00:00:00.000Z" --- Content` ] mockFs.readFile .mockResolvedValueOnce(fileContents[0]) .mockResolvedValueOnce(fileContents[1]) .mockResolvedValueOnce(fileContents[2]) mockPath.basename .mockReturnValueOnce('old') .mockReturnValueOnce('new') .mockReturnValueOnce('middle') const result = await storageManager.listMemories() expect(result[0].id).toBe('new') expect(result[1].id).toBe('middle') expect(result[2].id).toBe('old') }) it('should return empty array if directory read fails', async () => { mockFs.readdir.mockRejectedValue(new Error('Permission denied')) const result = await storageManager.listMemories() expect(result).toEqual([]) }) it('should ensure storage directory exists before listing', async () => { const ensureSpy = vi.spyOn(storageManager, 'ensureStorageDirectory') ensureSpy.mockResolvedValue(undefined) mockFs.readdir.mockResolvedValue([]) await storageManager.listMemories() expect(ensureSpy).toHaveBeenCalled() }) }) describe('Backup Functionality', () => { beforeEach(() => { vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-01-02T10-30-45-123Z') }) it('should create backup of existing file', async () => { mockFs.access.mockResolvedValue(undefined) // File exists mockFs.readFile.mockResolvedValue('original content') mockFs.writeFile.mockResolvedValue(undefined) await storageManager.createBackup('test-memory') expect(mockFs.readFile).toHaveBeenCalledWith( expect.stringContaining('test-memory.md'), 'utf-8' ) expect(mockFs.writeFile).toHaveBeenCalledWith( expect.stringContaining('test-memory-2025-01-02T10-30-45-123Z.md'), 'original content', 'utf-8' ) }) it('should not create backup if file does not exist', async () => { mockFs.access.mockRejectedValue(new Error('File not found')) await storageManager.createBackup('non-existent') expect(mockFs.readFile).not.toHaveBeenCalled() expect(mockFs.writeFile).not.toHaveBeenCalled() }) it('should sanitize timestamp for filename', async () => { mockFs.access.mockResolvedValue(undefined) mockFs.readFile.mockResolvedValue('content') mockFs.writeFile.mockResolvedValue(undefined) vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-01-02T10:30:45.123Z') await storageManager.createBackup('test-memory') expect(mockFs.writeFile).toHaveBeenCalledWith( expect.stringContaining('test-memory-2025-01-02T10-30-45-123Z.md'), 'content', 'utf-8' ) }) }) describe('Section Parsing', () => { it('should parse sections with different heading levels', () => { const content = `# Level 1 Heading Content for level 1 ## Level 2 Heading Content for level 2 More content ### Level 3 Heading Content for level 3 #### Level 4 Heading Content for level 4` const sections = storageManager.parseSections(content) expect(sections).toHaveLength(4) expect(sections[0]).toEqual({ name: 'Level 1 Heading', content: 'Content for level 1', level: 1 }) expect(sections[1]).toEqual({ name: 'Level 2 Heading', content: 'Content for level 2\nMore content', level: 2 }) expect(sections[2]).toEqual({ name: 'Level 3 Heading', content: 'Content for level 3', level: 3 }) expect(sections[3]).toEqual({ name: 'Level 4 Heading', content: 'Content for level 4', level: 4 }) }) it('should handle content before first heading', () => { const content = `This is content before any heading ## First Heading Content under heading` const sections = storageManager.parseSections(content) expect(sections).toHaveLength(1) expect(sections[0].name).toBe('First Heading') }) it('should handle empty sections', () => { const content = `## Empty Section ## Section with Content Some content here ## Another Empty Section` const sections = storageManager.parseSections(content) expect(sections).toHaveLength(3) expect(sections[0]).toEqual({ name: 'Empty Section', content: '', level: 2 }) expect(sections[1]).toEqual({ name: 'Section with Content', content: 'Some content here', level: 2 }) expect(sections[2]).toEqual({ name: 'Another Empty Section', content: '', level: 2 }) }) it('should trim whitespace from section content', () => { const content = `## Section with Whitespace Content with spaces ## Another Section More content ` const sections = storageManager.parseSections(content) expect(sections[0].content).toBe('Content with spaces') expect(sections[1].content).toBe('More content') }) it('should handle malformed headings', () => { const content = `##No space after hashes This should not be a heading ## Proper Heading This is proper content ### Extra spaces Content here` const sections = storageManager.parseSections(content) expect(sections).toHaveLength(2) expect(sections[0].name).toBe('Proper Heading') expect(sections[1].name).toBe('Extra spaces') }) it('should handle empty content', () => { const sections = storageManager.parseSections('') expect(sections).toEqual([]) }) it('should handle content with only whitespace', () => { const sections = storageManager.parseSections(' \n\n ') expect(sections).toEqual([]) }) }) describe('Section Finding', () => { const sampleContent = `# Introduction Welcome to the document ## Active Pipeline - Company A - Company B ## ruled_out companies - Company C - Company D ## Contact Network - Person 1 - Person 2` it('should find section by exact name match', () => { const section = storageManager.findSection(sampleContent, 'Active Pipeline') expect(section).not.toBeNull() expect(section?.name).toBe('Active Pipeline') expect(section?.content).toContain('Company A') }) it('should find section by case-insensitive match', () => { const section = storageManager.findSection(sampleContent, 'active pipeline') expect(section).not.toBeNull() expect(section?.name).toBe('Active Pipeline') }) it('should find section by normalized name (special characters to underscores)', () => { const section = storageManager.findSection(sampleContent, 'ruled_out_companies') expect(section).not.toBeNull() expect(section?.name).toBe('ruled_out companies') }) it('should return null for non-existent section', () => { const section = storageManager.findSection(sampleContent, 'Non-existent Section') expect(section).toBeNull() }) it('should handle empty content', () => { const section = storageManager.findSection('', 'Any Section') expect(section).toBeNull() }) it('should prioritize exact matches over normalized matches', () => { const contentWithBothFormats = `## Contact_Network Underscore version ## Contact Network Space version` const section = storageManager.findSection(contentWithBothFormats, 'Contact Network') expect(section?.content).toBe('Space version') }) }) describe('Section Updating', () => { const sampleMemory: Memory = { metadata: { id: 'test-memory', created: '2025-01-01T00:00:00.000Z', updated: '2025-01-01T00:00:00.000Z', tags: [], }, content: `## Existing Section Original content ## Another Section More content`, filePath: '/test/path/test-memory.md' } beforeEach(() => { // Create a fresh memory object for each test to avoid state pollution const freshMemory = { ...sampleMemory, content: `## Existing Section Original content ## Another Section More content` } vi.spyOn(storageManager, 'readMemory').mockResolvedValue(freshMemory) vi.spyOn(storageManager, 'writeMemory').mockResolvedValue(undefined) }) it('should append content to existing section', async () => { await storageManager.updateSection('test-memory', 'Existing Section', 'New content', 'append') expect(storageManager.writeMemory).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('Original content\n\nNew content') }) ) }) it('should replace content in existing section', async () => { await storageManager.updateSection('test-memory', 'Existing Section', 'Replacement content', 'replace') expect(storageManager.writeMemory).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('Replacement content') }) ) const writtenMemory = (storageManager.writeMemory as any).mock.calls[0][0] expect(writtenMemory.content).not.toContain('Original content') }) it('should create new section if it does not exist', async () => { await storageManager.updateSection('test-memory', 'New Section', 'New section content') expect(storageManager.writeMemory).toHaveBeenCalledWith( expect.objectContaining({ content: expect.stringContaining('## New Section') }) ) }) it('should default to append mode', async () => { // Reset the mock to ensure clean state const freshMemory = { ...sampleMemory, content: `## Existing Section Original content ## Another Section More content` } vi.spyOn(storageManager, 'readMemory').mockResolvedValue(freshMemory) await storageManager.updateSection('test-memory', 'Existing Section', 'Appended content') const writtenMemory = (storageManager.writeMemory as any).mock.calls[0][0] expect(writtenMemory.content).toContain('Original content\n\nAppended content') }) it('should handle appending to empty section', async () => { const memoryWithEmptySection = { ...sampleMemory, content: `## Empty Section ## Another Section Content here` } vi.spyOn(storageManager, 'readMemory').mockResolvedValue(memoryWithEmptySection) await storageManager.updateSection('test-memory', 'Empty Section', 'New content', 'append') const writtenMemory = (storageManager.writeMemory as any).mock.calls[0][0] expect(writtenMemory.content).toContain('New content') }) it('should throw error for non-existent memory', async () => { vi.spyOn(storageManager, 'readMemory').mockResolvedValue(null) await expect( storageManager.updateSection('non-existent', 'Section', 'Content') ).rejects.toThrow("Memory document 'non-existent' not found") }) it('should handle case-insensitive section matching', async () => { await storageManager.updateSection('test-memory', 'existing section', 'Updated content', 'replace') expect(storageManager.writeMemory).toHaveBeenCalled() }) }) describe('Content Rebuilding', () => { it('should rebuild content from sections correctly', () => { const sections = [ { name: 'Section 1', content: 'Content 1', level: 1 }, { name: 'Section 2', content: 'Content 2\nWith multiple lines', level: 2 }, { name: 'Section 3', content: '', level: 3 } ] // Access private method through type casting const rebuiltContent = (storageManager as any).rebuildContent(sections) expect(rebuiltContent).toBe(`# Section 1 Content 1 ## Section 2 Content 2 With multiple lines ### Section 3`) }) it('should handle sections with various heading levels', () => { const sections = [ { name: 'Level 1', content: 'Content', level: 1 }, { name: 'Level 4', content: 'Content', level: 4 }, { name: 'Level 6', content: 'Content', level: 6 } ] const rebuiltContent = (storageManager as any).rebuildContent(sections) expect(rebuiltContent).toContain('# Level 1') expect(rebuiltContent).toContain('#### Level 4') expect(rebuiltContent).toContain('###### Level 6') }) it('should handle empty sections array', () => { const rebuiltContent = (storageManager as any).rebuildContent([]) expect(rebuiltContent).toBe('') }) }) describe('Error Handling', () => { it('should handle file system permission errors gracefully', async () => { mockFs.writeFile.mockRejectedValue(new Error('Permission denied')) const memory: Memory = { metadata: { id: 'test', created: '2025-01-01T00:00:00.000Z', updated: '2025-01-01T00:00:00.000Z', tags: [] }, content: 'test', filePath: '/test' } await expect(storageManager.writeMemory(memory)).rejects.toThrow('Permission denied') }) it('should handle corrupted file content gracefully', async () => { mockFs.readFile.mockResolvedValue('corrupted frontmatter content') // Should not throw, should handle gracefully const result = await storageManager.readMemory('corrupted-memory') expect(result).not.toBeNull() }) }) })

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/nmeierpolys/mcp-structured-memory'

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