cf-memory-mcp 3.14.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.
- package/bin/cf-memory-mcp.js +216 -11
- package/package.json +1 -1
package/bin/cf-memory-mcp.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
2403
|
-
* just the fields the bridge can
|
|
2404
|
-
* fields like decisions/blockers
|
|
2405
|
-
*
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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": {
|