project-graph-mcp 1.0.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/package.json +7 -3
- package/src/.project-graph-cache.json +1 -0
- package/src/filters.js +1 -0
- package/src/graph-builder.js +1 -1
- package/src/lang-go.js +285 -0
- package/src/lang-python.js +197 -0
- package/src/lang-typescript.js +190 -0
- package/src/lang-utils.js +124 -0
- package/src/mcp-server.js +67 -2
- package/src/parser.js +42 -3
- package/src/server.js +0 -0
- package/src/tool-defs.js +33 -0
- package/src/tools.js +237 -7
package/src/tools.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* MCP Tools for Project Graph
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { parseProject, parseFile } from './parser.js';
|
|
5
|
+
import { parseProject, parseFile, findJSFiles } from './parser.js';
|
|
6
6
|
import { buildGraph, createSkeleton } from './graph-builder.js';
|
|
7
|
-
import { readFileSync } from 'fs';
|
|
7
|
+
import { readFileSync, statSync, writeFileSync, existsSync, unlinkSync } from 'fs';
|
|
8
8
|
import { execSync } from 'child_process';
|
|
9
|
+
import { join } from 'path';
|
|
9
10
|
|
|
10
11
|
/** @type {import('./graph-builder.js').Graph|null} */
|
|
11
12
|
let cachedGraph = null;
|
|
@@ -13,23 +14,158 @@ let cachedGraph = null;
|
|
|
13
14
|
/** @type {string|null} */
|
|
14
15
|
let cachedPath = null;
|
|
15
16
|
|
|
17
|
+
/** @type {Map<string, number>} file path -> mtimeMs */
|
|
18
|
+
let cachedMtimes = new Map();
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
|
-
*
|
|
21
|
+
* Save cache to disk
|
|
22
|
+
* @param {string} path
|
|
23
|
+
* @param {import('./graph-builder.js').Graph} graph
|
|
24
|
+
*/
|
|
25
|
+
function saveDiskCache(path, graph) {
|
|
26
|
+
try {
|
|
27
|
+
const cachePath = join(path, '.project-graph-cache.json');
|
|
28
|
+
const cacheData = {
|
|
29
|
+
version: 1,
|
|
30
|
+
path: path,
|
|
31
|
+
mtimes: Object.fromEntries(cachedMtimes),
|
|
32
|
+
graph: graph
|
|
33
|
+
};
|
|
34
|
+
writeFileSync(cachePath, JSON.stringify(cacheData), 'utf-8');
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// Ignore cache save errors
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load cache from disk
|
|
42
|
+
* @param {string} path
|
|
43
|
+
* @returns {boolean} true if cache was successfully loaded and is valid
|
|
44
|
+
*/
|
|
45
|
+
function loadDiskCache(path) {
|
|
46
|
+
try {
|
|
47
|
+
const cachePath = join(path, '.project-graph-cache.json');
|
|
48
|
+
if (!existsSync(cachePath)) return false;
|
|
49
|
+
|
|
50
|
+
const content = readFileSync(cachePath, 'utf-8');
|
|
51
|
+
const data = JSON.parse(content);
|
|
52
|
+
|
|
53
|
+
if (data.version !== 1 || data.path !== path) return false;
|
|
54
|
+
|
|
55
|
+
cachedMtimes.clear();
|
|
56
|
+
for (const [file, mtime] of Object.entries(data.mtimes)) {
|
|
57
|
+
cachedMtimes.set(file, mtime);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cachedGraph = data.graph;
|
|
61
|
+
cachedPath = path;
|
|
62
|
+
|
|
63
|
+
const changed = detectChanges(path);
|
|
64
|
+
if (changed) {
|
|
65
|
+
cachedGraph = null;
|
|
66
|
+
cachedPath = null;
|
|
67
|
+
cachedMtimes.clear();
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get or build graph with smart mtime-based caching.
|
|
79
|
+
* On first call: full parse + build.
|
|
80
|
+
* On subsequent calls: check file mtimes, rebuild only if changes detected.
|
|
18
81
|
* @param {string} path
|
|
19
82
|
* @returns {Promise<import('./graph-builder.js').Graph>}
|
|
20
83
|
*/
|
|
21
84
|
async function getGraph(path) {
|
|
85
|
+
// Different path = full rebuild
|
|
22
86
|
if (cachedGraph && cachedPath === path) {
|
|
23
|
-
|
|
87
|
+
// Check for file changes via mtime
|
|
88
|
+
const changed = detectChanges(path);
|
|
89
|
+
if (!changed) {
|
|
90
|
+
return cachedGraph;
|
|
91
|
+
}
|
|
92
|
+
// Files changed - full rebuild (incremental would need graph-builder changes)
|
|
93
|
+
} else if (!cachedGraph) {
|
|
94
|
+
if (loadDiskCache(path)) {
|
|
95
|
+
return cachedGraph;
|
|
96
|
+
}
|
|
24
97
|
}
|
|
25
98
|
|
|
26
99
|
const parsed = await parseProject(path);
|
|
27
100
|
cachedGraph = buildGraph(parsed);
|
|
28
101
|
cachedPath = path;
|
|
29
102
|
|
|
103
|
+
// Snapshot mtimes for all parsed files
|
|
104
|
+
snapshotMtimes(path);
|
|
105
|
+
saveDiskCache(path, cachedGraph);
|
|
106
|
+
|
|
30
107
|
return cachedGraph;
|
|
31
108
|
}
|
|
32
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Detect if any JS files changed since last snapshot.
|
|
112
|
+
* Checks: new files, deleted files, modified files (via mtimeMs).
|
|
113
|
+
* @param {string} path
|
|
114
|
+
* @returns {boolean} true if changes detected
|
|
115
|
+
*/
|
|
116
|
+
function detectChanges(path) {
|
|
117
|
+
if (cachedMtimes.size === 0) return true;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const currentFiles = findJSFiles(path);
|
|
121
|
+
const currentSet = new Set(currentFiles);
|
|
122
|
+
const cachedSet = new Set(cachedMtimes.keys());
|
|
123
|
+
|
|
124
|
+
// New or deleted files
|
|
125
|
+
if (currentFiles.length !== cachedMtimes.size) return true;
|
|
126
|
+
for (const f of currentFiles) {
|
|
127
|
+
if (!cachedSet.has(f)) return true;
|
|
128
|
+
}
|
|
129
|
+
for (const f of cachedSet) {
|
|
130
|
+
if (!currentSet.has(f)) return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check mtimes
|
|
134
|
+
for (const file of currentFiles) {
|
|
135
|
+
try {
|
|
136
|
+
const mtime = statSync(file).mtimeMs;
|
|
137
|
+
if (mtime !== cachedMtimes.get(file)) return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return true; // File gone or unreadable
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
} catch {
|
|
145
|
+
return true; // Safety: rebuild on error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Snapshot current mtimes for all JS files in path.
|
|
151
|
+
* @param {string} path
|
|
152
|
+
*/
|
|
153
|
+
function snapshotMtimes(path) {
|
|
154
|
+
cachedMtimes.clear();
|
|
155
|
+
try {
|
|
156
|
+
const files = findJSFiles(path);
|
|
157
|
+
for (const file of files) {
|
|
158
|
+
try {
|
|
159
|
+
cachedMtimes.set(file, statSync(file).mtimeMs);
|
|
160
|
+
} catch {
|
|
161
|
+
// Skip unreadable files
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Ignore errors
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
33
169
|
/**
|
|
34
170
|
* Get compact project skeleton
|
|
35
171
|
* @param {string} path
|
|
@@ -110,13 +246,26 @@ export async function expand(symbol) {
|
|
|
110
246
|
// Find the source file
|
|
111
247
|
const parsed = await parseProject(path);
|
|
112
248
|
const cls = parsed.classes.find(c => c.name === fullName);
|
|
249
|
+
const fn = parsed.functions.find(f => f.name === fullName);
|
|
113
250
|
|
|
114
|
-
if (!cls) {
|
|
115
|
-
return { error: `
|
|
251
|
+
if (!cls && !fn) {
|
|
252
|
+
return { error: `Symbol not found: ${fullName}` };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (fn && !methodKey) {
|
|
256
|
+
return {
|
|
257
|
+
symbol,
|
|
258
|
+
fullName,
|
|
259
|
+
type: 'function',
|
|
260
|
+
file: fn.file,
|
|
261
|
+
line: fn.line,
|
|
262
|
+
exported: fn.exported,
|
|
263
|
+
calls: fn.calls,
|
|
264
|
+
};
|
|
116
265
|
}
|
|
117
266
|
|
|
118
267
|
// If method specified, extract method code
|
|
119
|
-
if (methodKey) {
|
|
268
|
+
if (methodKey && cls) {
|
|
120
269
|
const methodName = graph.reverseLegend[methodKey] || methodKey;
|
|
121
270
|
const content = readFileSync(cls.file, 'utf-8');
|
|
122
271
|
const methodCode = extractMethod(content, methodName);
|
|
@@ -231,10 +380,91 @@ function extractMethod(content, methodName) {
|
|
|
231
380
|
return content.slice(start);
|
|
232
381
|
}
|
|
233
382
|
|
|
383
|
+
/**
|
|
384
|
+
* Find call chain from one symbol to another
|
|
385
|
+
* @param {Object} options
|
|
386
|
+
* @param {string} options.from - Starting symbol (full or minified)
|
|
387
|
+
* @param {string} options.to - Target symbol (full or minified)
|
|
388
|
+
* @param {string} [options.path] - Project path
|
|
389
|
+
* @returns {Promise<string[]|Object>}
|
|
390
|
+
*/
|
|
391
|
+
export async function getCallChain(options = {}) {
|
|
392
|
+
const { from, to, path } = options;
|
|
393
|
+
if (!from || !to) {
|
|
394
|
+
return { error: 'Both "from" and "to" parameters are required' };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const projectPath = path || cachedPath || 'src/components';
|
|
398
|
+
const graph = await getGraph(projectPath);
|
|
399
|
+
|
|
400
|
+
const fromSym = graph.legend[from] || from;
|
|
401
|
+
const toSym = graph.legend[to] || to;
|
|
402
|
+
|
|
403
|
+
// Build adjacency list for fast lookup
|
|
404
|
+
const adj = {};
|
|
405
|
+
for (const [caller, _, target] of graph.edges) {
|
|
406
|
+
if (!adj[caller]) adj[caller] = [];
|
|
407
|
+
adj[caller].push(target);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Queue stores { current: string, path: string[] }
|
|
411
|
+
const queue = [{ current: fromSym, path: [fromSym] }];
|
|
412
|
+
const visitedNodes = new Set();
|
|
413
|
+
const expandedBases = new Set();
|
|
414
|
+
visitedNodes.add(fromSym);
|
|
415
|
+
|
|
416
|
+
while (queue.length > 0) {
|
|
417
|
+
const { current, path: currentPath } = queue.shift();
|
|
418
|
+
|
|
419
|
+
const currentBase = current.split('.')[0];
|
|
420
|
+
const currentMethod = current.split('.')[1];
|
|
421
|
+
|
|
422
|
+
if (current === toSym || currentBase === toSym || currentMethod === toSym) {
|
|
423
|
+
const fullPath = currentPath.map(sym => {
|
|
424
|
+
const parts = sym.split('.');
|
|
425
|
+
const base = graph.reverseLegend[parts[0]] || parts[0];
|
|
426
|
+
if (parts.length === 2) {
|
|
427
|
+
const method = graph.reverseLegend[parts[1]] || parts[1];
|
|
428
|
+
return `${base}.${method}`;
|
|
429
|
+
}
|
|
430
|
+
return base;
|
|
431
|
+
});
|
|
432
|
+
return fullPath;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (expandedBases.has(currentBase)) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
expandedBases.add(currentBase);
|
|
439
|
+
|
|
440
|
+
const neighbors = adj[currentBase] || [];
|
|
441
|
+
for (const neighbor of neighbors) {
|
|
442
|
+
if (!visitedNodes.has(neighbor)) {
|
|
443
|
+
visitedNodes.add(neighbor);
|
|
444
|
+
queue.push({
|
|
445
|
+
current: neighbor,
|
|
446
|
+
path: [...currentPath, neighbor]
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return { error: `No call path found from "${from}" to "${to}"` };
|
|
453
|
+
}
|
|
454
|
+
|
|
234
455
|
/**
|
|
235
456
|
* Invalidate cache
|
|
236
457
|
*/
|
|
237
458
|
export function invalidateCache() {
|
|
459
|
+
if (cachedPath) {
|
|
460
|
+
try {
|
|
461
|
+
const cachePath = join(cachedPath, '.project-graph-cache.json');
|
|
462
|
+
if (existsSync(cachePath)) {
|
|
463
|
+
unlinkSync(cachePath);
|
|
464
|
+
}
|
|
465
|
+
} catch (e) {}
|
|
466
|
+
}
|
|
238
467
|
cachedGraph = null;
|
|
239
468
|
cachedPath = null;
|
|
469
|
+
cachedMtimes.clear();
|
|
240
470
|
}
|