mcp-memory-keeper 0.12.2 → 0.14.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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-06-05
11
+
12
+ ### Added
13
+
14
+ - **Checkpoints now survive an export → import round trip** (#37, follow-up to #36)
15
+ - `context_export` writes a new `0.5.0` format that includes the `checkpointItems` and `checkpointFiles` join rows (previously only the bare `checkpoints` rows were exported, which made imported checkpoints empty/useless).
16
+ - `context_import` restores checkpoints and rewires their links: each checkpoint gets a fresh id, and its item/file links are remapped onto the freshly-imported context-item and file-cache ids. Links pointing at a skipped or absent row are dropped rather than aborting the import. The whole restore happens inside the existing atomic transaction.
17
+ - The import summary now reports `Checkpoints: N (skipped M malformed), links restored: K`.
18
+ - **Backward compatible:** importing a pre-`0.5.0` export (no join-row arrays) does not error and reports the checkpoints as not imported, preserving the prior explicit-not-imported behaviour.
19
+ - The entry-count cap from #36 now also bounds checkpoint and join-row arrays.
20
+ - **Merge safety:** on a key/path collision during `merge` import, the existing row is now UPDATED in place (its id preserved) instead of `INSERT OR REPLACE`, so a pre-existing checkpoint's links to that row are no longer cascade-deleted. Both `context_items` and `file_cache` (which have UNIQUE constraints) are handled this way.
21
+ - **Fidelity & observability:** `file_cache.size` is restored on import; duplicate-key/path rows that collapse during import are reported (`collapsed N duplicate-key`); dropped dangling checkpoint links are surfaced (`links restored: K (dropped N dangling)`); and `metadata.totalSize` now accounts for the checkpoint join arrays.
22
+
23
+ ## [0.13.0] - 2026-06-05
24
+
25
+ ### Fixed
26
+
27
+ - Server-reported version is now read from `package.json` at runtime instead of a hardcoded string, which had drifted to `0.10.0`. Added an E2E test asserting the reported version matches `package.json` so it can't fall out of sync again.
28
+
29
+ ### Security
30
+
31
+ - **Arbitrary local file read via unvalidated `context_import` filePath** (#35)
32
+ - `context_import` passed the caller-supplied `filePath` straight to `fs.readFileSync`, with no path confinement. A malicious MCP client — or a prompt-injected agent — could point it at any file the server process could read: full contents for any JSON file (imported into the session, then retrievable via `context_get`/`context_export`), and the leading bytes of any other file (echoed back inside the `JSON.parse` error message)
33
+ - Imports are now confined to a server-owned exports directory (`<DATA_DIR>/exports`, overridable via `MEMORY_KEEPER_EXPORT_DIR`). Relative paths resolve against that directory; absolute paths are accepted only if they resolve — after following symlinks via `realpath` — to a location inside it. Traversal (`../`) and absolute paths outside the directory are rejected
34
+ - `context_export` now writes to that same exports directory (previously the OS temp dir), so the export → import round trip stays within the confined location
35
+ - Import failures no longer echo raw exception messages — at every stage (read, JSON parse, and the database write) a generic message is returned and the detail is logged server-side, so no file bytes or inserted values can leak back to the caller
36
+ - Import is hardened further: the resolved path must be a regular file (directories are rejected), capped at 50 MB and 100k entries to avoid memory/CPU exhaustion, and the whole import runs in a single transaction so a malformed row can never leave an orphaned or partially-populated session. Per-item and session-name shape are validated; `merge` only merges when a current session actually exists (and the result is reported honestly)
37
+ - The "not found" and "outside the exports directory" cases now return an identical generic message, removing a file-existence oracle, and the resolved absolute path is never echoed back to the caller
38
+ - `context_export`'s tool description and the import path resolution now guard the exports-directory startup (graceful FATAL on an unreadable directory)
39
+ - **Round-trip fidelity fixes** surfaced by review of the above: imported items now restore their `channel`, `is_private`, and `metadata` columns (previously dropped); file-cache rows with `NULL` content are preserved (previously dropped); a stored `size` of `0` is no longer wrongly recomputed; skipped-malformed counts and "checkpoints present but not imported" are reported instead of being silently lost; and `context_export` warns when a produced file is too large to be re-imported. The new current session is published only after the import transaction commits, so a failed import can no longer leave `currentSessionId` pointing at a rolled-back session
40
+ - Added an E2E security regression test suite that reproduces the issue #35 PoC (arbitrary JSON read, `/etc/passwd` byte leak, `..` traversal) and covers symlink escape, the `exports-dir`-prefix sibling boundary, the no-existence-oracle guarantee, empty/non-string paths, directory paths, valid-JSON-but-not-an-export, malformed-item skipping, and a data-preserving round trip
41
+ - Reported by Zhihao Zhang (@mcfly-zzh)
42
+
10
43
  ## [0.12.2] - 2026-04-07
11
44
 
12
45
  ### Fixed
package/README.md CHANGED
@@ -814,7 +814,8 @@ mcp_context_search({
814
814
  Share context or backup your work:
815
815
 
816
816
  ```javascript
817
- // Export current session
817
+ // Export current session — writes into the exports directory
818
+ // (<DATA_DIR>/exports/, overridable via MEMORY_KEEPER_EXPORT_DIR)
818
819
  mcp_context_export(); // Creates memory-keeper-export-xxx.json
819
820
 
820
821
  // Export specific session
@@ -823,7 +824,9 @@ mcp_context_export({
823
824
  format: 'json',
824
825
  });
825
826
 
826
- // Import from file
827
+ // Import from a file inside the exports directory.
828
+ // A bare filename resolves against that directory; the absolute path
829
+ // returned by context_export also works.
827
830
  mcp_context_import({
828
831
  filePath: 'memory-keeper-export-xxx.json',
829
832
  });
@@ -835,6 +838,17 @@ mcp_context_import({
835
838
  });
836
839
  ```
837
840
 
841
+ > **Security note:** For safety, `context_import` only reads files inside the
842
+ > server-owned exports directory (`<DATA_DIR>/exports/`, or
843
+ > `MEMORY_KEEPER_EXPORT_DIR` if set). Absolute paths outside that directory and
844
+ > `../` traversal are rejected, so the tool cannot be steered at arbitrary
845
+ > files on disk. Drop any file you want to import into that directory first.
846
+ > Point `MEMORY_KEEPER_EXPORT_DIR` at a dedicated directory — not at a home
847
+ > folder or a tree containing secrets — since any JSON file inside it becomes
848
+ > importable. (Prior to this change, exports were written to the OS temp
849
+ > directory; existing exports there must be moved into the exports directory to
850
+ > be re-imported.)
851
+
838
852
  ### Knowledge Graph (Phase 4)
839
853
 
840
854
  Automatically extract entities and relationships from your context:
@@ -0,0 +1,303 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const globals_1 = require("@jest/globals");
37
+ const child_process_1 = require("child_process");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ /**
42
+ * E2E tests for GitHub issue #37: checkpoints (and their checkpoint_items /
43
+ * checkpoint_files join rows) must survive a context_export -> context_import
44
+ * round trip.
45
+ *
46
+ * Before the fix, exports carried the `checkpoints` rows but not the join rows,
47
+ * and import restored neither — so a re-imported session lost its checkpoints.
48
+ * The 0.5.0 export format adds `checkpointItems` / `checkpointFiles`, and import
49
+ * rewires their foreign keys onto the freshly-generated ids.
50
+ *
51
+ * The round-trip test imports into a SEPARATE server instance (its own
52
+ * DATA_DIR), so the only checkpoint named "cp-roundtrip" is the imported one —
53
+ * otherwise context_restore_checkpoint (which matches by name across all
54
+ * checkpoints) could resolve the original source checkpoint and mask a broken
55
+ * import.
56
+ *
57
+ * @see https://github.com/mkreyman/mcp-memory-keeper/issues/37
58
+ */
59
+ const SERVER_ENTRY = path.join(__dirname, '../../../dist/index.js');
60
+ async function startServer() {
61
+ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-checkpoint-'));
62
+ const exportsDir = path.join(dataDir, 'exports');
63
+ const proc = (0, child_process_1.spawn)('node', [SERVER_ENTRY], {
64
+ env: { ...process.env, DATA_DIR: dataDir },
65
+ stdio: ['pipe', 'pipe', 'pipe'],
66
+ });
67
+ if (global.testProcesses) {
68
+ global.testProcesses.push(proc);
69
+ }
70
+ // A single persistent reader dispatches each response to its pending request
71
+ // by id, so concurrent calls cannot share/consume each other's buffer.
72
+ const pending = new Map();
73
+ let buffer = '';
74
+ let nextId = 0;
75
+ proc.stdout?.on('data', (data) => {
76
+ buffer += data.toString();
77
+ const lines = buffer.split('\n');
78
+ buffer = lines.pop() || '';
79
+ for (const line of lines) {
80
+ if (!line.trim())
81
+ continue;
82
+ let msg;
83
+ try {
84
+ msg = JSON.parse(line);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (msg.id != null && pending.has(msg.id)) {
90
+ const p = pending.get(msg.id);
91
+ clearTimeout(p.timer);
92
+ pending.delete(msg.id);
93
+ p.resolve(msg);
94
+ }
95
+ }
96
+ });
97
+ const send = (method, params) => new Promise((resolve, reject) => {
98
+ const reqId = ++nextId;
99
+ const timer = setTimeout(() => {
100
+ pending.delete(reqId);
101
+ reject(new Error(`Timeout waiting for ${method} (id=${reqId})`));
102
+ }, 5000);
103
+ pending.set(reqId, { resolve, timer });
104
+ proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params, id: reqId }) + '\n');
105
+ });
106
+ const init = await send('initialize', {
107
+ protocolVersion: '2024-11-05',
108
+ capabilities: {},
109
+ clientInfo: { name: 'checkpoint-e2e', version: '1.0.0' },
110
+ });
111
+ (0, globals_1.expect)(init.result).toHaveProperty('protocolVersion');
112
+ proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
113
+ await new Promise(resolve => setTimeout(resolve, 150));
114
+ const call = async (name, args) => {
115
+ const res = await send('tools/call', { name, arguments: args });
116
+ return res.result?.content?.[0]?.text ?? JSON.stringify(res);
117
+ };
118
+ const close = async () => {
119
+ for (const p of pending.values())
120
+ clearTimeout(p.timer);
121
+ pending.clear();
122
+ if (!proc.killed) {
123
+ proc.kill('SIGTERM');
124
+ await new Promise(resolve => {
125
+ const t = setTimeout(() => {
126
+ proc.kill('SIGKILL');
127
+ resolve();
128
+ }, 3000);
129
+ proc.on('exit', () => {
130
+ clearTimeout(t);
131
+ resolve();
132
+ });
133
+ });
134
+ proc.removeAllListeners();
135
+ }
136
+ try {
137
+ fs.rmSync(dataDir, { recursive: true, force: true });
138
+ }
139
+ catch {
140
+ // ignore
141
+ }
142
+ };
143
+ return { proc, dataDir, exportsDir, call, close };
144
+ }
145
+ /** Pull the export file path out of a context_export success message. */
146
+ function parseExportPath(exportText) {
147
+ const match = exportText.match(/to:\s*(.+?\.json)/);
148
+ if (!match)
149
+ throw new Error(`Could not parse export path from: ${exportText}`);
150
+ return match[1];
151
+ }
152
+ (0, globals_1.describe)('E2E: checkpoint export/import round trip (issue #37)', () => {
153
+ let server;
154
+ (0, globals_1.beforeAll)(async () => {
155
+ server = await startServer();
156
+ }, 15000);
157
+ (0, globals_1.afterAll)(async () => {
158
+ await server?.close();
159
+ });
160
+ (0, globals_1.it)('restores a checkpoint and its linked items + files into a clean instance', async () => {
161
+ // --- Source instance: seed, checkpoint, export ---
162
+ await server.call('context_session_start', { name: 'cp-source' });
163
+ await server.call('context_save', { key: 'cp_item_a', value: 'value_a', category: 'task' });
164
+ await server.call('context_save', { key: 'cp_item_b', value: 'value_b', category: 'note' });
165
+ await server.call('context_cache_file', {
166
+ filePath: '/tmp/checkpoint-test.ts',
167
+ content: 'cached file content',
168
+ });
169
+ const cpText = await server.call('context_checkpoint', {
170
+ name: 'cp-roundtrip',
171
+ description: 'round trip checkpoint',
172
+ includeFiles: true,
173
+ includeGitStatus: false,
174
+ });
175
+ (0, globals_1.expect)(cpText).toMatch(/Created checkpoint/i);
176
+ (0, globals_1.expect)(cpText).toMatch(/Context items:\s*2\b/);
177
+ const exportText = await server.call('context_export', { confirmEmpty: true });
178
+ const exportPath = parseExportPath(exportText);
179
+ const exportBytes = fs.readFileSync(exportPath);
180
+ // --- Fresh instance: import the file, so the imported checkpoint is the
181
+ // ONLY one named "cp-roundtrip" (unambiguous restore-by-name). ---
182
+ const importer = await startServer();
183
+ try {
184
+ fs.mkdirSync(importer.exportsDir, { recursive: true });
185
+ const importFile = path.join(importer.exportsDir, 'incoming.json');
186
+ fs.writeFileSync(importFile, exportBytes);
187
+ const importText = await importer.call('context_import', { filePath: 'incoming.json' });
188
+ (0, globals_1.expect)(importText).toMatch(/Import successful/i);
189
+ (0, globals_1.expect)(importText).toMatch(/Context items:\s*2\b/);
190
+ // 2 item links + 1 file link = 3, none dropped.
191
+ (0, globals_1.expect)(importText).toMatch(/Checkpoints:\s*1, links restored:\s*3$/m);
192
+ // End-to-end proof: restoring reproduces the items AND the file, which can
193
+ // only be true if the join rows were rewired onto the freshly-imported
194
+ // rows in THIS instance.
195
+ const restoreText = await importer.call('context_restore_checkpoint', {
196
+ name: 'cp-roundtrip',
197
+ restoreFiles: true,
198
+ });
199
+ (0, globals_1.expect)(restoreText).toMatch(/Successfully restored from checkpoint/i);
200
+ (0, globals_1.expect)(restoreText).toMatch(/Context items:\s*2\b/);
201
+ (0, globals_1.expect)(restoreText).toMatch(/Files:\s*1\b/);
202
+ }
203
+ finally {
204
+ await importer.close();
205
+ }
206
+ }, 20000);
207
+ (0, globals_1.it)('reports legacy exports (no checkpointItems) as not imported, without error', async () => {
208
+ const legacy = path.join(server.exportsDir, 'legacy-export.json');
209
+ fs.mkdirSync(server.exportsDir, { recursive: true });
210
+ fs.writeFileSync(legacy, JSON.stringify({
211
+ version: '0.4.0',
212
+ session: { name: 'legacy', branch: 'main' },
213
+ contextItems: [{ id: 'old-1', key: 'legacy_key', value: 'legacy_value' }],
214
+ fileCache: [],
215
+ checkpoints: [{ id: 'old-cp', session_id: 'old-sess', name: 'legacy-cp' }],
216
+ // no checkpointItems / checkpointFiles keys
217
+ }));
218
+ try {
219
+ const text = await server.call('context_import', { filePath: 'legacy-export.json' });
220
+ (0, globals_1.expect)(text).toMatch(/Import successful/i);
221
+ (0, globals_1.expect)(text).toMatch(/Context items:\s*1\b/);
222
+ (0, globals_1.expect)(text).toMatch(/Checkpoints in file:\s*1 \(not imported/i);
223
+ }
224
+ finally {
225
+ fs.rmSync(legacy, { force: true });
226
+ }
227
+ });
228
+ (0, globals_1.it)('drops checkpoint links that point at skipped/absent rows and proves DB state', async () => {
229
+ // 0.5.0 export with a checkpoint whose item link references an id that is
230
+ // not present in contextItems — the checkpoint imports, the dangling link is
231
+ // dropped, and a subsequent restore yields exactly the one valid item.
232
+ const partial = path.join(server.exportsDir, 'partial-cp.json');
233
+ fs.mkdirSync(server.exportsDir, { recursive: true });
234
+ fs.writeFileSync(partial, JSON.stringify({
235
+ version: '0.5.0',
236
+ session: { name: 'partial', branch: 'main' },
237
+ contextItems: [{ id: 'item-1', key: 'k1', value: 'v1' }],
238
+ fileCache: [],
239
+ checkpoints: [{ id: 'cp-1', session_id: 's', name: 'partial-cp' }],
240
+ checkpointItems: [
241
+ { id: 'l1', checkpoint_id: 'cp-1', context_item_id: 'item-1' }, // valid
242
+ { id: 'l2', checkpoint_id: 'cp-1', context_item_id: 'missing' }, // dangling
243
+ ],
244
+ checkpointFiles: [],
245
+ }));
246
+ try {
247
+ const text = await server.call('context_import', { filePath: 'partial-cp.json' });
248
+ (0, globals_1.expect)(text).toMatch(/Import successful/i);
249
+ // 1 checkpoint, only the 1 valid link restored, 1 dangling link dropped.
250
+ (0, globals_1.expect)(text).toMatch(/Checkpoints:\s*1, links restored:\s*1 \(dropped 1 dangling\)/);
251
+ // "partial-cp" is unique here, so restore-by-name is unambiguous.
252
+ const restoreText = await server.call('context_restore_checkpoint', {
253
+ name: 'partial-cp',
254
+ restoreFiles: true,
255
+ });
256
+ (0, globals_1.expect)(restoreText).toMatch(/Context items:\s*1\b/); // not 2 — dangling link was dropped
257
+ }
258
+ finally {
259
+ fs.rmSync(partial, { force: true });
260
+ }
261
+ });
262
+ (0, globals_1.it)('merge import updates a colliding item in place and preserves a pre-existing checkpoint link', async () => {
263
+ // Regression guard: a merge import whose item key collides with an existing
264
+ // row must UPDATE in place (preserving the row id) rather than INSERT OR
265
+ // REPLACE. REPLACE would cascade-delete the existing checkpoint's link.
266
+ await server.call('context_session_start', { name: 'merge-target' });
267
+ await server.call('context_save', { key: 'shared_key', value: 'original', category: 'task' });
268
+ const cp = await server.call('context_checkpoint', {
269
+ name: 'pre-merge-cp',
270
+ includeFiles: false,
271
+ includeGitStatus: false,
272
+ });
273
+ (0, globals_1.expect)(cp).toMatch(/Context items:\s*1\b/);
274
+ const mergeFile = path.join(server.exportsDir, 'merge-in.json');
275
+ fs.mkdirSync(server.exportsDir, { recursive: true });
276
+ fs.writeFileSync(mergeFile, JSON.stringify({
277
+ version: '0.5.0',
278
+ session: { name: 'incoming', branch: 'main' },
279
+ contextItems: [{ id: 'x', key: 'shared_key', value: 'updated' }],
280
+ fileCache: [],
281
+ checkpoints: [],
282
+ checkpointItems: [],
283
+ checkpointFiles: [],
284
+ }));
285
+ try {
286
+ const imp = await server.call('context_import', { filePath: 'merge-in.json', merge: true });
287
+ (0, globals_1.expect)(imp).toMatch(/Mode:\s*Merged/);
288
+ // The colliding item was updated in place (value changed).
289
+ const got = await server.call('context_get', { key: 'shared_key' });
290
+ (0, globals_1.expect)(got).toContain('updated');
291
+ // The pre-existing checkpoint still restores its 1 item — the link
292
+ // survived because the row id was preserved (no REPLACE-cascade).
293
+ const restore = await server.call('context_restore_checkpoint', {
294
+ name: 'pre-merge-cp',
295
+ restoreFiles: false,
296
+ });
297
+ (0, globals_1.expect)(restore).toMatch(/Context items:\s*1\b/);
298
+ }
299
+ finally {
300
+ fs.rmSync(mergeFile, { force: true });
301
+ }
302
+ });
303
+ });