mcp-memory-keeper 0.12.1 → 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/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: '0.10.0',
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(os.tmpdir(), `memory-keeper-export-${targetSessionId.substring(0, 8)}.json`);
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
- const importData = JSON.parse(fs.readFileSync(filePath, 'utf8'));
1411
- // Create new session or merge
1412
- let targetSessionId;
1413
- if (merge && currentSessionId) {
1414
- targetSessionId = currentSessionId;
1415
- }
1416
- else {
1417
- targetSessionId = (0, uuid_1.v4)();
1418
- const importedSession = importData.session;
1419
- db.prepare(`
1420
- INSERT INTO sessions (id, name, description, branch, working_directory, created_at)
1421
- VALUES (?, ?, ?, ?, ?, ?)
1422
- `).run(targetSessionId, `Imported: ${importedSession.name}`, `Imported from ${filePath} on ${new Date().toISOString()}`, importedSession.branch, null, new Date().toISOString());
1423
- currentSessionId = targetSessionId;
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
- // Import file cache
1436
- const fileStmt = db.prepare(`
1437
- INSERT OR REPLACE INTO file_cache (id, session_id, file_path, content, hash, last_read)
1438
- VALUES (?, ?, ?, ?, ?, ?)
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: `Import successful!
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: `Import failed: ${error.message}`,
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: { type: 'string', description: 'Path to import file' },
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',
@@ -3804,6 +4023,15 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
3804
4023
  },
3805
4024
  insights: {
3806
4025
  type: 'array',
4026
+ items: {
4027
+ type: 'object',
4028
+ properties: {
4029
+ patterns: { type: 'object', properties: {} },
4030
+ relationships: { type: 'object', properties: {} },
4031
+ trends: { type: 'object', properties: {} },
4032
+ themes: { type: 'array', items: { type: 'string' } },
4033
+ },
4034
+ },
3807
4035
  description: 'For merge synthesis: array of insights to merge',
3808
4036
  },
3809
4037
  },
@@ -4340,6 +4568,7 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => {
4340
4568
  },
4341
4569
  metadata: {
4342
4570
  type: 'object',
4571
+ properties: {},
4343
4572
  description: 'Optional metadata for the relationship',
4344
4573
  },
4345
4574
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-keeper",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "MCP server for persistent context management in AI coding assistants",
5
5
  "main": "dist/index.js",
6
6
  "bin": {