cf-memory-mcp 3.10.1 → 3.12.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 +251 -9
- package/package.json +1 -1
package/bin/cf-memory-mcp.js
CHANGED
|
@@ -792,6 +792,11 @@ class CFMemoryMCP {
|
|
|
792
792
|
await this.maybeAttachResumeMetadata(message);
|
|
793
793
|
}
|
|
794
794
|
|
|
795
|
+
// Record which files the agent is touching so end_session can
|
|
796
|
+
// auto-populate handoff.files_read. Cheap (in-memory Map), safe
|
|
797
|
+
// (try/catch'd), and the agent's hand-written list always wins.
|
|
798
|
+
this.trackToolActivity(message);
|
|
799
|
+
|
|
795
800
|
let response = await this.makeRequest(message);
|
|
796
801
|
_mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} elapsed=${Date.now()-_t0}ms`);
|
|
797
802
|
|
|
@@ -801,6 +806,9 @@ class CFMemoryMCP {
|
|
|
801
806
|
// acting on stale indexed content as if it were current.
|
|
802
807
|
if (message.method === 'tools/call' &&
|
|
803
808
|
message.params && message.params.name === 'retrieve_context') {
|
|
809
|
+
// Track which files the response surfaced — these are what
|
|
810
|
+
// the agent will read next, and they belong in handoff.files_read.
|
|
811
|
+
this.trackRetrieveContextResponse(response);
|
|
804
812
|
this.maybeAnnotateStaleness(response, message.params.arguments);
|
|
805
813
|
|
|
806
814
|
// Auto-refresh path: when CF_MEMORY_AUTO_REFRESH=true OR the
|
|
@@ -818,6 +826,20 @@ class CFMemoryMCP {
|
|
|
818
826
|
}
|
|
819
827
|
}
|
|
820
828
|
|
|
829
|
+
// After end_session without keep_open, drop the implicit-session
|
|
830
|
+
// cache so a subsequent end_session creates a fresh session.
|
|
831
|
+
if (message.method === 'tools/call' && message.params?.name === 'end_session') {
|
|
832
|
+
this.clearImplicitSessionIfFinalized(message, response);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// After explicit start_session, cache the returned session_id as
|
|
836
|
+
// the implicit session for this cwd. Lets a subsequent
|
|
837
|
+
// end_session({handoff:{...}}) without session_id resolve to
|
|
838
|
+
// the just-created one.
|
|
839
|
+
if (message.method === 'tools/call' && message.params?.name === 'start_session') {
|
|
840
|
+
this.cacheImplicitSessionFromResponse(response);
|
|
841
|
+
}
|
|
842
|
+
|
|
821
843
|
// Send response to stdout
|
|
822
844
|
process.stdout.write(JSON.stringify(response) + '\n');
|
|
823
845
|
|
|
@@ -2111,17 +2133,35 @@ class CFMemoryMCP {
|
|
|
2111
2133
|
return;
|
|
2112
2134
|
}
|
|
2113
2135
|
|
|
2114
|
-
if (toolName === 'end_session'
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
await this.
|
|
2121
|
-
if (
|
|
2122
|
-
args.
|
|
2136
|
+
if (toolName === 'end_session') {
|
|
2137
|
+
// session_id auto-fill: if the caller didn't pass one, use
|
|
2138
|
+
// the implicit session for this cwd. Lets agents call
|
|
2139
|
+
// end_session({handoff:{...}}) without threading session_id
|
|
2140
|
+
// through every checkpoint.
|
|
2141
|
+
if (!args.session_id) {
|
|
2142
|
+
const implicit = await this.getOrCreateImplicitSession();
|
|
2143
|
+
if (implicit) {
|
|
2144
|
+
args.session_id = implicit;
|
|
2145
|
+
_mcpTrace('IMPLICIT_SESSION', `end_session using implicit session=${implicit}`);
|
|
2123
2146
|
}
|
|
2124
2147
|
}
|
|
2148
|
+
if (args.handoff && typeof args.handoff === 'object') {
|
|
2149
|
+
const meta = this.getRepoMetadata();
|
|
2150
|
+
if (!args.handoff.repo_path && meta.repo_path) args.handoff.repo_path = meta.repo_path;
|
|
2151
|
+
if (!args.handoff.branch && meta.branch) args.handoff.branch = meta.branch;
|
|
2152
|
+
if (!args.handoff.project_id) {
|
|
2153
|
+
const fake = { params: { name: 'retrieve_context', arguments: {} } };
|
|
2154
|
+
await this.maybeFillProjectId(fake);
|
|
2155
|
+
if (fake.params.arguments.project_id) {
|
|
2156
|
+
args.handoff.project_id = fake.params.arguments.project_id;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
// Auto-fill files_read from observed retrieve_context /
|
|
2160
|
+
// get_file_content / get_file_outline calls during this
|
|
2161
|
+
// bridge session, if the agent didn't supply them. The
|
|
2162
|
+
// agent's hand-written files_touched stays authoritative.
|
|
2163
|
+
this.autoFillHandoffFilesRead(args.handoff);
|
|
2164
|
+
}
|
|
2125
2165
|
return;
|
|
2126
2166
|
}
|
|
2127
2167
|
} catch (err) {
|
|
@@ -2129,6 +2169,208 @@ class CFMemoryMCP {
|
|
|
2129
2169
|
}
|
|
2130
2170
|
}
|
|
2131
2171
|
|
|
2172
|
+
/**
|
|
2173
|
+
* Return the implicit session_id for the current cwd, creating one via
|
|
2174
|
+
* start_session when no session is active. Lets agents skip explicit
|
|
2175
|
+
* session management — they can go straight to end_session({handoff})
|
|
2176
|
+
* for checkpoint or finalization.
|
|
2177
|
+
*
|
|
2178
|
+
* The cache is keyed by cwd so multiple bridge processes for different
|
|
2179
|
+
* repos don't share state. Sessions finalized via end_session WITHOUT
|
|
2180
|
+
* keep_open clear the implicit cache so the next call starts fresh.
|
|
2181
|
+
*/
|
|
2182
|
+
async getOrCreateImplicitSession() {
|
|
2183
|
+
try {
|
|
2184
|
+
const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
2185
|
+
if (!this._implicitSessionByCwd) this._implicitSessionByCwd = new Map();
|
|
2186
|
+
const cached = this._implicitSessionByCwd.get(cwd);
|
|
2187
|
+
if (cached) return cached;
|
|
2188
|
+
|
|
2189
|
+
// Lazily start a session, tagged with the same repo metadata as
|
|
2190
|
+
// start_session would auto-attach if called explicitly.
|
|
2191
|
+
const meta = this.getRepoMetadata();
|
|
2192
|
+
const startArgs = { context: 'main' };
|
|
2193
|
+
if (meta.repo_path) startArgs.repo_path = meta.repo_path;
|
|
2194
|
+
if (meta.branch) startArgs.branch = meta.branch;
|
|
2195
|
+
const fake = { params: { name: 'retrieve_context', arguments: {} } };
|
|
2196
|
+
await this.maybeFillProjectId(fake);
|
|
2197
|
+
if (fake.params.arguments.project_id) startArgs.project_id = fake.params.arguments.project_id;
|
|
2198
|
+
|
|
2199
|
+
const response = await this.makeRequest({
|
|
2200
|
+
jsonrpc: '2.0',
|
|
2201
|
+
id: `auto-session-${Date.now()}`,
|
|
2202
|
+
method: 'tools/call',
|
|
2203
|
+
params: { name: 'start_session', arguments: startArgs },
|
|
2204
|
+
});
|
|
2205
|
+
let sessionId = null;
|
|
2206
|
+
try {
|
|
2207
|
+
const payload = JSON.parse(response.result.content[0].text);
|
|
2208
|
+
sessionId = payload.session_id;
|
|
2209
|
+
} catch (_) { /* malformed response */ }
|
|
2210
|
+
if (sessionId) {
|
|
2211
|
+
this._implicitSessionByCwd.set(cwd, sessionId);
|
|
2212
|
+
_mcpTrace('IMPLICIT_SESSION', `created implicit session=${sessionId} for cwd=${cwd}`);
|
|
2213
|
+
}
|
|
2214
|
+
return sessionId;
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
this.logDebug(`getOrCreateImplicitSession failed: ${err && err.message}`);
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
/**
|
|
2222
|
+
* After an explicit start_session response, cache the returned session_id
|
|
2223
|
+
* as the implicit session for the current cwd. This way the next
|
|
2224
|
+
* end_session call without a session_id picks up the right one
|
|
2225
|
+
* automatically.
|
|
2226
|
+
*/
|
|
2227
|
+
cacheImplicitSessionFromResponse(response) {
|
|
2228
|
+
try {
|
|
2229
|
+
const text = response?.result?.content?.[0]?.text;
|
|
2230
|
+
if (!text) return;
|
|
2231
|
+
let parsed;
|
|
2232
|
+
try { parsed = JSON.parse(text); } catch (_) { return; }
|
|
2233
|
+
const sessionId = parsed?.session_id;
|
|
2234
|
+
if (!sessionId) return;
|
|
2235
|
+
const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
2236
|
+
if (!this._implicitSessionByCwd) this._implicitSessionByCwd = new Map();
|
|
2237
|
+
this._implicitSessionByCwd.set(cwd, sessionId);
|
|
2238
|
+
_mcpTrace('IMPLICIT_SESSION', `cached implicit session=${sessionId} for cwd=${cwd}`);
|
|
2239
|
+
} catch (err) {
|
|
2240
|
+
this.logDebug(`cacheImplicitSessionFromResponse failed: ${err && err.message}`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Clear the implicit session for the current cwd after a non-keep_open
|
|
2246
|
+
* end_session call. Lets a subsequent checkpoint create a fresh
|
|
2247
|
+
* session instead of reusing a finalized one.
|
|
2248
|
+
*/
|
|
2249
|
+
clearImplicitSessionIfFinalized(originalMessage, response) {
|
|
2250
|
+
try {
|
|
2251
|
+
if (originalMessage?.params?.name !== 'end_session') return;
|
|
2252
|
+
const args = originalMessage.params.arguments || {};
|
|
2253
|
+
if (args.keep_open) return; // checkpoint — keep cache
|
|
2254
|
+
// Parse the response to confirm the session was actually ended
|
|
2255
|
+
// (vs. an error). Don't clear if the server errored.
|
|
2256
|
+
const text = response?.result?.content?.[0]?.text;
|
|
2257
|
+
if (!text) return;
|
|
2258
|
+
try {
|
|
2259
|
+
const parsed = JSON.parse(text);
|
|
2260
|
+
if (parsed?.error || parsed?.kept_open) return;
|
|
2261
|
+
} catch (_) { return; }
|
|
2262
|
+
|
|
2263
|
+
const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
2264
|
+
if (this._implicitSessionByCwd?.has(cwd)) {
|
|
2265
|
+
this._implicitSessionByCwd.delete(cwd);
|
|
2266
|
+
_mcpTrace('IMPLICIT_SESSION', `cleared implicit session for cwd=${cwd}`);
|
|
2267
|
+
}
|
|
2268
|
+
} catch (err) {
|
|
2269
|
+
this.logDebug(`clearImplicitSessionIfFinalized failed: ${err && err.message}`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/**
|
|
2274
|
+
* Record file paths the agent touched via retrieve_context, get_file_content,
|
|
2275
|
+
* get_file_outline, and refresh_files. Used to seed end_session.handoff.files_read
|
|
2276
|
+
* when the agent didn't bother listing them. The set is bounded so a chatty
|
|
2277
|
+
* session can't blow up memory.
|
|
2278
|
+
*/
|
|
2279
|
+
trackToolActivity(message) {
|
|
2280
|
+
try {
|
|
2281
|
+
if (message?.method !== 'tools/call' || !message.params) return;
|
|
2282
|
+
const toolName = message.params.name;
|
|
2283
|
+
const args = message.params.arguments || {};
|
|
2284
|
+
if (!this._activityFiles) this._activityFiles = new Map();
|
|
2285
|
+
|
|
2286
|
+
const noteFile = (file_path, source) => {
|
|
2287
|
+
if (typeof file_path !== 'string' || !file_path) return;
|
|
2288
|
+
// Bound to 50 entries — past that we drop oldest.
|
|
2289
|
+
if (this._activityFiles.size >= 50 && !this._activityFiles.has(file_path)) {
|
|
2290
|
+
const firstKey = this._activityFiles.keys().next().value;
|
|
2291
|
+
this._activityFiles.delete(firstKey);
|
|
2292
|
+
}
|
|
2293
|
+
const existing = this._activityFiles.get(file_path) || { sources: new Set(), count: 0 };
|
|
2294
|
+
existing.sources.add(source);
|
|
2295
|
+
existing.count += 1;
|
|
2296
|
+
existing.last_seen = Date.now();
|
|
2297
|
+
this._activityFiles.set(file_path, existing);
|
|
2298
|
+
};
|
|
2299
|
+
|
|
2300
|
+
if (toolName === 'get_file_content' || toolName === 'get_file_outline') {
|
|
2301
|
+
noteFile(args.file_path, toolName);
|
|
2302
|
+
} else if (toolName === 'refresh_files' && Array.isArray(args.file_paths)) {
|
|
2303
|
+
for (const p of args.file_paths) noteFile(p, 'refresh_files');
|
|
2304
|
+
}
|
|
2305
|
+
} catch (err) {
|
|
2306
|
+
this.logDebug(`trackToolActivity failed: ${err && err.message}`);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* Record file paths from a retrieve_context response. Called after the
|
|
2312
|
+
* server responds so we capture what was actually returned (not just
|
|
2313
|
+
* what was queried for).
|
|
2314
|
+
*/
|
|
2315
|
+
trackRetrieveContextResponse(response) {
|
|
2316
|
+
try {
|
|
2317
|
+
if (!this._activityFiles) this._activityFiles = new Map();
|
|
2318
|
+
const text = response?.result?.content?.[0]?.text;
|
|
2319
|
+
if (!text) return;
|
|
2320
|
+
let parsed;
|
|
2321
|
+
try { parsed = JSON.parse(text); } catch (_) { return; }
|
|
2322
|
+
const results = Array.isArray(parsed?.results) ? parsed.results : [];
|
|
2323
|
+
for (const r of results.slice(0, 20)) {
|
|
2324
|
+
if (typeof r?.file_path !== 'string') continue;
|
|
2325
|
+
if (this._activityFiles.size >= 50 && !this._activityFiles.has(r.file_path)) {
|
|
2326
|
+
const firstKey = this._activityFiles.keys().next().value;
|
|
2327
|
+
this._activityFiles.delete(firstKey);
|
|
2328
|
+
}
|
|
2329
|
+
const existing = this._activityFiles.get(r.file_path) || { sources: new Set(), count: 0 };
|
|
2330
|
+
existing.sources.add('retrieve_context');
|
|
2331
|
+
existing.count += 1;
|
|
2332
|
+
existing.last_seen = Date.now();
|
|
2333
|
+
this._activityFiles.set(r.file_path, existing);
|
|
2334
|
+
}
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
this.logDebug(`trackRetrieveContextResponse failed: ${err && err.message}`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
/**
|
|
2341
|
+
* Populate handoff.files_read from observed tool activity, but only
|
|
2342
|
+
* when the agent did not already list any. Hand-written entries always
|
|
2343
|
+
* win — the bridge is a fallback, not a replacement.
|
|
2344
|
+
*/
|
|
2345
|
+
autoFillHandoffFilesRead(handoff) {
|
|
2346
|
+
try {
|
|
2347
|
+
if (!handoff || typeof handoff !== 'object') return;
|
|
2348
|
+
if (Array.isArray(handoff.files_read) && handoff.files_read.length > 0) return;
|
|
2349
|
+
if (!this._activityFiles || this._activityFiles.size === 0) return;
|
|
2350
|
+
|
|
2351
|
+
// Exclude files the agent already listed as touched — those
|
|
2352
|
+
// outrank "read". Then take the top 20 by access count.
|
|
2353
|
+
const touchedPaths = new Set(
|
|
2354
|
+
Array.isArray(handoff.files_touched)
|
|
2355
|
+
? handoff.files_touched.map(f => f.path).filter(Boolean)
|
|
2356
|
+
: []
|
|
2357
|
+
);
|
|
2358
|
+
const entries = Array.from(this._activityFiles.entries())
|
|
2359
|
+
.filter(([p]) => !touchedPaths.has(p))
|
|
2360
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
2361
|
+
.slice(0, 20);
|
|
2362
|
+
if (entries.length === 0) return;
|
|
2363
|
+
|
|
2364
|
+
handoff.files_read = entries.map(([path, info]) => ({
|
|
2365
|
+
path,
|
|
2366
|
+
why: `Observed via ${Array.from(info.sources).join(', ')} (${info.count}x)`,
|
|
2367
|
+
}));
|
|
2368
|
+
_mcpTrace('HANDOFF_AUTOFILL', `files_read=${entries.length}`);
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
this.logDebug(`autoFillHandoffFilesRead failed: ${err && err.message}`);
|
|
2371
|
+
}
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2132
2374
|
/**
|
|
2133
2375
|
* If the just-returned retrieve_context response had stale files,
|
|
2134
2376
|
* refresh them via refreshFilesCore, re-run the original query, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cf-memory-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.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": {
|