@levalicious/server-memory 0.0.6 → 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
@@ -4,6 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
4
4
  import { promises as fs } from 'fs';
5
5
  import path from 'path';
6
6
  import { fileURLToPath } from 'url';
7
+ import lockfile from 'proper-lockfile';
7
8
  // Define memory file path using environment variable with fallback
8
9
  const defaultMemoryPath = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory.json');
9
10
  // If MEMORY_FILE_PATH is just a filename, put it in the same directory as the script
@@ -12,6 +13,83 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
12
13
  ? process.env.MEMORY_FILE_PATH
13
14
  : path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
14
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
+ }
15
93
  // The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
16
94
  export class KnowledgeGraphManager {
17
95
  bclCtr = 0;
@@ -20,6 +98,22 @@ export class KnowledgeGraphManager {
20
98
  constructor(memoryFilePath = DEFAULT_MEMORY_FILE_PATH) {
21
99
  this.memoryFilePath = memoryFilePath;
22
100
  }
101
+ async withLock(fn) {
102
+ // Ensure file exists for locking
103
+ try {
104
+ await fs.access(this.memoryFilePath);
105
+ }
106
+ catch {
107
+ await fs.writeFile(this.memoryFilePath, "");
108
+ }
109
+ const release = await lockfile.lock(this.memoryFilePath, { retries: { retries: 5, minTimeout: 100 } });
110
+ try {
111
+ return await fn();
112
+ }
113
+ finally {
114
+ await release();
115
+ }
116
+ }
23
117
  async loadGraph() {
24
118
  try {
25
119
  const data = await fs.readFile(this.memoryFilePath, "utf-8");
@@ -45,81 +139,118 @@ export class KnowledgeGraphManager {
45
139
  ...graph.entities.map(e => JSON.stringify({ type: "entity", ...e })),
46
140
  ...graph.relations.map(r => JSON.stringify({ type: "relation", ...r })),
47
141
  ];
48
- await fs.writeFile(this.memoryFilePath, lines.join("\n"));
142
+ const content = lines.join("\n") + (lines.length > 0 ? "\n" : "");
143
+ await fs.writeFile(this.memoryFilePath, content);
49
144
  }
50
145
  async createEntities(entities) {
51
- const graph = await this.loadGraph();
52
- // Validate observation limits
53
- for (const entity of entities) {
54
- if (entity.observations.length > 2) {
55
- throw new Error(`Entity "${entity.name}" has ${entity.observations.length} observations. Maximum allowed is 2.`);
56
- }
57
- for (const obs of entity.observations) {
58
- if (obs.length > 140) {
59
- throw new Error(`Observation in entity "${entity.name}" exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
146
+ return this.withLock(async () => {
147
+ const graph = await this.loadGraph();
148
+ // Validate observation limits
149
+ for (const entity of entities) {
150
+ if (entity.observations.length > 2) {
151
+ throw new Error(`Entity "${entity.name}" has ${entity.observations.length} observations. Maximum allowed is 2.`);
152
+ }
153
+ for (const obs of entity.observations) {
154
+ if (obs.length > 140) {
155
+ throw new Error(`Observation in entity "${entity.name}" exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
156
+ }
60
157
  }
61
158
  }
62
- }
63
- const newEntities = entities.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name));
64
- graph.entities.push(...newEntities);
65
- await this.saveGraph(graph);
66
- return newEntities;
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 }));
163
+ graph.entities.push(...newEntities);
164
+ await this.saveGraph(graph);
165
+ return newEntities;
166
+ });
67
167
  }
68
168
  async createRelations(relations) {
69
- const graph = await this.loadGraph();
70
- const newRelations = relations.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
71
- existingRelation.to === r.to &&
72
- existingRelation.relationType === r.relationType));
73
- graph.relations.push(...newRelations);
74
- await this.saveGraph(graph);
75
- return newRelations;
169
+ return this.withLock(async () => {
170
+ const graph = await this.loadGraph();
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 &&
181
+ existingRelation.to === r.to &&
182
+ existingRelation.relationType === r.relationType))
183
+ .map(r => ({ ...r, mtime: now }));
184
+ graph.relations.push(...newRelations);
185
+ await this.saveGraph(graph);
186
+ return newRelations;
187
+ });
76
188
  }
77
189
  async addObservations(observations) {
78
- const graph = await this.loadGraph();
79
- const results = observations.map(o => {
80
- const entity = graph.entities.find(e => e.name === o.entityName);
81
- if (!entity) {
82
- throw new Error(`Entity with name ${o.entityName} not found`);
83
- }
84
- // Validate observation character limits
85
- for (const obs of o.contents) {
86
- if (obs.length > 140) {
87
- throw new Error(`Observation for "${o.entityName}" exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
190
+ return this.withLock(async () => {
191
+ const graph = await this.loadGraph();
192
+ const results = observations.map(o => {
193
+ const entity = graph.entities.find(e => e.name === o.entityName);
194
+ if (!entity) {
195
+ throw new Error(`Entity with name ${o.entityName} not found`);
88
196
  }
89
- }
90
- const newObservations = o.contents.filter(content => !entity.observations.includes(content));
91
- // Validate total observation count
92
- if (entity.observations.length + newObservations.length > 2) {
93
- throw new Error(`Adding ${newObservations.length} observations to "${o.entityName}" would exceed limit of 2 (currently has ${entity.observations.length}).`);
94
- }
95
- entity.observations.push(...newObservations);
96
- return { entityName: o.entityName, addedObservations: newObservations };
197
+ // Validate observation character limits
198
+ for (const obs of o.contents) {
199
+ if (obs.length > 140) {
200
+ throw new Error(`Observation for "${o.entityName}" exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
201
+ }
202
+ }
203
+ const newObservations = o.contents.filter(content => !entity.observations.includes(content));
204
+ // Validate total observation count
205
+ if (entity.observations.length + newObservations.length > 2) {
206
+ throw new Error(`Adding ${newObservations.length} observations to "${o.entityName}" would exceed limit of 2 (currently has ${entity.observations.length}).`);
207
+ }
208
+ entity.observations.push(...newObservations);
209
+ if (newObservations.length > 0) {
210
+ const now = Date.now();
211
+ entity.mtime = now;
212
+ entity.obsMtime = now;
213
+ }
214
+ return { entityName: o.entityName, addedObservations: newObservations };
215
+ });
216
+ await this.saveGraph(graph);
217
+ return results;
97
218
  });
98
- await this.saveGraph(graph);
99
- return results;
100
219
  }
101
220
  async deleteEntities(entityNames) {
102
- const graph = await this.loadGraph();
103
- graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
104
- graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
105
- await this.saveGraph(graph);
221
+ return this.withLock(async () => {
222
+ const graph = await this.loadGraph();
223
+ graph.entities = graph.entities.filter(e => !entityNames.includes(e.name));
224
+ graph.relations = graph.relations.filter(r => !entityNames.includes(r.from) && !entityNames.includes(r.to));
225
+ await this.saveGraph(graph);
226
+ });
106
227
  }
107
228
  async deleteObservations(deletions) {
108
- const graph = await this.loadGraph();
109
- deletions.forEach(d => {
110
- const entity = graph.entities.find(e => e.name === d.entityName);
111
- if (entity) {
112
- entity.observations = entity.observations.filter(o => !d.observations.includes(o));
113
- }
229
+ return this.withLock(async () => {
230
+ const graph = await this.loadGraph();
231
+ const now = Date.now();
232
+ deletions.forEach(d => {
233
+ const entity = graph.entities.find(e => e.name === d.entityName);
234
+ if (entity) {
235
+ const originalLen = entity.observations.length;
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
+ }
241
+ }
242
+ });
243
+ await this.saveGraph(graph);
114
244
  });
115
- await this.saveGraph(graph);
116
245
  }
117
246
  async deleteRelations(relations) {
118
- const graph = await this.loadGraph();
119
- graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.from === delRelation.from &&
120
- r.to === delRelation.to &&
121
- r.relationType === delRelation.relationType));
122
- await this.saveGraph(graph);
247
+ return this.withLock(async () => {
248
+ const graph = await this.loadGraph();
249
+ graph.relations = graph.relations.filter(r => !relations.some(delRelation => r.from === delRelation.from &&
250
+ r.to === delRelation.to &&
251
+ r.relationType === delRelation.relationType));
252
+ await this.saveGraph(graph);
253
+ });
123
254
  }
124
255
  // Regex-based search function
125
256
  async searchNodes(query) {
@@ -453,6 +584,44 @@ export class KnowledgeGraphManager {
453
584
  this.bclCtr = 0;
454
585
  this.bclTerm = "";
455
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
+ }
456
625
  }
457
626
  /**
458
627
  * Creates a configured MCP server instance with all tools registered.
@@ -616,18 +785,20 @@ export function createServer(memoryFilePath) {
616
785
  },
617
786
  {
618
787
  name: "search_nodes",
619
- 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).",
620
789
  inputSchema: {
621
790
  type: "object",
622
791
  properties: {
623
- 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" },
624
795
  },
625
796
  required: ["query"],
626
797
  },
627
798
  },
628
799
  {
629
800
  name: "open_nodes_filtered",
630
- 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).",
631
802
  inputSchema: {
632
803
  type: "object",
633
804
  properties: {
@@ -636,13 +807,15 @@ export function createServer(memoryFilePath) {
636
807
  items: { type: "string" },
637
808
  description: "An array of entity names to retrieve",
638
809
  },
810
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
811
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
639
812
  },
640
813
  required: ["names"],
641
814
  },
642
815
  },
643
816
  {
644
817
  name: "open_nodes",
645
- 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).",
646
819
  inputSchema: {
647
820
  type: "object",
648
821
  properties: {
@@ -651,43 +824,49 @@ export function createServer(memoryFilePath) {
651
824
  items: { type: "string" },
652
825
  description: "An array of entity names to retrieve",
653
826
  },
827
+ entityCursor: { type: "number", description: "Cursor for entity pagination" },
828
+ relationCursor: { type: "number", description: "Cursor for relation pagination" },
654
829
  },
655
830
  required: ["names"],
656
831
  },
657
832
  },
658
833
  {
659
834
  name: "get_neighbors",
660
- 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).",
661
836
  inputSchema: {
662
837
  type: "object",
663
838
  properties: {
664
839
  entityName: { type: "string", description: "The name of the entity to find neighbors for" },
665
840
  depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
666
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" },
667
844
  },
668
845
  required: ["entityName"],
669
846
  },
670
847
  },
671
848
  {
672
849
  name: "find_path",
673
- 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).",
674
851
  inputSchema: {
675
852
  type: "object",
676
853
  properties: {
677
854
  fromEntity: { type: "string", description: "The name of the starting entity" },
678
855
  toEntity: { type: "string", description: "The name of the target entity" },
679
856
  maxDepth: { type: "number", description: "Maximum depth to search (default: 5)", default: 5 },
857
+ cursor: { type: "number", description: "Cursor for pagination" },
680
858
  },
681
859
  required: ["fromEntity", "toEntity"],
682
860
  },
683
861
  },
684
862
  {
685
863
  name: "get_entities_by_type",
686
- description: "Get all entities of a specific type",
864
+ description: "Get all entities of a specific type. Results are paginated (max 512 chars).",
687
865
  inputSchema: {
688
866
  type: "object",
689
867
  properties: {
690
868
  entityType: { type: "string", description: "The type of entities to retrieve" },
869
+ cursor: { type: "number", description: "Cursor for pagination" },
691
870
  },
692
871
  required: ["entityType"],
693
872
  },
@@ -718,10 +897,12 @@ export function createServer(memoryFilePath) {
718
897
  },
719
898
  {
720
899
  name: "get_orphaned_entities",
721
- 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).",
722
901
  inputSchema: {
723
902
  type: "object",
724
- properties: {},
903
+ properties: {
904
+ cursor: { type: "number", description: "Cursor for pagination" },
905
+ },
725
906
  },
726
907
  },
727
908
  {
@@ -766,6 +947,28 @@ export function createServer(memoryFilePath) {
766
947
  properties: {},
767
948
  },
768
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
+ },
769
972
  ],
770
973
  };
771
974
  });
@@ -790,26 +993,40 @@ export function createServer(memoryFilePath) {
790
993
  case "delete_relations":
791
994
  await knowledgeGraphManager.deleteRelations(args.relations);
792
995
  return { content: [{ type: "text", text: "Relations deleted successfully" }] };
793
- case "search_nodes":
794
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.searchNodes(args.query), null, 2) }] };
795
- case "open_nodes_filtered":
796
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodesFiltered(args.names), null, 2) }] };
797
- case "open_nodes":
798
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.openNodes(args.names), null, 2) }] };
799
- case "get_neighbors":
800
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities), null, 2) }] };
801
- case "find_path":
802
- return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth), null, 2) }] };
803
- case "get_entities_by_type":
804
- 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
+ }
805
1020
  case "get_entity_types":
806
1021
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntityTypes(), null, 2) }] };
807
1022
  case "get_relation_types":
808
1023
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getRelationTypes(), null, 2) }] };
809
1024
  case "get_stats":
810
1025
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
811
- case "get_orphaned_entities":
812
- 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
+ }
813
1030
  case "validate_graph":
814
1031
  return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.validateGraph(), null, 2) }] };
815
1032
  case "evaluate_bcl":
@@ -819,6 +1036,10 @@ export function createServer(memoryFilePath) {
819
1036
  case "clear_bcl_term":
820
1037
  await knowledgeGraphManager.clearBCLTerm();
821
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
+ }
822
1043
  default:
823
1044
  throw new Error(`Unknown tool: ${name}`);
824
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.6",
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,11 +20,13 @@
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
+ "proper-lockfile": "^4.1.2"
24
25
  },
25
26
  "devDependencies": {
26
27
  "@types/jest": "^30.0.0",
27
28
  "@types/node": "^24",
29
+ "@types/proper-lockfile": "^4.1.4",
28
30
  "jest": "^30.2.0",
29
31
  "shx": "^0.4.0",
30
32
  "ts-jest": "^29.4.5",