@levalicious/server-memory 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,438 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { createServer } from '../index.js';
6
+ import { createTestClient, callTool } from './test-utils.js';
7
+ describe('MCP Memory Server E2E Tests', () => {
8
+ let testDir;
9
+ let memoryFile;
10
+ let client;
11
+ let cleanup;
12
+ beforeEach(async () => {
13
+ // Create a unique temp directory for each test
14
+ testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-memory-test-'));
15
+ memoryFile = path.join(testDir, 'test-memory.json');
16
+ const server = createServer(memoryFile);
17
+ const result = await createTestClient(server);
18
+ client = result.client;
19
+ cleanup = result.cleanup;
20
+ });
21
+ afterEach(async () => {
22
+ await cleanup();
23
+ // Clean up test directory
24
+ try {
25
+ await fs.rm(testDir, { recursive: true });
26
+ }
27
+ catch {
28
+ // Ignore cleanup errors
29
+ }
30
+ });
31
+ describe('Entity Operations', () => {
32
+ it('should create entities successfully', async () => {
33
+ const result = await callTool(client, 'create_entities', {
34
+ entities: [
35
+ { name: 'Alice', entityType: 'Person', observations: ['Likes coding'] },
36
+ { name: 'Bob', entityType: 'Person', observations: ['Likes music'] }
37
+ ]
38
+ });
39
+ expect(result).toHaveLength(2);
40
+ expect(result[0].name).toBe('Alice');
41
+ expect(result[1].name).toBe('Bob');
42
+ });
43
+ it('should not duplicate existing entities', async () => {
44
+ await callTool(client, 'create_entities', {
45
+ entities: [{ name: 'Alice', entityType: 'Person', observations: ['First'] }]
46
+ });
47
+ const result = await callTool(client, 'create_entities', {
48
+ entities: [
49
+ { name: 'Alice', entityType: 'Person', observations: ['Second'] },
50
+ { name: 'Bob', entityType: 'Person', observations: ['New'] }
51
+ ]
52
+ });
53
+ // Only Bob should be returned as new
54
+ expect(result).toHaveLength(1);
55
+ expect(result[0].name).toBe('Bob');
56
+ });
57
+ it('should reject entities with more than 2 observations', async () => {
58
+ await expect(callTool(client, 'create_entities', {
59
+ entities: [{
60
+ name: 'TooMany',
61
+ entityType: 'Test',
62
+ observations: ['One', 'Two', 'Three']
63
+ }]
64
+ })).rejects.toThrow(/Maximum allowed is 2/);
65
+ });
66
+ it('should reject observations longer than 140 characters', async () => {
67
+ const longObservation = 'x'.repeat(141);
68
+ await expect(callTool(client, 'create_entities', {
69
+ entities: [{
70
+ name: 'LongObs',
71
+ entityType: 'Test',
72
+ observations: [longObservation]
73
+ }]
74
+ })).rejects.toThrow(/exceeds 140 characters/);
75
+ });
76
+ it('should delete entities and their relations', async () => {
77
+ await callTool(client, 'create_entities', {
78
+ entities: [
79
+ { name: 'A', entityType: 'Node', observations: [] },
80
+ { name: 'B', entityType: 'Node', observations: [] }
81
+ ]
82
+ });
83
+ await callTool(client, 'create_relations', {
84
+ relations: [{ from: 'A', to: 'B', relationType: 'connects' }]
85
+ });
86
+ await callTool(client, 'delete_entities', { entityNames: ['A'] });
87
+ const stats = await callTool(client, 'get_stats', {});
88
+ expect(stats.entityCount).toBe(1);
89
+ expect(stats.relationCount).toBe(0); // Relation should be deleted too
90
+ });
91
+ });
92
+ describe('Observation Operations', () => {
93
+ beforeEach(async () => {
94
+ await callTool(client, 'create_entities', {
95
+ entities: [{ name: 'TestEntity', entityType: 'Test', observations: [] }]
96
+ });
97
+ });
98
+ it('should add observations to entities', async () => {
99
+ const result = await callTool(client, 'add_observations', {
100
+ observations: [{
101
+ entityName: 'TestEntity',
102
+ contents: ['New observation']
103
+ }]
104
+ });
105
+ expect(result[0].addedObservations).toContain('New observation');
106
+ });
107
+ it('should not duplicate existing observations', async () => {
108
+ await callTool(client, 'add_observations', {
109
+ observations: [{ entityName: 'TestEntity', contents: ['Existing'] }]
110
+ });
111
+ const result = await callTool(client, 'add_observations', {
112
+ observations: [{ entityName: 'TestEntity', contents: ['Existing', 'New'] }]
113
+ });
114
+ expect(result[0].addedObservations).toEqual(['New']);
115
+ });
116
+ it('should reject adding observations that would exceed limit', async () => {
117
+ await callTool(client, 'add_observations', {
118
+ observations: [{ entityName: 'TestEntity', contents: ['One', 'Two'] }]
119
+ });
120
+ await expect(callTool(client, 'add_observations', {
121
+ observations: [{ entityName: 'TestEntity', contents: ['Three'] }]
122
+ })).rejects.toThrow(/would exceed limit of 2/);
123
+ });
124
+ it('should delete specific observations', async () => {
125
+ await callTool(client, 'add_observations', {
126
+ observations: [{ entityName: 'TestEntity', contents: ['Keep', 'Delete'] }]
127
+ });
128
+ await callTool(client, 'delete_observations', {
129
+ deletions: [{ entityName: 'TestEntity', observations: ['Delete'] }]
130
+ });
131
+ const result = await callTool(client, 'open_nodes', { names: ['TestEntity'] });
132
+ expect(result.entities[0].observations).toEqual(['Keep']);
133
+ });
134
+ });
135
+ describe('Relation Operations', () => {
136
+ beforeEach(async () => {
137
+ await callTool(client, 'create_entities', {
138
+ entities: [
139
+ { name: 'A', entityType: 'Node', observations: [] },
140
+ { name: 'B', entityType: 'Node', observations: [] },
141
+ { name: 'C', entityType: 'Node', observations: [] }
142
+ ]
143
+ });
144
+ });
145
+ it('should create relations', async () => {
146
+ const result = await callTool(client, 'create_relations', {
147
+ relations: [
148
+ { from: 'A', to: 'B', relationType: 'connects' },
149
+ { from: 'B', to: 'C', relationType: 'connects' }
150
+ ]
151
+ });
152
+ expect(result).toHaveLength(2);
153
+ });
154
+ it('should not duplicate relations', async () => {
155
+ await callTool(client, 'create_relations', {
156
+ relations: [{ from: 'A', to: 'B', relationType: 'connects' }]
157
+ });
158
+ const result = await callTool(client, 'create_relations', {
159
+ relations: [
160
+ { from: 'A', to: 'B', relationType: 'connects' },
161
+ { from: 'A', to: 'C', relationType: 'connects' }
162
+ ]
163
+ });
164
+ expect(result).toHaveLength(1);
165
+ expect(result[0].to).toBe('C');
166
+ });
167
+ it('should delete relations', async () => {
168
+ await callTool(client, 'create_relations', {
169
+ relations: [
170
+ { from: 'A', to: 'B', relationType: 'connects' },
171
+ { from: 'B', to: 'C', relationType: 'connects' }
172
+ ]
173
+ });
174
+ await callTool(client, 'delete_relations', {
175
+ relations: [{ from: 'A', to: 'B', relationType: 'connects' }]
176
+ });
177
+ const stats = await callTool(client, 'get_stats', {});
178
+ expect(stats.relationCount).toBe(1);
179
+ });
180
+ });
181
+ describe('Search Operations', () => {
182
+ beforeEach(async () => {
183
+ await callTool(client, 'create_entities', {
184
+ entities: [
185
+ { name: 'JavaScript', entityType: 'Language', observations: ['Dynamic typing'] },
186
+ { name: 'TypeScript', entityType: 'Language', observations: ['Static typing'] },
187
+ { name: 'Python', entityType: 'Language', observations: ['Dynamic typing'] }
188
+ ]
189
+ });
190
+ await callTool(client, 'create_relations', {
191
+ relations: [{ from: 'TypeScript', to: 'JavaScript', relationType: 'extends' }]
192
+ });
193
+ });
194
+ it('should search by regex pattern', async () => {
195
+ const result = await callTool(client, 'search_nodes', {
196
+ query: 'Script'
197
+ });
198
+ expect(result.entities).toHaveLength(2);
199
+ expect(result.entities.map(e => e.name)).toContain('JavaScript');
200
+ expect(result.entities.map(e => e.name)).toContain('TypeScript');
201
+ });
202
+ it('should search with alternation', async () => {
203
+ const result = await callTool(client, 'search_nodes', {
204
+ query: 'JavaScript|Python'
205
+ });
206
+ expect(result.entities).toHaveLength(2);
207
+ });
208
+ it('should search in observations', async () => {
209
+ const result = await callTool(client, 'search_nodes', {
210
+ query: 'Static'
211
+ });
212
+ expect(result.entities).toHaveLength(1);
213
+ expect(result.entities[0].name).toBe('TypeScript');
214
+ });
215
+ it('should reject invalid regex', async () => {
216
+ await expect(callTool(client, 'search_nodes', { query: '[invalid' })).rejects.toThrow(/Invalid regex pattern/);
217
+ });
218
+ });
219
+ describe('Node Retrieval', () => {
220
+ beforeEach(async () => {
221
+ await callTool(client, 'create_entities', {
222
+ entities: [
223
+ { name: 'A', entityType: 'Node', observations: ['Root'] },
224
+ { name: 'B', entityType: 'Node', observations: [] },
225
+ { name: 'C', entityType: 'Node', observations: [] }
226
+ ]
227
+ });
228
+ await callTool(client, 'create_relations', {
229
+ relations: [
230
+ { from: 'A', to: 'B', relationType: 'parent_of' },
231
+ { from: 'A', to: 'C', relationType: 'parent_of' }
232
+ ]
233
+ });
234
+ });
235
+ it('should open nodes by name', async () => {
236
+ const result = await callTool(client, 'open_nodes', {
237
+ names: ['A', 'B']
238
+ });
239
+ expect(result.entities).toHaveLength(2);
240
+ // open_nodes returns all relations where 'from' is in the requested set
241
+ // A->B and A->C both have from='A' which is in the set
242
+ expect(result.relations).toHaveLength(2);
243
+ });
244
+ it('should open nodes filtered (only internal relations)', async () => {
245
+ const result = await callTool(client, 'open_nodes_filtered', {
246
+ names: ['B', 'C']
247
+ });
248
+ expect(result.entities).toHaveLength(2);
249
+ expect(result.relations).toHaveLength(0); // No relations between B and C
250
+ });
251
+ });
252
+ describe('Graph Traversal', () => {
253
+ beforeEach(async () => {
254
+ await callTool(client, 'create_entities', {
255
+ entities: [
256
+ { name: 'Root', entityType: 'Node', observations: [] },
257
+ { name: 'Child1', entityType: 'Node', observations: [] },
258
+ { name: 'Child2', entityType: 'Node', observations: [] },
259
+ { name: 'Grandchild', entityType: 'Node', observations: [] }
260
+ ]
261
+ });
262
+ await callTool(client, 'create_relations', {
263
+ relations: [
264
+ { from: 'Root', to: 'Child1', relationType: 'parent_of' },
265
+ { from: 'Root', to: 'Child2', relationType: 'parent_of' },
266
+ { from: 'Child1', to: 'Grandchild', relationType: 'parent_of' }
267
+ ]
268
+ });
269
+ });
270
+ it('should get neighbors at depth 0 (relations only by default)', async () => {
271
+ const result = await callTool(client, 'get_neighbors', {
272
+ entityName: 'Root',
273
+ depth: 0
274
+ });
275
+ expect(result.entities).toHaveLength(0); // withEntities defaults to false
276
+ expect(result.relations).toHaveLength(2); // Root's direct relations
277
+ });
278
+ it('should get neighbors with entities when requested', async () => {
279
+ const result = await callTool(client, 'get_neighbors', {
280
+ entityName: 'Root',
281
+ depth: 1,
282
+ withEntities: true
283
+ });
284
+ expect(result.entities.map(e => e.name)).toContain('Root');
285
+ expect(result.entities.map(e => e.name)).toContain('Child1');
286
+ expect(result.entities.map(e => e.name)).toContain('Child2');
287
+ });
288
+ it('should traverse to specified depth', async () => {
289
+ const result = await callTool(client, 'get_neighbors', {
290
+ entityName: 'Root',
291
+ depth: 2,
292
+ withEntities: true
293
+ });
294
+ expect(result.entities).toHaveLength(4); // All nodes
295
+ expect(result.relations).toHaveLength(3); // All relations
296
+ });
297
+ it('should deduplicate relations in traversal', async () => {
298
+ // Add a bidirectional relation
299
+ await callTool(client, 'create_relations', {
300
+ relations: [{ from: 'Child2', to: 'Root', relationType: 'child_of' }]
301
+ });
302
+ const result = await callTool(client, 'get_neighbors', {
303
+ entityName: 'Root',
304
+ depth: 1
305
+ });
306
+ // Each unique relation should appear only once
307
+ const relationKeys = result.relations.map(r => `${r.from}|${r.relationType}|${r.to}`);
308
+ const uniqueKeys = [...new Set(relationKeys)];
309
+ expect(relationKeys.length).toBe(uniqueKeys.length);
310
+ });
311
+ it('should find path between entities', async () => {
312
+ const result = await callTool(client, 'find_path', {
313
+ fromEntity: 'Root',
314
+ toEntity: 'Grandchild'
315
+ });
316
+ expect(result).toHaveLength(2);
317
+ expect(result[0].from).toBe('Root');
318
+ expect(result[1].to).toBe('Grandchild');
319
+ });
320
+ it('should return empty path when no path exists', async () => {
321
+ await callTool(client, 'create_entities', {
322
+ entities: [{ name: 'Isolated', entityType: 'Node', observations: [] }]
323
+ });
324
+ const result = await callTool(client, 'find_path', {
325
+ fromEntity: 'Root',
326
+ toEntity: 'Isolated'
327
+ });
328
+ expect(result).toHaveLength(0);
329
+ });
330
+ });
331
+ describe('Type Queries', () => {
332
+ beforeEach(async () => {
333
+ await callTool(client, 'create_entities', {
334
+ entities: [
335
+ { name: 'Alice', entityType: 'Person', observations: [] },
336
+ { name: 'Bob', entityType: 'Person', observations: [] },
337
+ { name: 'Acme', entityType: 'Company', observations: [] }
338
+ ]
339
+ });
340
+ await callTool(client, 'create_relations', {
341
+ relations: [
342
+ { from: 'Alice', to: 'Acme', relationType: 'works_at' },
343
+ { from: 'Bob', to: 'Acme', relationType: 'works_at' }
344
+ ]
345
+ });
346
+ });
347
+ it('should get entities by type', async () => {
348
+ const result = await callTool(client, 'get_entities_by_type', {
349
+ entityType: 'Person'
350
+ });
351
+ expect(result).toHaveLength(2);
352
+ expect(result.every(e => e.entityType === 'Person')).toBe(true);
353
+ });
354
+ it('should get all entity types', async () => {
355
+ const result = await callTool(client, 'get_entity_types', {});
356
+ expect(result).toContain('Person');
357
+ expect(result).toContain('Company');
358
+ expect(result).toHaveLength(2);
359
+ });
360
+ it('should get all relation types', async () => {
361
+ const result = await callTool(client, 'get_relation_types', {});
362
+ expect(result).toContain('works_at');
363
+ expect(result).toHaveLength(1);
364
+ });
365
+ });
366
+ describe('Statistics and Validation', () => {
367
+ it('should return correct stats', async () => {
368
+ await callTool(client, 'create_entities', {
369
+ entities: [
370
+ { name: 'A', entityType: 'Type1', observations: [] },
371
+ { name: 'B', entityType: 'Type2', observations: [] }
372
+ ]
373
+ });
374
+ await callTool(client, 'create_relations', {
375
+ relations: [{ from: 'A', to: 'B', relationType: 'rel1' }]
376
+ });
377
+ const stats = await callTool(client, 'get_stats', {});
378
+ expect(stats.entityCount).toBe(2);
379
+ expect(stats.relationCount).toBe(1);
380
+ expect(stats.entityTypes).toBe(2);
381
+ expect(stats.relationTypes).toBe(1);
382
+ });
383
+ it('should find orphaned entities', async () => {
384
+ await callTool(client, 'create_entities', {
385
+ entities: [
386
+ { name: 'Connected1', entityType: 'Node', observations: [] },
387
+ { name: 'Connected2', entityType: 'Node', observations: [] },
388
+ { name: 'Orphan', entityType: 'Node', observations: [] }
389
+ ]
390
+ });
391
+ await callTool(client, 'create_relations', {
392
+ relations: [{ from: 'Connected1', to: 'Connected2', relationType: 'links' }]
393
+ });
394
+ const result = await callTool(client, 'get_orphaned_entities', {});
395
+ expect(result).toHaveLength(1);
396
+ expect(result[0].name).toBe('Orphan');
397
+ });
398
+ it('should validate graph and report violations', async () => {
399
+ // Directly write invalid data to test validation
400
+ const invalidData = [
401
+ JSON.stringify({ type: 'entity', name: 'Valid', entityType: 'Test', observations: [] }),
402
+ JSON.stringify({ type: 'relation', from: 'Valid', to: 'Missing', relationType: 'refs' })
403
+ ].join('\n');
404
+ await fs.writeFile(memoryFile, invalidData);
405
+ const result = await callTool(client, 'validate_graph', {});
406
+ expect(result.missingEntities).toContain('Missing');
407
+ });
408
+ });
409
+ describe('BCL Evaluator', () => {
410
+ it('should evaluate K combinator (identity for first arg)', async () => {
411
+ // K = 00, evaluating K applied to two args should return first
412
+ // This is a simplified test - BCL semantics are complex
413
+ const result = await callTool(client, 'evaluate_bcl', {
414
+ program: '00',
415
+ maxSteps: 100
416
+ });
417
+ expect(result.halted).toBe(true);
418
+ });
419
+ it('should construct BCL terms incrementally', async () => {
420
+ let result = await callTool(client, 'add_bcl_term', { term: 'App' });
421
+ expect(result).toContain('more term');
422
+ result = await callTool(client, 'add_bcl_term', { term: 'K' });
423
+ expect(result).toContain('more term');
424
+ result = await callTool(client, 'add_bcl_term', { term: 'S' });
425
+ expect(result).toContain('Constructed Program');
426
+ });
427
+ it('should clear BCL constructor state', async () => {
428
+ await callTool(client, 'add_bcl_term', { term: 'App' });
429
+ await callTool(client, 'clear_bcl_term', {});
430
+ // After clearing, we should need to start fresh
431
+ const result = await callTool(client, 'add_bcl_term', { term: 'K' });
432
+ expect(result).toContain('Constructed Program');
433
+ });
434
+ it('should reject invalid BCL terms', async () => {
435
+ await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
436
+ });
437
+ });
438
+ });
@@ -0,0 +1,37 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ export async function createTestClient(server) {
4
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5
+ const client = new Client({
6
+ name: "test-client",
7
+ version: "1.0.0",
8
+ }, {
9
+ capabilities: {}
10
+ });
11
+ await server.connect(serverTransport);
12
+ await client.connect(clientTransport);
13
+ return {
14
+ client,
15
+ cleanup: async () => {
16
+ await client.close();
17
+ await server.close();
18
+ }
19
+ };
20
+ }
21
+ export async function callTool(client, name, args) {
22
+ const result = await client.callTool({ name, arguments: args });
23
+ const content = result.content;
24
+ if (!content || content.length === 0) {
25
+ return null;
26
+ }
27
+ const first = content[0];
28
+ if (first.type === 'text' && first.text) {
29
+ try {
30
+ return JSON.parse(first.text);
31
+ }
32
+ catch {
33
+ return first.text;
34
+ }
35
+ }
36
+ return first;
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.4",
3
+ "version": "0.0.5",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -16,14 +16,18 @@
16
16
  "scripts": {
17
17
  "build": "tsc && shx chmod +x dist/*.js",
18
18
  "prepare": "npm run build",
19
- "watch": "tsc --watch"
19
+ "watch": "tsc --watch",
20
+ "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
20
21
  },
21
22
  "dependencies": {
22
- "@modelcontextprotocol/sdk": "1.21.0"
23
+ "@modelcontextprotocol/sdk": "1.22.0"
23
24
  },
24
25
  "devDependencies": {
26
+ "@types/jest": "^30.0.0",
25
27
  "@types/node": "^24",
28
+ "jest": "^30.2.0",
26
29
  "shx": "^0.4.0",
30
+ "ts-jest": "^29.4.5",
27
31
  "typescript": "^5.6.2"
28
32
  }
29
33
  }