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.
Files changed (47) hide show
  1. package/README.md +221 -101
  2. package/dist/core/config/fairsquare-framework-schema.js +47 -60
  3. package/dist/core/config/nestjs-framework-schema.js +71 -44
  4. package/dist/core/config/schema.js +1 -1
  5. package/dist/core/config/timeouts.js +27 -0
  6. package/dist/core/embeddings/embeddings.service.js +122 -2
  7. package/dist/core/embeddings/natural-language-to-cypher.service.js +416 -17
  8. package/dist/core/parsers/parser-factory.js +5 -3
  9. package/dist/core/parsers/typescript-parser.js +618 -50
  10. package/dist/core/parsers/workspace-parser.js +554 -0
  11. package/dist/core/utils/edge-factory.js +37 -0
  12. package/dist/core/utils/file-change-detection.js +105 -0
  13. package/dist/core/utils/file-utils.js +20 -0
  14. package/dist/core/utils/index.js +3 -0
  15. package/dist/core/utils/path-utils.js +75 -0
  16. package/dist/core/utils/progress-reporter.js +112 -0
  17. package/dist/core/utils/project-id.js +176 -0
  18. package/dist/core/utils/retry.js +41 -0
  19. package/dist/core/workspace/index.js +4 -0
  20. package/dist/core/workspace/workspace-detector.js +221 -0
  21. package/dist/mcp/constants.js +153 -5
  22. package/dist/mcp/handlers/cross-file-edge.helpers.js +19 -0
  23. package/dist/mcp/handlers/file-change-detection.js +105 -0
  24. package/dist/mcp/handlers/graph-generator.handler.js +97 -32
  25. package/dist/mcp/handlers/incremental-parse.handler.js +146 -0
  26. package/dist/mcp/handlers/streaming-import.handler.js +210 -0
  27. package/dist/mcp/handlers/traversal.handler.js +130 -71
  28. package/dist/mcp/mcp.server.js +45 -6
  29. package/dist/mcp/service-init.js +79 -0
  30. package/dist/mcp/services/job-manager.js +165 -0
  31. package/dist/mcp/services/watch-manager.js +376 -0
  32. package/dist/mcp/services.js +2 -2
  33. package/dist/mcp/tools/check-parse-status.tool.js +64 -0
  34. package/dist/mcp/tools/impact-analysis.tool.js +84 -18
  35. package/dist/mcp/tools/index.js +13 -1
  36. package/dist/mcp/tools/list-projects.tool.js +62 -0
  37. package/dist/mcp/tools/list-watchers.tool.js +51 -0
  38. package/dist/mcp/tools/natural-language-to-cypher.tool.js +34 -8
  39. package/dist/mcp/tools/parse-typescript-project.tool.js +318 -58
  40. package/dist/mcp/tools/search-codebase.tool.js +56 -16
  41. package/dist/mcp/tools/start-watch-project.tool.js +100 -0
  42. package/dist/mcp/tools/stop-watch-project.tool.js +49 -0
  43. package/dist/mcp/tools/traverse-from-node.tool.js +68 -9
  44. package/dist/mcp/utils.js +35 -13
  45. package/dist/mcp/workers/parse-worker.js +198 -0
  46. package/dist/storage/neo4j/neo4j.service.js +147 -48
  47. 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
- async traverseFromNode(nodeId, embedding, options = {}) {
16
- const { maxDepth = DEFAULTS.traversalDepth, skip = DEFAULTS.skipOffset, direction = 'BOTH', relationshipTypes, includeStartNodeDetails = true, includeCode = false, maxNodesPerChain = 5, summaryOnly = false, title = `Node Traversal from: ${nodeId}`, snippetLength = DEFAULTS.codeSnippetLength, useWeightedTraversal = false, } = options;
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
- const result = summaryOnly
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: JSON.stringify(result) }],
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 allNodes = [startNode, ...traversalData.connections.map((c) => c.node)];
167
- const projectRoot = this.getCommonRootPath(allNodes);
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
- traversalData.connections.forEach((conn) => {
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: traversalData.connections.length,
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 allNodes = [startNode, ...traversalData.connections.map((c) => c.node)];
197
- const projectRoot = this.getCommonRootPath(allNodes);
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 = this.makeRelativePath(filePath, projectRoot);
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 ? this.makeRelativePath(node.properties.filePath, projectRoot) : node.properties.filePath,
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 code = node.properties.sourceCode;
234
- const maxLength = snippetLength; // Use the provided snippet length
235
- if (code.length <= maxLength) {
236
- result.sourceCode = code;
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
  }
@@ -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 { initializeServices } from './services.js';
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
- // Initialize external services (non-blocking)
35
- initializeServices().catch((error) => {
36
- debugLog('Service initialization error', error);
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();