cf-memory-mcp 3.8.4 → 3.8.6
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/README.md +95 -669
- package/bin/cf-memory-mcp-indexer.js +149 -69
- package/bin/cf-memory-mcp.js +1205 -63
- package/package.json +15 -53
package/bin/cf-memory-mcp.js
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
// Handle stdin data
|
|
186
|
-
for await (const chunk of process.stdin) {
|
|
187
|
-
buffer += chunk;
|
|
565
|
+
const readline = require('readline');
|
|
188
566
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
buffer = buffer.slice(newlineIndex + 1);
|
|
567
|
+
const rl = readline.createInterface({
|
|
568
|
+
input: process.stdin,
|
|
569
|
+
terminal: false
|
|
570
|
+
});
|
|
194
571
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
//
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
586
|
+
// Handle stdin close
|
|
587
|
+
rl.on('close', () => {
|
|
588
|
+
this.logDebug('Stdin closed, shutting down...');
|
|
589
|
+
process.exit(0);
|
|
590
|
+
});
|
|
205
591
|
|
|
206
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 (
|
|
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
|
-
|
|
436
|
-
'.
|
|
437
|
-
'.
|
|
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
|
-
//
|
|
524
|
-
|
|
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:
|
|
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) => {
|
|
@@ -805,9 +1802,11 @@ Usage:
|
|
|
805
1802
|
npx cf-memory-mcp Start the MCP server
|
|
806
1803
|
npx cf-memory-mcp --version Show version
|
|
807
1804
|
npx cf-memory-mcp --help Show this help
|
|
1805
|
+
npx cf-memory-mcp --diagnose Test connectivity and report issues
|
|
808
1806
|
|
|
809
1807
|
Environment Variables:
|
|
810
1808
|
CF_MEMORY_API_KEY=<key> Your CF Memory API key (required)
|
|
1809
|
+
CF_MEMORY_BASE_URL=<url> Override the default deployed worker
|
|
811
1810
|
CF_MEMORY_PROGRESS=true Stream indexing progress to stderr (optional)
|
|
812
1811
|
DEBUG=1 Enable debug logging
|
|
813
1812
|
MCP_DEBUG=1 Enable MCP debug logging
|
|
@@ -817,6 +1816,149 @@ For more information, visit: https://github.com/johnlam90/cf-memory-mcp
|
|
|
817
1816
|
process.exit(0);
|
|
818
1817
|
}
|
|
819
1818
|
|
|
1819
|
+
if (process.argv.includes('--diagnose')) {
|
|
1820
|
+
(async () => {
|
|
1821
|
+
console.log(`CF Memory MCP v${PACKAGE_VERSION} - Diagnostics`);
|
|
1822
|
+
console.log(`Node.js: ${process.version}`);
|
|
1823
|
+
console.log(`Platform: ${os.platform()} ${os.arch()}`);
|
|
1824
|
+
console.log(`Target server: ${BASE_URL}`);
|
|
1825
|
+
console.log(`API key set: ${API_KEY ? 'yes' : 'NO'}`);
|
|
1826
|
+
|
|
1827
|
+
if (!API_KEY) {
|
|
1828
|
+
console.error('\nError: CF_MEMORY_API_KEY not set');
|
|
1829
|
+
process.exit(1);
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// Test 1: Health check
|
|
1833
|
+
process.stdout.write('\n1. Health check... ');
|
|
1834
|
+
const healthStart = Date.now();
|
|
1835
|
+
try {
|
|
1836
|
+
const url = new URL(BASE_URL + '/health');
|
|
1837
|
+
const result = await new Promise((resolve, reject) => {
|
|
1838
|
+
const req = https.request({
|
|
1839
|
+
hostname: url.hostname,
|
|
1840
|
+
port: url.port || 443,
|
|
1841
|
+
path: url.pathname,
|
|
1842
|
+
method: 'GET',
|
|
1843
|
+
timeout: 5000,
|
|
1844
|
+
}, (res) => {
|
|
1845
|
+
let body = '';
|
|
1846
|
+
res.on('data', (c) => body += c);
|
|
1847
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
1848
|
+
});
|
|
1849
|
+
req.on('error', reject);
|
|
1850
|
+
req.on('timeout', () => reject(new Error('timeout')));
|
|
1851
|
+
req.end();
|
|
1852
|
+
});
|
|
1853
|
+
const elapsed = Date.now() - healthStart;
|
|
1854
|
+
console.log(`OK (${elapsed}ms, HTTP ${result.status})`);
|
|
1855
|
+
} catch (err) {
|
|
1856
|
+
console.log(`FAIL: ${err.message}`);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// Test 2: MCP initialize
|
|
1860
|
+
process.stdout.write('2. MCP initialize... ');
|
|
1861
|
+
const initStart = Date.now();
|
|
1862
|
+
try {
|
|
1863
|
+
const result = await new Promise((resolve, reject) => {
|
|
1864
|
+
const postData = JSON.stringify({
|
|
1865
|
+
jsonrpc: '2.0',
|
|
1866
|
+
id: 'diag',
|
|
1867
|
+
method: 'initialize',
|
|
1868
|
+
params: { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'diagnose', version: PACKAGE_VERSION } }
|
|
1869
|
+
});
|
|
1870
|
+
const url = new URL(STREAMABLE_HTTP_URL);
|
|
1871
|
+
const req = https.request({
|
|
1872
|
+
hostname: url.hostname,
|
|
1873
|
+
port: url.port || 443,
|
|
1874
|
+
path: url.pathname,
|
|
1875
|
+
method: 'POST',
|
|
1876
|
+
timeout: 10000,
|
|
1877
|
+
headers: {
|
|
1878
|
+
'Content-Type': 'application/json',
|
|
1879
|
+
'X-API-Key': API_KEY,
|
|
1880
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
1881
|
+
}
|
|
1882
|
+
}, (res) => {
|
|
1883
|
+
let body = '';
|
|
1884
|
+
res.on('data', (c) => body += c);
|
|
1885
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
1886
|
+
});
|
|
1887
|
+
req.on('error', reject);
|
|
1888
|
+
req.on('timeout', () => reject(new Error('timeout')));
|
|
1889
|
+
req.write(postData);
|
|
1890
|
+
req.end();
|
|
1891
|
+
});
|
|
1892
|
+
const elapsed = Date.now() - initStart;
|
|
1893
|
+
const parsed = JSON.parse(result.body);
|
|
1894
|
+
console.log(`OK (${elapsed}ms, ${parsed.result?.serverInfo?.name || 'unknown'})`);
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
console.log(`FAIL: ${err.message}`);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Test 3: MCP tools/call list_projects
|
|
1900
|
+
process.stdout.write('3. List projects... ');
|
|
1901
|
+
const listStart = Date.now();
|
|
1902
|
+
try {
|
|
1903
|
+
const result = await new Promise((resolve, reject) => {
|
|
1904
|
+
const postData = JSON.stringify({
|
|
1905
|
+
jsonrpc: '2.0',
|
|
1906
|
+
id: 'diag2',
|
|
1907
|
+
method: 'tools/call',
|
|
1908
|
+
params: { name: 'list_projects', arguments: {} }
|
|
1909
|
+
});
|
|
1910
|
+
const url = new URL(STREAMABLE_HTTP_URL);
|
|
1911
|
+
const req = https.request({
|
|
1912
|
+
hostname: url.hostname,
|
|
1913
|
+
port: url.port || 443,
|
|
1914
|
+
path: url.pathname,
|
|
1915
|
+
method: 'POST',
|
|
1916
|
+
timeout: 10000,
|
|
1917
|
+
headers: {
|
|
1918
|
+
'Content-Type': 'application/json',
|
|
1919
|
+
'X-API-Key': API_KEY,
|
|
1920
|
+
'Content-Length': Buffer.byteLength(postData)
|
|
1921
|
+
}
|
|
1922
|
+
}, (res) => {
|
|
1923
|
+
let body = '';
|
|
1924
|
+
res.on('data', (c) => body += c);
|
|
1925
|
+
res.on('end', () => resolve({ status: res.statusCode, body }));
|
|
1926
|
+
});
|
|
1927
|
+
req.on('error', reject);
|
|
1928
|
+
req.on('timeout', () => reject(new Error('timeout')));
|
|
1929
|
+
req.write(postData);
|
|
1930
|
+
req.end();
|
|
1931
|
+
});
|
|
1932
|
+
const elapsed = Date.now() - listStart;
|
|
1933
|
+
const parsed = JSON.parse(result.body);
|
|
1934
|
+
const projects = JSON.parse(parsed.result?.content?.[0]?.text || '[]');
|
|
1935
|
+
console.log(`OK (${elapsed}ms, ${projects.length} projects)`);
|
|
1936
|
+
|
|
1937
|
+
// 4. Auto-project detection for current cwd
|
|
1938
|
+
process.stdout.write('4. Auto-project from cwd... ');
|
|
1939
|
+
const cwd = process.env.CF_MEMORY_WATCH_PATH || process.cwd();
|
|
1940
|
+
const exact = projects.find(p => p.root_path === cwd);
|
|
1941
|
+
const prefix = exact || projects.find(p =>
|
|
1942
|
+
p.root_path && (cwd === p.root_path || cwd.startsWith(p.root_path + '/'))
|
|
1943
|
+
);
|
|
1944
|
+
if (prefix) {
|
|
1945
|
+
console.log(`OK (cwd=${cwd} -> ${prefix.name} [${prefix.id}])`);
|
|
1946
|
+
} else {
|
|
1947
|
+
console.log(`none (cwd=${cwd} doesn't match any project root)`);
|
|
1948
|
+
}
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
console.log(`FAIL: ${err.message}`);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
console.log('\nDiagnostics complete.');
|
|
1954
|
+
process.exit(0);
|
|
1955
|
+
})().catch(err => {
|
|
1956
|
+
console.error('Diagnostic error:', err);
|
|
1957
|
+
process.exit(1);
|
|
1958
|
+
});
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
820
1962
|
// Check API key before starting server
|
|
821
1963
|
if (!API_KEY) {
|
|
822
1964
|
console.error('Error: CF_MEMORY_API_KEY environment variable is required');
|
|
@@ -827,7 +1969,7 @@ if (!API_KEY) {
|
|
|
827
1969
|
console.error('Or run with:');
|
|
828
1970
|
console.error(' CF_MEMORY_API_KEY="your-api-key-here" npx cf-memory-mcp');
|
|
829
1971
|
console.error('');
|
|
830
|
-
console.error(
|
|
1972
|
+
console.error(`Target server: ${BASE_URL}`);
|
|
831
1973
|
process.exit(1);
|
|
832
1974
|
}
|
|
833
1975
|
|