cf-memory-mcp 3.22.0 → 3.24.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 +284 -16
  2. package/package.json +1 -1
@@ -216,7 +216,8 @@ const TOOLS_LIST = [
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
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
+ since: { type: 'string', description: 'Lower bound on handoff timestamp (ISO 8601). Scope resume to a time window.' },
220
+ status_filter: { description: 'Restrict matches to handoffs with this status. Single string or array of strings (e.g., ["in_progress","blocked"]).' }
220
221
  }
221
222
  }
222
223
  },
@@ -433,13 +434,40 @@ class CFMemoryMCP {
433
434
  this.logDebug(`User Agent: ${this.userAgent}`);
434
435
  }
435
436
 
437
+ /**
438
+ * Append a line to the persistent bridge log file if configured.
439
+ * Honors CF_MEMORY_LOG_FILE (explicit path) or CF_MEMORY_LOG=1/true
440
+ * (defaults to ~/.cf-memory/bridge.log). Bounded: rotates when the
441
+ * file exceeds 5MB to prevent runaway disk usage.
442
+ */
443
+ appendBridgeLog(line) {
444
+ try {
445
+ const explicit = process.env.CF_MEMORY_LOG_FILE;
446
+ const enabled = explicit || process.env.CF_MEMORY_LOG === '1' || process.env.CF_MEMORY_LOG === 'true';
447
+ if (!enabled) return;
448
+ const target = explicit || path.join(os.homedir(), '.cf-memory', 'bridge.log');
449
+ const dir = path.dirname(target);
450
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
451
+ // Rotate if file > 5MB.
452
+ try {
453
+ const st = fs.statSync(target);
454
+ if (st.size > 5 * 1024 * 1024) {
455
+ fs.renameSync(target, target + '.1');
456
+ }
457
+ } catch (_) { /* file doesn't exist yet */ }
458
+ fs.appendFileSync(target, line);
459
+ } catch (_) { /* logging failure is non-fatal */ }
460
+ }
461
+
436
462
  /**
437
463
  * Log debug messages to stderr (won't interfere with MCP communication)
438
464
  */
439
465
  logDebug(message) {
466
+ const line = `[DEBUG] ${new Date().toISOString()} ${message}\n`;
440
467
  if (process.env.DEBUG || process.env.MCP_DEBUG) {
441
- process.stderr.write(`[DEBUG] ${new Date().toISOString()} ${message}\n`);
468
+ process.stderr.write(line);
442
469
  }
470
+ this.appendBridgeLog(line);
443
471
  }
444
472
 
445
473
  /**
@@ -447,9 +475,13 @@ class CFMemoryMCP {
447
475
  */
448
476
  logError(message, error = null) {
449
477
  const timestamp = new Date().toISOString();
450
- process.stderr.write(`[ERROR] ${timestamp} ${message}\n`);
478
+ const line1 = `[ERROR] ${timestamp} ${message}\n`;
479
+ process.stderr.write(line1);
480
+ this.appendBridgeLog(line1);
451
481
  if (error && error.stack) {
452
- process.stderr.write(`[ERROR] ${timestamp} ${error.stack}\n`);
482
+ const line2 = `[ERROR] ${timestamp} ${error.stack}\n`;
483
+ process.stderr.write(line2);
484
+ this.appendBridgeLog(line2);
453
485
  }
454
486
  }
455
487
 
@@ -695,7 +727,11 @@ class CFMemoryMCP {
695
727
  // cfm://resume/current and recent handoffs at
696
728
  // cfm://resume/recent for clients that prefer the
697
729
  // resources protocol over tool calls.
698
- resources: {}
730
+ resources: {},
731
+ // Prompts capability exposes /resume and /list_threads
732
+ // for clients (Claude Desktop, etc.) that surface
733
+ // prompts as slash-commands in their UI.
734
+ prompts: {}
699
735
  },
700
736
  serverInfo: {
701
737
  name: 'cf-memory-mcp-simplified',
@@ -856,17 +892,147 @@ class CFMemoryMCP {
856
892
  return;
857
893
  }
858
894
 
859
- // Handle prompts/list locally (we have none)
895
+ // Handle prompts/list advertise the resume + threads prompts.
896
+ // These let MCP clients (Claude Desktop, etc.) show a slash-
897
+ // command UI like /resume that injects the prior handoff into
898
+ // the next message without an explicit tool call.
860
899
  if (message.method === 'prompts/list') {
861
900
  const response = {
862
901
  jsonrpc: '2.0',
863
902
  id: message.id,
864
- result: { prompts: [] }
903
+ result: {
904
+ prompts: [
905
+ {
906
+ name: 'resume',
907
+ description: 'Insert the prior resume handoff for this repo into the chat so the agent can pick up where the last session left off.',
908
+ arguments: [
909
+ { name: 'session_id', description: 'Optional: resume a specific session (full UUID or short prefix >=8 chars).', required: false },
910
+ ],
911
+ },
912
+ {
913
+ name: 'list_threads',
914
+ description: 'Insert a list of recent handoffs for this repo so the user can pick a specific thread to resume.',
915
+ arguments: [
916
+ { name: 'status', description: 'Optional: filter by status (in_progress, blocked, completed, abandoned).', required: false },
917
+ ],
918
+ },
919
+ ],
920
+ },
865
921
  };
866
922
  process.stdout.write(JSON.stringify(response) + '\n');
867
923
  return;
868
924
  }
869
925
 
926
+ // Handle prompts/get — render the requested prompt with current
927
+ // resume state baked in.
928
+ if (message.method === 'prompts/get') {
929
+ const promptName = message.params?.name;
930
+ const promptArgs = message.params?.arguments || {};
931
+ try {
932
+ if (promptName === 'resume') {
933
+ const meta = this.getRepoMetadata();
934
+ const args = { resume: true };
935
+ if (meta.repo_path) args.repo_path = meta.repo_path;
936
+ if (meta.branch) args.branch = meta.branch;
937
+ if (promptArgs.session_id) args.session_id_hint = promptArgs.session_id;
938
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
939
+ await this.maybeFillProjectId(fake);
940
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
941
+ const bootstrap = await this.makeRequest({
942
+ jsonrpc: '2.0',
943
+ id: `prompt-resume-${Date.now()}`,
944
+ method: 'tools/call',
945
+ params: { name: 'get_context_bootstrap', arguments: args },
946
+ });
947
+ const text = bootstrap?.result?.content?.[0]?.text || '{}';
948
+ const payload = JSON.parse(text);
949
+ const body = payload.resume_prompt
950
+ || (payload.empty_hint || 'No prior resume handoff for this context.');
951
+ const response = {
952
+ jsonrpc: '2.0',
953
+ id: message.id,
954
+ result: {
955
+ description: `Resume context for ${meta.repo_path || 'this cwd'}`,
956
+ messages: [
957
+ { role: 'user', content: { type: 'text', text: body } },
958
+ ],
959
+ },
960
+ };
961
+ process.stdout.write(JSON.stringify(response) + '\n');
962
+ return;
963
+ }
964
+ if (promptName === 'list_threads') {
965
+ const meta = this.getRepoMetadata();
966
+ const args = { resume: true };
967
+ if (meta.repo_path) args.repo_path = meta.repo_path;
968
+ if (meta.branch) args.branch = meta.branch;
969
+ if (promptArgs.status) args.status_filter = promptArgs.status;
970
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
971
+ await this.maybeFillProjectId(fake);
972
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
973
+ const bootstrap = await this.makeRequest({
974
+ jsonrpc: '2.0',
975
+ id: `prompt-list-${Date.now()}`,
976
+ method: 'tools/call',
977
+ params: { name: 'get_context_bootstrap', arguments: args },
978
+ });
979
+ const text = bootstrap?.result?.content?.[0]?.text || '{}';
980
+ const payload = JSON.parse(text);
981
+ const list = Array.isArray(payload.recent_handoffs) ? payload.recent_handoffs : [];
982
+ const fmtAge = (iso) => {
983
+ if (!iso) return '?';
984
+ const min = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
985
+ if (min < 60) return `${min}m`;
986
+ if (min < 1440) return `${Math.round(min/60)}h`;
987
+ return `${Math.round(min/1440)}d`;
988
+ };
989
+ const lines = ['## Recent handoffs for this repo', ''];
990
+ if (list.length === 0) {
991
+ lines.push(payload.empty_hint || '(no handoffs)');
992
+ } else {
993
+ for (const h of list) {
994
+ const shortId = (h.session_id || '').slice(0, 8);
995
+ const age = fmtAge(h.ended_at || h.started_at);
996
+ lines.push(`- \`${shortId}\` [${h.status || '?'}] ${age} ago — ${h.goal || '(no goal)'}`);
997
+ if (h.next_step) lines.push(` next: ${h.next_step}`);
998
+ }
999
+ lines.push('', 'Pick one via `/resume <id>` or call `get_context_bootstrap({resume:true, session_id_hint:"<id>"})`.');
1000
+ }
1001
+ const response = {
1002
+ jsonrpc: '2.0',
1003
+ id: message.id,
1004
+ result: {
1005
+ description: `Recent handoffs for ${meta.repo_path || 'this cwd'}`,
1006
+ messages: [
1007
+ { role: 'user', content: { type: 'text', text: lines.join('\n') } },
1008
+ ],
1009
+ },
1010
+ };
1011
+ process.stdout.write(JSON.stringify(response) + '\n');
1012
+ return;
1013
+ }
1014
+ // Unknown prompt name
1015
+ const response = {
1016
+ jsonrpc: '2.0',
1017
+ id: message.id,
1018
+ error: {
1019
+ code: -32602,
1020
+ message: `Unknown prompt: ${promptName}. Supported: resume, list_threads`,
1021
+ },
1022
+ };
1023
+ process.stdout.write(JSON.stringify(response) + '\n');
1024
+ return;
1025
+ } catch (err) {
1026
+ const response = {
1027
+ jsonrpc: '2.0',
1028
+ id: message.id,
1029
+ error: { code: -32603, message: `Failed to render prompt: ${err && err.message}` },
1030
+ };
1031
+ process.stdout.write(JSON.stringify(response) + '\n');
1032
+ return;
1033
+ }
1034
+ }
1035
+
870
1036
  // Handle ping locally
871
1037
  if (message.method === 'ping') {
872
1038
  const response = {
@@ -3583,6 +3749,7 @@ Usage:
3583
3749
  npx cf-memory-mcp resume [id] Print the prior resume handoff (markdown)
3584
3750
  npx cf-memory-mcp list List recent handoffs for cwd
3585
3751
  npx cf-memory-mcp checkpoint ["<goal>"] Snapshot current state (keep_open)
3752
+ npx cf-memory-mcp status Show bridge state + server resume availability
3586
3753
  npx cf-memory-mcp --version Show version
3587
3754
  npx cf-memory-mcp --help Show this help
3588
3755
  npx cf-memory-mcp --diagnose Test connectivity and report issues
@@ -3657,13 +3824,15 @@ async function runResumeCli() {
3657
3824
  process.exit(2);
3658
3825
  }
3659
3826
  const payload = JSON.parse(text);
3660
- // JSON output mode: emit the full payload as a single JSON object.
3661
- // Scripts can pipe through jq to extract specific fields.
3827
+ // Exit codes:
3828
+ // 0 handoff found and printed
3829
+ // 3 — no handoff found (lets scripts branch: `if ! cf-memory-mcp resume; then ...`)
3830
+ const found = !!payload.resume_handoff?.handoff;
3662
3831
  if (flags.json) {
3663
3832
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
3664
- process.exit(payload.resume_handoff ? 0 : 0);
3833
+ process.exit(found ? 0 : 3);
3665
3834
  }
3666
- if (payload.resume_handoff?.handoff) {
3835
+ if (found) {
3667
3836
  if (payload.resume_prompt) {
3668
3837
  process.stdout.write(payload.resume_prompt + '\n');
3669
3838
  } else {
@@ -3679,8 +3848,10 @@ async function runResumeCli() {
3679
3848
  }
3680
3849
  process.exit(0);
3681
3850
  } else {
3682
- process.stdout.write((payload.empty_hint || 'No resume handoff available.') + '\n');
3683
- process.exit(0);
3851
+ // No handoff: print empty_hint to stderr so it doesn't pollute
3852
+ // stdout for scripts that pipe the output.
3853
+ process.stderr.write((payload.empty_hint || 'No resume handoff available.') + '\n');
3854
+ process.exit(3);
3684
3855
  }
3685
3856
  } catch (err) {
3686
3857
  console.error('resume command failed:', err.message);
@@ -3725,11 +3896,11 @@ async function runListCli() {
3725
3896
  handoffs: list,
3726
3897
  empty_hint: list.length === 0 ? payload.empty_hint : undefined,
3727
3898
  }, null, 2) + '\n');
3728
- process.exit(0);
3899
+ process.exit(list.length === 0 ? 3 : 0);
3729
3900
  }
3730
3901
  if (list.length === 0) {
3731
- process.stdout.write((payload.empty_hint || 'No handoffs for this context.') + '\n');
3732
- process.exit(0);
3902
+ process.stderr.write((payload.empty_hint || 'No handoffs for this context.') + '\n');
3903
+ process.exit(3);
3733
3904
  }
3734
3905
  // Render a compact table to terminal.
3735
3906
  process.stdout.write(`Recent handoffs for ${meta.repo_path || 'this cwd'}:\n\n`);
@@ -3757,6 +3928,98 @@ async function runListCli() {
3757
3928
  }
3758
3929
  }
3759
3930
 
3931
+ async function runStatusCli() {
3932
+ const { flags } = parseCliArgs(process.argv.slice(3));
3933
+ const server = new CFMemoryMCP();
3934
+ server.logDebug = () => {};
3935
+ server.logError = (...a) => process.stderr.write(a.join(' ') + '\n');
3936
+ try {
3937
+ // Local bridge state (no network call required).
3938
+ const meta = server.getRepoMetadata();
3939
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
3940
+ const diskCachePath = server.getDiskCachePath();
3941
+ const diskCacheExists = diskCachePath && fs.existsSync(diskCachePath);
3942
+ let diskCacheAge = null;
3943
+ if (diskCacheExists) {
3944
+ try {
3945
+ const entry = JSON.parse(fs.readFileSync(diskCachePath, 'utf8'));
3946
+ diskCacheAge = Math.round((Date.now() - new Date(entry.cached_at).getTime()) / 60000);
3947
+ } catch (_) { /* unreadable cache */ }
3948
+ }
3949
+ // Lookup the live worker count of handoffs for this cwd (best-effort).
3950
+ let serverHandoffCount = null;
3951
+ let resumeAvailable = false;
3952
+ let latestGoal = null;
3953
+ if (API_KEY) {
3954
+ try {
3955
+ const args = { resume: true };
3956
+ if (meta.repo_path) args.repo_path = meta.repo_path;
3957
+ if (meta.branch) args.branch = meta.branch;
3958
+ const fake = { params: { name: 'retrieve_context', arguments: {} } };
3959
+ await server.maybeFillProjectId(fake);
3960
+ if (fake.params.arguments.project_id) args.project_id = fake.params.arguments.project_id;
3961
+ const response = await server.makeRequest({
3962
+ jsonrpc: '2.0',
3963
+ id: `cli-status-${Date.now()}`,
3964
+ method: 'tools/call',
3965
+ params: { name: 'get_context_bootstrap', arguments: args },
3966
+ });
3967
+ const text = response?.result?.content?.[0]?.text;
3968
+ const payload = JSON.parse(text || '{}');
3969
+ serverHandoffCount = Array.isArray(payload.recent_handoffs) ? payload.recent_handoffs.length : 0;
3970
+ resumeAvailable = !!payload.resume_handoff;
3971
+ latestGoal = payload.resume_handoff?.handoff?.goal || null;
3972
+ } catch (err) {
3973
+ // Network unreachable — that's OK, we still have local info.
3974
+ }
3975
+ }
3976
+
3977
+ const status = {
3978
+ version: PACKAGE_VERSION,
3979
+ cwd,
3980
+ repo_path: meta.repo_path || null,
3981
+ branch: meta.branch || null,
3982
+ base_url: BASE_URL,
3983
+ api_key_set: !!API_KEY,
3984
+ disk_cache: diskCacheExists
3985
+ ? { path: diskCachePath, age_minutes: diskCacheAge }
3986
+ : null,
3987
+ server: API_KEY
3988
+ ? { handoff_count: serverHandoffCount, resume_available: resumeAvailable, latest_goal: latestGoal }
3989
+ : { reachable: false, reason: 'CF_MEMORY_API_KEY not set' },
3990
+ };
3991
+
3992
+ if (flags.json) {
3993
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
3994
+ process.exit(0);
3995
+ }
3996
+ process.stdout.write(`cf-memory-mcp v${PACKAGE_VERSION}\n`);
3997
+ process.stdout.write(` cwd: ${cwd}\n`);
3998
+ if (meta.repo_path) process.stdout.write(` repo: ${meta.repo_path}\n`);
3999
+ if (meta.branch) process.stdout.write(` branch: ${meta.branch}\n`);
4000
+ process.stdout.write(` server: ${BASE_URL}${API_KEY ? '' : ' (no API key set)'}\n`);
4001
+ if (diskCacheExists) {
4002
+ process.stdout.write(` cache: ${diskCachePath} (${diskCacheAge}m old)\n`);
4003
+ } else {
4004
+ process.stdout.write(` cache: (none)\n`);
4005
+ }
4006
+ if (API_KEY) {
4007
+ if (resumeAvailable) {
4008
+ process.stdout.write(` resume: ✓ available — "${latestGoal || '(no goal)'}"\n`);
4009
+ } else {
4010
+ process.stdout.write(` resume: (no handoff for this context)\n`);
4011
+ }
4012
+ if (serverHandoffCount !== null) {
4013
+ process.stdout.write(` threads: ${serverHandoffCount} recent handoff${serverHandoffCount === 1 ? '' : 's'}\n`);
4014
+ }
4015
+ }
4016
+ process.exit(0);
4017
+ } catch (err) {
4018
+ console.error('status command failed:', err.message);
4019
+ process.exit(1);
4020
+ }
4021
+ }
4022
+
3760
4023
  async function runCheckpointCli() {
3761
4024
  if (!API_KEY) {
3762
4025
  console.error('Error: CF_MEMORY_API_KEY environment variable is required');
@@ -3844,6 +4107,11 @@ if (process.argv[2] === 'checkpoint') {
3844
4107
  return;
3845
4108
  }
3846
4109
 
4110
+ if (process.argv[2] === 'status') {
4111
+ runStatusCli();
4112
+ return;
4113
+ }
4114
+
3847
4115
  if (process.argv.includes('--diagnose')) {
3848
4116
  (async () => {
3849
4117
  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.22.0",
3
+ "version": "3.24.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": {