cf-memory-mcp 3.15.0 → 3.16.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.
Files changed (2) hide show
  1. package/bin/cf-memory-mcp.js +216 -11
  2. package/package.json +1 -1
@@ -688,7 +688,12 @@ class CFMemoryMCP {
688
688
  result: {
689
689
  protocolVersion: respondVersion,
690
690
  capabilities: {
691
- tools: {}
691
+ tools: {},
692
+ // Resources capability exposes the resume handoff at
693
+ // cfm://resume/current and recent handoffs at
694
+ // cfm://resume/recent for clients that prefer the
695
+ // resources protocol over tool calls.
696
+ resources: {}
692
697
  },
693
698
  serverInfo: {
694
699
  name: 'cf-memory-mcp-simplified',
@@ -726,12 +731,93 @@ class CFMemoryMCP {
726
731
  return;
727
732
  }
728
733
 
729
- // Handle resources/list locally (we have none)
734
+ // Handle resources/list locally expose the resume handoff
735
+ // and recent handoffs as MCP resources for clients that prefer
736
+ // the resources capability over tool calls.
730
737
  if (message.method === 'resources/list') {
738
+ const meta = this.getRepoMetadata();
739
+ const repoPath = meta.repo_path || 'unknown';
740
+ const branch = meta.branch || 'unknown';
741
+ const resources = [
742
+ {
743
+ uri: 'cfm://resume/current',
744
+ name: 'Current resume handoff',
745
+ description: `The most-recent matching handoff for ${repoPath} (branch=${branch}). Returns the same payload as get_context_bootstrap({resume:true}).`,
746
+ mimeType: 'application/json',
747
+ },
748
+ {
749
+ uri: 'cfm://resume/recent',
750
+ name: 'Recent handoffs (top 5)',
751
+ description: `The 5 most-recent handoffs for ${repoPath}, status-ranked (in_progress > completed). Useful for picking a specific session via session_id_hint.`,
752
+ mimeType: 'application/json',
753
+ },
754
+ ];
755
+ const response = {
756
+ jsonrpc: '2.0',
757
+ id: message.id,
758
+ result: { resources },
759
+ };
760
+ process.stdout.write(JSON.stringify(response) + '\n');
761
+ return;
762
+ }
763
+
764
+ // Handle resources/read — serve the resume packet as if the
765
+ // agent had called get_context_bootstrap({resume:true}). Lets
766
+ // MCP clients that use the resources capability (instead of
767
+ // tools) recover prior chat state.
768
+ if (message.method === 'resources/read') {
769
+ const uri = message.params?.uri;
770
+ if (uri === 'cfm://resume/current' || uri === 'cfm://resume/recent') {
771
+ try {
772
+ const meta = this.getRepoMetadata();
773
+ const args = { resume: true };
774
+ if (meta.repo_path) args.repo_path = meta.repo_path;
775
+ if (meta.branch) args.branch = meta.branch;
776
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
777
+ await this.maybeFillProjectId(fake);
778
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
779
+ const bootstrap = await this.makeRequest({
780
+ jsonrpc: '2.0',
781
+ id: `resource-read-${Date.now()}`,
782
+ method: 'tools/call',
783
+ params: { name: 'get_context_bootstrap', arguments: args },
784
+ });
785
+ const text = bootstrap?.result?.content?.[0]?.text || '{}';
786
+ // For cfm://resume/recent, surface only the recent_handoffs summaries.
787
+ let payload = text;
788
+ if (uri === 'cfm://resume/recent') {
789
+ try {
790
+ const parsed = JSON.parse(text);
791
+ payload = JSON.stringify({
792
+ recent_handoffs: parsed.recent_handoffs || [],
793
+ }, null, 2);
794
+ } catch (_) { /* fall through with raw text */ }
795
+ }
796
+ const response = {
797
+ jsonrpc: '2.0',
798
+ id: message.id,
799
+ result: { contents: [{ uri, mimeType: 'application/json', text: payload }] },
800
+ };
801
+ process.stdout.write(JSON.stringify(response) + '\n');
802
+ return;
803
+ } catch (err) {
804
+ const response = {
805
+ jsonrpc: '2.0',
806
+ id: message.id,
807
+ error: { code: -32603, message: `Failed to read ${uri}: ${err && err.message}` },
808
+ };
809
+ process.stdout.write(JSON.stringify(response) + '\n');
810
+ return;
811
+ }
812
+ }
813
+ // Unknown resource URI
731
814
  const response = {
732
815
  jsonrpc: '2.0',
733
816
  id: message.id,
734
- result: { resources: [] }
817
+ error: {
818
+ code: -32602,
819
+ message: `Unknown resource URI: ${uri}. Supported: cfm://resume/current, cfm://resume/recent`,
820
+ },
735
821
  };
736
822
  process.stdout.write(JSON.stringify(response) + '\n');
737
823
  return;
@@ -2392,6 +2478,25 @@ class CFMemoryMCP {
2392
2478
  // Tool-call counter for auto-checkpoint safety net. Counts all
2393
2479
  // tool/call messages; auto-checkpoint fires every N calls.
2394
2480
  this._toolCallCount = (this._toolCallCount || 0) + 1;
2481
+
2482
+ // Activity timeline: keep the last 15 user-meaningful tool calls
2483
+ // in a ring buffer so synthesized handoffs include a "what was
2484
+ // I doing" trail. Excludes housekeeping (health_check, list_*)
2485
+ // and the internal auto-* calls themselves.
2486
+ const TIMELINE_TOOLS = new Set([
2487
+ 'retrieve_context', 'get_file_content', 'get_file_outline',
2488
+ 'get_related_code', 'refresh_files', 'refresh_stale',
2489
+ 'index_project', 'index_github', 'store_memory',
2490
+ 'find_stale_files',
2491
+ ]);
2492
+ if (TIMELINE_TOOLS.has(toolName)) {
2493
+ if (!this._activityTimeline) this._activityTimeline = [];
2494
+ const summary = this.summarizeToolCall(toolName, args);
2495
+ this._activityTimeline.push({ tool: toolName, summary, at: Date.now() });
2496
+ if (this._activityTimeline.length > 15) {
2497
+ this._activityTimeline.shift();
2498
+ }
2499
+ }
2395
2500
  } catch (err) {
2396
2501
  this.logDebug(`trackToolActivity failed: ${err && err.message}`);
2397
2502
  }
@@ -2399,11 +2504,11 @@ class CFMemoryMCP {
2399
2504
 
2400
2505
  /**
2401
2506
  * Build a minimal handoff packet from observed bridge activity. Used by
2402
- * end_session auto-synthesis and the periodic auto-checkpoint. Returns
2403
- * just the fields the bridge can credibly populate — agent-supplied
2404
- * fields like decisions/blockers are intentionally omitted so a future
2405
- * resume reader can tell the difference between "agent wrote this" and
2406
- * "bridge synthesized this".
2507
+ * end_session auto-synthesis, the periodic auto-checkpoint, and the
2508
+ * SIGINT/SIGTERM safety net. Returns just the fields the bridge can
2509
+ * credibly populate — agent-supplied fields like decisions/blockers
2510
+ * are intentionally omitted so a future resume reader can tell the
2511
+ * difference between "agent wrote this" and "bridge synthesized this".
2407
2512
  */
2408
2513
  synthesizeMinimalHandoff() {
2409
2514
  const handoff = {
@@ -2424,10 +2529,65 @@ class CFMemoryMCP {
2424
2529
  } else {
2425
2530
  handoff.goal = 'Bridge-synthesized handoff (no explicit goal)';
2426
2531
  }
2427
- handoff.notes = `Auto-synthesized by cf-memory-mcp bridge. Agent did not provide a handoff. Tool calls observed: ${this._toolCallCount || 0}. Set CF_MEMORY_AUTO_HANDOFF=off to disable.`;
2532
+
2533
+ // Activity timeline: last 15 meaningful tool calls, condensed to
2534
+ // a single line each. Goes in handoff.notes so the next chat
2535
+ // sees the recent trajectory ("what was I doing").
2536
+ const timelineNote = this.formatActivityTimeline();
2537
+ const baseNote = `Auto-synthesized by cf-memory-mcp bridge. Agent did not provide a handoff. Tool calls observed: ${this._toolCallCount || 0}. Set CF_MEMORY_AUTO_HANDOFF=off to disable.`;
2538
+ handoff.notes = timelineNote ? `${baseNote}\n\nRecent activity:\n${timelineNote}` : baseNote;
2428
2539
  return handoff;
2429
2540
  }
2430
2541
 
2542
+ /**
2543
+ * Condense an MCP tool call into a single-line summary. Used for the
2544
+ * activity timeline so the synthesized handoff includes "what was I
2545
+ * doing" context. Keeps each line under 120 chars.
2546
+ */
2547
+ summarizeToolCall(toolName, args) {
2548
+ try {
2549
+ const trim = (s, n) => typeof s === 'string' ? s.slice(0, n) : '';
2550
+ switch (toolName) {
2551
+ case 'retrieve_context':
2552
+ return `retrieve_context: "${trim(args.query, 80)}"`;
2553
+ case 'get_file_content':
2554
+ return `get_file_content: ${trim(args.file_path, 80)}`;
2555
+ case 'get_file_outline':
2556
+ return `get_file_outline: ${trim(args.file_path, 80)}`;
2557
+ case 'get_related_code':
2558
+ return `get_related_code: ${trim(args.chunk_name || args.chunk_id, 80)}`;
2559
+ case 'refresh_files':
2560
+ return `refresh_files: ${(args.file_paths || []).slice(0, 3).join(', ')}${(args.file_paths || []).length > 3 ? '…' : ''}`;
2561
+ case 'refresh_stale':
2562
+ return `refresh_stale${args.project_id ? ' '+args.project_id : ''}`;
2563
+ case 'index_project':
2564
+ return `index_project: ${trim(args.project_path, 80)}`;
2565
+ case 'index_github':
2566
+ return `index_github: ${trim(args.repo_url, 80)}`;
2567
+ case 'store_memory':
2568
+ return `store_memory: "${trim(args.content, 80)}"`;
2569
+ case 'find_stale_files':
2570
+ return `find_stale_files`;
2571
+ default:
2572
+ return toolName;
2573
+ }
2574
+ } catch (_) {
2575
+ return toolName;
2576
+ }
2577
+ }
2578
+
2579
+ /**
2580
+ * Format the activity timeline as a compact bullet list. Returns ""
2581
+ * when no timeline exists.
2582
+ */
2583
+ formatActivityTimeline() {
2584
+ if (!this._activityTimeline || this._activityTimeline.length === 0) return '';
2585
+ return this._activityTimeline
2586
+ .slice(-10) // last 10 of the up-to-15 buffer
2587
+ .map(e => ` - ${e.summary}`)
2588
+ .join('\n');
2589
+ }
2590
+
2431
2591
  /**
2432
2592
  * Pre-fetch the resume handoff for the current cwd at bridge startup.
2433
2593
  * The result is cached and surfaced when the agent's first call is
@@ -3061,11 +3221,56 @@ class CFMemoryMCP {
3061
3221
  }
3062
3222
 
3063
3223
  /**
3064
- * Graceful shutdown
3224
+ * Graceful shutdown. Best-effort: tries to flush a synthesized handoff
3225
+ * to the active implicit session so the resume state survives the
3226
+ * shutdown. Uses keep_open so the session can still be properly closed
3227
+ * later if the agent reconnects. Bounded by a 2s timeout so we never
3228
+ * hang the exit on a slow network round-trip.
3229
+ *
3230
+ * Opt out via CF_MEMORY_SHUTDOWN_HANDOFF=off.
3065
3231
  */
3066
3232
  shutdown(reason = 'UNKNOWN') {
3067
3233
  this.logDebug(`Shutting down (reason: ${reason})`);
3068
- process.exit(0);
3234
+ const optOut = process.env.CF_MEMORY_SHUTDOWN_HANDOFF;
3235
+ const enabled = !(optOut === '0' || optOut === 'false' || optOut === 'off');
3236
+ if (!enabled) {
3237
+ process.exit(0);
3238
+ return;
3239
+ }
3240
+ // Resolve the implicit session WITHOUT creating a new one — we
3241
+ // don't want to leave an empty session row behind if the agent
3242
+ // never did anything. Only flush when there's tracked activity.
3243
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
3244
+ const sessionId = this._implicitSessionByCwd?.get(cwd);
3245
+ const hasActivity = this._activityFiles && this._activityFiles.size > 0;
3246
+ if (!sessionId || !hasActivity) {
3247
+ this.logDebug(`shutdown handoff skipped: session=${sessionId||'(none)'} activity=${this._activityFiles?.size||0}`);
3248
+ process.exit(0);
3249
+ return;
3250
+ }
3251
+
3252
+ const handoff = this.synthesizeMinimalHandoff();
3253
+ const meta = this.getRepoMetadata();
3254
+ if (meta.repo_path) handoff.repo_path = meta.repo_path;
3255
+ if (meta.branch) handoff.branch = meta.branch;
3256
+
3257
+ const args = { session_id: sessionId, keep_open: true, handoff };
3258
+ const message = {
3259
+ jsonrpc: '2.0',
3260
+ id: `shutdown-${Date.now()}`,
3261
+ method: 'tools/call',
3262
+ params: { name: 'end_session', arguments: args },
3263
+ };
3264
+ _mcpTrace('SHUTDOWN_HANDOFF', `session=${sessionId} reason=${reason}`);
3265
+
3266
+ // Race the request against a 2s deadline so we never hang exit.
3267
+ const deadline = new Promise(resolve => setTimeout(resolve, 2000));
3268
+ Promise.race([
3269
+ this.makeRequest(message).catch(err => {
3270
+ this.logDebug(`shutdown handoff request failed: ${err && err.message}`);
3271
+ }),
3272
+ deadline,
3273
+ ]).finally(() => process.exit(0));
3069
3274
  }
3070
3275
  }
3071
3276
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.15.0",
3
+ "version": "3.16.0",
4
4
  "description": "Cloudflare-hosted MCP server for code indexing, retrieval, and assistant memory with a direct remote MCP endpoint and local stdio bridge.",
5
5
  "main": "bin/cf-memory-mcp.js",
6
6
  "bin": {