@cartisien/engram-mcp 0.1.0 → 0.2.0

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/src/index.js CHANGED
@@ -37,46 +37,54 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
38
38
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
39
39
  const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
40
- const engram_js_1 = require("../engram.js");
40
+ const engram_1 = require("@cartisien/engram");
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const fs = __importStar(require("fs"));
44
44
  const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
45
45
  const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
46
- // Ensure DB directory exists
46
+ const GRAPH_MODEL = process.env.ENGRAM_GRAPH_MODEL || 'qwen2.5:32b';
47
+ const CONSOLIDATE_MODEL = process.env.ENGRAM_CONSOLIDATE_MODEL || 'qwen2.5:32b';
47
48
  const dbDir = path.dirname(DB_PATH);
48
49
  if (!fs.existsSync(dbDir))
49
50
  fs.mkdirSync(dbDir, { recursive: true });
50
- const memory = new engram_js_1.Engram({
51
+ const memory = new engram_1.Engram({
51
52
  dbPath: DB_PATH,
52
53
  embeddingUrl: EMBEDDING_URL,
53
54
  semanticSearch: true,
55
+ graphMemory: process.env.ENGRAM_GRAPH === '1',
56
+ graphModel: GRAPH_MODEL,
57
+ consolidateModel: CONSOLIDATE_MODEL,
54
58
  });
55
- const server = new index_js_1.Server({ name: 'engram', version: '0.1.0' }, { capabilities: { tools: {} } });
59
+ const server = new index_js_1.Server({ name: 'engram', version: '0.2.0' }, { capabilities: { tools: {} } });
56
60
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
57
61
  tools: [
62
+ // ── Session memory ──────────────────────────────────────────────────
58
63
  {
59
64
  name: 'remember',
60
- description: 'Store a memory entry for a session. Embeddings are generated automatically for semantic recall.',
65
+ description: 'Store a memory entry for a session. Embeddings and graph extraction happen automatically.',
61
66
  inputSchema: {
62
67
  type: 'object',
63
68
  properties: {
64
- sessionId: { type: 'string', description: 'Session identifier (e.g. agent name or user ID)' },
65
- content: { type: 'string', description: 'The memory content to store' },
66
- role: { type: 'string', enum: ['user', 'assistant', 'system'], description: 'Role of the memory source', default: 'user' },
69
+ sessionId: { type: 'string', description: 'Session identifier' },
70
+ content: { type: 'string', description: 'Memory content to store' },
71
+ role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
72
+ metadata: { type: 'object', description: 'Optional key-value metadata' },
67
73
  },
68
74
  required: ['sessionId', 'content'],
69
75
  },
70
76
  },
71
77
  {
72
78
  name: 'recall',
73
- description: 'Retrieve relevant memories using semantic search. Falls back to keyword search if embeddings unavailable.',
79
+ description: 'Retrieve relevant memories via semantic search (falls back to keyword). Searches working + long_term tiers. Pass userId to blend in cross-session user facts.',
74
80
  inputSchema: {
75
81
  type: 'object',
76
82
  properties: {
77
83
  sessionId: { type: 'string', description: 'Session identifier' },
78
- query: { type: 'string', description: 'Search query — semantic similarity is used' },
79
- limit: { type: 'number', description: 'Max results to return (default 10)', default: 10 },
84
+ query: { type: 'string', description: 'Search query' },
85
+ limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
86
+ userId: { type: 'string', description: 'Optional: also blend in this user\'s cross-session memories' },
87
+ tiers: { type: 'string', description: 'Comma-separated tiers to search: working,long_term,archived (default: working,long_term)' },
80
88
  },
81
89
  required: ['sessionId', 'query'],
82
90
  },
@@ -88,75 +96,206 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
88
96
  type: 'object',
89
97
  properties: {
90
98
  sessionId: { type: 'string', description: 'Session identifier' },
91
- limit: { type: 'number', description: 'Max entries to return (default 20)', default: 20 },
99
+ limit: { type: 'number', description: 'Max entries (default 20)', default: 20 },
92
100
  },
93
101
  required: ['sessionId'],
94
102
  },
95
103
  },
96
104
  {
97
105
  name: 'forget',
98
- description: 'Delete memories for a session. Delete all, one by ID, or entries before a date.',
106
+ description: 'Delete session memories. Delete all, one by ID, or entries before a date.',
99
107
  inputSchema: {
100
108
  type: 'object',
101
109
  properties: {
102
110
  sessionId: { type: 'string', description: 'Session identifier' },
103
111
  id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
104
- before: { type: 'string', description: 'ISO date string — delete entries before this date (optional)' },
112
+ before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
105
113
  },
106
114
  required: ['sessionId'],
107
115
  },
108
116
  },
109
117
  {
110
118
  name: 'stats',
111
- description: 'Get memory statistics for a session.',
119
+ description: 'Memory statistics for a session — total, by role, by tier (working/long_term/archived), graph counts.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ sessionId: { type: 'string', description: 'Session identifier' },
124
+ },
125
+ required: ['sessionId'],
126
+ },
127
+ },
128
+ {
129
+ name: 'consolidate',
130
+ description: 'Consolidate old working memories into dense long-term summaries via LLM. Archives originals.',
112
131
  inputSchema: {
113
132
  type: 'object',
114
133
  properties: {
115
134
  sessionId: { type: 'string', description: 'Session identifier' },
135
+ batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
136
+ keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
137
+ dryRun: { type: 'boolean', description: 'Preview summaries without writing (default false)' },
116
138
  },
117
139
  required: ['sessionId'],
118
140
  },
119
141
  },
142
+ {
143
+ name: 'graph',
144
+ description: 'Query the knowledge graph for an entity — returns relationships and source memories. Requires ENGRAM_GRAPH=1.',
145
+ inputSchema: {
146
+ type: 'object',
147
+ properties: {
148
+ sessionId: { type: 'string', description: 'Session identifier' },
149
+ entity: { type: 'string', description: 'Entity name to look up (case-insensitive)' },
150
+ },
151
+ required: ['sessionId', 'entity'],
152
+ },
153
+ },
154
+ // ── User-scoped (cross-session) memory ──────────────────────────────
155
+ {
156
+ name: 'remember_user',
157
+ description: 'Store a user-scoped memory that persists across all sessions. Use for preferences, identity, long-term facts.',
158
+ inputSchema: {
159
+ type: 'object',
160
+ properties: {
161
+ userId: { type: 'string', description: 'User identifier' },
162
+ content: { type: 'string', description: 'Memory content to store' },
163
+ role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
164
+ metadata: { type: 'object', description: 'Optional key-value metadata' },
165
+ },
166
+ required: ['userId', 'content'],
167
+ },
168
+ },
169
+ {
170
+ name: 'recall_user',
171
+ description: 'Recall user-scoped memories — works from any session context.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ userId: { type: 'string', description: 'User identifier' },
176
+ query: { type: 'string', description: 'Search query (optional — returns all if omitted)' },
177
+ limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
178
+ },
179
+ required: ['userId'],
180
+ },
181
+ },
182
+ {
183
+ name: 'forget_user',
184
+ description: 'Delete user-scoped memories.',
185
+ inputSchema: {
186
+ type: 'object',
187
+ properties: {
188
+ userId: { type: 'string', description: 'User identifier' },
189
+ id: { type: 'string', description: 'Specific memory ID (optional)' },
190
+ before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
191
+ },
192
+ required: ['userId'],
193
+ },
194
+ },
195
+ {
196
+ name: 'consolidate_user',
197
+ description: 'Consolidate user-scoped working memories into long-term summaries.',
198
+ inputSchema: {
199
+ type: 'object',
200
+ properties: {
201
+ userId: { type: 'string', description: 'User identifier' },
202
+ batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
203
+ keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
204
+ dryRun: { type: 'boolean', description: 'Preview without writing (default false)' },
205
+ },
206
+ required: ['userId'],
207
+ },
208
+ },
209
+ {
210
+ name: 'user_stats',
211
+ description: 'Memory statistics for a user — total, by role, by tier.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ userId: { type: 'string', description: 'User identifier' },
216
+ },
217
+ required: ['userId'],
218
+ },
219
+ },
120
220
  ],
121
221
  }));
122
222
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
123
223
  const { name, arguments: args = {} } = request.params;
124
224
  try {
125
225
  switch (name) {
226
+ // ── Session ──────────────────────────────────────────────────────
126
227
  case 'remember': {
127
- const entry = await memory.remember(args.sessionId, args.content, args.role || 'user');
128
- return {
129
- content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }],
130
- };
228
+ const entry = await memory.remember(args.sessionId, args.content, args.role || 'user', args.metadata);
229
+ return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
131
230
  }
132
231
  case 'recall': {
133
- const entries = await memory.recall(args.sessionId, args.query, args.limit || 10);
134
- return {
135
- content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
136
- };
232
+ const tiers = args.tiers
233
+ ? args.tiers.split(',').map(t => t.trim())
234
+ : undefined;
235
+ const entries = await memory.recall(args.sessionId, args.query, args.limit || 10, {
236
+ tiers,
237
+ userId: args.userId,
238
+ });
239
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
137
240
  }
138
241
  case 'history': {
139
242
  const entries = await memory.history(args.sessionId, args.limit || 20);
140
- return {
141
- content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
142
- };
243
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
143
244
  }
144
245
  case 'forget': {
145
- const options = {};
246
+ const opts = {};
146
247
  if (args.id)
147
- options.id = args.id;
248
+ opts.id = args.id;
148
249
  if (args.before)
149
- options.before = new Date(args.before);
150
- const deleted = await memory.forget(args.sessionId, options);
151
- return {
152
- content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
153
- };
250
+ opts.before = new Date(args.before);
251
+ const deleted = await memory.forget(args.sessionId, opts);
252
+ return { content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }] };
154
253
  }
155
254
  case 'stats': {
156
255
  const stats = await memory.stats(args.sessionId);
157
- return {
158
- content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
159
- };
256
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
257
+ }
258
+ case 'consolidate': {
259
+ const result = await memory.consolidate(args.sessionId, {
260
+ batch: args.batch,
261
+ keep: args.keep,
262
+ dryRun: args.dryRun,
263
+ });
264
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
265
+ }
266
+ case 'graph': {
267
+ const result = await memory.graph(args.sessionId, args.entity);
268
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
269
+ }
270
+ // ── User-scoped ───────────────────────────────────────────────────
271
+ case 'remember_user': {
272
+ const entry = await memory.rememberUser(args.userId, args.content, args.role || 'user', args.metadata);
273
+ return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
274
+ }
275
+ case 'recall_user': {
276
+ const entries = await memory.recallUser(args.userId, args.query, args.limit || 10);
277
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
278
+ }
279
+ case 'forget_user': {
280
+ const opts = {};
281
+ if (args.id)
282
+ opts.id = args.id;
283
+ if (args.before)
284
+ opts.before = new Date(args.before);
285
+ const deleted = await memory.forgetUser(args.userId, opts);
286
+ return { content: [{ type: 'text', text: `Deleted ${deleted} user memor${deleted === 1 ? 'y' : 'ies'}.` }] };
287
+ }
288
+ case 'consolidate_user': {
289
+ const result = await memory.consolidateUser(args.userId, {
290
+ batch: args.batch,
291
+ keep: args.keep,
292
+ dryRun: args.dryRun,
293
+ });
294
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
295
+ }
296
+ case 'user_stats': {
297
+ const stats = await memory.userStats(args.userId);
298
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
160
299
  }
161
300
  default:
162
301
  return {
@@ -175,7 +314,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
175
314
  async function main() {
176
315
  const transport = new stdio_js_1.StdioServerTransport();
177
316
  await server.connect(transport);
178
- process.stderr.write('Engram MCP server running\n');
317
+ process.stderr.write('Engram MCP server v0.2.0 running\n');
179
318
  }
180
319
  main().catch((err) => {
181
320
  process.stderr.write(`Fatal: ${err.message}\n`);
package/package.json CHANGED
@@ -1,22 +1,24 @@
1
1
  {
2
2
  "name": "@cartisien/engram-mcp",
3
- "version": "0.1.0",
4
- "description": "MCP server for @cartisien/engram \u2014 persistent semantic memory for AI agents",
5
- "main": "dist/index.js",
3
+ "version": "0.2.0",
4
+ "description": "MCP server for @cartisien/engram persistent semantic memory for AI agents",
5
+ "type": "module",
6
+ "main": "dist/src/index.js",
6
7
  "bin": {
7
8
  "engram-mcp": "dist/src/index.js"
8
9
  },
9
10
  "scripts": {
10
11
  "build": "tsc",
11
- "start": "node dist/index.js"
12
+ "start": "node dist/src/index.js"
12
13
  },
13
14
  "dependencies": {
15
+ "@cartisien/engram": "^0.6.1",
14
16
  "@modelcontextprotocol/sdk": "^1.0.0",
15
- "sqlite": "^5.1.1",
16
17
  "sqlite3": "^5.1.7"
17
18
  },
18
19
  "devDependencies": {
20
+ "@types/node": "^20.0.0",
19
21
  "typescript": "^5.0.0",
20
- "@types/node": "^20.0.0"
22
+ "vitest": "^2.0.0"
21
23
  }
22
- }
24
+ }
package/src/index.ts CHANGED
@@ -5,15 +5,16 @@ import {
5
5
  CallToolRequestSchema,
6
6
  ListToolsRequestSchema,
7
7
  } from '@modelcontextprotocol/sdk/types.js';
8
- import { Engram } from '../engram.js';
8
+ import { Engram } from '@cartisien/engram';
9
9
  import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import * as fs from 'fs';
12
12
 
13
13
  const DB_PATH = process.env.ENGRAM_DB || path.join(os.homedir(), '.engram', 'memory.db');
14
14
  const EMBEDDING_URL = process.env.ENGRAM_EMBEDDING_URL || 'http://192.168.68.73:11434';
15
+ const GRAPH_MODEL = process.env.ENGRAM_GRAPH_MODEL || 'qwen2.5:32b';
16
+ const CONSOLIDATE_MODEL = process.env.ENGRAM_CONSOLIDATE_MODEL || 'qwen2.5:32b';
15
17
 
16
- // Ensure DB directory exists
17
18
  const dbDir = path.dirname(DB_PATH);
18
19
  if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
19
20
 
@@ -21,37 +22,44 @@ const memory = new Engram({
21
22
  dbPath: DB_PATH,
22
23
  embeddingUrl: EMBEDDING_URL,
23
24
  semanticSearch: true,
25
+ graphMemory: process.env.ENGRAM_GRAPH === '1',
26
+ graphModel: GRAPH_MODEL,
27
+ consolidateModel: CONSOLIDATE_MODEL,
24
28
  });
25
29
 
26
30
  const server = new Server(
27
- { name: 'engram', version: '0.1.0' },
31
+ { name: 'engram', version: '0.2.0' },
28
32
  { capabilities: { tools: {} } }
29
33
  );
30
34
 
31
35
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
36
  tools: [
37
+ // ── Session memory ──────────────────────────────────────────────────
33
38
  {
34
39
  name: 'remember',
35
- description: 'Store a memory entry for a session. Embeddings are generated automatically for semantic recall.',
40
+ description: 'Store a memory entry for a session. Embeddings and graph extraction happen automatically.',
36
41
  inputSchema: {
37
42
  type: 'object',
38
43
  properties: {
39
- sessionId: { type: 'string', description: 'Session identifier (e.g. agent name or user ID)' },
40
- content: { type: 'string', description: 'The memory content to store' },
41
- role: { type: 'string', enum: ['user', 'assistant', 'system'], description: 'Role of the memory source', default: 'user' },
44
+ sessionId: { type: 'string', description: 'Session identifier' },
45
+ content: { type: 'string', description: 'Memory content to store' },
46
+ role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
47
+ metadata: { type: 'object', description: 'Optional key-value metadata' },
42
48
  },
43
49
  required: ['sessionId', 'content'],
44
50
  },
45
51
  },
46
52
  {
47
53
  name: 'recall',
48
- description: 'Retrieve relevant memories using semantic search. Falls back to keyword search if embeddings unavailable.',
54
+ description: 'Retrieve relevant memories via semantic search (falls back to keyword). Searches working + long_term tiers. Pass userId to blend in cross-session user facts.',
49
55
  inputSchema: {
50
56
  type: 'object',
51
57
  properties: {
52
- sessionId: { type: 'string', description: 'Session identifier' },
53
- query: { type: 'string', description: 'Search query — semantic similarity is used' },
54
- limit: { type: 'number', description: 'Max results to return (default 10)', default: 10 },
58
+ sessionId: { type: 'string', description: 'Session identifier' },
59
+ query: { type: 'string', description: 'Search query' },
60
+ limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
61
+ userId: { type: 'string', description: 'Optional: also blend in this user\'s cross-session memories' },
62
+ tiers: { type: 'string', description: 'Comma-separated tiers to search: working,long_term,archived (default: working,long_term)' },
55
63
  },
56
64
  required: ['sessionId', 'query'],
57
65
  },
@@ -62,36 +70,128 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
62
70
  inputSchema: {
63
71
  type: 'object',
64
72
  properties: {
65
- sessionId: { type: 'string', description: 'Session identifier' },
66
- limit: { type: 'number', description: 'Max entries to return (default 20)', default: 20 },
73
+ sessionId: { type: 'string', description: 'Session identifier' },
74
+ limit: { type: 'number', description: 'Max entries (default 20)', default: 20 },
67
75
  },
68
76
  required: ['sessionId'],
69
77
  },
70
78
  },
71
79
  {
72
80
  name: 'forget',
73
- description: 'Delete memories for a session. Delete all, one by ID, or entries before a date.',
81
+ description: 'Delete session memories. Delete all, one by ID, or entries before a date.',
74
82
  inputSchema: {
75
83
  type: 'object',
76
84
  properties: {
77
- sessionId: { type: 'string', description: 'Session identifier' },
78
- id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
79
- before: { type: 'string', description: 'ISO date string — delete entries before this date (optional)' },
85
+ sessionId: { type: 'string', description: 'Session identifier' },
86
+ id: { type: 'string', description: 'Specific memory ID to delete (optional)' },
87
+ before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
80
88
  },
81
89
  required: ['sessionId'],
82
90
  },
83
91
  },
84
92
  {
85
93
  name: 'stats',
86
- description: 'Get memory statistics for a session.',
94
+ description: 'Memory statistics for a session — total, by role, by tier (working/long_term/archived), graph counts.',
87
95
  inputSchema: {
88
96
  type: 'object',
89
97
  properties: {
90
- sessionId: { type: 'string', description: 'Session identifier' },
98
+ sessionId: { type: 'string', description: 'Session identifier' },
91
99
  },
92
100
  required: ['sessionId'],
93
101
  },
94
102
  },
103
+ {
104
+ name: 'consolidate',
105
+ description: 'Consolidate old working memories into dense long-term summaries via LLM. Archives originals.',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ sessionId: { type: 'string', description: 'Session identifier' },
110
+ batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
111
+ keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
112
+ dryRun: { type: 'boolean', description: 'Preview summaries without writing (default false)' },
113
+ },
114
+ required: ['sessionId'],
115
+ },
116
+ },
117
+ {
118
+ name: 'graph',
119
+ description: 'Query the knowledge graph for an entity — returns relationships and source memories. Requires ENGRAM_GRAPH=1.',
120
+ inputSchema: {
121
+ type: 'object',
122
+ properties: {
123
+ sessionId: { type: 'string', description: 'Session identifier' },
124
+ entity: { type: 'string', description: 'Entity name to look up (case-insensitive)' },
125
+ },
126
+ required: ['sessionId', 'entity'],
127
+ },
128
+ },
129
+ // ── User-scoped (cross-session) memory ──────────────────────────────
130
+ {
131
+ name: 'remember_user',
132
+ description: 'Store a user-scoped memory that persists across all sessions. Use for preferences, identity, long-term facts.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: {
136
+ userId: { type: 'string', description: 'User identifier' },
137
+ content: { type: 'string', description: 'Memory content to store' },
138
+ role: { type: 'string', enum: ['user', 'assistant', 'system'], default: 'user' },
139
+ metadata: { type: 'object', description: 'Optional key-value metadata' },
140
+ },
141
+ required: ['userId', 'content'],
142
+ },
143
+ },
144
+ {
145
+ name: 'recall_user',
146
+ description: 'Recall user-scoped memories — works from any session context.',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ userId: { type: 'string', description: 'User identifier' },
151
+ query: { type: 'string', description: 'Search query (optional — returns all if omitted)' },
152
+ limit: { type: 'number', description: 'Max results (default 10)', default: 10 },
153
+ },
154
+ required: ['userId'],
155
+ },
156
+ },
157
+ {
158
+ name: 'forget_user',
159
+ description: 'Delete user-scoped memories.',
160
+ inputSchema: {
161
+ type: 'object',
162
+ properties: {
163
+ userId: { type: 'string', description: 'User identifier' },
164
+ id: { type: 'string', description: 'Specific memory ID (optional)' },
165
+ before: { type: 'string', description: 'ISO date — delete entries before this date (optional)' },
166
+ },
167
+ required: ['userId'],
168
+ },
169
+ },
170
+ {
171
+ name: 'consolidate_user',
172
+ description: 'Consolidate user-scoped working memories into long-term summaries.',
173
+ inputSchema: {
174
+ type: 'object',
175
+ properties: {
176
+ userId: { type: 'string', description: 'User identifier' },
177
+ batch: { type: 'number', description: 'Number of memories to consolidate (default 50)' },
178
+ keep: { type: 'number', description: 'Most recent N to leave untouched (default 20)' },
179
+ dryRun: { type: 'boolean', description: 'Preview without writing (default false)' },
180
+ },
181
+ required: ['userId'],
182
+ },
183
+ },
184
+ {
185
+ name: 'user_stats',
186
+ description: 'Memory statistics for a user — total, by role, by tier.',
187
+ inputSchema: {
188
+ type: 'object',
189
+ properties: {
190
+ userId: { type: 'string', description: 'User identifier' },
191
+ },
192
+ required: ['userId'],
193
+ },
194
+ },
95
195
  ],
96
196
  }));
97
197
 
@@ -100,26 +200,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
100
200
 
101
201
  try {
102
202
  switch (name) {
203
+
204
+ // ── Session ──────────────────────────────────────────────────────
205
+
103
206
  case 'remember': {
104
207
  const entry = await memory.remember(
105
208
  args.sessionId as string,
106
209
  args.content as string,
107
- (args.role as 'user' | 'assistant' | 'system') || 'user'
210
+ (args.role as 'user' | 'assistant' | 'system') || 'user',
211
+ args.metadata as Record<string, unknown> | undefined
108
212
  );
109
- return {
110
- content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }],
111
- };
213
+ return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
112
214
  }
113
215
 
114
216
  case 'recall': {
217
+ const tiers = args.tiers
218
+ ? (args.tiers as string).split(',').map(t => t.trim()) as any
219
+ : undefined;
115
220
  const entries = await memory.recall(
116
221
  args.sessionId as string,
117
222
  args.query as string,
118
- (args.limit as number) || 10
223
+ (args.limit as number) || 10,
224
+ {
225
+ tiers,
226
+ userId: args.userId as string | undefined,
227
+ }
119
228
  );
120
- return {
121
- content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
122
- };
229
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
123
230
  }
124
231
 
125
232
  case 'history': {
@@ -127,26 +234,80 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
127
234
  args.sessionId as string,
128
235
  (args.limit as number) || 20
129
236
  );
130
- return {
131
- content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }],
132
- };
237
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
133
238
  }
134
239
 
135
240
  case 'forget': {
136
- const options: { id?: string; before?: Date } = {};
137
- if (args.id) options.id = args.id as string;
138
- if (args.before) options.before = new Date(args.before as string);
139
- const deleted = await memory.forget(args.sessionId as string, options);
140
- return {
141
- content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }],
142
- };
241
+ const opts: Parameters<typeof memory.forget>[1] = {};
242
+ if (args.id) opts.id = args.id as string;
243
+ if (args.before) opts.before = new Date(args.before as string);
244
+ const deleted = await memory.forget(args.sessionId as string, opts);
245
+ return { content: [{ type: 'text', text: `Deleted ${deleted} memor${deleted === 1 ? 'y' : 'ies'}.` }] };
143
246
  }
144
247
 
145
248
  case 'stats': {
146
249
  const stats = await memory.stats(args.sessionId as string);
147
- return {
148
- content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }],
149
- };
250
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
251
+ }
252
+
253
+ case 'consolidate': {
254
+ const result = await memory.consolidate(args.sessionId as string, {
255
+ batch: args.batch as number | undefined,
256
+ keep: args.keep as number | undefined,
257
+ dryRun: args.dryRun as boolean | undefined,
258
+ });
259
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
260
+ }
261
+
262
+ case 'graph': {
263
+ const result = await memory.graph(
264
+ args.sessionId as string,
265
+ args.entity as string
266
+ );
267
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
268
+ }
269
+
270
+ // ── User-scoped ───────────────────────────────────────────────────
271
+
272
+ case 'remember_user': {
273
+ const entry = await memory.rememberUser(
274
+ args.userId as string,
275
+ args.content as string,
276
+ (args.role as 'user' | 'assistant' | 'system') || 'user',
277
+ args.metadata as Record<string, unknown> | undefined
278
+ );
279
+ return { content: [{ type: 'text', text: JSON.stringify(entry, null, 2) }] };
280
+ }
281
+
282
+ case 'recall_user': {
283
+ const entries = await memory.recallUser(
284
+ args.userId as string,
285
+ args.query as string | undefined,
286
+ (args.limit as number) || 10
287
+ );
288
+ return { content: [{ type: 'text', text: JSON.stringify(entries, null, 2) }] };
289
+ }
290
+
291
+ case 'forget_user': {
292
+ const opts: Parameters<typeof memory.forgetUser>[1] = {};
293
+ if (args.id) opts.id = args.id as string;
294
+ if (args.before) opts.before = new Date(args.before as string);
295
+ const deleted = await memory.forgetUser(args.userId as string, opts);
296
+ return { content: [{ type: 'text', text: `Deleted ${deleted} user memor${deleted === 1 ? 'y' : 'ies'}.` }] };
297
+ }
298
+
299
+ case 'consolidate_user': {
300
+ const result = await memory.consolidateUser(args.userId as string, {
301
+ batch: args.batch as number | undefined,
302
+ keep: args.keep as number | undefined,
303
+ dryRun: args.dryRun as boolean | undefined,
304
+ });
305
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
306
+ }
307
+
308
+ case 'user_stats': {
309
+ const stats = await memory.userStats(args.userId as string);
310
+ return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
150
311
  }
151
312
 
152
313
  default:
@@ -166,7 +327,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
166
327
  async function main() {
167
328
  const transport = new StdioServerTransport();
168
329
  await server.connect(transport);
169
- process.stderr.write('Engram MCP server running\n');
330
+ process.stderr.write('Engram MCP server v0.2.0 running\n');
170
331
  }
171
332
 
172
333
  main().catch((err) => {
@@ -0,0 +1,274 @@
1
+ /**
2
+ * MCP server tool coverage tests.
3
+ * We test the tool definitions (schema) and the handler dispatch logic
4
+ * by exercising the Engram SDK directly — the same code paths the MCP
5
+ * server uses. This avoids needing a running MCP transport in CI.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
9
+ import { Engram } from '@cartisien/engram';
10
+
11
+ // Mirror the handler logic so we can test every case without transport
12
+ async function dispatch(memory: Engram, name: string, args: Record<string, unknown>) {
13
+ switch (name) {
14
+ case 'remember':
15
+ return memory.remember(
16
+ args.sessionId as string,
17
+ args.content as string,
18
+ (args.role as any) || 'user',
19
+ args.metadata as any
20
+ );
21
+ case 'recall':
22
+ return memory.recall(
23
+ args.sessionId as string,
24
+ args.query as string,
25
+ (args.limit as number) || 10,
26
+ { tiers: args.tiers ? (args.tiers as string).split(',') as any : undefined,
27
+ userId: args.userId as string | undefined }
28
+ );
29
+ case 'history':
30
+ return memory.history(args.sessionId as string, (args.limit as number) || 20);
31
+ case 'forget': {
32
+ const opts: any = {};
33
+ if (args.id) opts.id = args.id;
34
+ if (args.before) opts.before = new Date(args.before as string);
35
+ return memory.forget(args.sessionId as string, opts);
36
+ }
37
+ case 'stats':
38
+ return memory.stats(args.sessionId as string);
39
+ case 'consolidate':
40
+ return memory.consolidate(args.sessionId as string, {
41
+ batch: args.batch as any, keep: args.keep as any, dryRun: args.dryRun as any
42
+ });
43
+ case 'graph':
44
+ return memory.graph(args.sessionId as string, args.entity as string);
45
+ case 'remember_user':
46
+ return memory.rememberUser(args.userId as string, args.content as string, (args.role as any) || 'user', args.metadata as any);
47
+ case 'recall_user':
48
+ return memory.recallUser(args.userId as string, args.query as string | undefined, (args.limit as number) || 10);
49
+ case 'forget_user': {
50
+ const opts: any = {};
51
+ if (args.id) opts.id = args.id;
52
+ if (args.before) opts.before = new Date(args.before as string);
53
+ return memory.forgetUser(args.userId as string, opts);
54
+ }
55
+ case 'consolidate_user':
56
+ return memory.consolidateUser(args.userId as string, {
57
+ batch: args.batch as any, keep: args.keep as any, dryRun: args.dryRun as any
58
+ });
59
+ case 'user_stats':
60
+ return memory.userStats(args.userId as string);
61
+ default:
62
+ throw new Error(`Unknown tool: ${name}`);
63
+ }
64
+ }
65
+
66
+ describe('MCP tool handlers — all 12 tools', () => {
67
+ let memory: Engram;
68
+
69
+ beforeEach(() => {
70
+ memory = new Engram({ dbPath: ':memory:', semanticSearch: false });
71
+ });
72
+ afterEach(async () => { await memory.close(); });
73
+
74
+ // ── remember ─────────────────────────────────────────────────────────────
75
+
76
+ it('remember: stores entry and returns id', async () => {
77
+ const entry: any = await dispatch(memory, 'remember', {
78
+ sessionId: 's1', content: 'Test fact', role: 'user'
79
+ });
80
+ expect(entry.id).toBeTruthy();
81
+ expect(entry.content).toBe('Test fact');
82
+ expect(entry.tier).toBe('working');
83
+ });
84
+
85
+ it('remember: stores with metadata', async () => {
86
+ const entry: any = await dispatch(memory, 'remember', {
87
+ sessionId: 's1', content: 'Fact with meta', metadata: { source: 'test' }
88
+ });
89
+ expect(entry.id).toBeTruthy();
90
+ });
91
+
92
+ // ── recall ────────────────────────────────────────────────────────────────
93
+
94
+ it('recall: returns stored memories', async () => {
95
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'GovScout uses React' });
96
+ const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'GovScout' });
97
+ expect(results.length).toBeGreaterThanOrEqual(1);
98
+ expect(results[0].content).toContain('GovScout');
99
+ });
100
+
101
+ it('recall: returns empty for unknown session', async () => {
102
+ const results: any = await dispatch(memory, 'recall', { sessionId: 'nobody', query: 'anything' });
103
+ expect(results).toEqual([]);
104
+ });
105
+
106
+ it('recall: blends user memories when userId provided', async () => {
107
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'Session fact' });
108
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'User fact' });
109
+ const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'fact', userId: 'u1' });
110
+ const contents = results.map((r: any) => r.content);
111
+ expect(contents).toContain('Session fact');
112
+ expect(contents).toContain('User fact');
113
+ });
114
+
115
+ it('recall: tier filter works', async () => {
116
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'Working tier fact' });
117
+ const results: any = await dispatch(memory, 'recall', {
118
+ sessionId: 's1', query: 'fact', tiers: 'working'
119
+ });
120
+ expect(results.every((r: any) => r.tier === 'working')).toBe(true);
121
+ });
122
+
123
+ // ── history ───────────────────────────────────────────────────────────────
124
+
125
+ it('history: returns chronological entries', async () => {
126
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'First' });
127
+ await new Promise(r => setTimeout(r, 5));
128
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'Second' });
129
+ const entries: any = await dispatch(memory, 'history', { sessionId: 's1' });
130
+ expect(entries[0].content).toBe('First');
131
+ expect(entries[1].content).toBe('Second');
132
+ });
133
+
134
+ // ── forget ────────────────────────────────────────────────────────────────
135
+
136
+ it('forget: deletes all session memories', async () => {
137
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'To delete' });
138
+ const deleted: any = await dispatch(memory, 'forget', { sessionId: 's1' });
139
+ expect(deleted).toBe(1);
140
+ const results: any = await dispatch(memory, 'recall', { sessionId: 's1', query: 'delete' });
141
+ expect(results).toEqual([]);
142
+ });
143
+
144
+ it('forget: deletes by id', async () => {
145
+ const e: any = await dispatch(memory, 'remember', { sessionId: 's1', content: 'Target' });
146
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'Keep' });
147
+ const deleted: any = await dispatch(memory, 'forget', { sessionId: 's1', id: e.id });
148
+ expect(deleted).toBe(1);
149
+ const left: any = await dispatch(memory, 'history', { sessionId: 's1' });
150
+ expect(left.map((r: any) => r.content)).not.toContain('Target');
151
+ });
152
+
153
+ // ── stats ─────────────────────────────────────────────────────────────────
154
+
155
+ it('stats: returns total, byRole, byTier', async () => {
156
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'f1', role: 'user' });
157
+ await dispatch(memory, 'remember', { sessionId: 's1', content: 'f2', role: 'assistant' });
158
+ const stats: any = await dispatch(memory, 'stats', { sessionId: 's1' });
159
+ expect(stats.total).toBe(2);
160
+ expect(stats.byRole.user).toBe(1);
161
+ expect(stats.byRole.assistant).toBe(1);
162
+ expect(stats.byTier.working).toBe(2);
163
+ });
164
+
165
+ // ── consolidate ───────────────────────────────────────────────────────────
166
+
167
+ it('consolidate: dry run returns summarized count', async () => {
168
+ const result: any = await dispatch(memory, 'consolidate', {
169
+ sessionId: 's1', dryRun: true
170
+ });
171
+ expect(typeof result.summarized).toBe('number');
172
+ expect(result.created).toBe(0);
173
+ });
174
+
175
+ it('consolidate: returns zeros for empty session', async () => {
176
+ const result: any = await dispatch(memory, 'consolidate', { sessionId: 'empty' });
177
+ expect(result.summarized).toBe(0);
178
+ expect(result.created).toBe(0);
179
+ expect(result.archived).toBe(0);
180
+ });
181
+
182
+ // ── graph ─────────────────────────────────────────────────────────────────
183
+
184
+ it('graph: returns entity + relationships + memories', async () => {
185
+ const result: any = await dispatch(memory, 'graph', {
186
+ sessionId: 's1', entity: 'govscout'
187
+ });
188
+ expect(result.entity).toBe('govscout');
189
+ expect(Array.isArray(result.relationships)).toBe(true);
190
+ expect(Array.isArray(result.relatedMemories)).toBe(true);
191
+ });
192
+
193
+ // ── remember_user ─────────────────────────────────────────────────────────
194
+
195
+ it('remember_user: stores and returns user entry', async () => {
196
+ const entry: any = await dispatch(memory, 'remember_user', {
197
+ userId: 'u1', content: 'Prefers TypeScript'
198
+ });
199
+ expect(entry.userId).toBe('u1');
200
+ expect(entry.content).toBe('Prefers TypeScript');
201
+ expect(entry.tier).toBe('working');
202
+ });
203
+
204
+ // ── recall_user ───────────────────────────────────────────────────────────
205
+
206
+ it('recall_user: retrieves user memories', async () => {
207
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Loves dark mode' });
208
+ const results: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
209
+ expect(results.length).toBe(1);
210
+ expect(results[0].content).toBe('Loves dark mode');
211
+ });
212
+
213
+ it('recall_user: isolated between users', async () => {
214
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'U1 fact' });
215
+ await dispatch(memory, 'remember_user', { userId: 'u2', content: 'U2 fact' });
216
+ const u1: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
217
+ expect(u1.map((r: any) => r.content)).not.toContain('U2 fact');
218
+ });
219
+
220
+ it('recall_user: empty for unknown user', async () => {
221
+ const results: any = await dispatch(memory, 'recall_user', { userId: 'nobody' });
222
+ expect(results).toEqual([]);
223
+ });
224
+
225
+ // ── forget_user ───────────────────────────────────────────────────────────
226
+
227
+ it('forget_user: deletes all user memories', async () => {
228
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'To delete' });
229
+ const deleted: any = await dispatch(memory, 'forget_user', { userId: 'u1' });
230
+ expect(deleted).toBe(1);
231
+ const left: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
232
+ expect(left).toEqual([]);
233
+ });
234
+
235
+ it('forget_user: deletes by id', async () => {
236
+ const e: any = await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Target' });
237
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'Keep' });
238
+ await dispatch(memory, 'forget_user', { userId: 'u1', id: e.id });
239
+ const left: any = await dispatch(memory, 'recall_user', { userId: 'u1' });
240
+ expect(left.map((r: any) => r.content)).not.toContain('Target');
241
+ expect(left.map((r: any) => r.content)).toContain('Keep');
242
+ });
243
+
244
+ // ── consolidate_user ──────────────────────────────────────────────────────
245
+
246
+ it('consolidate_user: dry run on empty user', async () => {
247
+ const result: any = await dispatch(memory, 'consolidate_user', {
248
+ userId: 'nobody', dryRun: true
249
+ });
250
+ expect(result.summarized).toBe(0);
251
+ });
252
+
253
+ // ── user_stats ────────────────────────────────────────────────────────────
254
+
255
+ it('user_stats: returns counts', async () => {
256
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'f1', role: 'user' });
257
+ await dispatch(memory, 'remember_user', { userId: 'u1', content: 'f2', role: 'assistant' });
258
+ const stats: any = await dispatch(memory, 'user_stats', { userId: 'u1' });
259
+ expect(stats.total).toBe(2);
260
+ expect(stats.byTier.working).toBe(2);
261
+ expect(stats.byRole.user).toBe(1);
262
+ });
263
+
264
+ it('user_stats: zeros for unknown user', async () => {
265
+ const stats: any = await dispatch(memory, 'user_stats', { userId: 'nobody' });
266
+ expect(stats.total).toBe(0);
267
+ });
268
+
269
+ // ── unknown tool ──────────────────────────────────────────────────────────
270
+
271
+ it('unknown tool: throws', async () => {
272
+ await expect(dispatch(memory, 'nonexistent', {})).rejects.toThrow('Unknown tool');
273
+ });
274
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": { "rootDir": "." },
4
+ "include": ["src/**/*", "tests/**/*"]
5
+ }