cf-memory-mcp 3.8.5 → 3.8.7

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.
@@ -1,5 +1,34 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Diagnostic logging — captures every MCP request/response with timing to
4
+ // /tmp/cf-memory-mcp.log when CF_MEMORY_TRACE=1 is set. Helps debug "tool
5
+ // gets stuck" reports without bothering users running normally.
6
+ // Uses globalThis.process to avoid TDZ with the later `const process = require('process')`.
7
+ const _MCP_TRACE = globalThis.process.env.CF_MEMORY_TRACE === '1' || globalThis.process.env.CF_MEMORY_TRACE === 'true';
8
+ const _MCP_TRACE_PATH = '/tmp/cf-memory-mcp.log';
9
+ const _MCP_TRACE_MAX_BYTES = 5 * 1024 * 1024; // 5MB cap; rotates by truncating
10
+
11
+ function _mcpTrace(label, data) {
12
+ if (!_MCP_TRACE) return;
13
+ try {
14
+ const fs = require('fs');
15
+ // Cheap rotation: if the file is too big, truncate it. We don't
16
+ // archive — debugging old sessions isn't important enough to
17
+ // justify managing rotation files.
18
+ try {
19
+ const st = fs.statSync(_MCP_TRACE_PATH);
20
+ if (st.size > _MCP_TRACE_MAX_BYTES) {
21
+ fs.truncateSync(_MCP_TRACE_PATH, 0);
22
+ fs.appendFileSync(_MCP_TRACE_PATH,
23
+ `[${new Date().toISOString()}] [pid=${globalThis.process.pid}] TRUNCATE: log exceeded ${_MCP_TRACE_MAX_BYTES} bytes\n`);
24
+ }
25
+ } catch (_) { /* file doesn't exist yet, that's fine */ }
26
+ fs.appendFileSync(_MCP_TRACE_PATH,
27
+ `[${new Date().toISOString()}] [pid=${globalThis.process.pid}] ${label}: ${data}\n`);
28
+ } catch (_) {}
29
+ }
30
+ _mcpTrace('STARTUP', `node=${globalThis.process.version} args=${JSON.stringify(globalThis.process.argv.slice(2))}`);
31
+
3
32
  /**
4
33
  * CF Memory MCP - Portable MCP Server
5
34
  *
@@ -22,19 +51,312 @@ const path = require('path');
22
51
  const crypto = require('crypto');
23
52
 
24
53
  // Configuration
25
- const STREAMABLE_HTTP_URL = 'https://cf-memory-mcp-simplified.johnlam90.workers.dev/mcp/message';
26
- const LEGACY_SERVER_URL = 'https://cf-memory-mcp-simplified.johnlam90.workers.dev/mcp/message';
27
- const PROGRESS_SSE_URL = 'https://cf-memory-mcp-simplified.johnlam90.workers.dev/api/indexing/progress';
54
+ // Migrate users with stale shell exports pointing to the dead workers.dev URL.
55
+ // This was the old default; if it's set in the user's shell env it would
56
+ // silently override the .mcp.json setting and make every tool call fail.
57
+ function _resolveBaseUrl() {
58
+ const raw = process.env.CF_MEMORY_BASE_URL;
59
+ if (!raw || raw.includes('workers.dev')) {
60
+ return 'https://memcp.ai';
61
+ }
62
+ return raw;
63
+ }
64
+ const BASE_URL = _resolveBaseUrl();
65
+ const STREAMABLE_HTTP_URL = `${BASE_URL}/mcp`;
66
+ const LEGACY_SERVER_URL = `${BASE_URL}/mcp/message`;
67
+ const PROGRESS_SSE_URL = `${BASE_URL}/api/indexing/progress`;
28
68
  const PACKAGE_VERSION = require('../package.json').version;
29
- const TIMEOUT_MS = 60000; // Increased timeout for batch operations
69
+ // Default per-request timeout. Batch uploads use BATCH_TIMEOUT_MS below.
70
+ const TIMEOUT_MS = 60000;
71
+ // Batch uploads can take longer because the worker processes each file
72
+ // (hash, parse, embed, store). Give them a wider window.
73
+ const BATCH_TIMEOUT_MS = Number(process.env.CF_MEMORY_BATCH_TIMEOUT_MS || 180000);
30
74
  const CONNECT_TIMEOUT_MS = 10000;
31
75
 
76
+ // HTTPS agent with keep-alive to reuse TLS connections across requests.
77
+ // Reduces latency by avoiding repeated TLS handshakes.
78
+ const httpsAgent = new https.Agent({
79
+ keepAlive: true,
80
+ keepAliveMsecs: 30000,
81
+ maxSockets: 10,
82
+ maxFreeSockets: 5,
83
+ timeout: TIMEOUT_MS,
84
+ });
85
+
32
86
  // Get API key from environment variable (will be checked later)
33
87
  const API_KEY = process.env.CF_MEMORY_API_KEY;
34
88
 
35
- // Optional: stream indexing progress via SSE
89
+ // CF_MEMORY_PROGRESS=1 stream indexing progress via SSE to stderr.
90
+ // The bridge fires startProgressStream() with a generated session id when
91
+ // a long-running index_project starts, and the server emits per-file
92
+ // events through /api/indexing/progress.
36
93
  const ENABLE_PROGRESS = process.env.CF_MEMORY_PROGRESS === '1' || process.env.CF_MEMORY_PROGRESS === 'true';
37
94
 
95
+ // Static tools list for fast local response (avoids network round-trip during connection)
96
+ const TOOLS_LIST = [
97
+ {
98
+ name: 'index_project',
99
+ description: 'Index a local codebase for semantic code search. Scans files, parses into chunks (functions, classes, methods, types), generates embeddings, and stores them for retrieval. Use this BEFORE calling retrieve_context on a new project. Incremental: skips unchanged files based on content hash. Supports 110+ languages including TypeScript, Python, Go, Rust, Java, C++, Swift, Ruby, GLSL/HLSL shaders, and more.',
100
+ inputSchema: {
101
+ type: 'object',
102
+ properties: {
103
+ project_path: { type: 'string', description: 'Absolute path to the project root directory' },
104
+ project_name: { type: 'string', description: 'Display name for the project (defaults to directory basename)' },
105
+ force_reindex: { type: 'boolean', description: 'If true, wipes existing chunks and rebuilds from scratch. Use only when needed; incremental is much faster.' }
106
+ },
107
+ required: ['project_path']
108
+ }
109
+ },
110
+ {
111
+ name: 'index_github',
112
+ description: 'Index a public GitHub repository server-side without cloning locally. Use when the user wants to search a remote codebase.',
113
+ inputSchema: {
114
+ type: 'object',
115
+ properties: {
116
+ repo_url: { type: 'string', description: 'GitHub URL (e.g., https://github.com/user/repo)' },
117
+ branch: { type: 'string', description: 'Branch to index (default: main)' }
118
+ },
119
+ required: ['repo_url']
120
+ }
121
+ },
122
+ {
123
+ name: 'list_projects',
124
+ description: 'List all indexed projects with their stats (file count, chunk count, languages, last indexed time). Use to discover what is available for retrieval before searching.',
125
+ inputSchema: {
126
+ type: 'object',
127
+ properties: {
128
+ page: { type: 'number', description: 'Page number for pagination' },
129
+ limit: { type: 'number', description: 'Max projects per page' }
130
+ }
131
+ }
132
+ },
133
+ {
134
+ name: 'delete_project',
135
+ description: 'Permanently delete an indexed project and all its chunks/relationships. Cannot be undone.',
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ project_id: { type: 'string', description: 'Project ID (proj_xxx) to delete' }
140
+ },
141
+ required: ['project_id']
142
+ }
143
+ },
144
+ {
145
+ name: 'retrieve_context',
146
+ description: 'Search indexed code using hybrid retrieval (semantic embeddings + keyword BM25 + identifier name matching), with reciprocal rank fusion and cross-encoder reranking. Returns chunks enriched with file_imports, source_kind (code/test/doc/config), indexed_at, and a `stale` flag when the file has been edited since indexing. Doc/markdown filtered out of code queries by default. Use for any code search: "where is X defined", "how does Y work", "what handles Z".',
147
+ inputSchema: {
148
+ type: 'object',
149
+ properties: {
150
+ query: { type: 'string', description: 'Natural language query or specific identifier name' },
151
+ project_id: { type: 'string', description: 'Project ID (proj_xxx) or project name. Omit to search most recently used project.' },
152
+ limit: { type: 'number', description: 'Max results to return (default: 10, max: 50)' },
153
+ language_filter: { type: 'array', items: { type: 'string' }, description: 'Limit to languages, e.g. ["typescript", "python"]' },
154
+ chunk_type_filter: { type: 'array', items: { type: 'string' }, description: 'Limit to chunk types, e.g. ["function", "class", "interface"]' },
155
+ file_filter: { type: 'array', items: { type: 'string' }, description: 'Limit to files matching path substrings' },
156
+ all_projects: { type: 'boolean', description: 'Search across ALL your indexed projects (overrides project_id). Useful for "find X in any of my repos".' },
157
+ expand_context: { type: 'boolean', description: 'Include file_imports (the file\'s module/imports chunk) with each result. Default: true.' },
158
+ exclude_docs: { type: 'boolean', description: 'Filter out markdown/docs from code queries. Default: true (auto-disabled if query mentions docs/readme/tutorial).' }
159
+ },
160
+ required: ['query']
161
+ }
162
+ },
163
+ {
164
+ name: 'store_memory',
165
+ description: 'Persist a memory across conversations (fact, preference, task, entity, or session summary). Use for things the user explicitly wants remembered: their preferences, important facts about projects, ongoing tasks.',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ content: { type: 'string', description: 'The memory content to store' },
170
+ type: { type: 'string', enum: ['fact', 'preference', 'task', 'entity', 'session_summary'] },
171
+ importance: { type: 'number', minimum: 0, maximum: 1, description: 'Importance score 0-1; higher = more likely to surface in retrieval' }
172
+ },
173
+ required: ['content']
174
+ }
175
+ },
176
+ {
177
+ name: 'retrieve_memories',
178
+ description: 'Semantic search across stored memories. Use to recall what the user previously shared or asked about.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ query: { type: 'string', description: 'Natural language query' },
183
+ limit: { type: 'number', description: 'Max memories to return' }
184
+ },
185
+ required: ['query']
186
+ }
187
+ },
188
+ {
189
+ name: 'get_context_bootstrap',
190
+ description: 'Get the most important memories to load at session start. Returns user preferences, ongoing tasks, and relevant facts within the token budget.',
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ max_tokens: { type: 'number', description: 'Token budget for bootstrap context (default: 1000)' },
195
+ current_context: { type: 'string', description: 'Current conversation context to bias relevance' }
196
+ }
197
+ }
198
+ },
199
+ {
200
+ name: 'start_session',
201
+ description: 'Begin a new tracked conversation session. Returns a session_id used by end_session for summarization.',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ context: { type: 'string', enum: ['main', 'group', 'background'], description: 'Session context type' },
206
+ platform: { type: 'string', description: 'Client platform (e.g., "claude-code", "claude-desktop")' }
207
+ },
208
+ required: ['context']
209
+ }
210
+ },
211
+ {
212
+ name: 'end_session',
213
+ description: 'End a tracked session and optionally extract memories from the summary.',
214
+ inputSchema: {
215
+ type: 'object',
216
+ properties: {
217
+ session_id: { type: 'string', description: 'Session ID returned by start_session' },
218
+ summary: { type: 'string', description: 'Optional summary of what was discussed' }
219
+ },
220
+ required: ['session_id']
221
+ }
222
+ },
223
+ {
224
+ name: 'store_entity',
225
+ description: 'Store a structured entity (person, project, company, concept, location) with named attributes for later relational queries.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ name: { type: 'string', description: 'Entity name' },
230
+ type: { type: 'string', enum: ['person', 'project', 'company', 'concept', 'location'] },
231
+ attributes: { type: 'object', description: 'Key-value attributes of the entity' }
232
+ },
233
+ required: ['name', 'type', 'attributes']
234
+ }
235
+ },
236
+ {
237
+ name: 'get_related_code',
238
+ description: 'Navigate the code relationship graph: find callers (who calls this), callees (what this calls), imports, type usages, and inheritance for a specific chunk. Use after retrieve_context to explore connected code.',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ project_id: { type: 'string', description: 'Project ID' },
243
+ chunk_name: { type: 'string', description: 'Name of the function/class/method (looked up by name)' },
244
+ chunk_id: { type: 'string', description: 'Specific chunk ID (alternative to chunk_name)' },
245
+ relationship_type: { type: 'string', enum: ['calls', 'imports', 'extends', 'implements', 'uses_type', 'all'], description: 'Type of relationship (default: all)' },
246
+ direction: { type: 'string', enum: ['callers', 'callees', 'both'], description: 'Direction of edges to traverse (default: both)' },
247
+ limit: { type: 'number', description: 'Max related chunks per direction (default: 20)' }
248
+ },
249
+ required: ['project_id']
250
+ }
251
+ },
252
+ {
253
+ name: 'get_stats',
254
+ description: 'Get aggregated statistics across all your indexed projects: total project/file/chunk counts plus language breakdown. Use to understand what is available before searching, or to verify indexing succeeded.',
255
+ inputSchema: {
256
+ type: 'object',
257
+ properties: {}
258
+ }
259
+ },
260
+ {
261
+ name: 'list_files',
262
+ description: 'List indexed files in a project, ranked by chunk count (most complex files first). Filter by path substring or language. Use to explore project structure before searching.',
263
+ inputSchema: {
264
+ type: 'object',
265
+ properties: {
266
+ project_id: { type: 'string', description: 'Project ID or name' },
267
+ path_pattern: { type: 'string', description: 'Filter files by path substring (e.g., "services/" or "utils.ts")' },
268
+ language: { type: 'string', description: 'Filter by language' },
269
+ limit: { type: 'number', description: 'Max files (default: 100, max: 500)' }
270
+ },
271
+ required: ['project_id']
272
+ }
273
+ },
274
+ {
275
+ name: 'get_file_outline',
276
+ description: 'Get the structural outline of a file: functions, classes, methods, interfaces with their line ranges. Returns names only (not content) for a quick overview. Supports partial file path matching.',
277
+ inputSchema: {
278
+ type: 'object',
279
+ properties: {
280
+ project_id: { type: 'string', description: 'Project ID or name' },
281
+ file_path: { type: 'string', description: 'File path (exact or partial substring match)' }
282
+ },
283
+ required: ['project_id', 'file_path']
284
+ }
285
+ },
286
+ {
287
+ name: 'get_file_content',
288
+ description: 'Reassemble and return the full content of an indexed file from its stored chunks. Use when retrieve_context returned a fragment and you need the whole file context, or when working remotely without local filesystem access. Returns indexed_at + file_hash for staleness checks.',
289
+ inputSchema: {
290
+ type: 'object',
291
+ properties: {
292
+ project_id: { type: 'string', description: 'Project ID or name' },
293
+ file_path: { type: 'string', description: 'File path (exact or partial substring match)' },
294
+ max_chars: { type: 'number', description: 'Truncate content at this many characters (default 50000, max 100000)' }
295
+ },
296
+ required: ['project_id', 'file_path']
297
+ }
298
+ },
299
+ {
300
+ name: 'health_check',
301
+ description: 'Verify the MCP server is reachable and authentication works. Returns the user_id, server name/version, and a current timestamp. Use as a first probe when troubleshooting connectivity.',
302
+ inputSchema: {
303
+ type: 'object',
304
+ properties: {}
305
+ }
306
+ },
307
+ {
308
+ name: 'refresh_files',
309
+ description: 'Re-index specific files without scanning the whole project. Use after retrieve_context returns stale results — pass the file paths from the stale chunks and only those files get re-embedded. Much faster than a full re-index for targeted updates.',
310
+ inputSchema: {
311
+ type: 'object',
312
+ properties: {
313
+ project_id: { type: 'string', description: 'Project ID or name' },
314
+ file_paths: { type: 'array', items: { type: 'string' }, description: 'Paths relative to project root (e.g. ["src/foo.ts", "src/bar.ts"])' },
315
+ project_root: { type: 'string', description: 'Absolute path to project root. Defaults to CF_MEMORY_WATCH_PATH or current working directory.' }
316
+ },
317
+ required: ['project_id', 'file_paths']
318
+ }
319
+ },
320
+ {
321
+ name: 'find_stale_files',
322
+ description: 'Compare indexed file hashes against current local file hashes. Returns lists of stale (edited since indexing), missing (file removed locally), and fresh files. Includes a hint to refresh_files for the stale ones. Use at session start to know what needs re-indexing.',
323
+ inputSchema: {
324
+ type: 'object',
325
+ properties: {
326
+ project_id: { type: 'string', description: 'Project ID or name' },
327
+ project_root: { type: 'string', description: 'Absolute path to project root. Defaults to CF_MEMORY_WATCH_PATH or cwd.' },
328
+ limit: { type: 'number', description: 'Max files to inspect (default 500)' }
329
+ },
330
+ required: ['project_id']
331
+ }
332
+ },
333
+ {
334
+ name: 'refresh_stale',
335
+ description: 'One-call convenience: finds stale files (edited since indexing) and refreshes them in one operation. Use this at session start or whenever you suspect the index might be out of date.',
336
+ inputSchema: {
337
+ type: 'object',
338
+ properties: {
339
+ project_id: { type: 'string', description: 'Project ID or name' },
340
+ project_root: { type: 'string', description: 'Absolute path to project root. Defaults to CF_MEMORY_WATCH_PATH or cwd.' },
341
+ max_files: { type: 'number', description: 'Cap how many stale files get refreshed in one call (default 100)' }
342
+ },
343
+ required: ['project_id']
344
+ }
345
+ },
346
+ {
347
+ name: 'delete_memory',
348
+ description: 'Delete persisted memories. Requires at least one filter (memory_id, type, or older_than_days) to prevent accidental mass deletion. Use to clean up outdated memories or remove obsolete preferences.',
349
+ inputSchema: {
350
+ type: 'object',
351
+ properties: {
352
+ memory_id: { type: 'string', description: 'Specific memory ID to delete' },
353
+ type: { type: 'string', enum: ['fact', 'preference', 'task', 'entity', 'session_summary'], description: 'Delete all memories of this type' },
354
+ older_than_days: { type: 'number', description: 'Delete memories created more than N days ago' }
355
+ }
356
+ }
357
+ }
358
+ ];
359
+
38
360
  /**
39
361
  * Cross-platform MCP stdio bridge
40
362
  * Handles communication between MCP clients and the Cloudflare Worker
@@ -57,7 +379,7 @@ class CFMemoryMCP {
57
379
 
58
380
  // Set up stdio encoding
59
381
  process.stdin.setEncoding('utf8');
60
- process.stdout.setEncoding('utf8');
382
+ // Note: stdout.setEncoding doesn't exist on writable streams
61
383
 
62
384
  this.logDebug('CF Memory MCP server starting...');
63
385
  this.logDebug(`Streamable HTTP URL: ${this.streamableHttpUrl}`);
@@ -90,20 +412,80 @@ class CFMemoryMCP {
90
412
  */
91
413
  async start() {
92
414
  try {
93
- // Skip connectivity test in MCP mode - it will be tested when first request is made
94
415
  this.logDebug('Starting MCP message processing...');
95
-
416
+
417
+ // Pre-warm the HTTPS connection in the background so the first
418
+ // real tool call doesn't pay the TLS handshake cost.
419
+ this.prewarmConnection();
420
+
421
+ // Background-prime the project_id auto-detection cache by calling
422
+ // list_projects and matching against cwd. By the time the first
423
+ // retrieve_context arrives, the cache is hot — no extra roundtrip.
424
+ this.prewarmProjectIdCache();
425
+
96
426
  // Start auto-watcher if CF_MEMORY_AUTO_WATCH is set
97
427
  if (process.env.CF_MEMORY_AUTO_WATCH === '1' || process.env.CF_MEMORY_AUTO_WATCH === 'true') {
98
428
  this.startAutoWatcher();
99
429
  }
100
-
430
+
101
431
  await this.processStdio();
102
432
  } catch (error) {
103
433
  this.logError('Failed to start MCP server:', error);
104
434
  process.exit(1);
105
435
  }
106
436
  }
437
+
438
+ /**
439
+ * Resolve the current cwd to a project_id in the background and
440
+ * cache the result, so the first retrieve_context query that
441
+ * triggers maybeFillProjectId() gets an instant hit.
442
+ */
443
+ async prewarmProjectIdCache() {
444
+ try {
445
+ // Fake message just to drive the helper through its happy path.
446
+ // It'll call list_projects, find the matching project for cwd,
447
+ // and populate this._projectIdByCwdCache.
448
+ const fakeMessage = {
449
+ params: {
450
+ name: 'retrieve_context',
451
+ arguments: {}, // no project_id — triggers auto-fill
452
+ },
453
+ };
454
+ await this.maybeFillProjectId(fakeMessage);
455
+ } catch (err) {
456
+ this.logDebug(`prewarmProjectIdCache failed: ${err && err.message}`);
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Pre-warm the HTTPS connection so the first real request is fast.
462
+ * Fires a lightweight HEAD-equivalent (initialize) in the background.
463
+ * Failure is silently ignored - this is just an optimization.
464
+ */
465
+ prewarmConnection() {
466
+ const url = new URL(BASE_URL + '/health');
467
+ const options = {
468
+ hostname: url.hostname,
469
+ port: url.port || 443,
470
+ path: url.pathname,
471
+ method: 'GET',
472
+ timeout: CONNECT_TIMEOUT_MS,
473
+ agent: httpsAgent,
474
+ headers: { 'User-Agent': this.userAgent }
475
+ };
476
+
477
+ try {
478
+ const req = https.request(options, (res) => {
479
+ res.on('data', () => {});
480
+ res.on('end', () => this.logDebug('Connection pre-warmed'));
481
+ });
482
+ req.on('error', () => {});
483
+ req.on('timeout', () => req.destroy());
484
+ req.end();
485
+ } catch (_) {
486
+ // Ignore pre-warm failures
487
+ }
488
+ }
107
489
 
108
490
  /**
109
491
  * Start auto-watching files for changes
@@ -147,7 +529,7 @@ class CFMemoryMCP {
147
529
  protocolVersion: '2025-03-26',
148
530
  capabilities: { tools: {} },
149
531
  clientInfo: {
150
- name: 'cf-memory-mcp',
532
+ name: 'cf-memory-mcp-simplified',
151
533
  version: PACKAGE_VERSION
152
534
  }
153
535
  }
@@ -180,52 +562,70 @@ class CFMemoryMCP {
180
562
  * Process stdio input/output for MCP communication
181
563
  */
182
564
  async processStdio() {
183
- let buffer = '';
184
-
185
- // Handle stdin data
186
- for await (const chunk of process.stdin) {
187
- buffer += chunk;
565
+ const readline = require('readline');
188
566
 
189
- // Process complete JSON-RPC messages (one per line)
190
- let newlineIndex;
191
- while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
192
- const line = buffer.slice(0, newlineIndex).trim();
193
- buffer = buffer.slice(newlineIndex + 1);
567
+ const rl = readline.createInterface({
568
+ input: process.stdin,
569
+ terminal: false
570
+ });
194
571
 
195
- if (line) {
196
- await this.handleMessage(line);
197
- }
198
- }
199
- }
572
+ // Process each line concurrently. JSON-RPC messages have unique ids,
573
+ // so responses can be matched even if they arrive out of order. This
574
+ // prevents a slow tool call (e.g., a remote retrieve) from blocking
575
+ // all subsequent calls, which the user reported as "MCP gets stuck".
576
+ rl.on('line', (line) => {
577
+ const trimmed = line.trim();
578
+ if (!trimmed) return;
579
+ // Fire-and-forget. handleMessage catches its own errors and writes
580
+ // a JSON-RPC error response, so unhandled rejections shouldn't escape.
581
+ this.handleMessage(trimmed).catch(err => {
582
+ this.logError('Unhandled message handler error:', err);
583
+ });
584
+ });
200
585
 
201
- // Process any remaining buffer content
202
- if (buffer.trim()) {
203
- await this.handleMessage(buffer.trim());
204
- }
586
+ // Handle stdin close
587
+ rl.on('close', () => {
588
+ this.logDebug('Stdin closed, shutting down...');
589
+ process.exit(0);
590
+ });
205
591
 
206
- this.logDebug('Stdin closed, shutting down...');
592
+ // Keep the process alive
593
+ await new Promise(() => {});
207
594
  }
208
595
 
209
596
  /**
210
597
  * Handle a single MCP message
211
598
  */
212
599
  async handleMessage(messageStr) {
600
+ const _t0 = Date.now();
601
+ _mcpTrace('IN', messageStr.length > 500 ? messageStr.slice(0, 500) + '...' : messageStr);
213
602
  try {
214
603
  const message = JSON.parse(messageStr);
215
604
  this.logDebug(`Processing message: ${message.method} (id: ${message.id})`);
605
+ // Trace on early-exit paths handled below: returns after stdout.write.
606
+ // For brevity, we instead trace once at the end of every dispatch.
216
607
 
217
608
  // Handle lifecycle methods locally
218
609
  if (message.method === 'initialize') {
610
+ // Per MCP spec: echo the client's protocolVersion if we support
611
+ // it, otherwise return our preferred version. We support both
612
+ // the 2024-11-05 and 2025-03-26 wire formats.
613
+ const SUPPORTED_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
614
+ const PREFERRED_VERSION = '2025-03-26';
615
+ const clientVersion = message.params && message.params.protocolVersion;
616
+ const respondVersion = SUPPORTED_VERSIONS.includes(clientVersion)
617
+ ? clientVersion
618
+ : PREFERRED_VERSION;
219
619
  const response = {
220
620
  jsonrpc: '2.0',
221
621
  id: message.id,
222
622
  result: {
223
- protocolVersion: '2025-03-26',
623
+ protocolVersion: respondVersion,
224
624
  capabilities: {
225
625
  tools: {}
226
626
  },
227
627
  serverInfo: {
228
- name: 'cf-memory-mcp',
628
+ name: 'cf-memory-mcp-simplified',
229
629
  version: PACKAGE_VERSION
230
630
  }
231
631
  }
@@ -239,24 +639,131 @@ class CFMemoryMCP {
239
639
  return;
240
640
  }
241
641
 
642
+ // Handle tools/list locally for fast connection
643
+ if (message.method === 'tools/list') {
644
+ const response = {
645
+ jsonrpc: '2.0',
646
+ id: message.id,
647
+ result: { tools: TOOLS_LIST }
648
+ };
649
+ process.stdout.write(JSON.stringify(response) + '\n');
650
+ return;
651
+ }
652
+
653
+ // Handle resources/list locally (we have none)
654
+ if (message.method === 'resources/list') {
655
+ const response = {
656
+ jsonrpc: '2.0',
657
+ id: message.id,
658
+ result: { resources: [] }
659
+ };
660
+ process.stdout.write(JSON.stringify(response) + '\n');
661
+ return;
662
+ }
663
+
664
+ // Handle resources/templates/list locally (we have none)
665
+ if (message.method === 'resources/templates/list') {
666
+ const response = {
667
+ jsonrpc: '2.0',
668
+ id: message.id,
669
+ result: { resourceTemplates: [] }
670
+ };
671
+ process.stdout.write(JSON.stringify(response) + '\n');
672
+ return;
673
+ }
674
+
675
+ // Handle prompts/list locally (we have none)
676
+ if (message.method === 'prompts/list') {
677
+ const response = {
678
+ jsonrpc: '2.0',
679
+ id: message.id,
680
+ result: { prompts: [] }
681
+ };
682
+ process.stdout.write(JSON.stringify(response) + '\n');
683
+ return;
684
+ }
685
+
686
+ // Handle ping locally
687
+ if (message.method === 'ping') {
688
+ const response = {
689
+ jsonrpc: '2.0',
690
+ id: message.id,
691
+ result: {}
692
+ };
693
+ process.stdout.write(JSON.stringify(response) + '\n');
694
+ return;
695
+ }
696
+
697
+ // Intercept refresh_files: read specific files locally and upload
698
+ // them to refresh the index without re-scanning the whole project.
699
+ if (message.method === 'tools/call' && message.params && message.params.name === 'refresh_files') {
700
+ await this.handleRefreshFiles(message);
701
+ return;
702
+ }
703
+
704
+ // Intercept find_stale_files: compare indexed file hashes against
705
+ // current local file hashes to identify what needs refresh.
706
+ if (message.method === 'tools/call' && message.params && message.params.name === 'find_stale_files') {
707
+ await this.handleFindStaleFiles(message);
708
+ return;
709
+ }
710
+
711
+ // Intercept refresh_stale: find_stale_files + refresh_files in one call.
712
+ if (message.method === 'tools/call' && message.params && message.params.name === 'refresh_stale') {
713
+ await this.handleRefreshStale(message);
714
+ return;
715
+ }
716
+
242
717
  // Intercept index_project tool call to perform local scanning
243
718
  if (message.method === 'tools/call' && message.params && message.params.name === 'index_project') {
244
719
  await this.handleIndexProject(message);
245
720
  return;
246
721
  }
247
722
 
723
+ _mcpTrace('DISPATCH', `id=${message.id} method=${message.method} -> network`);
724
+ // If this is a retrieve_context call with no project_id and no
725
+ // all_projects, try to auto-fill project_id from the current
726
+ // working directory. Saves the user from having to specify
727
+ // project_id every time when working in a known project.
728
+ if (message.method === 'tools/call' &&
729
+ message.params && message.params.name === 'retrieve_context') {
730
+ await this.maybeFillProjectId(message);
731
+ }
732
+
248
733
  const response = await this.makeRequest(message);
734
+ _mcpTrace('DISPATCH_DONE', `id=${message.id} method=${message.method} elapsed=${Date.now()-_t0}ms`);
735
+
736
+ // Annotate retrieve_context results with local staleness when
737
+ // possible. The server tells us when it indexed each file and
738
+ // what hash; we compare against the local file. Stops users
739
+ // acting on stale indexed content as if it were current.
740
+ if (message.method === 'tools/call' &&
741
+ message.params && message.params.name === 'retrieve_context') {
742
+ this.maybeAnnotateStaleness(response, message.params.arguments);
743
+ }
249
744
 
250
745
  // Send response to stdout
251
746
  process.stdout.write(JSON.stringify(response) + '\n');
252
747
 
253
748
  } catch (error) {
254
749
  this.logError('Error handling message:', error);
750
+ _mcpTrace('ERROR', `${error.message} elapsed=${Date.now()-_t0}ms`);
751
+
752
+ // Try to extract message id so the client can correlate the error.
753
+ // Without an id, the client may wait indefinitely for a response.
754
+ let messageId = null;
755
+ try {
756
+ const parsed = JSON.parse(messageStr);
757
+ if (parsed && parsed.id !== undefined) {
758
+ messageId = parsed.id;
759
+ }
760
+ } catch (_) {
761
+ // messageStr is unparseable; nothing to correlate.
762
+ }
255
763
 
256
- // Send error response
257
764
  const errorResponse = {
258
765
  jsonrpc: '2.0',
259
- id: null,
766
+ id: messageId,
260
767
  error: {
261
768
  code: -32700,
262
769
  message: 'Parse error',
@@ -270,6 +777,285 @@ class CFMemoryMCP {
270
777
  /**
271
778
  * Handle local project indexing - scans local files and sends them via MCP
272
779
  */
780
+ /**
781
+ * Refresh specific files in the index without re-scanning the whole
782
+ * project. Reads each file locally and uploads to /api/projects/:id/files/batch.
783
+ * Use this after retrieve_context returns stale results — instead of doing
784
+ * a full re-index, just refresh the affected files.
785
+ */
786
+ async handleRefreshFiles(message) {
787
+ const args = (message.params && message.params.arguments) || {};
788
+ const projectIdOrName = args.project_id;
789
+ const filePaths = Array.isArray(args.file_paths) ? args.file_paths : [];
790
+ const projectRoot = args.project_root ? path.resolve(args.project_root)
791
+ : (process.env.CF_MEMORY_WATCH_PATH || process.cwd());
792
+
793
+ const respond = (payload) => {
794
+ process.stdout.write(JSON.stringify({
795
+ jsonrpc: '2.0',
796
+ id: message.id,
797
+ result: { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] },
798
+ }) + '\n');
799
+ };
800
+
801
+ if (!projectIdOrName) {
802
+ return respond({ error: 'project_id is required' });
803
+ }
804
+ if (filePaths.length === 0) {
805
+ return respond({ error: 'file_paths (string[]) is required' });
806
+ }
807
+
808
+ // Resolve to project ID via list_projects if a name was given.
809
+ let projectId = projectIdOrName;
810
+ if (!projectId.startsWith('proj_')) {
811
+ const list = await this.makeRequest({
812
+ jsonrpc: '2.0',
813
+ id: `refresh-list-${Date.now()}`,
814
+ method: 'tools/call',
815
+ params: { name: 'list_projects', arguments: {} },
816
+ });
817
+ try {
818
+ const projects = JSON.parse(list.result.content[0].text);
819
+ const match = Array.isArray(projects)
820
+ ? projects.find(p => p.name === projectId || p.id === projectId)
821
+ : null;
822
+ if (match?.id) projectId = match.id;
823
+ } catch (_) {}
824
+ }
825
+
826
+ // Read each file locally. Note: uploadFileBatch expects `relativePath`
827
+ // (not `path`) — it maps to `file_path` server-side.
828
+ const files = [];
829
+ const skipped = [];
830
+ for (const rel of filePaths) {
831
+ try {
832
+ const full = path.resolve(projectRoot, rel);
833
+ const stat = fs.statSync(full);
834
+ if (!stat.isFile()) {
835
+ skipped.push({ path: rel, reason: 'not a file' });
836
+ continue;
837
+ }
838
+ const content = fs.readFileSync(full, 'utf8');
839
+ files.push({
840
+ relativePath: rel,
841
+ content,
842
+ last_modified: stat.mtime.toISOString(),
843
+ });
844
+ } catch (err) {
845
+ skipped.push({ path: rel, reason: err && err.message ? err.message : 'read failed' });
846
+ }
847
+ }
848
+
849
+ if (files.length === 0) {
850
+ return respond({
851
+ project_id: projectId,
852
+ files_refreshed: 0,
853
+ skipped,
854
+ });
855
+ }
856
+
857
+ const uploadResult = await this.uploadFileBatch(projectId, files);
858
+ const refreshed = (uploadResult && typeof uploadResult.files_indexed === 'number') ? uploadResult.files_indexed : 0;
859
+ const chunks = (uploadResult && typeof uploadResult.chunks_created === 'number') ? uploadResult.chunks_created : 0;
860
+
861
+ return respond({
862
+ project_id: projectId,
863
+ files_attempted: files.length,
864
+ files_refreshed: refreshed,
865
+ chunks_created: chunks,
866
+ skipped,
867
+ errors: uploadResult && Array.isArray(uploadResult.errors) ? uploadResult.errors : undefined,
868
+ });
869
+ }
870
+
871
+ /**
872
+ * Check which indexed files have been edited locally since their
873
+ * last index. Calls list_files to get the indexed files + their
874
+ * hashes, then reads each local file and compares SHA-256.
875
+ */
876
+ async handleFindStaleFiles(message) {
877
+ const args = (message.params && message.params.arguments) || {};
878
+ const projectIdOrName = args.project_id;
879
+ const projectRoot = args.project_root ? path.resolve(args.project_root)
880
+ : (process.env.CF_MEMORY_WATCH_PATH || process.cwd());
881
+ const limit = args.limit || 500;
882
+
883
+ const respond = (payload) => {
884
+ process.stdout.write(JSON.stringify({
885
+ jsonrpc: '2.0',
886
+ id: message.id,
887
+ result: { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] },
888
+ }) + '\n');
889
+ };
890
+
891
+ if (!projectIdOrName) return respond({ error: 'project_id is required' });
892
+
893
+ // Get the list of indexed files with their hashes via the new
894
+ // /api/projects/:id/files endpoint... actually list_files returns
895
+ // metadata but not file_hash. We'll add hash to the response.
896
+ // For now, fetch files via direct API.
897
+ const listFilesRes = await this.makeRequest({
898
+ jsonrpc: '2.0',
899
+ id: `stale-list-${Date.now()}`,
900
+ method: 'tools/call',
901
+ params: { name: 'list_files', arguments: { project_id: projectIdOrName, limit } },
902
+ });
903
+
904
+ let filesList = [];
905
+ try {
906
+ filesList = JSON.parse(listFilesRes.result.content[0].text);
907
+ if (!Array.isArray(filesList)) filesList = [];
908
+ } catch (_) {
909
+ return respond({ error: 'Failed to list indexed files' });
910
+ }
911
+
912
+ // Each file in the list already carries file_hash + indexed_at
913
+ // (list_files was extended to return them), so we don't need any
914
+ // additional server roundtrips. Just hash the local files.
915
+ const stale = [];
916
+ const missing = [];
917
+ const fresh = [];
918
+ for (const f of filesList) {
919
+ const filePath = f.file_path;
920
+ const indexedHash = f.file_hash;
921
+ const indexedAt = f.indexed_at;
922
+ if (!indexedHash) continue;
923
+
924
+ const full = path.resolve(projectRoot, filePath);
925
+ let content;
926
+ try {
927
+ content = fs.readFileSync(full, 'utf8');
928
+ } catch (_) {
929
+ missing.push({ file_path: filePath, indexed_at: indexedAt, reason: 'file missing locally' });
930
+ continue;
931
+ }
932
+ const localHash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
933
+ if (localHash !== indexedHash) {
934
+ stale.push({ file_path: filePath, indexed_at: indexedAt });
935
+ } else {
936
+ fresh.push(filePath);
937
+ }
938
+ }
939
+
940
+ return respond({
941
+ project_id: projectIdOrName,
942
+ total_indexed: filesList.length,
943
+ fresh_count: fresh.length,
944
+ stale_count: stale.length,
945
+ missing_count: missing.length,
946
+ stale,
947
+ missing,
948
+ hint: stale.length > 0
949
+ ? `Call refresh_files with file_paths=${JSON.stringify(stale.map(s => s.file_path).slice(0, 10))} to update the index.`
950
+ : 'Index is up to date.',
951
+ });
952
+ }
953
+
954
+ /**
955
+ * Convenience: find stale files and refresh them in one call.
956
+ * Equivalent to find_stale_files + refresh_files but with one
957
+ * round-trip instead of two and no need to copy paths between calls.
958
+ */
959
+ async handleRefreshStale(message) {
960
+ const args = (message.params && message.params.arguments) || {};
961
+ const projectIdOrName = args.project_id;
962
+ const projectRoot = args.project_root ? path.resolve(args.project_root)
963
+ : (process.env.CF_MEMORY_WATCH_PATH || process.cwd());
964
+ const maxFiles = args.max_files || 100;
965
+
966
+ const respond = (payload) => {
967
+ process.stdout.write(JSON.stringify({
968
+ jsonrpc: '2.0',
969
+ id: message.id,
970
+ result: { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] },
971
+ }) + '\n');
972
+ };
973
+
974
+ if (!projectIdOrName) return respond({ error: 'project_id is required' });
975
+
976
+ // Step 1: find stale files (reuses the existing handler logic).
977
+ // We can't call handleFindStaleFiles directly because it writes to
978
+ // stdout, so duplicate the small core.
979
+ const listRes = await this.makeRequest({
980
+ jsonrpc: '2.0',
981
+ id: `rs-list-${Date.now()}`,
982
+ method: 'tools/call',
983
+ params: { name: 'list_files', arguments: { project_id: projectIdOrName, limit: 500 } },
984
+ });
985
+ let filesList = [];
986
+ try {
987
+ filesList = JSON.parse(listRes.result.content[0].text);
988
+ if (!Array.isArray(filesList)) filesList = [];
989
+ } catch (_) {
990
+ return respond({ error: 'Failed to list indexed files' });
991
+ }
992
+
993
+ const staleFiles = [];
994
+ const missing = [];
995
+ for (const f of filesList) {
996
+ const filePath = f.file_path;
997
+ const indexedHash = f.file_hash;
998
+ if (!indexedHash) continue;
999
+ const full = path.resolve(projectRoot, filePath);
1000
+ let content;
1001
+ try {
1002
+ content = fs.readFileSync(full, 'utf8');
1003
+ } catch (_) {
1004
+ missing.push(filePath);
1005
+ continue;
1006
+ }
1007
+ const localHash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
1008
+ if (localHash !== indexedHash) {
1009
+ staleFiles.push({ relativePath: filePath, content, last_modified: '' });
1010
+ }
1011
+ }
1012
+
1013
+ const toRefresh = staleFiles.slice(0, maxFiles);
1014
+ if (toRefresh.length === 0) {
1015
+ return respond({
1016
+ project_id: projectIdOrName,
1017
+ stale_count: 0,
1018
+ missing_count: missing.length,
1019
+ refreshed: 0,
1020
+ message: 'Index is up to date.',
1021
+ });
1022
+ }
1023
+
1024
+ // Resolve project name to ID once
1025
+ let projectId = projectIdOrName;
1026
+ if (!projectId.startsWith('proj_') && filesList.length > 0) {
1027
+ // list_files only works if we can resolve the project; we already
1028
+ // got results so just look up via list_projects to get the ID.
1029
+ const projListRes = await this.makeRequest({
1030
+ jsonrpc: '2.0',
1031
+ id: `rs-proj-${Date.now()}`,
1032
+ method: 'tools/call',
1033
+ params: { name: 'list_projects', arguments: {} },
1034
+ });
1035
+ try {
1036
+ const projects = JSON.parse(projListRes.result.content[0].text);
1037
+ const match = Array.isArray(projects)
1038
+ ? projects.find(p => p.name === projectId || p.id === projectId)
1039
+ : null;
1040
+ if (match?.id) projectId = match.id;
1041
+ } catch (_) {}
1042
+ }
1043
+
1044
+ const uploadResult = await this.uploadFileBatch(projectId, toRefresh);
1045
+ const refreshed = (uploadResult && typeof uploadResult.files_indexed === 'number') ? uploadResult.files_indexed : 0;
1046
+ const chunks = (uploadResult && typeof uploadResult.chunks_created === 'number') ? uploadResult.chunks_created : 0;
1047
+
1048
+ return respond({
1049
+ project_id: projectId,
1050
+ stale_count: staleFiles.length,
1051
+ missing_count: missing.length,
1052
+ refreshed,
1053
+ chunks_created: chunks,
1054
+ refreshed_files: toRefresh.map(f => f.relativePath),
1055
+ truncated: staleFiles.length > maxFiles,
1056
+ });
1057
+ }
1058
+
273
1059
  async handleIndexProject(message) {
274
1060
  const { project_path, project_name, include_patterns, exclude_patterns, force_reindex } = message.params.arguments;
275
1061
  const resolvedPath = path.resolve(project_path);
@@ -277,6 +1063,20 @@ class CFMemoryMCP {
277
1063
 
278
1064
  this.logDebug(`Intercepted index_project for: ${resolvedPath} (${name})`);
279
1065
 
1066
+ // Optionally start a progress stream so the user sees per-file events
1067
+ // on stderr as indexing happens. Only useful for long indexes; cheap
1068
+ // to skip when unset (the env check is at module load).
1069
+ let progressStream = null;
1070
+ if (ENABLE_PROGRESS) {
1071
+ const sessionId = `idx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1072
+ try {
1073
+ progressStream = this.startProgressStream(sessionId);
1074
+ process.stderr.write(`[INDEX] streaming progress (session=${sessionId})\n`);
1075
+ } catch (err) {
1076
+ this.logDebug(`Could not start progress stream: ${err && err.message}`);
1077
+ }
1078
+ }
1079
+
280
1080
  try {
281
1081
  // 1. Scan Local Files
282
1082
  this.logDebug(`Scanning files in ${resolvedPath}...`);
@@ -344,19 +1144,53 @@ class CFMemoryMCP {
344
1144
  // Adaptive batching: limit by file count and payload bytes to reduce timeouts.
345
1145
  const batches = this.createAdaptiveBatches(files);
346
1146
 
347
- for (let b = 0; b < batches.length; b++) {
348
- const batch = batches[b];
349
- const approxBytes = batch.reduce((sum, f) => sum + Buffer.byteLength(f.content || '', 'utf8'), 0);
350
- this.logDebug(`Uploading batch ${b + 1}/${batches.length} (${batch.length} files, ~${Math.round(approxBytes / 1024)}KB)`);
351
-
352
- const uploadResult = await this.uploadFileBatch(projectId, batch);
353
-
354
- if (uploadResult && typeof uploadResult.files_indexed === 'number') {
1147
+ let totalSkipped = 0;
1148
+ let totalUnchanged = 0;
1149
+ const batchErrors = [];
1150
+ const failedBatches = [];
1151
+
1152
+ // Process batches in parallel (concurrency = 3). Each batch is
1153
+ // independent on the server side, so overlapping them gives a
1154
+ // ~3x speedup for large projects. Higher concurrency risks
1155
+ // overwhelming the Cloudflare Worker / hitting per-account
1156
+ // request limits, so we cap conservatively.
1157
+ const CONCURRENCY = Math.min(3, batches.length);
1158
+ const aggregateBatchResult = (uploadResult, b, batch) => {
1159
+ if (!uploadResult) {
1160
+ failedBatches.push({ batch: b + 1, files: batch.length, reason: 'no response (timeout/network)' });
1161
+ this.logError(`Batch ${b + 1}/${batches.length} returned no response (timeout or network error). ${batch.length} files unaccounted for.`);
1162
+ return;
1163
+ }
1164
+ if (typeof uploadResult.files_indexed === 'number') {
355
1165
  totalIndexed += uploadResult.files_indexed;
356
1166
  }
357
- if (uploadResult && typeof uploadResult.chunks_created === 'number') {
1167
+ if (typeof uploadResult.chunks_created === 'number') {
358
1168
  totalChunks += uploadResult.chunks_created;
359
1169
  }
1170
+ if (typeof uploadResult.files_skipped === 'number') {
1171
+ totalSkipped += uploadResult.files_skipped;
1172
+ }
1173
+ if (typeof uploadResult.files_unchanged === 'number') {
1174
+ totalUnchanged += uploadResult.files_unchanged;
1175
+ }
1176
+ if (Array.isArray(uploadResult.errors) && uploadResult.errors.length > 0) {
1177
+ for (const err of uploadResult.errors) {
1178
+ batchErrors.push(err);
1179
+ }
1180
+ }
1181
+ };
1182
+
1183
+ for (let i = 0; i < batches.length; i += CONCURRENCY) {
1184
+ const window = [];
1185
+ for (let j = i; j < Math.min(i + CONCURRENCY, batches.length); j++) {
1186
+ const batch = batches[j];
1187
+ const approxBytes = batch.reduce((sum, f) => sum + Buffer.byteLength(f.content || '', 'utf8'), 0);
1188
+ this.logDebug(`Uploading batch ${j + 1}/${batches.length} (${batch.length} files, ~${Math.round(approxBytes / 1024)}KB)`);
1189
+ window.push(
1190
+ this.uploadFileBatch(projectId, batch).then(res => aggregateBatchResult(res, j, batch))
1191
+ );
1192
+ }
1193
+ await Promise.all(window);
360
1194
  }
361
1195
 
362
1196
  // 3. Cleanup stale files (accuracy): remove server-side files not present locally.
@@ -369,21 +1203,32 @@ class CFMemoryMCP {
369
1203
  }
370
1204
  }
371
1205
 
372
- // 4. Return aggregated success
1206
+ // 4. Return aggregated success (with skipped/error visibility)
1207
+ const status = failedBatches.length > 0 ? 'partial' : 'complete';
1208
+ const responsePayload = {
1209
+ project_id: projectId,
1210
+ project_name: name,
1211
+ files_found: files.length,
1212
+ files_indexed: totalIndexed,
1213
+ files_unchanged: totalUnchanged,
1214
+ files_skipped: totalSkipped,
1215
+ chunks_created: totalChunks,
1216
+ status
1217
+ };
1218
+ if (batchErrors.length > 0) {
1219
+ responsePayload.errors = batchErrors.slice(0, 50);
1220
+ responsePayload.errors_truncated = batchErrors.length > 50;
1221
+ }
1222
+ if (failedBatches.length > 0) {
1223
+ responsePayload.failed_batches = failedBatches;
1224
+ }
373
1225
  const response = {
374
1226
  jsonrpc: '2.0',
375
1227
  id: message.id,
376
1228
  result: {
377
1229
  content: [{
378
1230
  type: 'text',
379
- text: JSON.stringify({
380
- project_id: projectId,
381
- project_name: name,
382
- files_found: files.length,
383
- files_indexed: totalIndexed,
384
- chunks_created: totalChunks,
385
- status: 'complete'
386
- }, null, 2)
1231
+ text: JSON.stringify(responsePayload, null, 2)
387
1232
  }]
388
1233
  }
389
1234
  };
@@ -400,6 +1245,11 @@ class CFMemoryMCP {
400
1245
  }
401
1246
  };
402
1247
  process.stdout.write(JSON.stringify(response) + '\n');
1248
+ } finally {
1249
+ // Close the SSE progress stream now that indexing is done.
1250
+ if (progressStream && typeof progressStream.stop === 'function') {
1251
+ try { progressStream.stop(); } catch (_) {}
1252
+ }
403
1253
  }
404
1254
  }
405
1255
 
@@ -424,17 +1274,47 @@ class CFMemoryMCP {
424
1274
  // IDE/Editor history & settings
425
1275
  '.history', '.vscode', '.idea', '.vs',
426
1276
  // Cloudflare/tooling
427
- '.wrangler', '.turbo', '.cache'
1277
+ '.wrangler', '.turbo', '.cache',
1278
+ // Legacy/archived rule directories that poison retrieval with
1279
+ // stale architecture docs and example prompts.
1280
+ '.augment', '.kiro', '.intent', '.husky', '.claude',
428
1281
  ];
429
1282
 
430
- // Default file extensions to include
1283
+ // Default file extensions to include - covers 110+ languages
1284
+ // Must stay in sync with src-simplified/utils/index.ts DEFAULT_INCLUDE_EXTENSIONS
431
1285
  const DEFAULT_INCLUDE_EXTS = [
1286
+ // Mainstream
432
1287
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
433
1288
  '.py', '.rb', '.go', '.rs', '.java', '.kt', '.scala',
434
1289
  '.cs', '.cpp', '.c', '.h', '.hpp', '.swift', '.php',
435
- '.sql', '.sh', '.bash',
436
- '.json', '.yaml', '.yml', '.toml',
437
- '.html', '.css', '.scss', '.vue', '.svelte',
1290
+ // Functional & systems
1291
+ '.lua', '.ex', '.exs', '.hs', '.lhs', '.pl', '.pm',
1292
+ '.groovy', '.gvy', '.gradle', '.r', '.R', '.dart',
1293
+ '.ml', '.mli', '.fs', '.fsi', '.fsx',
1294
+ '.clj', '.cljs', '.cljc', '.edn', '.jl',
1295
+ '.tf', '.tfvars', '.hcl', '.zig', '.nim', '.nims', '.nimble',
1296
+ '.cr', '.vv', '.d', '.di', '.erl', '.hrl',
1297
+ // Legacy & specialized
1298
+ '.sol', '.cob', '.cbl', '.cpy', '.asm', '.s', '.S',
1299
+ '.proto', '.graphql', '.gql', '.mk',
1300
+ '.scm', '.ss', '.rkt', '.lisp', '.lsp', '.cl', '.pro', '.P',
1301
+ '.m', '.mat', '.f', '.f90', '.f95', '.f03', '.for',
1302
+ '.adb', '.ads', '.ada', '.vhd', '.vhdl', '.v', '.sv', '.svh',
1303
+ '.wat', '.wast', '.elm', '.purs', '.nix',
1304
+ '.cmake', '.bzl', '.bazel', '.feature',
1305
+ '.ps1', '.psm1', '.psd1', '.tcl', '.tk', '.awk',
1306
+ '.mm', '.cls', '.trigger', '.abap', '.coffee', '.litcoffee', '.sas',
1307
+ // Shaders & GPU
1308
+ '.as', '.glsl', '.vert', '.frag', '.hlsl', '.fx', '.qs',
1309
+ '.wgsl', '.metal', '.cu', '.cuh', '.fish',
1310
+ '.scad', '.jsonnet', '.libsonnet',
1311
+ '.re', '.rei', '.res', '.resi', '.sml', '.sig',
1312
+ '.pony', '.fth', '.4th', '.chpl', '.x10', '.cecil', '.io', '.red', '.reds',
1313
+ // Common config & data
1314
+ '.sql', '.sh', '.bash', '.zsh',
1315
+ '.json', '.yaml', '.yml', '.toml', '.xml',
1316
+ '.html', '.css', '.scss', '.less',
1317
+ '.vue', '.svelte', '.astro',
438
1318
  '.md', '.mdx'
439
1319
  ];
440
1320
 
@@ -467,6 +1347,11 @@ class CFMemoryMCP {
467
1347
  } else if (entry.isFile()) {
468
1348
  const ext = path.extname(entry.name).toLowerCase();
469
1349
 
1350
+ // Skip TypeScript declaration files — they're autogenerated
1351
+ // or framework types with thousands of identifiers that
1352
+ // aren't actual project code.
1353
+ if (entry.name.endsWith('.d.ts')) continue;
1354
+
470
1355
  // Check if file should be excluded
471
1356
  const fileExcluded = effectiveExcludes.some(pattern => {
472
1357
  if (pattern.startsWith('*.')) {
@@ -520,8 +1405,9 @@ class CFMemoryMCP {
520
1405
  */
521
1406
  createAdaptiveBatches(files) {
522
1407
  // Defaults tuned for Workers + typical project sizes.
523
- // Max files per request keeps JSON parsing and request time stable.
524
- const maxFiles = Number(process.env.CF_MEMORY_UPLOAD_BATCH_FILES || 100);
1408
+ // Each file on the worker takes ~0.5-2s (hash + parse + embed + store).
1409
+ // Keep batch size small enough that one batch fits comfortably in BATCH_TIMEOUT_MS.
1410
+ const maxFiles = Number(process.env.CF_MEMORY_UPLOAD_BATCH_FILES || 25);
525
1411
  // Max payload bytes (approx). Keep below a few MB to avoid timeouts.
526
1412
  const maxBytes = Number(process.env.CF_MEMORY_UPLOAD_BATCH_BYTES || (1.5 * 1024 * 1024));
527
1413
 
@@ -590,7 +1476,8 @@ class CFMemoryMCP {
590
1476
  path: batchPath,
591
1477
  method: 'POST',
592
1478
  headers,
593
- timeout: TIMEOUT_MS
1479
+ timeout: BATCH_TIMEOUT_MS,
1480
+ agent: httpsAgent
594
1481
  };
595
1482
 
596
1483
  const req = https.request(options, (res) => {
@@ -635,7 +1522,8 @@ class CFMemoryMCP {
635
1522
  path: cleanupPath,
636
1523
  method: 'POST',
637
1524
  headers,
638
- timeout: TIMEOUT_MS
1525
+ timeout: TIMEOUT_MS,
1526
+ agent: httpsAgent
639
1527
  };
640
1528
 
641
1529
  const req = https.request(options, (res) => {
@@ -657,6 +1545,114 @@ class CFMemoryMCP {
657
1545
  });
658
1546
  }
659
1547
 
1548
+ /**
1549
+ * Compare each result's indexed_file_hash against the local file's
1550
+ * current SHA-256 and set a `stale` field if they diverge. Works for
1551
+ * the common case where the user has the project_path locally and
1552
+ * the result file_paths are relative to it. Gracefully no-ops if we
1553
+ * can't resolve the local path or read the file.
1554
+ */
1555
+ /**
1556
+ * If retrieve_context is called without project_id and without
1557
+ * all_projects, try to find a project whose root_path matches the
1558
+ * current working directory (or CF_MEMORY_WATCH_PATH). Pass that
1559
+ * project_id through. Cached for the process lifetime so we don't
1560
+ * call list_projects on every retrieve.
1561
+ */
1562
+ async maybeFillProjectId(message) {
1563
+ try {
1564
+ const args = message.params && message.params.arguments;
1565
+ if (!args || args.project_id || args.all_projects) return;
1566
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
1567
+ if (!cwd) return;
1568
+
1569
+ // Cache the resolution for the lifetime of this bridge process
1570
+ // so we don't pay a list_projects round-trip per query.
1571
+ if (!this._projectIdByCwdCache) this._projectIdByCwdCache = new Map();
1572
+ const cached = this._projectIdByCwdCache.get(cwd);
1573
+ if (cached !== undefined) {
1574
+ if (cached) args.project_id = cached;
1575
+ return;
1576
+ }
1577
+
1578
+ const list = await this.makeRequest({
1579
+ jsonrpc: '2.0',
1580
+ id: `auto-proj-${Date.now()}`,
1581
+ method: 'tools/call',
1582
+ params: { name: 'list_projects', arguments: {} },
1583
+ });
1584
+ let projects = [];
1585
+ try {
1586
+ projects = JSON.parse(list.result.content[0].text);
1587
+ if (!Array.isArray(projects)) projects = [];
1588
+ } catch (_) {}
1589
+
1590
+ // Exact root_path match wins; otherwise prefix match (cwd is
1591
+ // inside an indexed project).
1592
+ const exact = projects.find(p => p.root_path === cwd);
1593
+ const prefix = exact || projects.find(p =>
1594
+ p.root_path && (cwd === p.root_path || cwd.startsWith(p.root_path + '/'))
1595
+ );
1596
+ const found = prefix?.id || null;
1597
+ this._projectIdByCwdCache.set(cwd, found);
1598
+ if (found) {
1599
+ args.project_id = found;
1600
+ _mcpTrace('AUTO_PROJECT', `cwd=${cwd} -> ${found}`);
1601
+ }
1602
+ } catch (err) {
1603
+ this.logDebug(`maybeFillProjectId failed: ${err && err.message}`);
1604
+ }
1605
+ }
1606
+
1607
+ maybeAnnotateStaleness(response, args) {
1608
+ try {
1609
+ const text = response?.result?.content?.[0]?.text;
1610
+ if (!text) return;
1611
+ let parsed;
1612
+ try { parsed = JSON.parse(text); } catch (_) { return; }
1613
+ const results = Array.isArray(parsed?.results) ? parsed.results : null;
1614
+ if (!results || results.length === 0) return;
1615
+
1616
+ // Try to find a local project root. We watch CF_MEMORY_WATCH_PATH
1617
+ // (when auto-watch is on) or fall back to the cwd.
1618
+ const root = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
1619
+ const stalePaths = new Set();
1620
+ for (const r of results) {
1621
+ if (!r || !r.file_path || !r.indexed_file_hash) continue;
1622
+ const full = path.resolve(root, r.file_path);
1623
+ let content;
1624
+ try { content = fs.readFileSync(full, 'utf8'); } catch (_) { continue; }
1625
+ // SHA-256 hex of UTF-8 content. Matches the server's hashContent().
1626
+ const localHash = crypto.createHash('sha256').update(content, 'utf8').digest('hex');
1627
+ if (localHash !== r.indexed_file_hash) {
1628
+ r.stale = {
1629
+ last_indexed_at: r.indexed_at,
1630
+ reason: 'File edited locally since this chunk was indexed.',
1631
+ };
1632
+ stalePaths.add(r.file_path);
1633
+ }
1634
+ }
1635
+ if (stalePaths.size > 0) {
1636
+ parsed.stale_count = stalePaths.size;
1637
+ // Concrete actionable hint with the actual file paths and the
1638
+ // project_id from the first stale result. The model can copy
1639
+ // this directly into a refresh_files call.
1640
+ const projectId = results.find(r => r.stale && r.project_id)?.project_id;
1641
+ parsed.stale_refresh_hint = {
1642
+ tool: 'refresh_files',
1643
+ arguments: {
1644
+ project_id: projectId,
1645
+ file_paths: Array.from(stalePaths),
1646
+ },
1647
+ alternative: 'Or call refresh_stale to refresh every stale file in the project at once.',
1648
+ };
1649
+ response.result.content[0].text = JSON.stringify(parsed);
1650
+ }
1651
+ } catch (err) {
1652
+ this.logDebug(`maybeAnnotateStaleness failed: ${err && err.message}`);
1653
+ }
1654
+ }
1655
+
660
1656
  async makeRequest(message, extraHeaders = null) {
661
1657
  return new Promise((resolve) => {
662
1658
  const serverUrl = this.useStreamableHttp ? this.streamableHttpUrl : this.legacyServerUrl;
@@ -683,7 +1679,8 @@ class CFMemoryMCP {
683
1679
  path: url.pathname,
684
1680
  method: 'POST',
685
1681
  headers,
686
- timeout: TIMEOUT_MS
1682
+ timeout: TIMEOUT_MS,
1683
+ agent: httpsAgent
687
1684
  };
688
1685
 
689
1686
  const req = https.request(options, (res) => {
@@ -698,13 +1695,19 @@ class CFMemoryMCP {
698
1695
  const response = JSON.parse(body);
699
1696
  resolve(response);
700
1697
  } catch (error) {
1698
+ // Include HTTP status + body snippet so callers can
1699
+ // see whether they got a 404, a Cloudflare HTML error
1700
+ // page, or some other non-JSON response. Without this,
1701
+ // "Invalid JSON" alone hides whether the worker is
1702
+ // even reachable.
1703
+ const bodyPreview = body.slice(0, 200).replace(/\s+/g, ' ');
701
1704
  resolve({
702
1705
  jsonrpc: '2.0',
703
1706
  id: message.id || null,
704
1707
  error: {
705
1708
  code: -32603,
706
- message: 'Invalid JSON response from server',
707
- data: error.message
1709
+ message: `Invalid JSON response from server (HTTP ${res.statusCode})`,
1710
+ data: `${error.message}; body[0..200]=${bodyPreview}`
708
1711
  }
709
1712
  });
710
1713
  }
@@ -805,9 +1808,11 @@ Usage:
805
1808
  npx cf-memory-mcp Start the MCP server
806
1809
  npx cf-memory-mcp --version Show version
807
1810
  npx cf-memory-mcp --help Show this help
1811
+ npx cf-memory-mcp --diagnose Test connectivity and report issues
808
1812
 
809
1813
  Environment Variables:
810
1814
  CF_MEMORY_API_KEY=<key> Your CF Memory API key (required)
1815
+ CF_MEMORY_BASE_URL=<url> Override the default deployed worker
811
1816
  CF_MEMORY_PROGRESS=true Stream indexing progress to stderr (optional)
812
1817
  DEBUG=1 Enable debug logging
813
1818
  MCP_DEBUG=1 Enable MCP debug logging
@@ -817,6 +1822,149 @@ For more information, visit: https://github.com/johnlam90/cf-memory-mcp
817
1822
  process.exit(0);
818
1823
  }
819
1824
 
1825
+ if (process.argv.includes('--diagnose')) {
1826
+ (async () => {
1827
+ console.log(`CF Memory MCP v${PACKAGE_VERSION} - Diagnostics`);
1828
+ console.log(`Node.js: ${process.version}`);
1829
+ console.log(`Platform: ${os.platform()} ${os.arch()}`);
1830
+ console.log(`Target server: ${BASE_URL}`);
1831
+ console.log(`API key set: ${API_KEY ? 'yes' : 'NO'}`);
1832
+
1833
+ if (!API_KEY) {
1834
+ console.error('\nError: CF_MEMORY_API_KEY not set');
1835
+ process.exit(1);
1836
+ }
1837
+
1838
+ // Test 1: Health check
1839
+ process.stdout.write('\n1. Health check... ');
1840
+ const healthStart = Date.now();
1841
+ try {
1842
+ const url = new URL(BASE_URL + '/health');
1843
+ const result = await new Promise((resolve, reject) => {
1844
+ const req = https.request({
1845
+ hostname: url.hostname,
1846
+ port: url.port || 443,
1847
+ path: url.pathname,
1848
+ method: 'GET',
1849
+ timeout: 5000,
1850
+ }, (res) => {
1851
+ let body = '';
1852
+ res.on('data', (c) => body += c);
1853
+ res.on('end', () => resolve({ status: res.statusCode, body }));
1854
+ });
1855
+ req.on('error', reject);
1856
+ req.on('timeout', () => reject(new Error('timeout')));
1857
+ req.end();
1858
+ });
1859
+ const elapsed = Date.now() - healthStart;
1860
+ console.log(`OK (${elapsed}ms, HTTP ${result.status})`);
1861
+ } catch (err) {
1862
+ console.log(`FAIL: ${err.message}`);
1863
+ }
1864
+
1865
+ // Test 2: MCP initialize
1866
+ process.stdout.write('2. MCP initialize... ');
1867
+ const initStart = Date.now();
1868
+ try {
1869
+ const result = await new Promise((resolve, reject) => {
1870
+ const postData = JSON.stringify({
1871
+ jsonrpc: '2.0',
1872
+ id: 'diag',
1873
+ method: 'initialize',
1874
+ params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'diagnose', version: PACKAGE_VERSION } }
1875
+ });
1876
+ const url = new URL(STREAMABLE_HTTP_URL);
1877
+ const req = https.request({
1878
+ hostname: url.hostname,
1879
+ port: url.port || 443,
1880
+ path: url.pathname,
1881
+ method: 'POST',
1882
+ timeout: 10000,
1883
+ headers: {
1884
+ 'Content-Type': 'application/json',
1885
+ 'X-API-Key': API_KEY,
1886
+ 'Content-Length': Buffer.byteLength(postData)
1887
+ }
1888
+ }, (res) => {
1889
+ let body = '';
1890
+ res.on('data', (c) => body += c);
1891
+ res.on('end', () => resolve({ status: res.statusCode, body }));
1892
+ });
1893
+ req.on('error', reject);
1894
+ req.on('timeout', () => reject(new Error('timeout')));
1895
+ req.write(postData);
1896
+ req.end();
1897
+ });
1898
+ const elapsed = Date.now() - initStart;
1899
+ const parsed = JSON.parse(result.body);
1900
+ console.log(`OK (${elapsed}ms, ${parsed.result?.serverInfo?.name || 'unknown'})`);
1901
+ } catch (err) {
1902
+ console.log(`FAIL: ${err.message}`);
1903
+ }
1904
+
1905
+ // Test 3: MCP tools/call list_projects
1906
+ process.stdout.write('3. List projects... ');
1907
+ const listStart = Date.now();
1908
+ try {
1909
+ const result = await new Promise((resolve, reject) => {
1910
+ const postData = JSON.stringify({
1911
+ jsonrpc: '2.0',
1912
+ id: 'diag2',
1913
+ method: 'tools/call',
1914
+ params: { name: 'list_projects', arguments: {} }
1915
+ });
1916
+ const url = new URL(STREAMABLE_HTTP_URL);
1917
+ const req = https.request({
1918
+ hostname: url.hostname,
1919
+ port: url.port || 443,
1920
+ path: url.pathname,
1921
+ method: 'POST',
1922
+ timeout: 10000,
1923
+ headers: {
1924
+ 'Content-Type': 'application/json',
1925
+ 'X-API-Key': API_KEY,
1926
+ 'Content-Length': Buffer.byteLength(postData)
1927
+ }
1928
+ }, (res) => {
1929
+ let body = '';
1930
+ res.on('data', (c) => body += c);
1931
+ res.on('end', () => resolve({ status: res.statusCode, body }));
1932
+ });
1933
+ req.on('error', reject);
1934
+ req.on('timeout', () => reject(new Error('timeout')));
1935
+ req.write(postData);
1936
+ req.end();
1937
+ });
1938
+ const elapsed = Date.now() - listStart;
1939
+ const parsed = JSON.parse(result.body);
1940
+ const projects = JSON.parse(parsed.result?.content?.[0]?.text || '[]');
1941
+ console.log(`OK (${elapsed}ms, ${projects.length} projects)`);
1942
+
1943
+ // 4. Auto-project detection for current cwd
1944
+ process.stdout.write('4. Auto-project from cwd... ');
1945
+ const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
1946
+ const exact = projects.find(p => p.root_path === cwd);
1947
+ const prefix = exact || projects.find(p =>
1948
+ p.root_path && (cwd === p.root_path || cwd.startsWith(p.root_path + '/'))
1949
+ );
1950
+ if (prefix) {
1951
+ console.log(`OK (cwd=${cwd} -> ${prefix.name} [${prefix.id}])`);
1952
+ } else {
1953
+ console.log(`none (cwd=${cwd} doesn't match any project root)`);
1954
+ }
1955
+ } catch (err) {
1956
+ console.log(`FAIL: ${err.message}`);
1957
+ }
1958
+
1959
+ console.log('\nDiagnostics complete.');
1960
+ process.exit(0);
1961
+ })().catch(err => {
1962
+ console.error('Diagnostic error:', err);
1963
+ process.exit(1);
1964
+ });
1965
+ return;
1966
+ }
1967
+
820
1968
  // Check API key before starting server
821
1969
  if (!API_KEY) {
822
1970
  console.error('Error: CF_MEMORY_API_KEY environment variable is required');
@@ -827,7 +1975,7 @@ if (!API_KEY) {
827
1975
  console.error('Or run with:');
828
1976
  console.error(' CF_MEMORY_API_KEY="your-api-key-here" npx cf-memory-mcp');
829
1977
  console.error('');
830
- console.error('Get your API key from: https://cf-memory-mcp.johnlam90.workers.dev');
1978
+ console.error(`Target server: ${BASE_URL}`);
831
1979
  process.exit(1);
832
1980
  }
833
1981