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 +33 -0
- package/README.md +16 -2
- package/dist/__tests__/e2e/checkpoint-round-trip.test.js +303 -0
- 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 +515 -48
- package/package.json +1 -1
|
@@ -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
|
+
});
|