cf-memory-mcp 3.18.0 → 3.20.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 +218 -16
  2. package/package.json +1 -1
@@ -215,7 +215,8 @@ const TOOLS_LIST = [
215
215
  branch: { type: 'string', description: 'Current git branch. Mismatch downgrades match_confidence but does not hide. Bridge auto-fills from git.' },
216
216
  session_id_hint: { type: 'string', description: 'Resume a specific session by id (overrides repo/branch matching). Full UUID or short prefix (>=8 chars). Useful after seeing recent_handoffs.' },
217
217
  max_age_minutes: { type: 'number', description: 'Hard cutoff: hide handoffs older than this many minutes. Without it, older handoffs surface with reduced confidence.' },
218
- goal_contains: { type: 'string', description: 'Substring filter on handoff content (goal/notes/decisions). Case-insensitive. Lets agents find a specific thread by what it was about.' }
218
+ goal_contains: { type: 'string', description: 'Substring filter on handoff content (goal/notes/decisions). Case-insensitive. Lets agents find a specific thread by what it was about.' },
219
+ since: { type: 'string', description: 'Lower bound on handoff timestamp (ISO 8601). Scope resume to a time window.' }
219
220
  }
220
221
  }
221
222
  },
@@ -768,15 +769,25 @@ class CFMemoryMCP {
768
769
  // tools) recover prior chat state.
769
770
  if (message.method === 'resources/read') {
770
771
  const uri = message.params?.uri;
771
- if (uri === 'cfm://resume/current' || uri === 'cfm://resume/recent') {
772
+ const isCurrent = uri === 'cfm://resume/current';
773
+ const isRecent = uri === 'cfm://resume/recent';
774
+ // cfm://resume/session/<id-or-prefix>
775
+ const sessionMatch = typeof uri === 'string' && uri.startsWith('cfm://resume/session/')
776
+ ? uri.slice('cfm://resume/session/'.length)
777
+ : null;
778
+ if (isCurrent || isRecent || sessionMatch) {
772
779
  try {
773
780
  const meta = this.getRepoMetadata();
774
781
  const args = { resume: true };
775
- if (meta.repo_path) args.repo_path = meta.repo_path;
776
- if (meta.branch) args.branch = meta.branch;
777
- const fake = { params: { name: 'retrieve_context', arguments: {} } };
778
- await this.maybeFillProjectId(fake);
779
- if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
782
+ if (sessionMatch) {
783
+ args.session_id_hint = sessionMatch;
784
+ } else {
785
+ if (meta.repo_path) args.repo_path = meta.repo_path;
786
+ if (meta.branch) args.branch = meta.branch;
787
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
788
+ await this.maybeFillProjectId(fake);
789
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
790
+ }
780
791
  const bootstrap = await this.makeRequest({
781
792
  jsonrpc: '2.0',
782
793
  id: `resource-read-${Date.now()}`,
@@ -786,7 +797,7 @@ class CFMemoryMCP {
786
797
  const text = bootstrap?.result?.content?.[0]?.text || '{}';
787
798
  // For cfm://resume/recent, surface only the recent_handoffs summaries.
788
799
  let payload = text;
789
- if (uri === 'cfm://resume/recent') {
800
+ if (isRecent) {
790
801
  try {
791
802
  const parsed = JSON.parse(text);
792
803
  payload = JSON.stringify({
@@ -817,19 +828,29 @@ class CFMemoryMCP {
817
828
  id: message.id,
818
829
  error: {
819
830
  code: -32602,
820
- message: `Unknown resource URI: ${uri}. Supported: cfm://resume/current, cfm://resume/recent`,
831
+ message: `Unknown resource URI: ${uri}. Supported: cfm://resume/current, cfm://resume/recent, cfm://resume/session/<id-or-prefix>`,
821
832
  },
822
833
  };
823
834
  process.stdout.write(JSON.stringify(response) + '\n');
824
835
  return;
825
836
  }
826
837
 
827
- // Handle resources/templates/list locally (we have none)
838
+ // Handle resources/templates/list advertise the parameterized
839
+ // per-session resume URI so clients can discover the template.
828
840
  if (message.method === 'resources/templates/list') {
829
841
  const response = {
830
842
  jsonrpc: '2.0',
831
843
  id: message.id,
832
- result: { resourceTemplates: [] }
844
+ result: {
845
+ resourceTemplates: [
846
+ {
847
+ uriTemplate: 'cfm://resume/session/{session_id}',
848
+ name: 'Specific session handoff',
849
+ description: 'Fetch a specific session\'s handoff by full UUID or short prefix (>=8 chars).',
850
+ mimeType: 'application/json',
851
+ },
852
+ ],
853
+ },
833
854
  };
834
855
  process.stdout.write(JSON.stringify(response) + '\n');
835
856
  return;
@@ -945,7 +966,27 @@ class CFMemoryMCP {
945
966
  // (try/catch'd), and the agent's hand-written list always wins.
946
967
  this.trackToolActivity(message);
947
968
 
948
- let response = await this.makeRequest(message);
969
+ let response;
970
+ try {
971
+ response = await this.makeRequest(message);
972
+ } catch (err) {
973
+ // Network failure / worker unreachable. For resume bootstrap
974
+ // calls, serve from local disk so the agent isn't stuck
975
+ // when Cloudflare is down or the user is offline.
976
+ if (message.method === 'tools/call' &&
977
+ message.params?.name === 'get_context_bootstrap' &&
978
+ message.params.arguments?.resume) {
979
+ const fallback = this.loadResumeFromDisk(message.id);
980
+ if (fallback) {
981
+ _mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} fallback=disk elapsed=${Date.now()-_t0}ms`);
982
+ process.stdout.write(JSON.stringify(fallback) + '\n');
983
+ return;
984
+ }
985
+ }
986
+ // Not a resume call, or no disk fallback available: surface
987
+ // the original error to the client.
988
+ throw err;
989
+ }
949
990
  _mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} elapsed=${Date.now()-_t0}ms`);
950
991
 
951
992
  // Annotate retrieve_context results with local staleness when
@@ -988,14 +1029,21 @@ class CFMemoryMCP {
988
1029
  this.cacheImplicitSessionFromResponse(response);
989
1030
  }
990
1031
 
991
- // Inject bridge-side git diff into resume responses. Lets the
992
- // agent see what's changed in the repo since the handoff was
993
- // written files modified, commits added, branch moves
994
- // without having to call git themselves.
1032
+ // Resume bootstrap: if the server returned an error envelope
1033
+ // (not just a fetch failure), try the disk fallback. Then
1034
+ // run the normal diff injection + disk persist for success.
995
1035
  if (message.method === 'tools/call' &&
996
1036
  message.params?.name === 'get_context_bootstrap' &&
997
1037
  message.params.arguments?.resume) {
1038
+ if (response?.error) {
1039
+ const fallback = this.loadResumeFromDisk(message.id);
1040
+ if (fallback) {
1041
+ response = fallback;
1042
+ }
1043
+ }
998
1044
  this.injectRepoDiffIntoResume(response);
1045
+ // Persist to disk for offline fallback on the next call.
1046
+ this.saveResumeToDisk(response);
999
1047
  }
1000
1048
 
1001
1049
  // Send response to stdout
@@ -2744,6 +2792,92 @@ class CFMemoryMCP {
2744
2792
  .join('\n');
2745
2793
  }
2746
2794
 
2795
+ /**
2796
+ * Local-disk cache for the resume packet. Path is
2797
+ * ~/.cf-memory/handoff-<sha256-prefix-of-cwd>.json
2798
+ * Used as a fallback when the worker is unreachable (network down,
2799
+ * Cloudflare outage). Refreshed on every successful resume fetch.
2800
+ */
2801
+ getDiskCachePath() {
2802
+ try {
2803
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
2804
+ const crypto = require('crypto');
2805
+ const hash = crypto.createHash('sha256').update(cwd).digest('hex').slice(0, 16);
2806
+ const dir = path.join(os.homedir(), '.cf-memory');
2807
+ return path.join(dir, `handoff-${hash}.json`);
2808
+ } catch (_) { return null; }
2809
+ }
2810
+
2811
+ /**
2812
+ * Persist a resume bootstrap response to local disk. Called after
2813
+ * a successful resume fetch (prewarm or live). Fire-and-forget;
2814
+ * disk errors are non-fatal.
2815
+ */
2816
+ saveResumeToDisk(response) {
2817
+ try {
2818
+ const optOut = process.env.CF_MEMORY_DISK_CACHE;
2819
+ if (optOut === '0' || optOut === 'false' || optOut === 'off') return;
2820
+ const p = this.getDiskCachePath();
2821
+ if (!p) return;
2822
+ const text = response?.result?.content?.[0]?.text;
2823
+ if (!text) return;
2824
+ // Only persist when there's actually a handoff to cache.
2825
+ try {
2826
+ const parsed = JSON.parse(text);
2827
+ if (!parsed?.resume_handoff) return;
2828
+ } catch (_) { return; }
2829
+ const dir = path.dirname(p);
2830
+ if (!fs.existsSync(dir)) {
2831
+ fs.mkdirSync(dir, { recursive: true });
2832
+ }
2833
+ const entry = {
2834
+ cached_at: new Date().toISOString(),
2835
+ cwd: process.env.CF_MEMORY_WATCH_PATH || process.cwd(),
2836
+ response_text: text,
2837
+ };
2838
+ fs.writeFileSync(p, JSON.stringify(entry, null, 2));
2839
+ _mcpTrace('DISK_CACHE', `saved resume packet to ${p}`);
2840
+ } catch (err) {
2841
+ this.logDebug(`saveResumeToDisk failed: ${err && err.message}`);
2842
+ }
2843
+ }
2844
+
2845
+ /**
2846
+ * Load the most-recent disk-cached resume packet for the current cwd.
2847
+ * Returns a response-shaped object the bridge can write to stdout, or
2848
+ * null when nothing cached / disk unreadable. The returned envelope
2849
+ * is tagged with a `from_disk_cache:true` marker + `disk_cache_age`
2850
+ * so the agent knows this is a fallback, not a fresh fetch.
2851
+ */
2852
+ loadResumeFromDisk(messageId) {
2853
+ try {
2854
+ const p = this.getDiskCachePath();
2855
+ if (!p || !fs.existsSync(p)) return null;
2856
+ const entry = JSON.parse(fs.readFileSync(p, 'utf8'));
2857
+ const parsed = JSON.parse(entry.response_text);
2858
+ const cachedAt = entry.cached_at;
2859
+ const ageMinutes = Math.round((Date.now() - new Date(cachedAt).getTime()) / 60000);
2860
+ // Annotate the envelope so the agent knows it's fallback.
2861
+ if (parsed.resume_handoff) {
2862
+ parsed.resume_handoff.from_disk_cache = true;
2863
+ parsed.resume_handoff.disk_cache_age_minutes = ageMinutes;
2864
+ parsed.resume_handoff.disk_cache_cached_at = cachedAt;
2865
+ }
2866
+ // Replace empty_hint with one that explains the fallback.
2867
+ parsed.empty_hint = `Worker unreachable; served from local disk cache (cached ${ageMinutes} min ago at ${cachedAt}). Refresh by calling get_context_bootstrap when the worker is back online.`;
2868
+ const response = {
2869
+ jsonrpc: '2.0',
2870
+ id: messageId,
2871
+ result: { content: [{ type: 'text', text: JSON.stringify(parsed) }] },
2872
+ };
2873
+ _mcpTrace('DISK_CACHE', `served resume from disk (age=${ageMinutes}min)`);
2874
+ return response;
2875
+ } catch (err) {
2876
+ this.logDebug(`loadResumeFromDisk failed: ${err && err.message}`);
2877
+ return null;
2878
+ }
2879
+ }
2880
+
2747
2881
  /**
2748
2882
  * Pre-fetch the resume handoff for the current cwd at bridge startup.
2749
2883
  * The result is cached and surfaced when the agent's first call is
@@ -2785,6 +2919,8 @@ class CFMemoryMCP {
2785
2919
  const parsed = JSON.parse(text || '{}');
2786
2920
  handoff_present = !!parsed.resume_handoff;
2787
2921
  } catch (_) { /* ignore */ }
2922
+ // Persist to disk for offline fallback. Best-effort.
2923
+ if (handoff_present) this.saveResumeToDisk(response);
2788
2924
  _mcpTrace('RESUME_PREWARM', `elapsed=${elapsed}ms handoff_present=${handoff_present}`);
2789
2925
  } catch (err) {
2790
2926
  this.logDebug(`prewarmResumeContext failed: ${err && err.message}`);
@@ -3444,6 +3580,8 @@ A portable MCP (Model Context Protocol) server for AI memory storage using Cloud
3444
3580
 
3445
3581
  Usage:
3446
3582
  npx cf-memory-mcp Start the MCP server
3583
+ npx cf-memory-mcp resume Print the prior resume handoff for cwd (markdown)
3584
+ npx cf-memory-mcp resume <id> Print a specific handoff by session_id prefix
3447
3585
  npx cf-memory-mcp --version Show version
3448
3586
  npx cf-memory-mcp --help Show this help
3449
3587
  npx cf-memory-mcp --diagnose Test connectivity and report issues
@@ -3460,6 +3598,70 @@ For more information, visit: https://github.com/johnlam90/cf-memory-mcp
3460
3598
  process.exit(0);
3461
3599
  }
3462
3600
 
3601
+ // Read-only inspection command: print the prior resume handoff to stdout.
3602
+ // Lets a human see what state is captured without involving an MCP client.
3603
+ async function runResumeCli() {
3604
+ if (!API_KEY) {
3605
+ console.error('Error: CF_MEMORY_API_KEY environment variable is required');
3606
+ process.exit(1);
3607
+ }
3608
+ const server = new CFMemoryMCP();
3609
+ server.logDebug = () => {};
3610
+ server.logError = (...a) => process.stderr.write(a.join(' ') + '\n');
3611
+ try {
3612
+ const meta = server.getRepoMetadata();
3613
+ const args = { resume: true };
3614
+ if (meta.repo_path) args.repo_path = meta.repo_path;
3615
+ if (meta.branch) args.branch = meta.branch;
3616
+ // Optional positional session_id argument.
3617
+ const sessionArg = process.argv[3];
3618
+ if (sessionArg) args.session_id_hint = sessionArg;
3619
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
3620
+ await server.maybeFillProjectId(fake);
3621
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
3622
+
3623
+ const response = await server.makeRequest({
3624
+ jsonrpc: '2.0',
3625
+ id: `cli-resume-${Date.now()}`,
3626
+ method: 'tools/call',
3627
+ params: { name: 'get_context_bootstrap', arguments: args },
3628
+ });
3629
+ const text = response?.result?.content?.[0]?.text;
3630
+ if (!text) {
3631
+ console.error('No response from server.');
3632
+ process.exit(2);
3633
+ }
3634
+ const payload = JSON.parse(text);
3635
+ if (payload.resume_handoff?.handoff) {
3636
+ if (payload.resume_prompt) {
3637
+ process.stdout.write(payload.resume_prompt + '\n');
3638
+ } else {
3639
+ process.stdout.write(JSON.stringify(payload.resume_handoff, null, 2) + '\n');
3640
+ }
3641
+ if (Array.isArray(payload.recent_handoffs) && payload.recent_handoffs.length > 1) {
3642
+ process.stdout.write('\n---\nOther recent handoffs:\n');
3643
+ for (const h of payload.recent_handoffs.slice(0, 4)) {
3644
+ const shortId = (h.session_id || '').slice(0, 8);
3645
+ process.stdout.write(` ${shortId} [${h.status || '?'}] ${h.goal || ''}\n`);
3646
+ }
3647
+ process.stdout.write(`(Pass any short id as: npx cf-memory-mcp resume <id>)\n`);
3648
+ }
3649
+ process.exit(0);
3650
+ } else {
3651
+ process.stdout.write((payload.empty_hint || 'No resume handoff available.') + '\n');
3652
+ process.exit(0);
3653
+ }
3654
+ } catch (err) {
3655
+ console.error('resume command failed:', err.message);
3656
+ process.exit(1);
3657
+ }
3658
+ }
3659
+
3660
+ if (process.argv[2] === 'resume') {
3661
+ runResumeCli();
3662
+ return;
3663
+ }
3664
+
3463
3665
  if (process.argv.includes('--diagnose')) {
3464
3666
  (async () => {
3465
3667
  console.log(`CF Memory MCP v${PACKAGE_VERSION} - Diagnostics`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cf-memory-mcp",
3
- "version": "3.18.0",
3
+ "version": "3.20.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": {