@levalicious/server-memory 0.0.7 → 0.0.8

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.
package/dist/server.js CHANGED
@@ -13,6 +13,83 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
13
13
  ? process.env.MEMORY_FILE_PATH
14
14
  : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
15
15
  : defaultMemoryPath;
16
+ export const MAX_CHARS = 2048;
17
+ function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
18
+ const result = [];
19
+ let i = cursor;
20
+ // Calculate overhead for wrapper: {"items":[],"nextCursor":null,"totalCount":123}
21
+ const wrapperTemplate = { items: [], nextCursor: null, totalCount: items.length };
22
+ let overhead = JSON.stringify(wrapperTemplate).length;
23
+ let charCount = overhead;
24
+ while (i < items.length) {
25
+ const itemJson = JSON.stringify(items[i]);
26
+ const addedChars = itemJson.length + (result.length > 0 ? 1 : 0); // +1 for comma
27
+ if (charCount + addedChars > maxChars) {
28
+ break;
29
+ }
30
+ result.push(items[i]);
31
+ charCount += addedChars;
32
+ i++;
33
+ }
34
+ // Update nextCursor - recalculate if we stopped early (cursor digits may differ from null)
35
+ const nextCursor = i < items.length ? i : null;
36
+ return {
37
+ items: result,
38
+ nextCursor,
39
+ totalCount: items.length
40
+ };
41
+ }
42
+ function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
43
+ // Build incrementally, measuring actual serialized size
44
+ const entityCount = graph.entities.length;
45
+ const relationCount = graph.relations.length;
46
+ // Start with empty result to measure base overhead
47
+ const emptyResult = {
48
+ entities: { items: [], nextCursor: null, totalCount: entityCount },
49
+ relations: { items: [], nextCursor: null, totalCount: relationCount }
50
+ };
51
+ let currentSize = JSON.stringify(emptyResult).length;
52
+ const resultEntities = [];
53
+ const resultRelations = [];
54
+ let entityIdx = entityCursor;
55
+ let relationIdx = relationCursor;
56
+ // Add entities until we hit the limit
57
+ while (entityIdx < graph.entities.length) {
58
+ const entity = graph.entities[entityIdx];
59
+ const entityJson = JSON.stringify(entity);
60
+ const addedChars = entityJson.length + (resultEntities.length > 0 ? 1 : 0);
61
+ if (currentSize + addedChars > MAX_CHARS) {
62
+ break;
63
+ }
64
+ resultEntities.push(entity);
65
+ currentSize += addedChars;
66
+ entityIdx++;
67
+ }
68
+ // Add relations with remaining space
69
+ while (relationIdx < graph.relations.length) {
70
+ const relation = graph.relations[relationIdx];
71
+ const relationJson = JSON.stringify(relation);
72
+ const addedChars = relationJson.length + (resultRelations.length > 0 ? 1 : 0);
73
+ if (currentSize + addedChars > MAX_CHARS) {
74
+ break;
75
+ }
76
+ resultRelations.push(relation);
77
+ currentSize += addedChars;
78
+ relationIdx++;
79
+ }
80
+ return {
81
+ entities: {
82
+ items: resultEntities,
83
+ nextCursor: entityIdx < graph.entities.length ? entityIdx : null,
84
+ totalCount: entityCount
85
+ },
86
+ relations: {
87
+ items: resultRelations,
88
+ nextCursor: relationIdx < graph.relations.length ? relationIdx : null,
89
+ totalCount: relationCount
90
+ }
91
+ };
92
+ }
16
93
  // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
17
94
  export class KnowledgeGraphManager {
18
95
  bclCtr = 0;
@@ -79,7 +156,10 @@ export class KnowledgeGraphManager {
79
156
  }
80
157
  }
81
158
  }
82
- const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
159
+ const now = Date.now();
160
+ const newEntities = entities
161
+ .filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name))
162
+ .map(e => ({ ...e, mtime: now, obsMtime: e.observations.length > 0 ? now : undefined }));
83
163
  graph.entities.push(...newEntities);
84
164
  await this.saveGraph(graph);
85
165
  return newEntities;
@@ -88,9 +168,19 @@ export class KnowledgeGraphManager {
88
168
  async createRelations(relations) {
89
169
  return this.withLock(async () => {
90
170
  const graph = await this.loadGraph();
91
- const newRelations = relations.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
171
+ const now = Date.now();
172
+ // Update mtime on 'from' entities when relations are added
173
+ const fromEntityNames = new Set(relations.map(r => r.from));
174
+ graph.entities.forEach(e => {
175
+ if (fromEntityNames.has(e.name)) {
176
+ e.mtime = now;
177
+ }
178
+ });
179
+ const newRelations = relations
180
+ .filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
92
181
  existingRelation.to === r.to &&
93
- existingRelation.relationType === r.relationType));
182
+ existingRelation.relationType === r.relationType))
183
+ .map(r => ({ ...r, mtime: now }));
94
184
  graph.relations.push(...newRelations);
95
185
  await this.saveGraph(graph);
96
186
  return newRelations;
@@ -116,6 +206,11 @@ export class KnowledgeGraphManager {
116
206
  throw new Error(`Adding ${newObservations.length} observations to "${o.entityName}" would exceed limit of 2 (currently has ${entity.observations.length}).`);
117
207
  }
118
208
  entity.observations.push(...newObservations);
209
+ if (newObservations.length > 0) {
210
+ const now = Date.now();
211
+ entity.mtime = now;
212
+ entity.obsMtime = now;
213
+ }
119
214
  return { entityName: o.entityName, addedObservations: newObservations };
120
215
  });
121
216
  await this.saveGraph(graph);
@@ -133,10 +228,16 @@ export class KnowledgeGraphManager {
133
228
  async deleteObservations(deletions) {
134
229
  return this.withLock(async () => {
135
230
  const graph = await this.loadGraph();
231
+ const now = Date.now();
136
232
  deletions.forEach(d => {
137
233
  const entity = graph.entities.find(e => e.name === d.entityName);
138
234
  if (entity) {
235
+ const originalLen = entity.observations.length;
139
236
  entity.observations = entity.observations.filter(o => !d.observations.includes(o));
237
+ if (entity.observations.length !== originalLen) {
238
+ entity.mtime = now;
239
+ entity.obsMtime = now;
240
+ }
140
241
  }
141
242
  });
142
243
  await this.saveGraph(graph);
@@ -483,6 +584,44 @@ export class KnowledgeGraphManager {
483
584
  this.bclCtr = 0;
484
585
  this.bclTerm = "";
485
586
  }
587
+ async addThought(observations, previousCtxId) {
588
+ return this.withLock(async () => {
589
+ const graph = await this.loadGraph();
590
+ // Validate observations
591
+ if (observations.length > 2) {
592
+ throw new Error(`Thought has ${observations.length} observations. Maximum allowed is 2.`);
593
+ }
594
+ for (const obs of observations) {
595
+ if (obs.length > 140) {
596
+ throw new Error(`Observation exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
597
+ }
598
+ }
599
+ // Generate new context ID
600
+ const now = Date.now();
601
+ const ctxId = `thought_${now}_${Math.random().toString(36).substring(2, 8)}`;
602
+ // Create thought entity
603
+ const thoughtEntity = {
604
+ name: ctxId,
605
+ entityType: "Thought",
606
+ observations,
607
+ mtime: now,
608
+ obsMtime: observations.length > 0 ? now : undefined,
609
+ };
610
+ graph.entities.push(thoughtEntity);
611
+ // Link to previous thought if it exists
612
+ if (previousCtxId) {
613
+ const prevEntity = graph.entities.find(e => e.name === previousCtxId);
614
+ if (prevEntity) {
615
+ // Update mtime on previous entity since we're adding a relation from it
616
+ prevEntity.mtime = now;
617
+ // Bidirectional chain: previous -> new (follows) and new -> previous (preceded_by)
618
+ graph.relations.push({ from: previousCtxId, to: ctxId, relationType: "follows", mtime: now }, { from: ctxId, to: previousCtxId, relationType: "preceded_by", mtime: now });
619
+ }
620
+ }
621
+ await this.saveGraph(graph);
622
+ return { ctxId };
623
+ });
624
+ }
486
625
  }
487
626
  /**
488
627
  * Creates a configured MCP server instance with all tools registered.
@@ -646,18 +785,20 @@ export function createServer(memoryFilePath) {
646
785
  },
647
786
  {
648
787
  name: "search_nodes",
649
- description: "Search for nodes in the knowledge graph using a regex pattern",
788
+ description: "Search for nodes in the knowledge graph using a regex pattern. Results are paginated (max 512 chars).",
650
789
  inputSchema: {
651
790
  type: "object",
652
791
  properties: {
653
- query: { type: "string", description: "Regex pattern to match against entity names, types, and observations. Use | for alternatives (e.g., 'Taranis|wheel'). Special regex characters must be escaped for literal matching." },
792
+ query: { type: "string", description: "Regex pattern to match against entity names, types, and observations." },
793
+ entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
794
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
654
795
  },
655
796
  required: ["query"],
656
797
  },
657
798
  },
658
799
  {
659
800
  name: "open_nodes_filtered",
660
- description: "Open specific nodes in the knowledge graph by their names, filtering relations to only those between the opened nodes",
801
+ description: "Open specific nodes in the knowledge graph by their names, filtering relations to only those between the opened nodes. Results are paginated (max 512 chars).",
661
802
  inputSchema: {
662
803
  type: "object",
663
804
  properties: {
@@ -666,13 +807,15 @@ export function createServer(memoryFilePath) {
666
807
  items: { type: "string" },
667
808
  description: "An array of entity names to retrieve",
668
809
  },
810
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
811
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
669
812
  },
670
813
  required: ["names"],
671
814
  },
672
815
  },
673
816
  {
674
817
  name: "open_nodes",
675
- description: "Open specific nodes in the knowledge graph by their names",
818
+ description: "Open specific nodes in the knowledge graph by their names. Results are paginated (max 512 chars).",
676
819
  inputSchema: {
677
820
  type: "object",
678
821
  properties: {
@@ -681,43 +824,49 @@ export function createServer(memoryFilePath) {
681
824
  items: { type: "string" },
682
825
  description: "An array of entity names to retrieve",
683
826
  },
827
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
828
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
684
829
  },
685
830
  required: ["names"],
686
831
  },
687
832
  },
688
833
  {
689
834
  name: "get_neighbors",
690
- description: "Get neighboring entities connected to a specific entity within a given depth",
835
+ description: "Get neighboring entities connected to a specific entity within a given depth. Results are paginated (max 512 chars).",
691
836
  inputSchema: {
692
837
  type: "object",
693
838
  properties: {
694
839
  entityName: { type: "string", description: "The name of the entity to find neighbors for" },
695
840
  depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
696
841
  withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
842
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
843
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
697
844
  },
698
845
  required: ["entityName"],
699
846
  },
700
847
  },
701
848
  {
702
849
  name: "find_path",
703
- description: "Find a path between two entities in the knowledge graph",
850
+ description: "Find a path between two entities in the knowledge graph. Results are paginated (max 512 chars).",
704
851
  inputSchema: {
705
852
  type: "object",
706
853
  properties: {
707
854
  fromEntity: { type: "string", description: "The name of the starting entity" },
708
855
  toEntity: { type: "string", description: "The name of the target entity" },
709
856
  maxDepth: { type: "number", description: "Maximum depth to search (default: 5)", default: 5 },
857
+ cursor: { type: "number", description: "Cursor for pagination" },
710
858
  },
711
859
  required: ["fromEntity", "toEntity"],
712
860
  },
713
861
  },
714
862
  {
715
863
  name: "get_entities_by_type",
716
- description: "Get all entities of a specific type",
864
+ description: "Get all entities of a specific type. Results are paginated (max 512 chars).",
717
865
  inputSchema: {
718
866
  type: "object",
719
867
  properties: {
720
868
  entityType: { type: "string", description: "The type of entities to retrieve" },
869
+ cursor: { type: "number", description: "Cursor for pagination" },
721
870
  },
722
871
  required: ["entityType"],
723
872
  },
@@ -748,10 +897,12 @@ export function createServer(memoryFilePath) {
748
897
  },
749
898
  {
750
899
  name: "get_orphaned_entities",
751
- description: "Get entities that have no relations (orphaned entities)",
900
+ description: "Get entities that have no relations (orphaned entities). Results are paginated (max 512 chars).",
752
901
  inputSchema: {
753
902
  type: "object",
754
- properties: {},
903
+ properties: {
904
+ cursor: { type: "number", description: "Cursor for pagination" },
905
+ },
755
906
  },
756
907
  },
757
908
  {
@@ -796,6 +947,28 @@ export function createServer(memoryFilePath) {
796
947
  properties: {},
797
948
  },
798
949
  },
950
+ {
951
+ name: "sequentialthinking",
952
+ description: `Record a thought in the knowledge graph. Creates a Thought entity with observations and links it to the previous thought if provided. Returns the new thought's context ID for chaining.
953
+
954
+ Use this to build chains of reasoning that persist in the graph. Each thought can have up to 2 observations (max 140 chars each).`,
955
+ inputSchema: {
956
+ type: "object",
957
+ properties: {
958
+ previousCtxId: {
959
+ type: "string",
960
+ description: "Context ID of the previous thought to chain from. Omit for first thought in a chain."
961
+ },
962
+ observations: {
963
+ type: "array",
964
+ items: { type: "string", maxLength: 140 },
965
+ maxItems: 2,
966
+ description: "Observations for this thought (max 2, each max 140 chars)"
967
+ },
968
+ },
969
+ required: ["observations"],
970
+ },
971
+ },
799
972
  ],
800
973
  };
801
974
  });
@@ -820,26 +993,40 @@ export function createServer(memoryFilePath) {
820
993
  case "delete_relations":
821
994
  await knowledgeGraphManager.deleteRelations(args.relations);
822
995
  return { content: [{ type: "text", text: "Relations deleted successfully" }] };
823
- case "search_nodes":
824
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query), null, 2) }] };
825
- case "open_nodes_filtered":
826
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodesFiltered(args.names), null, 2) }] };
827
- case "open_nodes":
828
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names), null, 2) }] };
829
- case "get_neighbors":
830
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities), null, 2) }] };
831
- case "find_path":
832
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth), null, 2) }] };
833
- case "get_entities_by_type":
834
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntitiesByType(args.entityType), null, 2) }] };
996
+ case "search_nodes": {
997
+ const graph = await knowledgeGraphManager.searchNodes(args.query);
998
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
999
+ }
1000
+ case "open_nodes_filtered": {
1001
+ const graph = await knowledgeGraphManager.openNodesFiltered(args.names);
1002
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1003
+ }
1004
+ case "open_nodes": {
1005
+ const graph = await knowledgeGraphManager.openNodes(args.names);
1006
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1007
+ }
1008
+ case "get_neighbors": {
1009
+ const graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
1010
+ return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
1011
+ }
1012
+ case "find_path": {
1013
+ const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
1014
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
1015
+ }
1016
+ case "get_entities_by_type": {
1017
+ const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
1018
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1019
+ }
835
1020
  case "get_entity_types":
836
1021
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntityTypes(), null, 2) }] };
837
1022
  case "get_relation_types":
838
1023
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getRelationTypes(), null, 2) }] };
839
1024
  case "get_stats":
840
1025
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
841
- case "get_orphaned_entities":
842
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getOrphanedEntities(), null, 2) }] };
1026
+ case "get_orphaned_entities": {
1027
+ const entities = await knowledgeGraphManager.getOrphanedEntities();
1028
+ return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
1029
+ }
843
1030
  case "validate_graph":
844
1031
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.validateGraph(), null, 2) }] };
845
1032
  case "evaluate_bcl":
@@ -849,6 +1036,10 @@ export function createServer(memoryFilePath) {
849
1036
  case "clear_bcl_term":
850
1037
  await knowledgeGraphManager.clearBCLTerm();
851
1038
  return { content: [{ type: "text", text: "BCL term constructor cleared successfully" }] };
1039
+ case "sequentialthinking": {
1040
+ const result = await knowledgeGraphManager.addThought(args.observations, args.previousCtxId);
1041
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1042
+ }
852
1043
  default:
853
1044
  throw new Error(`Unknown tool: ${name}`);
854
1045
  }
@@ -129,7 +129,7 @@ describe('MCP Memory Server E2E Tests', () => {
129
129
  deletions: [{ entityName: 'TestEntity', observations: ['Delete'] }]
130
130
  });
131
131
  const result = await callTool(client, 'open_nodes', { names: ['TestEntity'] });
132
- expect(result.entities[0].observations).toEqual(['Keep']);
132
+ expect(result.entities.items[0].observations).toEqual(['Keep']);
133
133
  });
134
134
  });
135
135
  describe('Relation Operations', () => {
@@ -192,25 +192,41 @@ describe('MCP Memory Server E2E Tests', () => {
192
192
  });
193
193
  });
194
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');
195
+ // Accumulate all entities across pagination
196
+ const allEntities = [];
197
+ let entityCursor = 0;
198
+ while (entityCursor !== null) {
199
+ const result = await callTool(client, 'search_nodes', {
200
+ query: 'Script',
201
+ entityCursor
202
+ });
203
+ allEntities.push(...result.entities.items);
204
+ entityCursor = result.entities.nextCursor;
205
+ }
206
+ expect(allEntities).toHaveLength(2);
207
+ expect(allEntities.map(e => e.name)).toContain('JavaScript');
208
+ expect(allEntities.map(e => e.name)).toContain('TypeScript');
201
209
  });
202
210
  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);
211
+ // Accumulate all entities across pagination
212
+ const allEntities = [];
213
+ let entityCursor = 0;
214
+ while (entityCursor !== null) {
215
+ const result = await callTool(client, 'search_nodes', {
216
+ query: 'JavaScript|Python',
217
+ entityCursor
218
+ });
219
+ allEntities.push(...result.entities.items);
220
+ entityCursor = result.entities.nextCursor;
221
+ }
222
+ expect(allEntities).toHaveLength(2);
207
223
  });
208
224
  it('should search in observations', async () => {
209
225
  const result = await callTool(client, 'search_nodes', {
210
226
  query: 'Static'
211
227
  });
212
- expect(result.entities).toHaveLength(1);
213
- expect(result.entities[0].name).toBe('TypeScript');
228
+ expect(result.entities.items).toHaveLength(1);
229
+ expect(result.entities.items[0].name).toBe('TypeScript');
214
230
  });
215
231
  it('should reject invalid regex', async () => {
216
232
  await expect(callTool(client, 'search_nodes', { query: '[invalid' })).rejects.toThrow(/Invalid regex pattern/);
@@ -236,17 +252,17 @@ describe('MCP Memory Server E2E Tests', () => {
236
252
  const result = await callTool(client, 'open_nodes', {
237
253
  names: ['A', 'B']
238
254
  });
239
- expect(result.entities).toHaveLength(2);
255
+ expect(result.entities.items).toHaveLength(2);
240
256
  // open_nodes returns all relations where 'from' is in the requested set
241
257
  // A->B and A->C both have from='A' which is in the set
242
- expect(result.relations).toHaveLength(2);
258
+ expect(result.relations.items).toHaveLength(2);
243
259
  });
244
260
  it('should open nodes filtered (only internal relations)', async () => {
245
261
  const result = await callTool(client, 'open_nodes_filtered', {
246
262
  names: ['B', 'C']
247
263
  });
248
- expect(result.entities).toHaveLength(2);
249
- expect(result.relations).toHaveLength(0); // No relations between B and C
264
+ expect(result.entities.items).toHaveLength(2);
265
+ expect(result.relations.items).toHaveLength(0); // No relations between B and C
250
266
  });
251
267
  });
252
268
  describe('Graph Traversal', () => {
@@ -272,27 +288,57 @@ describe('MCP Memory Server E2E Tests', () => {
272
288
  entityName: 'Root',
273
289
  depth: 0
274
290
  });
275
- expect(result.entities).toHaveLength(0); // withEntities defaults to false
276
- expect(result.relations).toHaveLength(2); // Root's direct relations
291
+ expect(result.entities.items).toHaveLength(0); // withEntities defaults to false
292
+ expect(result.relations.items).toHaveLength(2); // Root's direct relations
277
293
  });
278
294
  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');
295
+ // Accumulate all entities across pagination
296
+ const allEntities = [];
297
+ let entityCursor = 0;
298
+ while (entityCursor !== null) {
299
+ const result = await callTool(client, 'get_neighbors', {
300
+ entityName: 'Root',
301
+ depth: 1,
302
+ withEntities: true,
303
+ entityCursor
304
+ });
305
+ allEntities.push(...result.entities.items);
306
+ entityCursor = result.entities.nextCursor;
307
+ }
308
+ expect(allEntities).toHaveLength(3);
309
+ expect(allEntities.map(e => e.name)).toContain('Root');
310
+ expect(allEntities.map(e => e.name)).toContain('Child1');
311
+ expect(allEntities.map(e => e.name)).toContain('Child2');
287
312
  });
288
313
  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
314
+ // Collect all entities and relations using pagination
315
+ const allEntities = [];
316
+ const allRelations = [];
317
+ let entityCursor = 0;
318
+ let relationCursor = 0;
319
+ // Paginate through all results
320
+ while (entityCursor !== null || relationCursor !== null) {
321
+ const result = await callTool(client, 'get_neighbors', {
322
+ entityName: 'Root',
323
+ depth: 2,
324
+ withEntities: true,
325
+ // Pass large cursor to skip when done, 0 to fetch
326
+ entityCursor: entityCursor !== null ? entityCursor : 999999,
327
+ relationCursor: relationCursor !== null ? relationCursor : 999999
328
+ });
329
+ // Collect entities if we still need them
330
+ if (entityCursor !== null) {
331
+ allEntities.push(...result.entities.items);
332
+ entityCursor = result.entities.nextCursor;
333
+ }
334
+ // Collect relations if we still need them
335
+ if (relationCursor !== null) {
336
+ allRelations.push(...result.relations.items);
337
+ relationCursor = result.relations.nextCursor;
338
+ }
339
+ }
340
+ expect(allEntities).toHaveLength(4); // All nodes
341
+ expect(allRelations).toHaveLength(3); // All relations
296
342
  });
297
343
  it('should deduplicate relations in traversal', async () => {
298
344
  // Add a bidirectional relation
@@ -304,7 +350,7 @@ describe('MCP Memory Server E2E Tests', () => {
304
350
  depth: 1
305
351
  });
306
352
  // Each unique relation should appear only once
307
- const relationKeys = result.relations.map(r => `${r.from}|${r.relationType}|${r.to}`);
353
+ const relationKeys = result.relations.items.map(r => `${r.from}|${r.relationType}|${r.to}`);
308
354
  const uniqueKeys = [...new Set(relationKeys)];
309
355
  expect(relationKeys.length).toBe(uniqueKeys.length);
310
356
  });
@@ -313,9 +359,9 @@ describe('MCP Memory Server E2E Tests', () => {
313
359
  fromEntity: 'Root',
314
360
  toEntity: 'Grandchild'
315
361
  });
316
- expect(result).toHaveLength(2);
317
- expect(result[0].from).toBe('Root');
318
- expect(result[1].to).toBe('Grandchild');
362
+ expect(result.items).toHaveLength(2);
363
+ expect(result.items[0].from).toBe('Root');
364
+ expect(result.items[1].to).toBe('Grandchild');
319
365
  });
320
366
  it('should return empty path when no path exists', async () => {
321
367
  await callTool(client, 'create_entities', {
@@ -325,7 +371,7 @@ describe('MCP Memory Server E2E Tests', () => {
325
371
  fromEntity: 'Root',
326
372
  toEntity: 'Isolated'
327
373
  });
328
- expect(result).toHaveLength(0);
374
+ expect(result.items).toHaveLength(0);
329
375
  });
330
376
  });
331
377
  describe('Type Queries', () => {
@@ -348,8 +394,8 @@ describe('MCP Memory Server E2E Tests', () => {
348
394
  const result = await callTool(client, 'get_entities_by_type', {
349
395
  entityType: 'Person'
350
396
  });
351
- expect(result).toHaveLength(2);
352
- expect(result.every(e => e.entityType === 'Person')).toBe(true);
397
+ expect(result.items).toHaveLength(2);
398
+ expect(result.items.every(e => e.entityType === 'Person')).toBe(true);
353
399
  });
354
400
  it('should get all entity types', async () => {
355
401
  const result = await callTool(client, 'get_entity_types', {});
@@ -392,8 +438,8 @@ describe('MCP Memory Server E2E Tests', () => {
392
438
  relations: [{ from: 'Connected1', to: 'Connected2', relationType: 'links' }]
393
439
  });
394
440
  const result = await callTool(client, 'get_orphaned_entities', {});
395
- expect(result).toHaveLength(1);
396
- expect(result[0].name).toBe('Orphan');
441
+ expect(result.items).toHaveLength(1);
442
+ expect(result.items[0].name).toBe('Orphan');
397
443
  });
398
444
  it('should validate graph and report violations', async () => {
399
445
  // Directly write invalid data to test validation
@@ -435,4 +481,59 @@ describe('MCP Memory Server E2E Tests', () => {
435
481
  await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
436
482
  });
437
483
  });
484
+ describe('Sequential Thinking', () => {
485
+ it('should create a thought and return ctxId', async () => {
486
+ const result = await callTool(client, 'sequentialthinking', {
487
+ observations: ['First thought observation']
488
+ });
489
+ expect(result.ctxId).toMatch(/^thought_\d+_[a-z0-9]+$/);
490
+ });
491
+ it('should chain thoughts with relations', async () => {
492
+ // Create first thought
493
+ const first = await callTool(client, 'sequentialthinking', {
494
+ observations: ['Starting point']
495
+ });
496
+ // Create second thought chained to first
497
+ const second = await callTool(client, 'sequentialthinking', {
498
+ previousCtxId: first.ctxId,
499
+ observations: ['Following up']
500
+ });
501
+ // Verify the chain via relations
502
+ const neighbors = await callTool(client, 'get_neighbors', {
503
+ entityName: first.ctxId,
504
+ depth: 1
505
+ });
506
+ // Should have 'follows' relation from first to second
507
+ expect(neighbors.relations.items.some(r => r.from === first.ctxId && r.to === second.ctxId && r.relationType === 'follows')).toBe(true);
508
+ });
509
+ it('should ignore invalid previousCtxId gracefully', async () => {
510
+ const result = await callTool(client, 'sequentialthinking', {
511
+ previousCtxId: 'nonexistent_thought',
512
+ observations: ['Orphaned thought']
513
+ });
514
+ expect(result.ctxId).toMatch(/^thought_\d+_[a-z0-9]+$/);
515
+ // Verify no relations were created
516
+ const neighbors = await callTool(client, 'get_neighbors', {
517
+ entityName: result.ctxId,
518
+ depth: 1
519
+ });
520
+ expect(neighbors.relations.totalCount).toBe(0);
521
+ });
522
+ it('should enforce observation limits on thoughts', async () => {
523
+ await expect(callTool(client, 'sequentialthinking', {
524
+ observations: ['One', 'Two', 'Three']
525
+ })).rejects.toThrow(/Maximum allowed is 2/);
526
+ });
527
+ it('should set mtime and obsMtime on thought entities', async () => {
528
+ const result = await callTool(client, 'sequentialthinking', {
529
+ observations: ['Timed thought']
530
+ });
531
+ const graph = await callTool(client, 'open_nodes', {
532
+ names: [result.ctxId]
533
+ });
534
+ const thought = graph.entities.items[0];
535
+ expect(thought.mtime).toBeDefined();
536
+ expect(thought.obsMtime).toBeDefined();
537
+ });
538
+ });
438
539
  });
@@ -1,5 +1,7 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
3
+ import { MAX_CHARS } from "../server.js";
4
+ export { MAX_CHARS };
3
5
  export async function createTestClient(server) {
4
6
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
5
7
  const client = new Client({
@@ -18,6 +20,16 @@ export async function createTestClient(server) {
18
20
  }
19
21
  };
20
22
  }
23
+ // Read operations that should be paginated
24
+ const PAGINATED_TOOLS = new Set([
25
+ 'search_nodes',
26
+ 'open_nodes',
27
+ 'open_nodes_filtered',
28
+ 'get_neighbors',
29
+ 'find_path',
30
+ 'get_entities_by_type',
31
+ 'get_orphaned_entities',
32
+ ]);
21
33
  export async function callTool(client, name, args) {
22
34
  const result = await client.callTool({ name, arguments: args });
23
35
  const content = result.content;
@@ -26,6 +38,10 @@ export async function callTool(client, name, args) {
26
38
  }
27
39
  const first = content[0];
28
40
  if (first.type === 'text' && first.text) {
41
+ // Only enforce char limit on paginated read operations
42
+ if (PAGINATED_TOOLS.has(name) && first.text.length > MAX_CHARS) {
43
+ throw new Error(`Response exceeds ${MAX_CHARS} char limit: got ${first.text.length} chars`);
44
+ }
29
45
  try {
30
46
  return JSON.parse(first.text);
31
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@levalicious/server-memory",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "MCP server for enabling memory for Claude through a knowledge graph",
5
5
  "license": "MIT",
6
6
  "author": "Levalicious",
@@ -20,7 +20,7 @@
20
20
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
21
21
  },
22
22
  "dependencies": {
23
- "@modelcontextprotocol/sdk": "1.22.0",
23
+ "@modelcontextprotocol/sdk": "1.24.0",
24
24
  "proper-lockfile": "^4.1.2"
25
25
  },
26
26
  "devDependencies": {