@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 +217 -26
- package/dist/tests/memory-server.test.js +144 -43
- package/dist/tests/test-utils.js +16 -0
- package/package.json +2 -2
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
case "
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
case "
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
});
|
package/dist/tests/test-utils.js
CHANGED
|
@@ -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.
|
|
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.
|
|
23
|
+
"@modelcontextprotocol/sdk": "1.24.0",
|
|
24
24
|
"proper-lockfile": "^4.1.2"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|