mcp-memory-keeper 0.12.2 → 0.13.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 +20 -0
- package/README.md +16 -2
- package/dist/__tests__/e2e/import-path-confinement.test.js +329 -0
- package/dist/__tests__/e2e/issue33-reproduce.test.js +234 -0
- package/dist/index.js +265 -46
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.13.0] - 2026-06-05
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
### Security
|
|
17
|
+
|
|
18
|
+
- **Arbitrary local file read via unvalidated `context_import` filePath** (#35)
|
|
19
|
+
- `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)
|
|
20
|
+
- 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
|
|
21
|
+
- `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
|
|
22
|
+
- 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
|
|
23
|
+
- 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)
|
|
24
|
+
- 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
|
|
25
|
+
- `context_export`'s tool description and the import path resolution now guard the exports-directory startup (graceful FATAL on an unreadable directory)
|
|
26
|
+
- **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
|
|
27
|
+
- 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
|
|
28
|
+
- Reported by Zhihao Zhang (@mcfly-zzh)
|
|
29
|
+
|
|
10
30
|
## [0.12.2] - 2026-04-07
|
|
11
31
|
|
|
12
32
|
### 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,329 @@
|
|
|
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
|
+
* Security regression tests for GitHub issue #35:
|
|
43
|
+
* "Arbitrary Local File Read via Unvalidated context_import.filePath".
|
|
44
|
+
*
|
|
45
|
+
* Before the fix, context_import passed the caller-supplied filePath straight
|
|
46
|
+
* to fs.readFileSync, so an MCP client (or a prompt-injected agent) could read
|
|
47
|
+
* any file the server process could read — full contents for any JSON file, and
|
|
48
|
+
* the leading bytes of any other file (echoed back inside the JSON.parse error).
|
|
49
|
+
*
|
|
50
|
+
* These tests spawn the real server over stdio and assert that:
|
|
51
|
+
* 1. Absolute paths outside the exports directory are rejected.
|
|
52
|
+
* 2. `..` traversal is rejected.
|
|
53
|
+
* 3. Non-JSON system files (e.g. /etc/passwd) are rejected without leaking
|
|
54
|
+
* any of their bytes in the error message.
|
|
55
|
+
* 4. The secret in a rejected JSON file never becomes retrievable.
|
|
56
|
+
* 5. The legitimate export -> import round trip (inside the exports dir)
|
|
57
|
+
* still works.
|
|
58
|
+
*
|
|
59
|
+
* @see https://github.com/mkreyman/mcp-memory-keeper/issues/35
|
|
60
|
+
*/
|
|
61
|
+
let serverProcess = null;
|
|
62
|
+
let serverInfo;
|
|
63
|
+
let tempDir;
|
|
64
|
+
let exportsDir;
|
|
65
|
+
let victimPath;
|
|
66
|
+
let msgId = 0;
|
|
67
|
+
let outputBuffer = '';
|
|
68
|
+
const SECRET = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY';
|
|
69
|
+
const SECRET_KEY = 'AWS_SECRET_ACCESS_KEY';
|
|
70
|
+
function sendRequest(method, params = {}, timeoutMs = 5000) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const id = ++msgId;
|
|
73
|
+
const timeout = setTimeout(() => {
|
|
74
|
+
reject(new Error(`Timeout waiting for response to ${method} (id=${id})`));
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
const onData = (data) => {
|
|
77
|
+
outputBuffer += data.toString();
|
|
78
|
+
const lines = outputBuffer.split('\n');
|
|
79
|
+
outputBuffer = lines.pop() || '';
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (!line.trim())
|
|
82
|
+
continue;
|
|
83
|
+
try {
|
|
84
|
+
const msg = JSON.parse(line);
|
|
85
|
+
if (msg.id === id) {
|
|
86
|
+
clearTimeout(timeout);
|
|
87
|
+
serverProcess?.stdout?.removeListener('data', onData);
|
|
88
|
+
resolve(msg);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// Not JSON, skip
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
serverProcess?.stdout?.on('data', onData);
|
|
97
|
+
serverProcess?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n');
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
function callTool(name, args) {
|
|
101
|
+
return sendRequest('tools/call', { name, arguments: args }).then(res => res.result?.content?.[0]?.text ?? JSON.stringify(res));
|
|
102
|
+
}
|
|
103
|
+
(0, globals_1.describe)('Security: context_import path confinement (issue #35)', () => {
|
|
104
|
+
(0, globals_1.beforeAll)(async () => {
|
|
105
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-import-sec-'));
|
|
106
|
+
// Default exports dir is <DATA_DIR>/exports.
|
|
107
|
+
exportsDir = path.join(tempDir, 'exports');
|
|
108
|
+
// A "victim" JSON file OUTSIDE the exports directory (but inside the data
|
|
109
|
+
// dir) — proves confinement is to the exports dir, not just the data dir.
|
|
110
|
+
victimPath = path.join(tempDir, 'victim_export.json');
|
|
111
|
+
fs.writeFileSync(victimPath, JSON.stringify({
|
|
112
|
+
session: { name: 'victims-private-session', branch: 'main' },
|
|
113
|
+
contextItems: [
|
|
114
|
+
{
|
|
115
|
+
key: SECRET_KEY,
|
|
116
|
+
value: SECRET,
|
|
117
|
+
category: 'secret',
|
|
118
|
+
priority: 'high',
|
|
119
|
+
created_at: new Date(0).toISOString(),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
fileCache: [],
|
|
123
|
+
}));
|
|
124
|
+
serverProcess = (0, child_process_1.spawn)('node', [path.join(__dirname, '../../../dist/index.js')], {
|
|
125
|
+
env: { ...process.env, DATA_DIR: tempDir },
|
|
126
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
127
|
+
});
|
|
128
|
+
if (global.testProcesses) {
|
|
129
|
+
global.testProcesses.push(serverProcess);
|
|
130
|
+
}
|
|
131
|
+
const initResponse = await sendRequest('initialize', {
|
|
132
|
+
protocolVersion: '2024-11-05',
|
|
133
|
+
capabilities: {},
|
|
134
|
+
clientInfo: { name: 'import-sec-test', version: '1.0.0' },
|
|
135
|
+
});
|
|
136
|
+
(0, globals_1.expect)(initResponse.result).toHaveProperty('protocolVersion');
|
|
137
|
+
serverInfo = initResponse.result.serverInfo;
|
|
138
|
+
serverProcess?.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
139
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
140
|
+
}, 10000);
|
|
141
|
+
(0, globals_1.afterAll)(async () => {
|
|
142
|
+
if (serverProcess && !serverProcess.killed) {
|
|
143
|
+
serverProcess.kill('SIGTERM');
|
|
144
|
+
await new Promise(resolve => {
|
|
145
|
+
const timeout = setTimeout(() => {
|
|
146
|
+
serverProcess?.kill('SIGKILL');
|
|
147
|
+
resolve();
|
|
148
|
+
}, 3000);
|
|
149
|
+
serverProcess?.on('exit', () => {
|
|
150
|
+
clearTimeout(timeout);
|
|
151
|
+
resolve();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
serverProcess?.removeAllListeners();
|
|
155
|
+
}
|
|
156
|
+
serverProcess = null;
|
|
157
|
+
try {
|
|
158
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
// ignore cleanup errors
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
(0, globals_1.it)('reports its version from package.json (no drift)', () => {
|
|
165
|
+
// The server version is read from package.json at runtime; this guards
|
|
166
|
+
// against the hardcoded value silently falling out of sync on a release.
|
|
167
|
+
const pkgVersion = require('../../../package.json').version;
|
|
168
|
+
(0, globals_1.expect)(serverInfo?.name).toBe('memory-keeper');
|
|
169
|
+
(0, globals_1.expect)(serverInfo?.version).toBe(pkgVersion);
|
|
170
|
+
});
|
|
171
|
+
(0, globals_1.it)('rejects an absolute path to a JSON file outside the exports directory', async () => {
|
|
172
|
+
const text = await callTool('context_import', { filePath: victimPath });
|
|
173
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
174
|
+
(0, globals_1.expect)(text).toMatch(/not found in the exports directory/i);
|
|
175
|
+
// The secret must never appear in the response.
|
|
176
|
+
(0, globals_1.expect)(text).not.toContain(SECRET);
|
|
177
|
+
});
|
|
178
|
+
(0, globals_1.it)('returns the same message for a missing path and an existing outside path (no existence oracle)', async () => {
|
|
179
|
+
// A file that exists outside the exports dir and a path that does not exist
|
|
180
|
+
// at all must be indistinguishable, so the error cannot be used to probe
|
|
181
|
+
// for the existence of arbitrary files on the host.
|
|
182
|
+
const missing = await callTool('context_import', { filePath: '/no/such/file/anywhere.json' });
|
|
183
|
+
const existsOutside = await callTool('context_import', { filePath: victimPath });
|
|
184
|
+
const strip = (t) => t.replace(/^Import failed:\s*/i, '').trim();
|
|
185
|
+
(0, globals_1.expect)(strip(existsOutside)).toBe(strip(missing));
|
|
186
|
+
// And neither echoes the resolved exports-directory absolute path.
|
|
187
|
+
(0, globals_1.expect)(existsOutside).not.toContain(exportsDir);
|
|
188
|
+
});
|
|
189
|
+
(0, globals_1.it)('does not make the rejected file’s secret retrievable via context_get', async () => {
|
|
190
|
+
const text = await callTool('context_get', { key: SECRET_KEY });
|
|
191
|
+
(0, globals_1.expect)(text).not.toContain(SECRET);
|
|
192
|
+
});
|
|
193
|
+
// Use an explicit conditional skip (not a bare `return`) so the platform gap
|
|
194
|
+
// is visible in the test report on systems without /etc/passwd.
|
|
195
|
+
const hasEtcPasswd = fs.existsSync('/etc/passwd');
|
|
196
|
+
(hasEtcPasswd ? globals_1.it : globals_1.it.skip)('rejects a non-JSON system file without leaking its bytes', async () => {
|
|
197
|
+
const text = await callTool('context_import', { filePath: '/etc/passwd' });
|
|
198
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
199
|
+
// The classic leak was "Unexpected token 'r', "root:x:0:0"...". Ensure no
|
|
200
|
+
// file bytes are reflected back.
|
|
201
|
+
(0, globals_1.expect)(text).not.toMatch(/root:/);
|
|
202
|
+
(0, globals_1.expect)(text).not.toMatch(/Unexpected token/);
|
|
203
|
+
});
|
|
204
|
+
(0, globals_1.it)('rejects `..` traversal out of the exports directory', async () => {
|
|
205
|
+
const text = await callTool('context_import', {
|
|
206
|
+
filePath: '../../../../../../etc/hostname',
|
|
207
|
+
});
|
|
208
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
209
|
+
(0, globals_1.expect)(text).not.toMatch(/Unexpected token/);
|
|
210
|
+
});
|
|
211
|
+
(0, globals_1.it)('rejects a symlink inside the exports dir that points outside it', async () => {
|
|
212
|
+
// realpathSync must resolve the symlink to its target before the
|
|
213
|
+
// confinement check, so an inside-the-dir symlink cannot escape.
|
|
214
|
+
const linkPath = path.join(exportsDir, 'escape.json');
|
|
215
|
+
try {
|
|
216
|
+
fs.symlinkSync(victimPath, linkPath);
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
// Only treat a genuine platform restriction as "skip"; surface anything else.
|
|
220
|
+
const code = e.code;
|
|
221
|
+
if (code === 'EPERM' || code === 'ENOSYS' || code === 'EACCES') {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const text = await callTool('context_import', { filePath: 'escape.json' });
|
|
228
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
229
|
+
(0, globals_1.expect)(text).toMatch(/not found in the exports directory/i);
|
|
230
|
+
(0, globals_1.expect)(text).not.toContain(SECRET);
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
fs.unlinkSync(linkPath);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
(0, globals_1.it)('rejects a sibling directory whose name shares the exports-dir prefix', async () => {
|
|
237
|
+
// Guards the `startsWith(exportsDirReal + path.sep)` boundary: a sibling
|
|
238
|
+
// like "<exportsDir>-evil" must NOT be treated as inside the exports dir.
|
|
239
|
+
const evilDir = `${exportsDir}-evil`;
|
|
240
|
+
fs.mkdirSync(evilDir, { recursive: true });
|
|
241
|
+
const evilFile = path.join(evilDir, 'data.json');
|
|
242
|
+
fs.writeFileSync(evilFile, JSON.stringify({ session: { name: 'x' }, contextItems: [] }));
|
|
243
|
+
try {
|
|
244
|
+
const text = await callTool('context_import', { filePath: evilFile });
|
|
245
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
246
|
+
(0, globals_1.expect)(text).toMatch(/not found in the exports directory/i);
|
|
247
|
+
}
|
|
248
|
+
finally {
|
|
249
|
+
fs.rmSync(evilDir, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
(0, globals_1.it)('rejects an empty filePath', async () => {
|
|
253
|
+
const text = await callTool('context_import', { filePath: '' });
|
|
254
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
255
|
+
(0, globals_1.expect)(text).toMatch(/non-empty string/i);
|
|
256
|
+
});
|
|
257
|
+
(0, globals_1.it)('rejects a non-string filePath', async () => {
|
|
258
|
+
const text = await callTool('context_import', { filePath: 12345 });
|
|
259
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
260
|
+
(0, globals_1.expect)(text).toMatch(/non-empty string/i);
|
|
261
|
+
});
|
|
262
|
+
(0, globals_1.it)('rejects the exports directory itself (not a regular file)', async () => {
|
|
263
|
+
const text = await callTool('context_import', { filePath: exportsDir });
|
|
264
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
265
|
+
(0, globals_1.expect)(text).toMatch(/regular file/i);
|
|
266
|
+
});
|
|
267
|
+
(0, globals_1.it)('rejects a confined file that is valid JSON but not an export', async () => {
|
|
268
|
+
const notExport = path.join(exportsDir, 'not-an-export.json');
|
|
269
|
+
fs.writeFileSync(notExport, JSON.stringify({ hello: 'world' }));
|
|
270
|
+
try {
|
|
271
|
+
const text = await callTool('context_import', { filePath: 'not-an-export.json' });
|
|
272
|
+
(0, globals_1.expect)(text).toMatch(/Import failed/i);
|
|
273
|
+
(0, globals_1.expect)(text).toMatch(/not a valid memory-keeper export/i);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
fs.rmSync(notExport, { force: true });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
(0, globals_1.it)('still supports the legitimate export -> import round trip and preserves data', async () => {
|
|
280
|
+
// Seed a context item, export it (lands inside the exports dir), then
|
|
281
|
+
// import the produced file back.
|
|
282
|
+
await callTool('context_save', {
|
|
283
|
+
key: 'roundtrip_key',
|
|
284
|
+
value: 'roundtrip_value',
|
|
285
|
+
category: 'note',
|
|
286
|
+
});
|
|
287
|
+
const exportText = await callTool('context_export', { confirmEmpty: true });
|
|
288
|
+
// `.+?\.json` (with `.` not matching newlines) tolerates spaces in the
|
|
289
|
+
// path, unlike `\S+`.
|
|
290
|
+
const match = exportText.match(/to:\s*(.+?\.json)/);
|
|
291
|
+
(0, globals_1.expect)(match).toBeTruthy();
|
|
292
|
+
const exportPath = match[1];
|
|
293
|
+
// Export must write inside the exports directory.
|
|
294
|
+
(0, globals_1.expect)(exportPath.startsWith(exportsDir)).toBe(true);
|
|
295
|
+
const importText = await callTool('context_import', { filePath: exportPath });
|
|
296
|
+
(0, globals_1.expect)(importText).toMatch(/Import successful/i);
|
|
297
|
+
// A regression that silently dropped every item would still say "successful";
|
|
298
|
+
// assert at least one item was imported AND that the value is retrievable.
|
|
299
|
+
(0, globals_1.expect)(importText).toMatch(/Context items:\s*[1-9]/);
|
|
300
|
+
const getResult = await callTool('context_get', { key: 'roundtrip_key' });
|
|
301
|
+
(0, globals_1.expect)(getResult).toContain('roundtrip_value');
|
|
302
|
+
});
|
|
303
|
+
(0, globals_1.it)('skips malformed items but imports the valid ones and reports the skip count', async () => {
|
|
304
|
+
// A confined, well-formed export envelope with one good item and several
|
|
305
|
+
// malformed ones: the good item imports, the bad ones are skipped (not
|
|
306
|
+
// silently — the count is surfaced), and nothing aborts the transaction.
|
|
307
|
+
const mixed = path.join(exportsDir, 'mixed.json');
|
|
308
|
+
fs.writeFileSync(mixed, JSON.stringify({
|
|
309
|
+
session: { name: 'mixed', branch: 'main' },
|
|
310
|
+
contextItems: [
|
|
311
|
+
{ key: 'good_key', value: 'good_value', category: 'note', priority: 'high' },
|
|
312
|
+
{ key: 'no_value' }, // missing value -> skipped
|
|
313
|
+
{ value: 'no_key' }, // missing key -> skipped
|
|
314
|
+
'not-an-object', // not an object -> skipped
|
|
315
|
+
],
|
|
316
|
+
fileCache: [],
|
|
317
|
+
}));
|
|
318
|
+
try {
|
|
319
|
+
const text = await callTool('context_import', { filePath: 'mixed.json' });
|
|
320
|
+
(0, globals_1.expect)(text).toMatch(/Import successful/i);
|
|
321
|
+
(0, globals_1.expect)(text).toMatch(/Context items:\s*1\s*\(skipped 3 malformed\)/);
|
|
322
|
+
const getResult = await callTool('context_get', { key: 'good_key' });
|
|
323
|
+
(0, globals_1.expect)(getResult).toContain('good_value');
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
fs.rmSync(mixed, { force: true });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
* Issue #33 reproduction test.
|
|
43
|
+
*
|
|
44
|
+
* Reproduces the exact scenario reported: spawn the MCP server with each
|
|
45
|
+
* tool profile (minimal, standard, full), call tools/list, and validate
|
|
46
|
+
* that every returned tool schema passes strict JSON Schema validation
|
|
47
|
+
* that OpenAI-compatible providers enforce.
|
|
48
|
+
*
|
|
49
|
+
* The reporter found:
|
|
50
|
+
* - minimal: works
|
|
51
|
+
* - standard: works
|
|
52
|
+
* - full: fails (context_delegate.input.insights missing items)
|
|
53
|
+
*
|
|
54
|
+
* After the fix, all three profiles must pass.
|
|
55
|
+
*
|
|
56
|
+
* @see https://github.com/mkreyman/mcp-memory-keeper/issues/33
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Strict schema validation matching what OpenAI-compatible providers enforce.
|
|
60
|
+
* Returns violation strings. Empty array = valid.
|
|
61
|
+
*/
|
|
62
|
+
function strictValidateSchema(prop, path, toolName) {
|
|
63
|
+
const violations = [];
|
|
64
|
+
if (!prop.type) {
|
|
65
|
+
violations.push(`${toolName}: ${path} — missing 'type'`);
|
|
66
|
+
return violations;
|
|
67
|
+
}
|
|
68
|
+
// Arrays MUST have items (the exact bug from issue #33)
|
|
69
|
+
if (prop.type === 'array' && !prop.items) {
|
|
70
|
+
violations.push(`${toolName}: ${path} — array missing 'items' (issue #33)`);
|
|
71
|
+
}
|
|
72
|
+
// Enum values must match declared type
|
|
73
|
+
if (prop.enum && prop.type === 'string') {
|
|
74
|
+
for (const val of prop.enum) {
|
|
75
|
+
if (typeof val !== 'string') {
|
|
76
|
+
violations.push(`${toolName}: ${path} — enum value '${val}' is not a string`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Required fields must exist in properties
|
|
81
|
+
if (prop.type === 'object' && prop.required && prop.properties) {
|
|
82
|
+
for (const req of prop.required) {
|
|
83
|
+
if (!(req in prop.properties)) {
|
|
84
|
+
violations.push(`${toolName}: ${path} — required '${req}' not in properties`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Recurse into object properties
|
|
89
|
+
if (prop.type === 'object' && prop.properties) {
|
|
90
|
+
for (const [key, value] of Object.entries(prop.properties)) {
|
|
91
|
+
violations.push(...strictValidateSchema(value, `${path}.${key}`, toolName));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Recurse into array items
|
|
95
|
+
if (prop.type === 'array' && prop.items && typeof prop.items === 'object') {
|
|
96
|
+
violations.push(...strictValidateSchema(prop.items, `${path}.items`, toolName));
|
|
97
|
+
}
|
|
98
|
+
return violations;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Spawn a server with a given TOOL_PROFILE, call tools/list, return the tools.
|
|
102
|
+
*/
|
|
103
|
+
async function getToolsForProfile(profile) {
|
|
104
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `mcp-issue33-${profile}-`));
|
|
105
|
+
const proc = (0, child_process_1.spawn)('node', [path.join(__dirname, '../../../dist/index.js')], {
|
|
106
|
+
env: { ...process.env, DATA_DIR: tempDir, TOOL_PROFILE: profile },
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
108
|
+
});
|
|
109
|
+
if (global.testProcesses) {
|
|
110
|
+
global.testProcesses.push(proc);
|
|
111
|
+
}
|
|
112
|
+
let msgId = 0;
|
|
113
|
+
let outputBuffer = '';
|
|
114
|
+
const sendRequest = (method, params = {}) => {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const id = ++msgId;
|
|
117
|
+
const timeout = setTimeout(() => reject(new Error(`Timeout: ${method} on ${profile}`)), 10000);
|
|
118
|
+
const onData = (data) => {
|
|
119
|
+
outputBuffer += data.toString();
|
|
120
|
+
const lines = outputBuffer.split('\n');
|
|
121
|
+
outputBuffer = lines.pop() || '';
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (!line.trim())
|
|
124
|
+
continue;
|
|
125
|
+
try {
|
|
126
|
+
const msg = JSON.parse(line);
|
|
127
|
+
if (msg.id === id) {
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
proc.stdout?.removeListener('data', onData);
|
|
130
|
+
resolve(msg);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// skip
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
proc.stdout?.on('data', onData);
|
|
139
|
+
proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n');
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
// Initialize
|
|
143
|
+
await sendRequest('initialize', {
|
|
144
|
+
protocolVersion: '2024-11-05',
|
|
145
|
+
capabilities: {},
|
|
146
|
+
clientInfo: { name: 'issue33-repro', version: '1.0.0' },
|
|
147
|
+
});
|
|
148
|
+
proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n');
|
|
149
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
150
|
+
// Get tools
|
|
151
|
+
const response = await sendRequest('tools/list');
|
|
152
|
+
return { tools: response.result.tools, process: proc, tempDir };
|
|
153
|
+
}
|
|
154
|
+
const profiles = ['minimal', 'standard', 'full'];
|
|
155
|
+
const servers = [];
|
|
156
|
+
(0, globals_1.describe)('Issue #33 reproduction: all profiles must pass strict schema validation', () => {
|
|
157
|
+
(0, globals_1.afterAll)(async () => {
|
|
158
|
+
for (const server of servers) {
|
|
159
|
+
if (server.process && !server.process.killed) {
|
|
160
|
+
server.process.kill('SIGTERM');
|
|
161
|
+
await new Promise(resolve => {
|
|
162
|
+
const timeout = setTimeout(() => {
|
|
163
|
+
server.process?.kill('SIGKILL');
|
|
164
|
+
resolve();
|
|
165
|
+
}, 3000);
|
|
166
|
+
server.process?.on('exit', () => {
|
|
167
|
+
clearTimeout(timeout);
|
|
168
|
+
resolve();
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
server.process?.removeAllListeners();
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
fs.rmSync(server.tempDir, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// ignore
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
for (const profile of profiles) {
|
|
182
|
+
(0, globals_1.describe)(`TOOL_PROFILE=${profile}`, () => {
|
|
183
|
+
let tools;
|
|
184
|
+
beforeAll(async () => {
|
|
185
|
+
const result = await getToolsForProfile(profile);
|
|
186
|
+
tools = result.tools;
|
|
187
|
+
servers.push({ process: result.process, tempDir: result.tempDir });
|
|
188
|
+
}, 15000);
|
|
189
|
+
(0, globals_1.it)('should return tools', () => {
|
|
190
|
+
(0, globals_1.expect)(tools.length).toBeGreaterThan(0);
|
|
191
|
+
});
|
|
192
|
+
(0, globals_1.it)('all tool schemas should pass strict validation', () => {
|
|
193
|
+
const allViolations = [];
|
|
194
|
+
for (const tool of tools) {
|
|
195
|
+
allViolations.push(...strictValidateSchema(tool.inputSchema, 'inputSchema', tool.name));
|
|
196
|
+
}
|
|
197
|
+
if (allViolations.length > 0) {
|
|
198
|
+
throw new Error(`Profile "${profile}" has ${allViolations.length} schema violation(s):\n` +
|
|
199
|
+
allViolations.map(v => ` ${v}`).join('\n'));
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
(0, globals_1.it)('specifically: no array property should be missing items', () => {
|
|
203
|
+
const arrayViolations = [];
|
|
204
|
+
for (const tool of tools) {
|
|
205
|
+
arrayViolations.push(...strictValidateSchema(tool.inputSchema, 'inputSchema', tool.name).filter(v => v.includes('missing')));
|
|
206
|
+
}
|
|
207
|
+
(0, globals_1.expect)(arrayViolations).toHaveLength(0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// The reporter specifically bisected these tools
|
|
212
|
+
(0, globals_1.describe)('reporter-flagged tools', () => {
|
|
213
|
+
let fullTools;
|
|
214
|
+
beforeAll(async () => {
|
|
215
|
+
const result = await getToolsForProfile('full');
|
|
216
|
+
fullTools = result.tools;
|
|
217
|
+
servers.push({ process: result.process, tempDir: result.tempDir });
|
|
218
|
+
}, 15000);
|
|
219
|
+
const flaggedTools = [
|
|
220
|
+
'context_delegate',
|
|
221
|
+
'context_find_related',
|
|
222
|
+
'context_visualize',
|
|
223
|
+
'context_branch_session',
|
|
224
|
+
];
|
|
225
|
+
for (const toolName of flaggedTools) {
|
|
226
|
+
(0, globals_1.it)(`${toolName} should pass strict schema validation`, () => {
|
|
227
|
+
const tool = fullTools.find((t) => t.name === toolName);
|
|
228
|
+
(0, globals_1.expect)(tool).toBeDefined();
|
|
229
|
+
const violations = strictValidateSchema(tool.inputSchema, 'inputSchema', toolName);
|
|
230
|
+
(0, globals_1.expect)(violations).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
package/dist/index.js
CHANGED
|
@@ -69,6 +69,82 @@ catch (err) {
|
|
|
69
69
|
`Set DATA_DIR to a writable location or create the directory manually.`);
|
|
70
70
|
process.exit(1);
|
|
71
71
|
}
|
|
72
|
+
// Server-owned directory for session exports/imports. Both context_export
|
|
73
|
+
// (writes) and context_import (reads) are confined to this directory so that
|
|
74
|
+
// context_import can never be steered at an arbitrary file on disk. Override
|
|
75
|
+
// with MEMORY_KEEPER_EXPORT_DIR; defaults to a subdirectory of the data dir.
|
|
76
|
+
const exportsDir = process.env.MEMORY_KEEPER_EXPORT_DIR
|
|
77
|
+
? path.resolve(process.env.MEMORY_KEEPER_EXPORT_DIR)
|
|
78
|
+
: path.join(dataDir, 'exports');
|
|
79
|
+
try {
|
|
80
|
+
fs.mkdirSync(exportsDir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
console.error(`[memory-keeper] FATAL: Cannot create exports directory "${exportsDir}": ${err.message}\n` +
|
|
84
|
+
`Set MEMORY_KEEPER_EXPORT_DIR to a writable location or create the directory manually.`);
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
// Resolve symlinks once so confinement checks compare real paths on both sides.
|
|
88
|
+
let exportsDirReal;
|
|
89
|
+
try {
|
|
90
|
+
exportsDirReal = fs.realpathSync(exportsDir);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error(`[memory-keeper] FATAL: Cannot resolve exports directory "${exportsDir}": ${err.message}\n` +
|
|
94
|
+
`Set MEMORY_KEEPER_EXPORT_DIR to a readable location or create the directory manually.`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
// Upper bound on the size of a file context_import will read into memory. The
|
|
98
|
+
// exports directory is server-owned, but context_export can write arbitrarily
|
|
99
|
+
// large session dumps there, so cap the read to avoid memory exhaustion.
|
|
100
|
+
const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
|
|
101
|
+
// Upper bound on how many context items / file-cache entries a single import
|
|
102
|
+
// will process. better-sqlite3 transactions run synchronously and block the
|
|
103
|
+
// event loop, so an export crafted with millions of tiny rows (still under the
|
|
104
|
+
// byte cap) must not be allowed to stall the server.
|
|
105
|
+
const MAX_IMPORT_ITEMS = 100_000;
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a caller-supplied import path and confine it to the exports
|
|
108
|
+
* directory. Relative paths are resolved against the exports directory;
|
|
109
|
+
* absolute paths are accepted only if they resolve (after following symlinks)
|
|
110
|
+
* to a location inside the exports directory.
|
|
111
|
+
*
|
|
112
|
+
* Throws an Error whose message is safe to surface to the caller — it never
|
|
113
|
+
* contains any bytes read from the target file.
|
|
114
|
+
*/
|
|
115
|
+
function resolveConfinedImportPath(filePath) {
|
|
116
|
+
if (typeof filePath !== 'string' || filePath.length === 0) {
|
|
117
|
+
throw new Error('filePath is required and must be a non-empty string');
|
|
118
|
+
}
|
|
119
|
+
// Resolve relative paths against the exports directory; leave absolute as-is.
|
|
120
|
+
const candidate = path.resolve(exportsDirReal, filePath);
|
|
121
|
+
// realpathSync follows symlinks and requires the file to exist; an attacker
|
|
122
|
+
// cannot use a symlink inside the exports dir to escape it. statSync on the
|
|
123
|
+
// resolved path then tells us it is a regular file of acceptable size.
|
|
124
|
+
let resolved;
|
|
125
|
+
let stats;
|
|
126
|
+
try {
|
|
127
|
+
resolved = fs.realpathSync(candidate);
|
|
128
|
+
stats = fs.statSync(resolved);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
throw new Error('import file not found in the exports directory');
|
|
132
|
+
}
|
|
133
|
+
// Use the SAME message as the not-found case so a file that exists outside
|
|
134
|
+
// the exports directory is indistinguishable from a missing one (no
|
|
135
|
+
// existence oracle), and never echo the resolved absolute path.
|
|
136
|
+
const withinExports = resolved === exportsDirReal || resolved.startsWith(exportsDirReal + path.sep);
|
|
137
|
+
if (!withinExports) {
|
|
138
|
+
throw new Error('import file not found in the exports directory');
|
|
139
|
+
}
|
|
140
|
+
if (!stats.isFile()) {
|
|
141
|
+
throw new Error('import path must be a regular file');
|
|
142
|
+
}
|
|
143
|
+
if (stats.size > MAX_IMPORT_BYTES) {
|
|
144
|
+
throw new Error(`import file exceeds the maximum allowed size (${MAX_IMPORT_BYTES} bytes)`);
|
|
145
|
+
}
|
|
146
|
+
return resolved;
|
|
147
|
+
}
|
|
72
148
|
// Warn users whose legacy DB is sitting in CWD
|
|
73
149
|
const legacyDb = path.join(process.cwd(), 'context.db');
|
|
74
150
|
if (process.cwd() !== dataDir && fs.existsSync(legacyDb)) {
|
|
@@ -327,10 +403,23 @@ function createSummary(items, options) {
|
|
|
327
403
|
}
|
|
328
404
|
return summary;
|
|
329
405
|
}
|
|
406
|
+
// Read the package version at runtime so the server-reported version can never
|
|
407
|
+
// drift from package.json. The compiled entry (dist/index.js) sits one level
|
|
408
|
+
// below the package root, so package.json is at ../package.json.
|
|
409
|
+
function readPackageVersion() {
|
|
410
|
+
try {
|
|
411
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
412
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return '0.0.0';
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const SERVER_VERSION = readPackageVersion();
|
|
330
419
|
// Create MCP server
|
|
331
420
|
const server = new index_js_1.Server({
|
|
332
421
|
name: 'memory-keeper',
|
|
333
|
-
version:
|
|
422
|
+
version: SERVER_VERSION,
|
|
334
423
|
}, {
|
|
335
424
|
capabilities: {
|
|
336
425
|
tools: {},
|
|
@@ -1349,7 +1438,7 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1349
1438
|
},
|
|
1350
1439
|
};
|
|
1351
1440
|
if (format === 'json') {
|
|
1352
|
-
const exportPath = path.join(
|
|
1441
|
+
const exportPath = path.join(exportsDir, `memory-keeper-export-${targetSessionId.substring(0, 8)}.json`);
|
|
1353
1442
|
// Check write permissions
|
|
1354
1443
|
try {
|
|
1355
1444
|
fs.writeFileSync(exportPath, JSON.stringify(exportData, null, 2));
|
|
@@ -1366,11 +1455,16 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1366
1455
|
checkpoints: checkpoints.length,
|
|
1367
1456
|
size: fs.statSync(exportPath).size,
|
|
1368
1457
|
};
|
|
1458
|
+
// context_import caps reads at MAX_IMPORT_BYTES; warn if this export is
|
|
1459
|
+
// too large to be re-imported, so the round trip never breaks silently.
|
|
1460
|
+
const sizeWarning = stats.size > MAX_IMPORT_BYTES
|
|
1461
|
+
? `\n⚠️ This export is ${(stats.size / (1024 * 1024)).toFixed(1)} MB and exceeds the ${MAX_IMPORT_BYTES / (1024 * 1024)} MB import limit; context_import will reject it.`
|
|
1462
|
+
: '';
|
|
1369
1463
|
return {
|
|
1370
1464
|
content: [
|
|
1371
1465
|
{
|
|
1372
1466
|
type: 'text',
|
|
1373
|
-
text: includeStats
|
|
1467
|
+
text: (includeStats
|
|
1374
1468
|
? `✅ Successfully exported session "${session.name}" to: ${exportPath}
|
|
1375
1469
|
|
|
1376
1470
|
📊 Export Statistics:
|
|
@@ -1382,7 +1476,7 @@ Checkpoint: ${autoSave ? `git-commit-${new Date().toISOString()}` : 'None'}`,
|
|
|
1382
1476
|
Session ID: ${targetSessionId}`
|
|
1383
1477
|
: `Exported session to: ${exportPath}
|
|
1384
1478
|
Items: ${stats.items}
|
|
1385
|
-
Files: ${stats.files}
|
|
1479
|
+
Files: ${stats.files}`) + sizeWarning,
|
|
1386
1480
|
},
|
|
1387
1481
|
],
|
|
1388
1482
|
exportPath,
|
|
@@ -1406,61 +1500,179 @@ Files: ${stats.files}`,
|
|
|
1406
1500
|
}
|
|
1407
1501
|
case 'context_import': {
|
|
1408
1502
|
const { filePath, merge = false } = args;
|
|
1503
|
+
// Confine the path to the server-owned exports directory BEFORE touching
|
|
1504
|
+
// the filesystem. A failure here is a path/permission problem, never file
|
|
1505
|
+
// content, so the message is safe to return verbatim.
|
|
1506
|
+
let safePath;
|
|
1409
1507
|
try {
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
// Import context items
|
|
1426
|
-
const itemStmt = db.prepare(`
|
|
1427
|
-
INSERT OR REPLACE INTO context_items (id, session_id, key, value, category, priority, size, created_at, updated_at)
|
|
1428
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1429
|
-
`);
|
|
1430
|
-
let itemCount = 0;
|
|
1431
|
-
for (const item of importData.contextItems) {
|
|
1432
|
-
itemStmt.run((0, uuid_1.v4)(), targetSessionId, item.key, item.value, item.category, item.priority, item.size || calculateSize(item.value), item.created_at);
|
|
1433
|
-
itemCount++;
|
|
1508
|
+
safePath = resolveConfinedImportPath(filePath);
|
|
1509
|
+
}
|
|
1510
|
+
catch (error) {
|
|
1511
|
+
return {
|
|
1512
|
+
content: [{ type: 'text', text: `Import failed: ${error.message}` }],
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
// Read and parse separately so a JSON.parse error can never echo file
|
|
1516
|
+
// bytes back to the caller (V8 includes the offending input in its
|
|
1517
|
+
// SyntaxError message).
|
|
1518
|
+
let importData;
|
|
1519
|
+
try {
|
|
1520
|
+
const raw = fs.readFileSync(safePath, 'utf8');
|
|
1521
|
+
try {
|
|
1522
|
+
importData = JSON.parse(raw);
|
|
1434
1523
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
`);
|
|
1440
|
-
let fileCount = 0;
|
|
1441
|
-
for (const file of importData.fileCache || []) {
|
|
1442
|
-
fileStmt.run((0, uuid_1.v4)(), targetSessionId, file.file_path, file.content, file.hash, file.last_read);
|
|
1443
|
-
fileCount++;
|
|
1524
|
+
catch {
|
|
1525
|
+
return {
|
|
1526
|
+
content: [{ type: 'text', text: 'Import failed: file is not valid JSON export data' }],
|
|
1527
|
+
};
|
|
1444
1528
|
}
|
|
1529
|
+
}
|
|
1530
|
+
catch {
|
|
1531
|
+
return {
|
|
1532
|
+
content: [{ type: 'text', text: 'Import failed: could not read import file' }],
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
// Validate the import has the expected shape before using it.
|
|
1536
|
+
if (typeof importData !== 'object' ||
|
|
1537
|
+
importData === null ||
|
|
1538
|
+
typeof importData.session !== 'object' ||
|
|
1539
|
+
importData.session === null ||
|
|
1540
|
+
typeof importData.session.name !== 'string' ||
|
|
1541
|
+
!Array.isArray(importData.contextItems)) {
|
|
1445
1542
|
return {
|
|
1446
1543
|
content: [
|
|
1447
1544
|
{
|
|
1448
1545
|
type: 'text',
|
|
1449
|
-
text:
|
|
1450
|
-
Session: ${targetSessionId.substring(0, 8)}
|
|
1451
|
-
Context items: ${itemCount}
|
|
1452
|
-
Files: ${fileCount}
|
|
1453
|
-
Mode: ${merge ? 'Merged' : 'New session'}`,
|
|
1546
|
+
text: 'Import failed: file is not a valid memory-keeper export (missing session or contextItems)',
|
|
1454
1547
|
},
|
|
1455
1548
|
],
|
|
1456
1549
|
};
|
|
1457
1550
|
}
|
|
1551
|
+
// Merge only when there is actually a session to merge into; otherwise
|
|
1552
|
+
// fall back to creating a new session (and report that honestly).
|
|
1553
|
+
const merged = merge && !!currentSessionId;
|
|
1554
|
+
const fileCacheEntries = Array.isArray(importData.fileCache) ? importData.fileCache : [];
|
|
1555
|
+
// Bound the work before opening the (synchronous, event-loop-blocking)
|
|
1556
|
+
// transaction so a huge row count cannot stall the server.
|
|
1557
|
+
if (importData.contextItems.length > MAX_IMPORT_ITEMS ||
|
|
1558
|
+
fileCacheEntries.length > MAX_IMPORT_ITEMS) {
|
|
1559
|
+
return {
|
|
1560
|
+
content: [
|
|
1561
|
+
{
|
|
1562
|
+
type: 'text',
|
|
1563
|
+
text: `Import failed: file exceeds the maximum allowed entry count (${MAX_IMPORT_ITEMS})`,
|
|
1564
|
+
},
|
|
1565
|
+
],
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1568
|
+
try {
|
|
1569
|
+
// Apply the whole import atomically so a malformed row cannot leave an
|
|
1570
|
+
// orphaned session or a partially-populated one behind. We do NOT mutate
|
|
1571
|
+
// the module-level currentSessionId inside the transaction: better-sqlite3
|
|
1572
|
+
// rolls back the database on error but cannot revert a JS assignment, so a
|
|
1573
|
+
// post-assignment failure would leave currentSessionId dangling. Instead we
|
|
1574
|
+
// return the new id and publish it only after the transaction commits.
|
|
1575
|
+
const runImport = db.transaction(() => {
|
|
1576
|
+
let targetSessionId;
|
|
1577
|
+
const createdNew = !merged;
|
|
1578
|
+
if (merged) {
|
|
1579
|
+
targetSessionId = currentSessionId;
|
|
1580
|
+
}
|
|
1581
|
+
else {
|
|
1582
|
+
targetSessionId = (0, uuid_1.v4)();
|
|
1583
|
+
const importedSession = importData.session;
|
|
1584
|
+
db.prepare(`
|
|
1585
|
+
INSERT INTO sessions (id, name, description, branch, working_directory, created_at)
|
|
1586
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1587
|
+
`).run(targetSessionId, `Imported: ${importedSession.name}`, `Imported from ${safePath} on ${new Date().toISOString()}`, typeof importedSession.branch === 'string' ? importedSession.branch : null, null, new Date().toISOString());
|
|
1588
|
+
}
|
|
1589
|
+
// Import context items. Restore the channel/is_private/metadata columns
|
|
1590
|
+
// too so a round trip is faithful, not lossy.
|
|
1591
|
+
const itemStmt = db.prepare(`
|
|
1592
|
+
INSERT OR REPLACE INTO context_items (id, session_id, key, value, category, priority, size, metadata, is_private, channel, created_at, updated_at)
|
|
1593
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
|
1594
|
+
`);
|
|
1595
|
+
let itemCount = 0;
|
|
1596
|
+
let skippedItems = 0;
|
|
1597
|
+
for (const item of importData.contextItems) {
|
|
1598
|
+
// Skip entries that are not well-formed items rather than letting a
|
|
1599
|
+
// bad row abort the whole transaction with a SQLite binding error.
|
|
1600
|
+
if (typeof item !== 'object' ||
|
|
1601
|
+
item === null ||
|
|
1602
|
+
typeof item.key !== 'string' ||
|
|
1603
|
+
typeof item.value !== 'string') {
|
|
1604
|
+
skippedItems++;
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
itemStmt.run((0, uuid_1.v4)(), targetSessionId, item.key, item.value, typeof item.category === 'string' ? item.category : null, typeof item.priority === 'string' ? item.priority : null,
|
|
1608
|
+
// Use the stored size only when it is a valid non-negative number;
|
|
1609
|
+
// `|| calculateSize(...)` would wrongly recompute a legitimate 0.
|
|
1610
|
+
typeof item.size === 'number' && item.size >= 0
|
|
1611
|
+
? item.size
|
|
1612
|
+
: calculateSize(item.value), typeof item.metadata === 'string' ? item.metadata : null, typeof item.is_private === 'number' ? item.is_private : 0, typeof item.channel === 'string' ? item.channel : 'general', typeof item.created_at === 'string' ? item.created_at : new Date().toISOString());
|
|
1613
|
+
itemCount++;
|
|
1614
|
+
}
|
|
1615
|
+
// Import file cache. content is a nullable column, so null is valid and
|
|
1616
|
+
// must NOT be treated as a malformed row (that would drop legit rows).
|
|
1617
|
+
const fileStmt = db.prepare(`
|
|
1618
|
+
INSERT OR REPLACE INTO file_cache (id, session_id, file_path, content, hash, last_read)
|
|
1619
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
1620
|
+
`);
|
|
1621
|
+
let fileCount = 0;
|
|
1622
|
+
let skippedFiles = 0;
|
|
1623
|
+
for (const file of fileCacheEntries) {
|
|
1624
|
+
if (typeof file !== 'object' ||
|
|
1625
|
+
file === null ||
|
|
1626
|
+
typeof file.file_path !== 'string' ||
|
|
1627
|
+
(file.content !== null && typeof file.content !== 'string')) {
|
|
1628
|
+
skippedFiles++;
|
|
1629
|
+
continue;
|
|
1630
|
+
}
|
|
1631
|
+
fileStmt.run((0, uuid_1.v4)(), targetSessionId, file.file_path, file.content ?? null, typeof file.hash === 'string' ? file.hash : null, typeof file.last_read === 'string' ? file.last_read : null);
|
|
1632
|
+
fileCount++;
|
|
1633
|
+
}
|
|
1634
|
+
return { targetSessionId, createdNew, itemCount, fileCount, skippedItems, skippedFiles };
|
|
1635
|
+
});
|
|
1636
|
+
const result = runImport();
|
|
1637
|
+
// Publish the new current session only after a successful commit.
|
|
1638
|
+
if (result.createdNew) {
|
|
1639
|
+
currentSessionId = result.targetSessionId;
|
|
1640
|
+
}
|
|
1641
|
+
const lines = [
|
|
1642
|
+
'Import successful!',
|
|
1643
|
+
`Session: ${result.targetSessionId.substring(0, 8)}`,
|
|
1644
|
+
`Context items: ${result.itemCount}${result.skippedItems ? ` (skipped ${result.skippedItems} malformed)` : ''}`,
|
|
1645
|
+
`Files: ${result.fileCount}${result.skippedFiles ? ` (skipped ${result.skippedFiles} malformed)` : ''}`,
|
|
1646
|
+
`Mode: ${result.createdNew ? 'New session' : 'Merged'}`,
|
|
1647
|
+
];
|
|
1648
|
+
// Checkpoints are present in exports but not restored by import; say so
|
|
1649
|
+
// explicitly instead of silently dropping them.
|
|
1650
|
+
const checkpointCount = Array.isArray(importData.checkpoints)
|
|
1651
|
+
? importData.checkpoints.length
|
|
1652
|
+
: 0;
|
|
1653
|
+
if (checkpointCount > 0) {
|
|
1654
|
+
lines.push(`Checkpoints in file: ${checkpointCount} (not imported)`);
|
|
1655
|
+
}
|
|
1656
|
+
// Warn when the caller asked to merge but there was no active session.
|
|
1657
|
+
if (merge && !merged) {
|
|
1658
|
+
lines.push('Note: merge requested but no active session existed; created a new session.');
|
|
1659
|
+
}
|
|
1660
|
+
if (result.itemCount === 0 && importData.contextItems.length > 0) {
|
|
1661
|
+
lines.push('Warning: no valid context items were found; nothing was imported.');
|
|
1662
|
+
}
|
|
1663
|
+
return {
|
|
1664
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1458
1667
|
catch (error) {
|
|
1668
|
+
// The error here comes from the DB layer and may echo the values being
|
|
1669
|
+
// inserted; keep the caller-facing message generic and log the detail.
|
|
1670
|
+
console.error(`[memory-keeper] context_import failed: ${error?.message ?? error}`);
|
|
1459
1671
|
return {
|
|
1460
1672
|
content: [
|
|
1461
1673
|
{
|
|
1462
1674
|
type: 'text',
|
|
1463
|
-
text:
|
|
1675
|
+
text: 'Import failed: could not write imported data to the database',
|
|
1464
1676
|
},
|
|
1465
1677
|
],
|
|
1466
1678
|
};
|
|
@@ -3644,7 +3856,9 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
3644
3856
|
// Phase 3: Export/Import
|
|
3645
3857
|
{
|
|
3646
3858
|
name: 'context_export',
|
|
3647
|
-
description: 'Export session data for backup or sharing'
|
|
3859
|
+
description: 'Export session data for backup or sharing. JSON exports are written to the ' +
|
|
3860
|
+
"server's exports directory (<DATA_DIR>/exports, overridable via MEMORY_KEEPER_EXPORT_DIR); " +
|
|
3861
|
+
'the response includes the full path, which can be passed back to context_import.',
|
|
3648
3862
|
inputSchema: {
|
|
3649
3863
|
type: 'object',
|
|
3650
3864
|
properties: {
|
|
@@ -3660,11 +3874,16 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
|
|
|
3660
3874
|
},
|
|
3661
3875
|
{
|
|
3662
3876
|
name: 'context_import',
|
|
3663
|
-
description: 'Import previously exported session data'
|
|
3877
|
+
description: 'Import previously exported session data. For security, imports are confined to the ' +
|
|
3878
|
+
"server's exports directory (where context_export writes); arbitrary filesystem paths are rejected.",
|
|
3664
3879
|
inputSchema: {
|
|
3665
3880
|
type: 'object',
|
|
3666
3881
|
properties: {
|
|
3667
|
-
filePath: {
|
|
3882
|
+
filePath: {
|
|
3883
|
+
type: 'string',
|
|
3884
|
+
description: 'Path to an export file inside the exports directory. Relative paths resolve ' +
|
|
3885
|
+
'against that directory; absolute paths must point inside it.',
|
|
3886
|
+
},
|
|
3668
3887
|
merge: {
|
|
3669
3888
|
type: 'boolean',
|
|
3670
3889
|
description: 'Merge with current session instead of creating new',
|