code-graph-context 1.1.0 → 2.0.1
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 +221 -101
- package/dist/core/config/fairsquare-framework-schema.js +47 -60
- package/dist/core/config/nestjs-framework-schema.js +71 -44
- package/dist/core/config/schema.js +1 -1
- package/dist/core/config/timeouts.js +27 -0
- package/dist/core/embeddings/embeddings.service.js +122 -2
- package/dist/core/embeddings/natural-language-to-cypher.service.js +416 -17
- package/dist/core/parsers/parser-factory.js +5 -3
- package/dist/core/parsers/typescript-parser.js +618 -50
- package/dist/core/parsers/workspace-parser.js +554 -0
- package/dist/core/utils/edge-factory.js +37 -0
- package/dist/core/utils/file-change-detection.js +105 -0
- package/dist/core/utils/file-utils.js +20 -0
- package/dist/core/utils/index.js +3 -0
- package/dist/core/utils/path-utils.js +75 -0
- package/dist/core/utils/progress-reporter.js +112 -0
- package/dist/core/utils/project-id.js +176 -0
- package/dist/core/utils/retry.js +41 -0
- package/dist/core/workspace/index.js +4 -0
- package/dist/core/workspace/workspace-detector.js +221 -0
- package/dist/mcp/constants.js +153 -5
- package/dist/mcp/handlers/cross-file-edge.helpers.js +19 -0
- package/dist/mcp/handlers/file-change-detection.js +105 -0
- package/dist/mcp/handlers/graph-generator.handler.js +97 -32
- package/dist/mcp/handlers/incremental-parse.handler.js +146 -0
- package/dist/mcp/handlers/streaming-import.handler.js +210 -0
- package/dist/mcp/handlers/traversal.handler.js +130 -71
- package/dist/mcp/mcp.server.js +45 -6
- package/dist/mcp/service-init.js +79 -0
- package/dist/mcp/services/job-manager.js +165 -0
- package/dist/mcp/services/watch-manager.js +376 -0
- package/dist/mcp/services.js +2 -2
- package/dist/mcp/tools/check-parse-status.tool.js +64 -0
- package/dist/mcp/tools/impact-analysis.tool.js +84 -18
- package/dist/mcp/tools/index.js +13 -1
- package/dist/mcp/tools/list-projects.tool.js +62 -0
- package/dist/mcp/tools/list-watchers.tool.js +51 -0
- package/dist/mcp/tools/natural-language-to-cypher.tool.js +34 -8
- package/dist/mcp/tools/parse-typescript-project.tool.js +318 -58
- package/dist/mcp/tools/search-codebase.tool.js +56 -16
- package/dist/mcp/tools/start-watch-project.tool.js +100 -0
- package/dist/mcp/tools/stop-watch-project.tool.js +49 -0
- package/dist/mcp/tools/traverse-from-node.tool.js +68 -9
- package/dist/mcp/utils.js +35 -13
- package/dist/mcp/workers/parse-worker.js +198 -0
- package/dist/storage/neo4j/neo4j.service.js +147 -48
- package/package.json +4 -2
|
@@ -2,40 +2,109 @@
|
|
|
2
2
|
* Traversal Handler
|
|
3
3
|
* Handles graph traversal operations with formatting and pagination
|
|
4
4
|
*/
|
|
5
|
+
import path from 'path';
|
|
5
6
|
import { MAX_TRAVERSAL_DEPTH } from '../../constants.js';
|
|
7
|
+
import { getCommonRoot, normalizeFilePath, toRelativePath } from '../../core/utils/path-utils.js';
|
|
6
8
|
import { QUERIES } from '../../storage/neo4j/neo4j.service.js';
|
|
7
9
|
import { DEFAULTS } from '../constants.js';
|
|
8
|
-
import { createErrorResponse, createSuccessResponse, debugLog } from '../utils.js';
|
|
10
|
+
import { createErrorResponse, createSuccessResponse, debugLog, truncateCode } from '../utils.js';
|
|
9
11
|
export class TraversalHandler {
|
|
10
12
|
neo4jService;
|
|
11
|
-
static NODE_NOT_FOUND_QUERY = 'MATCH (n) WHERE n.id = $nodeId RETURN n';
|
|
13
|
+
static NODE_NOT_FOUND_QUERY = 'MATCH (n) WHERE n.id = $nodeId AND n.projectId = $projectId RETURN n';
|
|
14
|
+
static GET_NODE_BY_FILE_PATH_QUERY = 'MATCH (sf:SourceFile {filePath: $filePath}) WHERE sf.projectId = $projectId RETURN sf.id AS nodeId LIMIT 1';
|
|
15
|
+
// Fallback: search by filePath ending (for partial paths) or by name
|
|
16
|
+
static GET_NODE_BY_FILE_PATH_FUZZY_QUERY = `
|
|
17
|
+
MATCH (sf:SourceFile)
|
|
18
|
+
WHERE sf.projectId = $projectId
|
|
19
|
+
AND (sf.filePath ENDS WITH $filePath OR sf.filePath ENDS WITH $fileName OR sf.name = $fileName)
|
|
20
|
+
RETURN sf.id AS nodeId, sf.filePath AS filePath
|
|
21
|
+
ORDER BY sf.filePath
|
|
22
|
+
LIMIT 5
|
|
23
|
+
`;
|
|
12
24
|
constructor(neo4jService) {
|
|
13
25
|
this.neo4jService = neo4jService;
|
|
14
26
|
}
|
|
15
|
-
|
|
16
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a file path to a SourceFile node ID
|
|
29
|
+
* Tries exact match first, then fuzzy match by path ending or filename
|
|
30
|
+
* @param filePath - The file path to look up (can be absolute, relative, or just filename)
|
|
31
|
+
* @param projectId - The project ID to scope the search
|
|
32
|
+
* @returns The node ID if found, null otherwise
|
|
33
|
+
*/
|
|
34
|
+
async resolveNodeIdFromFilePath(filePath, projectId) {
|
|
35
|
+
// Normalize the input path
|
|
36
|
+
const normalizedInput = normalizeFilePath(filePath);
|
|
37
|
+
// Try exact match first with normalized path
|
|
38
|
+
const exactResult = await this.neo4jService.run(TraversalHandler.GET_NODE_BY_FILE_PATH_QUERY, {
|
|
39
|
+
filePath: normalizedInput,
|
|
40
|
+
projectId,
|
|
41
|
+
});
|
|
42
|
+
if (exactResult.length > 0) {
|
|
43
|
+
return exactResult[0].nodeId;
|
|
44
|
+
}
|
|
45
|
+
// Extract filename for fuzzy matching using path module
|
|
46
|
+
const fileName = path.basename(filePath);
|
|
47
|
+
// For ends-with matching, use the original path without leading ./ or /
|
|
48
|
+
const pathForMatching = filePath.replace(/^\.[\\/]/, '').replace(/^[\\/]/, '');
|
|
49
|
+
// Try fuzzy match
|
|
50
|
+
const fuzzyResult = await this.neo4jService.run(TraversalHandler.GET_NODE_BY_FILE_PATH_FUZZY_QUERY, {
|
|
51
|
+
filePath: '/' + pathForMatching,
|
|
52
|
+
fileName,
|
|
53
|
+
projectId,
|
|
54
|
+
});
|
|
55
|
+
if (fuzzyResult.length === 1) {
|
|
56
|
+
// Single match - use it
|
|
57
|
+
return fuzzyResult[0].nodeId;
|
|
58
|
+
}
|
|
59
|
+
else if (fuzzyResult.length > 1) {
|
|
60
|
+
// Multiple matches - throw error to let caller provide better guidance
|
|
61
|
+
await debugLog('Multiple file matches found', {
|
|
62
|
+
searchPath: filePath,
|
|
63
|
+
matches: fuzzyResult.map((r) => r.filePath),
|
|
64
|
+
});
|
|
65
|
+
const matchList = fuzzyResult.map((r) => ` - ${r.filePath}`).join('\n');
|
|
66
|
+
throw new Error(`Ambiguous file path "${filePath}" matches multiple files:\n${matchList}\n\nPlease provide a more specific path.`);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
async traverseFromNode(nodeId, embedding, options) {
|
|
71
|
+
const { projectId, maxDepth = DEFAULTS.traversalDepth, skip = DEFAULTS.skipOffset, limit = 50, direction = 'BOTH', relationshipTypes, includeStartNodeDetails = true, includeCode = false, maxNodesPerChain = 5, summaryOnly = false, title = `Node Traversal from: ${nodeId}`, snippetLength = DEFAULTS.codeSnippetLength, useWeightedTraversal = false, maxTotalNodes = 50, } = options;
|
|
17
72
|
try {
|
|
18
|
-
await debugLog('Starting node traversal', { nodeId, maxDepth, skip });
|
|
19
|
-
const startNode = await this.getStartNode(nodeId);
|
|
73
|
+
await debugLog('Starting node traversal', { nodeId, projectId, maxDepth, skip });
|
|
74
|
+
const startNode = await this.getStartNode(nodeId, projectId);
|
|
20
75
|
if (!startNode) {
|
|
21
|
-
return createErrorResponse(`Node with ID "${nodeId}" not found.`);
|
|
76
|
+
return createErrorResponse(`Node with ID "${nodeId}" not found in project "${projectId}".`);
|
|
22
77
|
}
|
|
23
78
|
const maxNodesPerDepth = Math.ceil(maxNodesPerChain * 1.5);
|
|
24
79
|
const traversalData = useWeightedTraversal
|
|
25
|
-
? await this.performTraversalByDepth(nodeId, embedding, maxDepth, maxNodesPerDepth, direction, relationshipTypes)
|
|
26
|
-
: await this.performTraversal(nodeId, embedding, maxDepth, skip, direction, relationshipTypes);
|
|
80
|
+
? await this.performTraversalByDepth(nodeId, projectId, embedding, maxDepth, maxNodesPerDepth, direction, relationshipTypes)
|
|
81
|
+
: await this.performTraversal(nodeId, projectId, embedding, maxDepth, skip, direction, relationshipTypes);
|
|
27
82
|
if (!traversalData) {
|
|
28
83
|
return createSuccessResponse(`No connections found for node "${nodeId}".`);
|
|
29
84
|
}
|
|
30
|
-
|
|
85
|
+
let result = summaryOnly
|
|
31
86
|
? this.formatSummaryOnlyJSON(startNode, traversalData, title)
|
|
32
|
-
: this.formatTraversalJSON(startNode, traversalData, title, includeStartNodeDetails, includeCode, maxNodesPerChain, snippetLength);
|
|
87
|
+
: this.formatTraversalJSON(startNode, traversalData, title, includeStartNodeDetails, includeCode, maxNodesPerChain, snippetLength, maxTotalNodes, skip, limit);
|
|
88
|
+
// Auto-summarize if output is too large (>50KB)
|
|
89
|
+
const MAX_OUTPUT_BYTES = 50000;
|
|
90
|
+
let resultStr = JSON.stringify(result);
|
|
91
|
+
if (!summaryOnly && resultStr.length > MAX_OUTPUT_BYTES) {
|
|
92
|
+
await debugLog('Output too large, auto-summarizing', {
|
|
93
|
+
originalSize: resultStr.length,
|
|
94
|
+
maxSize: MAX_OUTPUT_BYTES,
|
|
95
|
+
});
|
|
96
|
+
result = this.formatSummaryOnlyJSON(startNode, traversalData, title);
|
|
97
|
+
result.autoSummarized = true;
|
|
98
|
+
result.originalSize = resultStr.length;
|
|
99
|
+
resultStr = JSON.stringify(result);
|
|
100
|
+
}
|
|
33
101
|
await debugLog('Traversal completed', {
|
|
34
102
|
connectionsFound: traversalData.connections.length,
|
|
35
103
|
uniqueFiles: this.getUniqueFileCount(traversalData.connections),
|
|
104
|
+
outputSize: resultStr.length,
|
|
36
105
|
});
|
|
37
106
|
return {
|
|
38
|
-
content: [{ type: 'text', text:
|
|
107
|
+
content: [{ type: 'text', text: resultStr }],
|
|
39
108
|
};
|
|
40
109
|
}
|
|
41
110
|
catch (error) {
|
|
@@ -44,15 +113,23 @@ export class TraversalHandler {
|
|
|
44
113
|
return createErrorResponse(error);
|
|
45
114
|
}
|
|
46
115
|
}
|
|
47
|
-
async getStartNode(nodeId) {
|
|
48
|
-
const startNodeResult = await this.neo4jService.run(TraversalHandler.NODE_NOT_FOUND_QUERY, { nodeId });
|
|
116
|
+
async getStartNode(nodeId, projectId) {
|
|
117
|
+
const startNodeResult = await this.neo4jService.run(TraversalHandler.NODE_NOT_FOUND_QUERY, { nodeId, projectId });
|
|
49
118
|
return startNodeResult.length > 0 ? startNodeResult[0].n : null;
|
|
50
119
|
}
|
|
51
|
-
async performTraversal(nodeId, embedding, maxDepth, skip, direction = 'BOTH', relationshipTypes) {
|
|
120
|
+
async performTraversal(nodeId, projectId, embedding, maxDepth, skip, direction = 'BOTH', relationshipTypes) {
|
|
52
121
|
const traversal = await this.neo4jService.run(QUERIES.EXPLORE_ALL_CONNECTIONS(Math.min(maxDepth, MAX_TRAVERSAL_DEPTH), direction, relationshipTypes), {
|
|
53
122
|
nodeId,
|
|
123
|
+
projectId,
|
|
54
124
|
skip: parseInt(skip.toString()),
|
|
55
125
|
});
|
|
126
|
+
await debugLog('Traversal query executed', {
|
|
127
|
+
direction,
|
|
128
|
+
maxDepth,
|
|
129
|
+
nodeId,
|
|
130
|
+
resultCount: traversal.length,
|
|
131
|
+
connectionsCount: traversal[0]?.result?.connections?.length ?? 0,
|
|
132
|
+
});
|
|
56
133
|
if (traversal.length === 0) {
|
|
57
134
|
return null;
|
|
58
135
|
}
|
|
@@ -62,7 +139,7 @@ export class TraversalHandler {
|
|
|
62
139
|
graph: result.graph ?? { nodes: [], relationships: [] },
|
|
63
140
|
};
|
|
64
141
|
}
|
|
65
|
-
async performTraversalByDepth(nodeId, embedding, maxDepth, maxNodesPerDepth, direction = 'BOTH', relationshipTypes) {
|
|
142
|
+
async performTraversalByDepth(nodeId, projectId, embedding, maxDepth, maxNodesPerDepth, direction = 'BOTH', relationshipTypes) {
|
|
66
143
|
// Track visited nodes to avoid cycles
|
|
67
144
|
const visitedNodeIds = new Set([nodeId]);
|
|
68
145
|
// Track the path (chain of relationships) to reach each node
|
|
@@ -85,6 +162,7 @@ export class TraversalHandler {
|
|
|
85
162
|
currentDepth: parseInt(depth.toString()),
|
|
86
163
|
queryEmbedding: embedding,
|
|
87
164
|
depthDecay: 0.85,
|
|
165
|
+
projectId,
|
|
88
166
|
});
|
|
89
167
|
if (traversalResults.length === 0) {
|
|
90
168
|
console.log(`No connections found at depth ${depth}`);
|
|
@@ -159,33 +237,51 @@ export class TraversalHandler {
|
|
|
159
237
|
getUniqueFileCount(connections) {
|
|
160
238
|
return new Set(connections.map((c) => c.node.properties.filePath).filter(Boolean)).size;
|
|
161
239
|
}
|
|
162
|
-
formatTraversalJSON(startNode, traversalData, title, includeStartNodeDetails, includeCode, maxNodesPerChain, snippetLength) {
|
|
240
|
+
formatTraversalJSON(startNode, traversalData, title, includeStartNodeDetails, includeCode, maxNodesPerChain, snippetLength, maxTotalNodes = 50, skip = 0, limit = 50) {
|
|
163
241
|
// JSON:API normalization - collect all unique nodes
|
|
164
242
|
const nodeMap = new Map();
|
|
165
243
|
// Get common root path from all nodes
|
|
166
|
-
const
|
|
167
|
-
|
|
244
|
+
const allFilePaths = [startNode, ...traversalData.connections.map((c) => c.node)]
|
|
245
|
+
.map((n) => n.properties.filePath)
|
|
246
|
+
.filter(Boolean);
|
|
247
|
+
const projectRoot = getCommonRoot(allFilePaths);
|
|
168
248
|
// Add start node to map
|
|
169
249
|
if (includeStartNodeDetails) {
|
|
170
250
|
const startNodeData = this.formatNodeJSON(startNode, includeCode, snippetLength, projectRoot);
|
|
171
251
|
nodeMap.set(startNode.properties.id, startNodeData);
|
|
172
252
|
}
|
|
173
|
-
// Collect all unique nodes from connections
|
|
174
|
-
|
|
253
|
+
// Collect all unique nodes from connections (limited by maxTotalNodes)
|
|
254
|
+
let nodeCount = nodeMap.size;
|
|
255
|
+
let truncatedNodes = 0;
|
|
256
|
+
for (const conn of traversalData.connections) {
|
|
175
257
|
const nodeId = conn.node.properties.id;
|
|
176
258
|
if (!nodeMap.has(nodeId)) {
|
|
259
|
+
if (nodeCount >= maxTotalNodes) {
|
|
260
|
+
truncatedNodes++;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
177
263
|
nodeMap.set(nodeId, this.formatNodeJSON(conn.node, includeCode, snippetLength, projectRoot));
|
|
264
|
+
nodeCount++;
|
|
178
265
|
}
|
|
179
|
-
}
|
|
266
|
+
}
|
|
180
267
|
const byDepth = this.groupConnectionsByDepth(traversalData.connections);
|
|
268
|
+
const totalConnections = traversalData.connections.length;
|
|
181
269
|
return {
|
|
182
270
|
projectRoot,
|
|
183
|
-
totalConnections
|
|
271
|
+
totalConnections,
|
|
184
272
|
uniqueFiles: this.getUniqueFileCount(traversalData.connections),
|
|
185
273
|
maxDepth: Object.keys(byDepth).length > 0 ? Math.max(...Object.keys(byDepth).map((d) => parseInt(d))) : 0,
|
|
186
274
|
startNodeId: includeStartNodeDetails ? startNode.properties.id : undefined,
|
|
187
275
|
nodes: Object.fromEntries(nodeMap),
|
|
188
276
|
depths: this.formatConnectionsByDepthWithReferences(byDepth, maxNodesPerChain),
|
|
277
|
+
pagination: {
|
|
278
|
+
skip,
|
|
279
|
+
limit,
|
|
280
|
+
returned: nodeMap.size,
|
|
281
|
+
totalConnections,
|
|
282
|
+
hasNextPage: skip + limit < totalConnections,
|
|
283
|
+
},
|
|
284
|
+
...(truncatedNodes > 0 && { nodesTruncated: truncatedNodes }),
|
|
189
285
|
};
|
|
190
286
|
}
|
|
191
287
|
formatSummaryOnlyJSON(startNode, traversalData, title) {
|
|
@@ -193,13 +289,15 @@ export class TraversalHandler {
|
|
|
193
289
|
const totalConnections = traversalData.connections.length;
|
|
194
290
|
const maxDepthFound = Object.keys(byDepth).length > 0 ? Math.max(...Object.keys(byDepth).map((d) => parseInt(d))) : 0;
|
|
195
291
|
const uniqueFiles = this.getUniqueFileCount(traversalData.connections);
|
|
196
|
-
const
|
|
197
|
-
|
|
292
|
+
const allFilePaths = [startNode, ...traversalData.connections.map((c) => c.node)]
|
|
293
|
+
.map((n) => n.properties.filePath)
|
|
294
|
+
.filter(Boolean);
|
|
295
|
+
const projectRoot = getCommonRoot(allFilePaths);
|
|
198
296
|
const fileMap = new Map();
|
|
199
297
|
traversalData.connections.forEach((conn) => {
|
|
200
298
|
const filePath = conn.node.properties.filePath;
|
|
201
299
|
if (filePath) {
|
|
202
|
-
const relativePath =
|
|
300
|
+
const relativePath = toRelativePath(filePath, projectRoot);
|
|
203
301
|
fileMap.set(relativePath, (fileMap.get(relativePath) ?? 0) + 1);
|
|
204
302
|
}
|
|
205
303
|
});
|
|
@@ -224,24 +322,17 @@ export class TraversalHandler {
|
|
|
224
322
|
const result = {
|
|
225
323
|
id: node.properties.id,
|
|
226
324
|
type: node.properties.semanticType ?? node.labels.at(-1) ?? 'Unknown',
|
|
227
|
-
filePath: projectRoot ?
|
|
325
|
+
filePath: projectRoot ? toRelativePath(node.properties.filePath, projectRoot) : node.properties.filePath,
|
|
228
326
|
};
|
|
229
327
|
if (node.properties.name) {
|
|
230
328
|
result.name = node.properties.name;
|
|
231
329
|
}
|
|
232
330
|
if (includeCode && node.properties.sourceCode && node.properties.coreType !== 'SourceFile') {
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
result.
|
|
237
|
-
|
|
238
|
-
else {
|
|
239
|
-
// Show first half and last half of the snippet
|
|
240
|
-
const half = Math.floor(maxLength / 2);
|
|
241
|
-
result.sourceCode =
|
|
242
|
-
code.substring(0, half) + '\n\n... [truncated] ...\n\n' + code.substring(code.length - half);
|
|
243
|
-
result.hasMore = true;
|
|
244
|
-
result.truncated = code.length - maxLength;
|
|
331
|
+
const truncateResult = truncateCode(node.properties.sourceCode, snippetLength);
|
|
332
|
+
result.sourceCode = truncateResult.text;
|
|
333
|
+
if (truncateResult.hasMore) {
|
|
334
|
+
result.hasMore = truncateResult.hasMore;
|
|
335
|
+
result.truncated = truncateResult.truncated;
|
|
245
336
|
}
|
|
246
337
|
}
|
|
247
338
|
return result;
|
|
@@ -269,36 +360,4 @@ export class TraversalHandler {
|
|
|
269
360
|
};
|
|
270
361
|
});
|
|
271
362
|
}
|
|
272
|
-
getCommonRootPath(nodes) {
|
|
273
|
-
const filePaths = nodes.map((n) => n.properties.filePath).filter(Boolean);
|
|
274
|
-
if (filePaths.length === 0)
|
|
275
|
-
return process.cwd();
|
|
276
|
-
// Split all paths into parts
|
|
277
|
-
const pathParts = filePaths.map((p) => p.split('/'));
|
|
278
|
-
// Find common prefix
|
|
279
|
-
const commonParts = [];
|
|
280
|
-
const firstPath = pathParts[0];
|
|
281
|
-
for (let i = 0; i < firstPath.length; i++) {
|
|
282
|
-
const part = firstPath[i];
|
|
283
|
-
if (pathParts.every((p) => p[i] === part)) {
|
|
284
|
-
commonParts.push(part);
|
|
285
|
-
}
|
|
286
|
-
else {
|
|
287
|
-
break;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return commonParts.join('/') || '/';
|
|
291
|
-
}
|
|
292
|
-
makeRelativePath(absolutePath, projectRoot) {
|
|
293
|
-
if (!absolutePath)
|
|
294
|
-
return '';
|
|
295
|
-
if (!projectRoot || projectRoot === '/')
|
|
296
|
-
return absolutePath;
|
|
297
|
-
// Ensure both paths end consistently
|
|
298
|
-
const root = projectRoot.endsWith('/') ? projectRoot : projectRoot + '/';
|
|
299
|
-
if (absolutePath.startsWith(root)) {
|
|
300
|
-
return absolutePath.substring(root.length);
|
|
301
|
-
}
|
|
302
|
-
return absolutePath;
|
|
303
|
-
}
|
|
304
363
|
}
|
package/dist/mcp/mcp.server.js
CHANGED
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
* MCP Server - Main Entry Point
|
|
5
5
|
* Clean, modular architecture for the Code Graph Context MCP Server
|
|
6
6
|
*/
|
|
7
|
-
// Load environment variables from .env file
|
|
8
|
-
import dotenv from 'dotenv';
|
|
9
7
|
import { dirname, join } from 'path';
|
|
10
8
|
import { fileURLToPath } from 'url';
|
|
9
|
+
// Load environment variables from .env file - must run before other imports use env vars
|
|
10
|
+
// eslint-disable-next-line import/order
|
|
11
|
+
import dotenv from 'dotenv';
|
|
11
12
|
const __filename = fileURLToPath(import.meta.url);
|
|
12
13
|
const __dirname = dirname(__filename);
|
|
13
14
|
// Go up two levels from dist/mcp/mcp.server.js to the root
|
|
@@ -16,7 +17,9 @@ dotenv.config({ path: join(rootDir, '.env') });
|
|
|
16
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
18
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
18
19
|
import { MCP_SERVER_CONFIG, MESSAGES } from './constants.js';
|
|
19
|
-
import {
|
|
20
|
+
import { performIncrementalParse } from './handlers/incremental-parse.handler.js';
|
|
21
|
+
import { watchManager } from './services/watch-manager.js';
|
|
22
|
+
import { initializeServices } from './service-init.js';
|
|
20
23
|
import { registerAllTools } from './tools/index.js';
|
|
21
24
|
import { debugLog } from './utils.js';
|
|
22
25
|
/**
|
|
@@ -31,9 +34,19 @@ const startServer = async () => {
|
|
|
31
34
|
});
|
|
32
35
|
// Register all tools
|
|
33
36
|
registerAllTools(server);
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
// Configure watch manager with incremental parse handler and MCP server
|
|
38
|
+
watchManager.setIncrementalParseHandler(performIncrementalParse);
|
|
39
|
+
watchManager.setMcpServer(server.server);
|
|
40
|
+
// Initialize external services (non-blocking but with proper error handling)
|
|
41
|
+
initializeServices().catch(async (error) => {
|
|
42
|
+
// Await the debugLog to ensure it completes before potential exit
|
|
43
|
+
await debugLog('Service initialization error', error);
|
|
44
|
+
// Log to stderr so it's visible even if debug file fails
|
|
45
|
+
console.error(JSON.stringify({
|
|
46
|
+
level: 'error',
|
|
47
|
+
message: 'Service initialization failed',
|
|
48
|
+
error: error instanceof Error ? error.message : String(error),
|
|
49
|
+
}));
|
|
37
50
|
});
|
|
38
51
|
// Create and connect transport
|
|
39
52
|
console.error(JSON.stringify({ level: 'info', message: MESSAGES.server.creatingTransport }));
|
|
@@ -42,6 +55,32 @@ const startServer = async () => {
|
|
|
42
55
|
await server.connect(transport);
|
|
43
56
|
console.error(JSON.stringify({ level: 'info', message: MESSAGES.server.connected }));
|
|
44
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Graceful shutdown handler
|
|
60
|
+
*/
|
|
61
|
+
const shutdown = async (signal) => {
|
|
62
|
+
console.error(JSON.stringify({ level: 'info', message: `Received ${signal}, shutting down...` }));
|
|
63
|
+
try {
|
|
64
|
+
await watchManager.stopAllWatchers();
|
|
65
|
+
await debugLog('Shutdown complete', { signal });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
console.error(JSON.stringify({ level: 'error', message: 'Error during shutdown', error: String(error) }));
|
|
69
|
+
}
|
|
70
|
+
process.exit(0);
|
|
71
|
+
};
|
|
72
|
+
// Register exception handlers to catch native crashes
|
|
73
|
+
process.on('uncaughtException', async (error) => {
|
|
74
|
+
console.error(JSON.stringify({ level: 'error', message: 'Uncaught exception', error: String(error), stack: error.stack }));
|
|
75
|
+
await debugLog('Uncaught exception', { error: String(error), stack: error.stack });
|
|
76
|
+
});
|
|
77
|
+
process.on('unhandledRejection', async (reason) => {
|
|
78
|
+
console.error(JSON.stringify({ level: 'error', message: 'Unhandled rejection', reason: String(reason) }));
|
|
79
|
+
await debugLog('Unhandled rejection', { reason: String(reason) });
|
|
80
|
+
});
|
|
81
|
+
// Register shutdown handlers
|
|
82
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
83
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
45
84
|
// Start the server
|
|
46
85
|
console.error(JSON.stringify({ level: 'info', message: MESSAGES.server.startingServer }));
|
|
47
86
|
await startServer();
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Initialization
|
|
3
|
+
* Handles initialization of external services like Neo4j schema and OpenAI assistant
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { Neo4jService, QUERIES } from '../storage/neo4j/neo4j.service.js';
|
|
8
|
+
import { FILE_PATHS, LOG_CONFIG } from './constants.js';
|
|
9
|
+
import { initializeNaturalLanguageService } from './tools/natural-language-to-cypher.tool.js';
|
|
10
|
+
import { debugLog } from './utils.js';
|
|
11
|
+
/**
|
|
12
|
+
* Initialize all external services required by the MCP server
|
|
13
|
+
*/
|
|
14
|
+
export const initializeServices = async () => {
|
|
15
|
+
await Promise.all([initializeNeo4jSchema(), initializeNaturalLanguageService()]);
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Dynamically discover schema from the actual graph contents.
|
|
19
|
+
* This is framework-agnostic - it discovers what's actually in the graph.
|
|
20
|
+
*/
|
|
21
|
+
const discoverSchemaFromGraph = async (neo4jService) => {
|
|
22
|
+
try {
|
|
23
|
+
// Discover actual node types, relationships, and patterns from the graph
|
|
24
|
+
const [nodeTypes, relationshipTypes, semanticTypes, commonPatterns] = await Promise.all([
|
|
25
|
+
neo4jService.run(QUERIES.DISCOVER_NODE_TYPES),
|
|
26
|
+
neo4jService.run(QUERIES.DISCOVER_RELATIONSHIP_TYPES),
|
|
27
|
+
neo4jService.run(QUERIES.DISCOVER_SEMANTIC_TYPES),
|
|
28
|
+
neo4jService.run(QUERIES.DISCOVER_COMMON_PATTERNS),
|
|
29
|
+
]);
|
|
30
|
+
return {
|
|
31
|
+
nodeTypes: nodeTypes.map((r) => ({
|
|
32
|
+
label: r.label,
|
|
33
|
+
count: typeof r.nodeCount === 'object' ? r.nodeCount.toNumber() : r.nodeCount,
|
|
34
|
+
properties: r.sampleProperties ?? [],
|
|
35
|
+
})),
|
|
36
|
+
relationshipTypes: relationshipTypes.map((r) => ({
|
|
37
|
+
type: r.relationshipType,
|
|
38
|
+
count: typeof r.relCount === 'object' ? r.relCount.toNumber() : r.relCount,
|
|
39
|
+
connections: r.connections ?? [],
|
|
40
|
+
})),
|
|
41
|
+
semanticTypes: semanticTypes.map((r) => ({
|
|
42
|
+
type: r.semanticType,
|
|
43
|
+
count: typeof r.count === 'object' ? r.count.toNumber() : r.count,
|
|
44
|
+
})),
|
|
45
|
+
commonPatterns: commonPatterns.map((r) => ({
|
|
46
|
+
from: r.fromType,
|
|
47
|
+
relationship: r.relType,
|
|
48
|
+
to: r.toType,
|
|
49
|
+
count: typeof r.count === 'object' ? r.count.toNumber() : r.count,
|
|
50
|
+
})),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
await debugLog('Failed to discover schema from graph', error);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Initialize Neo4j schema by fetching from APOC and discovering actual graph structure
|
|
60
|
+
*/
|
|
61
|
+
const initializeNeo4jSchema = async () => {
|
|
62
|
+
try {
|
|
63
|
+
const neo4jService = new Neo4jService();
|
|
64
|
+
const rawSchema = await neo4jService.getSchema();
|
|
65
|
+
// Dynamically discover what's actually in the graph
|
|
66
|
+
const discoveredSchema = await discoverSchemaFromGraph(neo4jService);
|
|
67
|
+
const schema = {
|
|
68
|
+
rawSchema,
|
|
69
|
+
discoveredSchema,
|
|
70
|
+
};
|
|
71
|
+
const schemaPath = join(process.cwd(), FILE_PATHS.schemaOutput);
|
|
72
|
+
await fs.writeFile(schemaPath, JSON.stringify(schema, null, LOG_CONFIG.jsonIndentation));
|
|
73
|
+
await debugLog('Neo4j schema cached successfully', { schemaPath });
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
await debugLog('Failed to initialize Neo4j schema', error);
|
|
77
|
+
// Don't throw - service can still function without cached schema
|
|
78
|
+
}
|
|
79
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Manager Service
|
|
3
|
+
* Tracks background parsing jobs for async mode
|
|
4
|
+
*/
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
6
|
+
const generateJobId = () => {
|
|
7
|
+
return `job_${randomBytes(8).toString('hex')}`;
|
|
8
|
+
};
|
|
9
|
+
const createInitialProgress = () => ({
|
|
10
|
+
phase: 'pending',
|
|
11
|
+
filesTotal: 0,
|
|
12
|
+
filesProcessed: 0,
|
|
13
|
+
nodesImported: 0,
|
|
14
|
+
edgesImported: 0,
|
|
15
|
+
currentChunk: 0,
|
|
16
|
+
totalChunks: 0,
|
|
17
|
+
});
|
|
18
|
+
// Cleanup interval: 5 minutes
|
|
19
|
+
const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
|
|
20
|
+
// Maximum concurrent jobs to prevent memory exhaustion
|
|
21
|
+
const MAX_JOBS = 100;
|
|
22
|
+
class JobManager {
|
|
23
|
+
jobs = new Map();
|
|
24
|
+
cleanupInterval = null;
|
|
25
|
+
constructor() {
|
|
26
|
+
// Start automatic cleanup scheduler
|
|
27
|
+
this.startCleanupScheduler();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Start the automatic cleanup scheduler.
|
|
31
|
+
* Runs every 5 minutes to remove old completed/failed jobs.
|
|
32
|
+
*/
|
|
33
|
+
startCleanupScheduler() {
|
|
34
|
+
if (this.cleanupInterval)
|
|
35
|
+
return; // Already running
|
|
36
|
+
this.cleanupInterval = setInterval(() => {
|
|
37
|
+
const cleaned = this.cleanupOldJobs();
|
|
38
|
+
if (cleaned > 0) {
|
|
39
|
+
console.log(`[JobManager] Cleaned up ${cleaned} old jobs`);
|
|
40
|
+
}
|
|
41
|
+
}, CLEANUP_INTERVAL_MS);
|
|
42
|
+
// Don't prevent Node.js from exiting if this is the only timer
|
|
43
|
+
this.cleanupInterval.unref();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Stop the cleanup scheduler (useful for testing or shutdown)
|
|
47
|
+
*/
|
|
48
|
+
stopCleanupScheduler() {
|
|
49
|
+
if (this.cleanupInterval) {
|
|
50
|
+
clearInterval(this.cleanupInterval);
|
|
51
|
+
this.cleanupInterval = null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Create a new parsing job
|
|
56
|
+
* @throws Error if maximum job limit is reached
|
|
57
|
+
*/
|
|
58
|
+
createJob(projectPath, projectId) {
|
|
59
|
+
// SECURITY: Enforce maximum job limit to prevent memory exhaustion
|
|
60
|
+
if (this.jobs.size >= MAX_JOBS) {
|
|
61
|
+
// Try to cleanup old jobs first
|
|
62
|
+
const cleaned = this.cleanupOldJobs(0); // Remove all completed/failed jobs
|
|
63
|
+
if (this.jobs.size >= MAX_JOBS) {
|
|
64
|
+
throw new Error(`Maximum job limit (${MAX_JOBS}) reached. ` +
|
|
65
|
+
`${this.listJobs('running').length} jobs are currently running. ` +
|
|
66
|
+
`Please wait for jobs to complete or cancel existing jobs.`);
|
|
67
|
+
}
|
|
68
|
+
if (cleaned > 0) {
|
|
69
|
+
console.log(`[JobManager] Auto-cleaned ${cleaned} old jobs to make room for new job`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const id = generateJobId();
|
|
73
|
+
const now = new Date();
|
|
74
|
+
const job = {
|
|
75
|
+
id,
|
|
76
|
+
status: 'pending',
|
|
77
|
+
projectId,
|
|
78
|
+
projectPath,
|
|
79
|
+
progress: createInitialProgress(),
|
|
80
|
+
createdAt: now,
|
|
81
|
+
updatedAt: now,
|
|
82
|
+
};
|
|
83
|
+
this.jobs.set(id, job);
|
|
84
|
+
return id;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Start a job (transition from pending to running)
|
|
88
|
+
*/
|
|
89
|
+
startJob(jobId) {
|
|
90
|
+
const job = this.jobs.get(jobId);
|
|
91
|
+
if (job) {
|
|
92
|
+
job.status = 'running';
|
|
93
|
+
job.updatedAt = new Date();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Update job progress
|
|
98
|
+
*/
|
|
99
|
+
updateProgress(jobId, progress) {
|
|
100
|
+
const job = this.jobs.get(jobId);
|
|
101
|
+
if (job) {
|
|
102
|
+
job.progress = { ...job.progress, ...progress };
|
|
103
|
+
job.updatedAt = new Date();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Mark job as completed with results
|
|
108
|
+
*/
|
|
109
|
+
completeJob(jobId, result) {
|
|
110
|
+
const job = this.jobs.get(jobId);
|
|
111
|
+
if (job) {
|
|
112
|
+
job.status = 'completed';
|
|
113
|
+
job.result = result;
|
|
114
|
+
job.progress.phase = 'complete';
|
|
115
|
+
job.updatedAt = new Date();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Mark job as failed with error message
|
|
120
|
+
*/
|
|
121
|
+
failJob(jobId, error) {
|
|
122
|
+
const job = this.jobs.get(jobId);
|
|
123
|
+
if (job) {
|
|
124
|
+
job.status = 'failed';
|
|
125
|
+
job.error = error;
|
|
126
|
+
job.updatedAt = new Date();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Get a job by ID
|
|
131
|
+
*/
|
|
132
|
+
getJob(jobId) {
|
|
133
|
+
return this.jobs.get(jobId);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* List all jobs (optionally filter by status)
|
|
137
|
+
*/
|
|
138
|
+
listJobs(status) {
|
|
139
|
+
const jobs = Array.from(this.jobs.values());
|
|
140
|
+
if (status) {
|
|
141
|
+
return jobs.filter((job) => job.status === status);
|
|
142
|
+
}
|
|
143
|
+
return jobs;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Clean up old completed/failed jobs
|
|
147
|
+
* @param maxAgeMs Maximum age in milliseconds (default: 1 hour)
|
|
148
|
+
*/
|
|
149
|
+
cleanupOldJobs(maxAgeMs = 3600000) {
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
let cleaned = 0;
|
|
152
|
+
for (const [id, job] of this.jobs.entries()) {
|
|
153
|
+
if (job.status === 'completed' || job.status === 'failed') {
|
|
154
|
+
const age = now - job.updatedAt.getTime();
|
|
155
|
+
if (age > maxAgeMs) {
|
|
156
|
+
this.jobs.delete(id);
|
|
157
|
+
cleaned++;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return cleaned;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Singleton instance
|
|
165
|
+
export const jobManager = new JobManager();
|