@levalicious/server-memory 0.0.7 → 0.0.9
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/README.md +46 -33
- package/dist/server.js +254 -31
- package/dist/tests/memory-server.test.js +170 -43
- package/dist/tests/test-utils.js +16 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,33 +8,37 @@ A basic implementation of persistent memory using a local knowledge graph. This
|
|
|
8
8
|
Entities are the primary nodes in the knowledge graph. Each entity has:
|
|
9
9
|
- A unique name (identifier)
|
|
10
10
|
- An entity type (e.g., "person", "organization", "event")
|
|
11
|
-
- A list of observations
|
|
11
|
+
- A list of observations (max 2, each max 140 characters)
|
|
12
|
+
- Modification timestamps (`mtime` for any change, `obsMtime` for observation changes)
|
|
12
13
|
|
|
13
14
|
Example:
|
|
14
15
|
```json
|
|
15
16
|
{
|
|
16
17
|
"name": "John_Smith",
|
|
17
18
|
"entityType": "person",
|
|
18
|
-
"observations": ["Speaks fluent Spanish"]
|
|
19
|
+
"observations": ["Speaks fluent Spanish"],
|
|
20
|
+
"mtime": 1733423456789,
|
|
21
|
+
"obsMtime": 1733423456789
|
|
19
22
|
}
|
|
20
23
|
```
|
|
21
24
|
|
|
22
25
|
### Relations
|
|
23
|
-
Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other.
|
|
26
|
+
Relations define directed connections between entities. They are always stored in active voice and describe how entities interact or relate to each other. Each relation has a modification timestamp (`mtime`).
|
|
24
27
|
|
|
25
28
|
Example:
|
|
26
29
|
```json
|
|
27
30
|
{
|
|
28
31
|
"from": "John_Smith",
|
|
29
32
|
"to": "Anthropic",
|
|
30
|
-
"relationType": "works_at"
|
|
33
|
+
"relationType": "works_at",
|
|
34
|
+
"mtime": 1733423456789
|
|
31
35
|
}
|
|
32
36
|
```
|
|
33
37
|
### Observations
|
|
34
38
|
Observations are discrete pieces of information about an entity. They are:
|
|
35
39
|
|
|
36
|
-
- Stored as strings
|
|
37
|
-
- Attached to specific entities
|
|
40
|
+
- Stored as strings (max 140 characters each)
|
|
41
|
+
- Attached to specific entities (max 2 per entity)
|
|
38
42
|
- Can be added or removed independently
|
|
39
43
|
- Should be atomic (one fact per observation)
|
|
40
44
|
|
|
@@ -44,8 +48,7 @@ Example:
|
|
|
44
48
|
"entityName": "John_Smith",
|
|
45
49
|
"observations": [
|
|
46
50
|
"Speaks fluent Spanish",
|
|
47
|
-
"Graduated in 2019"
|
|
48
|
-
"Prefers morning meetings"
|
|
51
|
+
"Graduated in 2019"
|
|
49
52
|
]
|
|
50
53
|
}
|
|
51
54
|
```
|
|
@@ -59,7 +62,7 @@ Example:
|
|
|
59
62
|
- Each object contains:
|
|
60
63
|
- `name` (string): Entity identifier
|
|
61
64
|
- `entityType` (string): Type classification
|
|
62
|
-
- `observations` (string[]): Associated observations
|
|
65
|
+
- `observations` (string[]): Associated observations (max 2, each max 140 chars)
|
|
63
66
|
- Ignores entities with existing names
|
|
64
67
|
|
|
65
68
|
- **create_relations**
|
|
@@ -76,9 +79,9 @@ Example:
|
|
|
76
79
|
- Input: `observations` (array of objects)
|
|
77
80
|
- Each object contains:
|
|
78
81
|
- `entityName` (string): Target entity
|
|
79
|
-
- `contents` (string[]): New observations to add
|
|
82
|
+
- `contents` (string[]): New observations to add (each max 140 chars)
|
|
80
83
|
- Returns added observations per entity
|
|
81
|
-
- Fails if entity doesn't exist
|
|
84
|
+
- Fails if entity doesn't exist or would exceed 2 observations
|
|
82
85
|
|
|
83
86
|
- **delete_entities**
|
|
84
87
|
- Remove entities and their relations
|
|
@@ -103,42 +106,45 @@ Example:
|
|
|
103
106
|
- `relationType` (string): Relationship type
|
|
104
107
|
- Silent operation if relation doesn't exist
|
|
105
108
|
|
|
106
|
-
- **read_graph**
|
|
107
|
-
- Read the entire knowledge graph
|
|
108
|
-
- No input required
|
|
109
|
-
- Returns complete graph structure with all entities and relations
|
|
110
|
-
|
|
111
109
|
- **search_nodes**
|
|
112
|
-
- Search for nodes
|
|
113
|
-
- Input: `query` (string)
|
|
110
|
+
- Search for nodes using a regex pattern
|
|
111
|
+
- Input: `query` (string), `entityCursor` (number, optional), `relationCursor` (number, optional)
|
|
114
112
|
- Searches across:
|
|
115
113
|
- Entity names
|
|
116
114
|
- Entity types
|
|
117
115
|
- Observation content
|
|
118
|
-
- Returns matching entities and their relations
|
|
116
|
+
- Returns matching entities and their relations (paginated)
|
|
117
|
+
|
|
118
|
+
- **open_nodes_filtered**
|
|
119
|
+
- Retrieve specific nodes by name with filtered relations
|
|
120
|
+
- Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
|
|
121
|
+
- Returns:
|
|
122
|
+
- Requested entities
|
|
123
|
+
- Only relations where both endpoints are in the requested set
|
|
124
|
+
- Silently skips non-existent nodes (paginated)
|
|
119
125
|
|
|
120
126
|
- **open_nodes**
|
|
121
127
|
- Retrieve specific nodes by name
|
|
122
|
-
- Input: `names` (string[])
|
|
128
|
+
- Input: `names` (string[]), `entityCursor` (number, optional), `relationCursor` (number, optional)
|
|
123
129
|
- Returns:
|
|
124
130
|
- Requested entities
|
|
125
|
-
- Relations
|
|
126
|
-
- Silently skips non-existent nodes
|
|
131
|
+
- Relations originating from requested entities
|
|
132
|
+
- Silently skips non-existent nodes (paginated)
|
|
127
133
|
|
|
128
134
|
- **get_neighbors**
|
|
129
135
|
- Get neighboring entities connected to a specific entity within a given depth
|
|
130
|
-
- Input: `entityName` (string), `depth` (number, default:
|
|
131
|
-
- Returns entities connected within specified depth
|
|
136
|
+
- Input: `entityName` (string), `depth` (number, default: 0), `withEntities` (boolean, default: false), `entityCursor` (number, optional), `relationCursor` (number, optional)
|
|
137
|
+
- Returns relations (and optionally entities) connected within specified depth (paginated)
|
|
132
138
|
|
|
133
139
|
- **find_path**
|
|
134
140
|
- Find a path between two entities in the knowledge graph
|
|
135
|
-
- Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5)
|
|
136
|
-
- Returns path between entities if one exists
|
|
141
|
+
- Input: `fromEntity` (string), `toEntity` (string), `maxDepth` (number, default: 5), `cursor` (number, optional)
|
|
142
|
+
- Returns path between entities if one exists (paginated)
|
|
137
143
|
|
|
138
144
|
- **get_entities_by_type**
|
|
139
145
|
- Get all entities of a specific type
|
|
140
|
-
- Input: `entityType` (string)
|
|
141
|
-
- Returns all entities matching the specified type
|
|
146
|
+
- Input: `entityType` (string), `cursor` (number, optional)
|
|
147
|
+
- Returns all entities matching the specified type (paginated)
|
|
142
148
|
|
|
143
149
|
- **get_entity_types**
|
|
144
150
|
- Get all unique entity types in the knowledge graph
|
|
@@ -156,14 +162,15 @@ Example:
|
|
|
156
162
|
- Returns entity count, relation count, entity types count, relation types count
|
|
157
163
|
|
|
158
164
|
- **get_orphaned_entities**
|
|
159
|
-
- Get entities that have no relations
|
|
160
|
-
-
|
|
161
|
-
-
|
|
165
|
+
- Get entities that have no relations (orphaned entities)
|
|
166
|
+
- Input: `strict` (boolean, default: false), `cursor` (number, optional)
|
|
167
|
+
- In strict mode, returns entities not connected to 'Self' entity (directly or indirectly)
|
|
168
|
+
- Returns entities with no connections (paginated)
|
|
162
169
|
|
|
163
170
|
- **validate_graph**
|
|
164
|
-
- Validate the knowledge graph
|
|
171
|
+
- Validate the knowledge graph
|
|
165
172
|
- No input required
|
|
166
|
-
- Returns
|
|
173
|
+
- Returns missing entities referenced in relations and observation limit violations
|
|
167
174
|
|
|
168
175
|
- **evaluate_bcl**
|
|
169
176
|
- Evaluate a Binary Combinatory Logic (BCL) program
|
|
@@ -182,6 +189,12 @@ Example:
|
|
|
182
189
|
- No input required
|
|
183
190
|
- Resets BCL constructor
|
|
184
191
|
|
|
192
|
+
- **sequentialthinking**
|
|
193
|
+
- Record a thought in the knowledge graph
|
|
194
|
+
- Input: `observations` (string[], max 2, each max 140 chars), `previousCtxId` (string, optional)
|
|
195
|
+
- Creates a Thought entity and links it to the previous thought if provided
|
|
196
|
+
- Returns the new thought's context ID for chaining
|
|
197
|
+
|
|
185
198
|
# Usage with Claude Desktop
|
|
186
199
|
|
|
187
200
|
### Setup
|
package/dist/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { promises as fs } from 'fs';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
5
6
|
import path from 'path';
|
|
6
7
|
import { fileURLToPath } from 'url';
|
|
7
8
|
import lockfile from 'proper-lockfile';
|
|
@@ -13,6 +14,83 @@ const DEFAULT_MEMORY_FILE_PATH = process.env.MEMORY_FILE_PATH
|
|
|
13
14
|
? process.env.MEMORY_FILE_PATH
|
|
14
15
|
: path.join(path.dirname(fileURLToPath(import.meta.url)), process.env.MEMORY_FILE_PATH)
|
|
15
16
|
: defaultMemoryPath;
|
|
17
|
+
export const MAX_CHARS = 2048;
|
|
18
|
+
function paginateItems(items, cursor = 0, maxChars = MAX_CHARS) {
|
|
19
|
+
const result = [];
|
|
20
|
+
let i = cursor;
|
|
21
|
+
// Calculate overhead for wrapper: {"items":[],"nextCursor":null,"totalCount":123}
|
|
22
|
+
const wrapperTemplate = { items: [], nextCursor: null, totalCount: items.length };
|
|
23
|
+
let overhead = JSON.stringify(wrapperTemplate).length;
|
|
24
|
+
let charCount = overhead;
|
|
25
|
+
while (i < items.length) {
|
|
26
|
+
const itemJson = JSON.stringify(items[i]);
|
|
27
|
+
const addedChars = itemJson.length + (result.length > 0 ? 1 : 0); // +1 for comma
|
|
28
|
+
if (charCount + addedChars > maxChars) {
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
result.push(items[i]);
|
|
32
|
+
charCount += addedChars;
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
// Update nextCursor - recalculate if we stopped early (cursor digits may differ from null)
|
|
36
|
+
const nextCursor = i < items.length ? i : null;
|
|
37
|
+
return {
|
|
38
|
+
items: result,
|
|
39
|
+
nextCursor,
|
|
40
|
+
totalCount: items.length
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function paginateGraph(graph, entityCursor = 0, relationCursor = 0) {
|
|
44
|
+
// Build incrementally, measuring actual serialized size
|
|
45
|
+
const entityCount = graph.entities.length;
|
|
46
|
+
const relationCount = graph.relations.length;
|
|
47
|
+
// Start with empty result to measure base overhead
|
|
48
|
+
const emptyResult = {
|
|
49
|
+
entities: { items: [], nextCursor: null, totalCount: entityCount },
|
|
50
|
+
relations: { items: [], nextCursor: null, totalCount: relationCount }
|
|
51
|
+
};
|
|
52
|
+
let currentSize = JSON.stringify(emptyResult).length;
|
|
53
|
+
const resultEntities = [];
|
|
54
|
+
const resultRelations = [];
|
|
55
|
+
let entityIdx = entityCursor;
|
|
56
|
+
let relationIdx = relationCursor;
|
|
57
|
+
// Add entities until we hit the limit
|
|
58
|
+
while (entityIdx < graph.entities.length) {
|
|
59
|
+
const entity = graph.entities[entityIdx];
|
|
60
|
+
const entityJson = JSON.stringify(entity);
|
|
61
|
+
const addedChars = entityJson.length + (resultEntities.length > 0 ? 1 : 0);
|
|
62
|
+
if (currentSize + addedChars > MAX_CHARS) {
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
resultEntities.push(entity);
|
|
66
|
+
currentSize += addedChars;
|
|
67
|
+
entityIdx++;
|
|
68
|
+
}
|
|
69
|
+
// Add relations with remaining space
|
|
70
|
+
while (relationIdx < graph.relations.length) {
|
|
71
|
+
const relation = graph.relations[relationIdx];
|
|
72
|
+
const relationJson = JSON.stringify(relation);
|
|
73
|
+
const addedChars = relationJson.length + (resultRelations.length > 0 ? 1 : 0);
|
|
74
|
+
if (currentSize + addedChars > MAX_CHARS) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
resultRelations.push(relation);
|
|
78
|
+
currentSize += addedChars;
|
|
79
|
+
relationIdx++;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
entities: {
|
|
83
|
+
items: resultEntities,
|
|
84
|
+
nextCursor: entityIdx < graph.entities.length ? entityIdx : null,
|
|
85
|
+
totalCount: entityCount
|
|
86
|
+
},
|
|
87
|
+
relations: {
|
|
88
|
+
items: resultRelations,
|
|
89
|
+
nextCursor: relationIdx < graph.relations.length ? relationIdx : null,
|
|
90
|
+
totalCount: relationCount
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
}
|
|
16
94
|
// The KnowledgeGraphManager class contains all operations to interact with the knowledge graph
|
|
17
95
|
export class KnowledgeGraphManager {
|
|
18
96
|
bclCtr = 0;
|
|
@@ -79,7 +157,10 @@ export class KnowledgeGraphManager {
|
|
|
79
157
|
}
|
|
80
158
|
}
|
|
81
159
|
}
|
|
82
|
-
const
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const newEntities = entities
|
|
162
|
+
.filter(e => !graph.entities.some(existingEntity => existingEntity.name === e.name))
|
|
163
|
+
.map(e => ({ ...e, mtime: now, obsMtime: e.observations.length > 0 ? now : undefined }));
|
|
83
164
|
graph.entities.push(...newEntities);
|
|
84
165
|
await this.saveGraph(graph);
|
|
85
166
|
return newEntities;
|
|
@@ -88,9 +169,19 @@ export class KnowledgeGraphManager {
|
|
|
88
169
|
async createRelations(relations) {
|
|
89
170
|
return this.withLock(async () => {
|
|
90
171
|
const graph = await this.loadGraph();
|
|
91
|
-
const
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
// Update mtime on 'from' entities when relations are added
|
|
174
|
+
const fromEntityNames = new Set(relations.map(r => r.from));
|
|
175
|
+
graph.entities.forEach(e => {
|
|
176
|
+
if (fromEntityNames.has(e.name)) {
|
|
177
|
+
e.mtime = now;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
const newRelations = relations
|
|
181
|
+
.filter(r => !graph.relations.some(existingRelation => existingRelation.from === r.from &&
|
|
92
182
|
existingRelation.to === r.to &&
|
|
93
|
-
existingRelation.relationType === r.relationType))
|
|
183
|
+
existingRelation.relationType === r.relationType))
|
|
184
|
+
.map(r => ({ ...r, mtime: now }));
|
|
94
185
|
graph.relations.push(...newRelations);
|
|
95
186
|
await this.saveGraph(graph);
|
|
96
187
|
return newRelations;
|
|
@@ -116,6 +207,11 @@ export class KnowledgeGraphManager {
|
|
|
116
207
|
throw new Error(`Adding ${newObservations.length} observations to "${o.entityName}" would exceed limit of 2 (currently has ${entity.observations.length}).`);
|
|
117
208
|
}
|
|
118
209
|
entity.observations.push(...newObservations);
|
|
210
|
+
if (newObservations.length > 0) {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
entity.mtime = now;
|
|
213
|
+
entity.obsMtime = now;
|
|
214
|
+
}
|
|
119
215
|
return { entityName: o.entityName, addedObservations: newObservations };
|
|
120
216
|
});
|
|
121
217
|
await this.saveGraph(graph);
|
|
@@ -133,10 +229,16 @@ export class KnowledgeGraphManager {
|
|
|
133
229
|
async deleteObservations(deletions) {
|
|
134
230
|
return this.withLock(async () => {
|
|
135
231
|
const graph = await this.loadGraph();
|
|
232
|
+
const now = Date.now();
|
|
136
233
|
deletions.forEach(d => {
|
|
137
234
|
const entity = graph.entities.find(e => e.name === d.entityName);
|
|
138
235
|
if (entity) {
|
|
236
|
+
const originalLen = entity.observations.length;
|
|
139
237
|
entity.observations = entity.observations.filter(o => !d.observations.includes(o));
|
|
238
|
+
if (entity.observations.length !== originalLen) {
|
|
239
|
+
entity.mtime = now;
|
|
240
|
+
entity.obsMtime = now;
|
|
241
|
+
}
|
|
140
242
|
}
|
|
141
243
|
});
|
|
142
244
|
await this.saveGraph(graph);
|
|
@@ -281,14 +383,44 @@ export class KnowledgeGraphManager {
|
|
|
281
383
|
relationTypes: relationTypes.size
|
|
282
384
|
};
|
|
283
385
|
}
|
|
284
|
-
async getOrphanedEntities() {
|
|
386
|
+
async getOrphanedEntities(strict = false) {
|
|
285
387
|
const graph = await this.loadGraph();
|
|
286
|
-
|
|
388
|
+
if (!strict) {
|
|
389
|
+
// Simple mode: entities with no relations at all
|
|
390
|
+
const connectedEntityNames = new Set();
|
|
391
|
+
graph.relations.forEach(r => {
|
|
392
|
+
connectedEntityNames.add(r.from);
|
|
393
|
+
connectedEntityNames.add(r.to);
|
|
394
|
+
});
|
|
395
|
+
return graph.entities.filter(e => !connectedEntityNames.has(e.name));
|
|
396
|
+
}
|
|
397
|
+
// Strict mode: entities not connected to "Self" (directly or indirectly)
|
|
398
|
+
// Build adjacency list (bidirectional)
|
|
399
|
+
const neighbors = new Map();
|
|
400
|
+
graph.entities.forEach(e => neighbors.set(e.name, new Set()));
|
|
287
401
|
graph.relations.forEach(r => {
|
|
288
|
-
|
|
289
|
-
|
|
402
|
+
neighbors.get(r.from)?.add(r.to);
|
|
403
|
+
neighbors.get(r.to)?.add(r.from);
|
|
290
404
|
});
|
|
291
|
-
|
|
405
|
+
// BFS from Self to find all connected entities
|
|
406
|
+
const connectedToSelf = new Set();
|
|
407
|
+
const queue = ['Self'];
|
|
408
|
+
while (queue.length > 0) {
|
|
409
|
+
const current = queue.shift();
|
|
410
|
+
if (connectedToSelf.has(current))
|
|
411
|
+
continue;
|
|
412
|
+
connectedToSelf.add(current);
|
|
413
|
+
const currentNeighbors = neighbors.get(current);
|
|
414
|
+
if (currentNeighbors) {
|
|
415
|
+
for (const neighbor of currentNeighbors) {
|
|
416
|
+
if (!connectedToSelf.has(neighbor)) {
|
|
417
|
+
queue.push(neighbor);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// Return entities not connected to Self (excluding Self itself if it exists)
|
|
423
|
+
return graph.entities.filter(e => !connectedToSelf.has(e.name));
|
|
292
424
|
}
|
|
293
425
|
async validateGraph() {
|
|
294
426
|
const graph = await this.loadGraph();
|
|
@@ -483,6 +615,44 @@ export class KnowledgeGraphManager {
|
|
|
483
615
|
this.bclCtr = 0;
|
|
484
616
|
this.bclTerm = "";
|
|
485
617
|
}
|
|
618
|
+
async addThought(observations, previousCtxId) {
|
|
619
|
+
return this.withLock(async () => {
|
|
620
|
+
const graph = await this.loadGraph();
|
|
621
|
+
// Validate observations
|
|
622
|
+
if (observations.length > 2) {
|
|
623
|
+
throw new Error(`Thought has ${observations.length} observations. Maximum allowed is 2.`);
|
|
624
|
+
}
|
|
625
|
+
for (const obs of observations) {
|
|
626
|
+
if (obs.length > 140) {
|
|
627
|
+
throw new Error(`Observation exceeds 140 characters (${obs.length} chars): "${obs.substring(0, 50)}..."`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
// Generate new context ID (24-char hex)
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
const ctxId = randomBytes(12).toString('hex');
|
|
633
|
+
// Create thought entity
|
|
634
|
+
const thoughtEntity = {
|
|
635
|
+
name: ctxId,
|
|
636
|
+
entityType: "Thought",
|
|
637
|
+
observations,
|
|
638
|
+
mtime: now,
|
|
639
|
+
obsMtime: observations.length > 0 ? now : undefined,
|
|
640
|
+
};
|
|
641
|
+
graph.entities.push(thoughtEntity);
|
|
642
|
+
// Link to previous thought if it exists
|
|
643
|
+
if (previousCtxId) {
|
|
644
|
+
const prevEntity = graph.entities.find(e => e.name === previousCtxId);
|
|
645
|
+
if (prevEntity) {
|
|
646
|
+
// Update mtime on previous entity since we're adding a relation from it
|
|
647
|
+
prevEntity.mtime = now;
|
|
648
|
+
// Bidirectional chain: previous -> new (follows) and new -> previous (preceded_by)
|
|
649
|
+
graph.relations.push({ from: previousCtxId, to: ctxId, relationType: "follows", mtime: now }, { from: ctxId, to: previousCtxId, relationType: "preceded_by", mtime: now });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
await this.saveGraph(graph);
|
|
653
|
+
return { ctxId };
|
|
654
|
+
});
|
|
655
|
+
}
|
|
486
656
|
}
|
|
487
657
|
/**
|
|
488
658
|
* Creates a configured MCP server instance with all tools registered.
|
|
@@ -646,18 +816,20 @@ export function createServer(memoryFilePath) {
|
|
|
646
816
|
},
|
|
647
817
|
{
|
|
648
818
|
name: "search_nodes",
|
|
649
|
-
description: "Search for nodes in the knowledge graph using a regex pattern",
|
|
819
|
+
description: "Search for nodes in the knowledge graph using a regex pattern. Results are paginated (max 512 chars).",
|
|
650
820
|
inputSchema: {
|
|
651
821
|
type: "object",
|
|
652
822
|
properties: {
|
|
653
|
-
query: { type: "string", description: "Regex pattern to match against entity names, types, and observations.
|
|
823
|
+
query: { type: "string", description: "Regex pattern to match against entity names, types, and observations." },
|
|
824
|
+
entityCursor: { type: "number", description: "Cursor for entity pagination (from previous response's nextCursor)" },
|
|
825
|
+
relationCursor: { type: "number", description: "Cursor for relation pagination" },
|
|
654
826
|
},
|
|
655
827
|
required: ["query"],
|
|
656
828
|
},
|
|
657
829
|
},
|
|
658
830
|
{
|
|
659
831
|
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",
|
|
832
|
+
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
833
|
inputSchema: {
|
|
662
834
|
type: "object",
|
|
663
835
|
properties: {
|
|
@@ -666,13 +838,15 @@ export function createServer(memoryFilePath) {
|
|
|
666
838
|
items: { type: "string" },
|
|
667
839
|
description: "An array of entity names to retrieve",
|
|
668
840
|
},
|
|
841
|
+
entityCursor: { type: "number", description: "Cursor for entity pagination" },
|
|
842
|
+
relationCursor: { type: "number", description: "Cursor for relation pagination" },
|
|
669
843
|
},
|
|
670
844
|
required: ["names"],
|
|
671
845
|
},
|
|
672
846
|
},
|
|
673
847
|
{
|
|
674
848
|
name: "open_nodes",
|
|
675
|
-
description: "Open specific nodes in the knowledge graph by their names",
|
|
849
|
+
description: "Open specific nodes in the knowledge graph by their names. Results are paginated (max 512 chars).",
|
|
676
850
|
inputSchema: {
|
|
677
851
|
type: "object",
|
|
678
852
|
properties: {
|
|
@@ -681,43 +855,49 @@ export function createServer(memoryFilePath) {
|
|
|
681
855
|
items: { type: "string" },
|
|
682
856
|
description: "An array of entity names to retrieve",
|
|
683
857
|
},
|
|
858
|
+
entityCursor: { type: "number", description: "Cursor for entity pagination" },
|
|
859
|
+
relationCursor: { type: "number", description: "Cursor for relation pagination" },
|
|
684
860
|
},
|
|
685
861
|
required: ["names"],
|
|
686
862
|
},
|
|
687
863
|
},
|
|
688
864
|
{
|
|
689
865
|
name: "get_neighbors",
|
|
690
|
-
description: "Get neighboring entities connected to a specific entity within a given depth",
|
|
866
|
+
description: "Get neighboring entities connected to a specific entity within a given depth. Results are paginated (max 512 chars).",
|
|
691
867
|
inputSchema: {
|
|
692
868
|
type: "object",
|
|
693
869
|
properties: {
|
|
694
870
|
entityName: { type: "string", description: "The name of the entity to find neighbors for" },
|
|
695
871
|
depth: { type: "number", description: "Maximum depth to traverse (default: 0)", default: 0 },
|
|
696
872
|
withEntities: { type: "boolean", description: "If true, include full entity data. Default returns only relations for lightweight structure exploration.", default: false },
|
|
873
|
+
entityCursor: { type: "number", description: "Cursor for entity pagination" },
|
|
874
|
+
relationCursor: { type: "number", description: "Cursor for relation pagination" },
|
|
697
875
|
},
|
|
698
876
|
required: ["entityName"],
|
|
699
877
|
},
|
|
700
878
|
},
|
|
701
879
|
{
|
|
702
880
|
name: "find_path",
|
|
703
|
-
description: "Find a path between two entities in the knowledge graph",
|
|
881
|
+
description: "Find a path between two entities in the knowledge graph. Results are paginated (max 512 chars).",
|
|
704
882
|
inputSchema: {
|
|
705
883
|
type: "object",
|
|
706
884
|
properties: {
|
|
707
885
|
fromEntity: { type: "string", description: "The name of the starting entity" },
|
|
708
886
|
toEntity: { type: "string", description: "The name of the target entity" },
|
|
709
887
|
maxDepth: { type: "number", description: "Maximum depth to search (default: 5)", default: 5 },
|
|
888
|
+
cursor: { type: "number", description: "Cursor for pagination" },
|
|
710
889
|
},
|
|
711
890
|
required: ["fromEntity", "toEntity"],
|
|
712
891
|
},
|
|
713
892
|
},
|
|
714
893
|
{
|
|
715
894
|
name: "get_entities_by_type",
|
|
716
|
-
description: "Get all entities of a specific type",
|
|
895
|
+
description: "Get all entities of a specific type. Results are paginated (max 512 chars).",
|
|
717
896
|
inputSchema: {
|
|
718
897
|
type: "object",
|
|
719
898
|
properties: {
|
|
720
899
|
entityType: { type: "string", description: "The type of entities to retrieve" },
|
|
900
|
+
cursor: { type: "number", description: "Cursor for pagination" },
|
|
721
901
|
},
|
|
722
902
|
required: ["entityType"],
|
|
723
903
|
},
|
|
@@ -748,10 +928,13 @@ export function createServer(memoryFilePath) {
|
|
|
748
928
|
},
|
|
749
929
|
{
|
|
750
930
|
name: "get_orphaned_entities",
|
|
751
|
-
description: "Get entities that have no relations (orphaned entities)",
|
|
931
|
+
description: "Get entities that have no relations (orphaned entities). In strict mode, returns entities not connected to 'Self' entity. Results are paginated (max 512 chars).",
|
|
752
932
|
inputSchema: {
|
|
753
933
|
type: "object",
|
|
754
|
-
properties: {
|
|
934
|
+
properties: {
|
|
935
|
+
strict: { type: "boolean", description: "If true, returns entities not connected to 'Self' (directly or indirectly). Default: false" },
|
|
936
|
+
cursor: { type: "number", description: "Cursor for pagination" },
|
|
937
|
+
},
|
|
755
938
|
},
|
|
756
939
|
},
|
|
757
940
|
{
|
|
@@ -796,6 +979,28 @@ export function createServer(memoryFilePath) {
|
|
|
796
979
|
properties: {},
|
|
797
980
|
},
|
|
798
981
|
},
|
|
982
|
+
{
|
|
983
|
+
name: "sequentialthinking",
|
|
984
|
+
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.
|
|
985
|
+
|
|
986
|
+
Use this to build chains of reasoning that persist in the graph. Each thought can have up to 2 observations (max 140 chars each).`,
|
|
987
|
+
inputSchema: {
|
|
988
|
+
type: "object",
|
|
989
|
+
properties: {
|
|
990
|
+
previousCtxId: {
|
|
991
|
+
type: "string",
|
|
992
|
+
description: "Context ID of the previous thought to chain from. Omit for first thought in a chain."
|
|
993
|
+
},
|
|
994
|
+
observations: {
|
|
995
|
+
type: "array",
|
|
996
|
+
items: { type: "string", maxLength: 140 },
|
|
997
|
+
maxItems: 2,
|
|
998
|
+
description: "Observations for this thought (max 2, each max 140 chars)"
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
required: ["observations"],
|
|
1002
|
+
},
|
|
1003
|
+
},
|
|
799
1004
|
],
|
|
800
1005
|
};
|
|
801
1006
|
});
|
|
@@ -820,26 +1025,40 @@ export function createServer(memoryFilePath) {
|
|
|
820
1025
|
case "delete_relations":
|
|
821
1026
|
await knowledgeGraphManager.deleteRelations(args.relations);
|
|
822
1027
|
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
|
-
|
|
1028
|
+
case "search_nodes": {
|
|
1029
|
+
const graph = await knowledgeGraphManager.searchNodes(args.query);
|
|
1030
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1031
|
+
}
|
|
1032
|
+
case "open_nodes_filtered": {
|
|
1033
|
+
const graph = await knowledgeGraphManager.openNodesFiltered(args.names);
|
|
1034
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1035
|
+
}
|
|
1036
|
+
case "open_nodes": {
|
|
1037
|
+
const graph = await knowledgeGraphManager.openNodes(args.names);
|
|
1038
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1039
|
+
}
|
|
1040
|
+
case "get_neighbors": {
|
|
1041
|
+
const graph = await knowledgeGraphManager.getNeighbors(args.entityName, args.depth, args.withEntities);
|
|
1042
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateGraph(graph, args.entityCursor ?? 0, args.relationCursor ?? 0)) }] };
|
|
1043
|
+
}
|
|
1044
|
+
case "find_path": {
|
|
1045
|
+
const path = await knowledgeGraphManager.findPath(args.fromEntity, args.toEntity, args.maxDepth);
|
|
1046
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateItems(path, args.cursor ?? 0)) }] };
|
|
1047
|
+
}
|
|
1048
|
+
case "get_entities_by_type": {
|
|
1049
|
+
const entities = await knowledgeGraphManager.getEntitiesByType(args.entityType);
|
|
1050
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
|
|
1051
|
+
}
|
|
835
1052
|
case "get_entity_types":
|
|
836
1053
|
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getEntityTypes(), null, 2) }] };
|
|
837
1054
|
case "get_relation_types":
|
|
838
1055
|
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getRelationTypes(), null, 2) }] };
|
|
839
1056
|
case "get_stats":
|
|
840
1057
|
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.getStats(), null, 2) }] };
|
|
841
|
-
case "get_orphaned_entities":
|
|
842
|
-
|
|
1058
|
+
case "get_orphaned_entities": {
|
|
1059
|
+
const entities = await knowledgeGraphManager.getOrphanedEntities(args.strict ?? false);
|
|
1060
|
+
return { content: [{ type: "text", text: JSON.stringify(paginateItems(entities, args.cursor ?? 0)) }] };
|
|
1061
|
+
}
|
|
843
1062
|
case "validate_graph":
|
|
844
1063
|
return { content: [{ type: "text", text: JSON.stringify(await knowledgeGraphManager.validateGraph(), null, 2) }] };
|
|
845
1064
|
case "evaluate_bcl":
|
|
@@ -849,6 +1068,10 @@ export function createServer(memoryFilePath) {
|
|
|
849
1068
|
case "clear_bcl_term":
|
|
850
1069
|
await knowledgeGraphManager.clearBCLTerm();
|
|
851
1070
|
return { content: [{ type: "text", text: "BCL term constructor cleared successfully" }] };
|
|
1071
|
+
case "sequentialthinking": {
|
|
1072
|
+
const result = await knowledgeGraphManager.addThought(args.observations, args.previousCtxId);
|
|
1073
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
1074
|
+
}
|
|
852
1075
|
default:
|
|
853
1076
|
throw new Error(`Unknown tool: ${name}`);
|
|
854
1077
|
}
|
|
@@ -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,34 @@ 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');
|
|
443
|
+
});
|
|
444
|
+
it('should find entities not connected to Self in strict mode', async () => {
|
|
445
|
+
await callTool(client, 'create_entities', {
|
|
446
|
+
entities: [
|
|
447
|
+
{ name: 'Self', entityType: 'Agent', observations: [] },
|
|
448
|
+
{ name: 'ConnectedToSelf', entityType: 'Node', observations: [] },
|
|
449
|
+
{ name: 'IndirectlyConnected', entityType: 'Node', observations: [] },
|
|
450
|
+
{ name: 'Island1', entityType: 'Node', observations: [] },
|
|
451
|
+
{ name: 'Island2', entityType: 'Node', observations: [] }
|
|
452
|
+
]
|
|
453
|
+
});
|
|
454
|
+
await callTool(client, 'create_relations', {
|
|
455
|
+
relations: [
|
|
456
|
+
{ from: 'Self', to: 'ConnectedToSelf', relationType: 'knows' },
|
|
457
|
+
{ from: 'ConnectedToSelf', to: 'IndirectlyConnected', relationType: 'links' },
|
|
458
|
+
{ from: 'Island1', to: 'Island2', relationType: 'links' } // Connected to each other but not to Self
|
|
459
|
+
]
|
|
460
|
+
});
|
|
461
|
+
// Non-strict: Island1 and Island2 are connected, so not orphaned
|
|
462
|
+
const nonStrict = await callTool(client, 'get_orphaned_entities', {});
|
|
463
|
+
expect(nonStrict.items).toHaveLength(0);
|
|
464
|
+
// Strict: Island1 and Island2 are not connected to Self
|
|
465
|
+
const strict = await callTool(client, 'get_orphaned_entities', { strict: true });
|
|
466
|
+
expect(strict.items).toHaveLength(2);
|
|
467
|
+
const names = strict.items.map(e => e.name).sort();
|
|
468
|
+
expect(names).toEqual(['Island1', 'Island2']);
|
|
397
469
|
});
|
|
398
470
|
it('should validate graph and report violations', async () => {
|
|
399
471
|
// Directly write invalid data to test validation
|
|
@@ -435,4 +507,59 @@ describe('MCP Memory Server E2E Tests', () => {
|
|
|
435
507
|
await expect(callTool(client, 'add_bcl_term', { term: 'invalid' })).rejects.toThrow(/Invalid BCL term/);
|
|
436
508
|
});
|
|
437
509
|
});
|
|
510
|
+
describe('Sequential Thinking', () => {
|
|
511
|
+
it('should create a thought and return ctxId', async () => {
|
|
512
|
+
const result = await callTool(client, 'sequentialthinking', {
|
|
513
|
+
observations: ['First thought observation']
|
|
514
|
+
});
|
|
515
|
+
expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
|
|
516
|
+
});
|
|
517
|
+
it('should chain thoughts with relations', async () => {
|
|
518
|
+
// Create first thought
|
|
519
|
+
const first = await callTool(client, 'sequentialthinking', {
|
|
520
|
+
observations: ['Starting point']
|
|
521
|
+
});
|
|
522
|
+
// Create second thought chained to first
|
|
523
|
+
const second = await callTool(client, 'sequentialthinking', {
|
|
524
|
+
previousCtxId: first.ctxId,
|
|
525
|
+
observations: ['Following up']
|
|
526
|
+
});
|
|
527
|
+
// Verify the chain via relations
|
|
528
|
+
const neighbors = await callTool(client, 'get_neighbors', {
|
|
529
|
+
entityName: first.ctxId,
|
|
530
|
+
depth: 1
|
|
531
|
+
});
|
|
532
|
+
// Should have 'follows' relation from first to second
|
|
533
|
+
expect(neighbors.relations.items.some(r => r.from === first.ctxId && r.to === second.ctxId && r.relationType === 'follows')).toBe(true);
|
|
534
|
+
});
|
|
535
|
+
it('should ignore invalid previousCtxId gracefully', async () => {
|
|
536
|
+
const result = await callTool(client, 'sequentialthinking', {
|
|
537
|
+
previousCtxId: 'nonexistent_thought',
|
|
538
|
+
observations: ['Orphaned thought']
|
|
539
|
+
});
|
|
540
|
+
expect(result.ctxId).toMatch(/^[0-9a-f]{24}$/);
|
|
541
|
+
// Verify no relations were created
|
|
542
|
+
const neighbors = await callTool(client, 'get_neighbors', {
|
|
543
|
+
entityName: result.ctxId,
|
|
544
|
+
depth: 1
|
|
545
|
+
});
|
|
546
|
+
expect(neighbors.relations.totalCount).toBe(0);
|
|
547
|
+
});
|
|
548
|
+
it('should enforce observation limits on thoughts', async () => {
|
|
549
|
+
await expect(callTool(client, 'sequentialthinking', {
|
|
550
|
+
observations: ['One', 'Two', 'Three']
|
|
551
|
+
})).rejects.toThrow(/Maximum allowed is 2/);
|
|
552
|
+
});
|
|
553
|
+
it('should set mtime and obsMtime on thought entities', async () => {
|
|
554
|
+
const result = await callTool(client, 'sequentialthinking', {
|
|
555
|
+
observations: ['Timed thought']
|
|
556
|
+
});
|
|
557
|
+
const graph = await callTool(client, 'open_nodes', {
|
|
558
|
+
names: [result.ctxId]
|
|
559
|
+
});
|
|
560
|
+
const thought = graph.entities.items[0];
|
|
561
|
+
expect(thought.mtime).toBeDefined();
|
|
562
|
+
expect(thought.obsMtime).toBeDefined();
|
|
563
|
+
});
|
|
564
|
+
});
|
|
438
565
|
});
|
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.9",
|
|
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": {
|