gitnexus 1.3.2 → 1.3.4

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 (46) hide show
  1. package/dist/cli/ai-context.js +23 -52
  2. package/dist/cli/analyze.js +4 -1
  3. package/dist/cli/index.js +1 -0
  4. package/dist/cli/mcp.js +11 -22
  5. package/dist/cli/serve.d.ts +1 -0
  6. package/dist/cli/serve.js +2 -1
  7. package/dist/cli/setup.js +2 -2
  8. package/dist/cli/wiki.js +6 -2
  9. package/dist/config/supported-languages.d.ts +2 -1
  10. package/dist/config/supported-languages.js +1 -1
  11. package/dist/core/embeddings/embedder.js +40 -1
  12. package/dist/core/graph/types.d.ts +2 -0
  13. package/dist/core/ingestion/entry-point-scoring.js +26 -1
  14. package/dist/core/ingestion/filesystem-walker.js +3 -3
  15. package/dist/core/ingestion/framework-detection.d.ts +12 -4
  16. package/dist/core/ingestion/framework-detection.js +105 -5
  17. package/dist/core/ingestion/import-processor.js +77 -0
  18. package/dist/core/ingestion/parsing-processor.js +51 -9
  19. package/dist/core/ingestion/process-processor.js +7 -1
  20. package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -0
  21. package/dist/core/ingestion/tree-sitter-queries.js +361 -282
  22. package/dist/core/ingestion/utils.js +6 -0
  23. package/dist/core/ingestion/workers/parse-worker.d.ts +3 -0
  24. package/dist/core/ingestion/workers/parse-worker.js +192 -1
  25. package/dist/core/kuzu/csv-generator.js +4 -2
  26. package/dist/core/kuzu/kuzu-adapter.d.ts +9 -0
  27. package/dist/core/kuzu/kuzu-adapter.js +68 -9
  28. package/dist/core/kuzu/schema.d.ts +6 -6
  29. package/dist/core/kuzu/schema.js +8 -0
  30. package/dist/core/tree-sitter/parser-loader.js +2 -0
  31. package/dist/core/wiki/generator.js +2 -2
  32. package/dist/mcp/local/local-backend.js +25 -13
  33. package/dist/mcp/server.d.ts +9 -0
  34. package/dist/mcp/server.js +13 -2
  35. package/dist/mcp/staleness.js +2 -2
  36. package/dist/server/api.d.ts +7 -5
  37. package/dist/server/api.js +145 -127
  38. package/dist/server/mcp-http.d.ts +13 -0
  39. package/dist/server/mcp-http.js +100 -0
  40. package/package.json +2 -1
  41. package/skills/gitnexus-cli.md +82 -0
  42. package/skills/{debugging.md → gitnexus-debugging.md} +12 -8
  43. package/skills/{exploring.md → gitnexus-exploring.md} +10 -7
  44. package/skills/gitnexus-guide.md +64 -0
  45. package/skills/{impact-analysis.md → gitnexus-impact-analysis.md} +14 -11
  46. package/skills/{refactoring.md → gitnexus-refactoring.md} +15 -7
@@ -8,7 +8,8 @@
8
8
  import fs from 'fs/promises';
9
9
  import path from 'path';
10
10
  import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
11
- import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
11
+ // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
12
+ // at MCP server startup — crashes on unsupported Node ABI versions (#89)
12
13
  // git utilities available if needed
13
14
  // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
14
15
  import { listRegisteredRepos, } from '../../storage/repo-manager.js';
@@ -506,6 +507,7 @@ export class LocalBackend {
506
507
  const tableCheck = await executeQuery(repo.id, `MATCH (e:CodeEmbedding) RETURN COUNT(*) AS cnt LIMIT 1`);
507
508
  if (!tableCheck.length || (tableCheck[0].cnt ?? tableCheck[0][0]) === 0)
508
509
  return [];
510
+ const { embedQuery, getEmbeddingDims } = await import('../core/embedder.js');
509
511
  const queryVec = await embedQuery(query);
510
512
  const dims = getEmbeddingDims();
511
513
  const queryVecStr = `[${queryVec.join(',')}]`;
@@ -901,29 +903,29 @@ export class LocalBackend {
901
903
  async detectChanges(repo, params) {
902
904
  await this.ensureInitialized(repo.id);
903
905
  const scope = params.scope || 'unstaged';
904
- const { execSync } = await import('child_process');
905
- // Build git diff command based on scope
906
- let diffCmd;
906
+ const { execFileSync } = await import('child_process');
907
+ // Build git diff args based on scope (using execFileSync to avoid shell injection)
908
+ let diffArgs;
907
909
  switch (scope) {
908
910
  case 'staged':
909
- diffCmd = 'git diff --staged --name-only';
911
+ diffArgs = ['diff', '--staged', '--name-only'];
910
912
  break;
911
913
  case 'all':
912
- diffCmd = 'git diff HEAD --name-only';
914
+ diffArgs = ['diff', 'HEAD', '--name-only'];
913
915
  break;
914
916
  case 'compare':
915
917
  if (!params.base_ref)
916
918
  return { error: 'base_ref is required for "compare" scope' };
917
- diffCmd = `git diff ${params.base_ref} --name-only`;
919
+ diffArgs = ['diff', params.base_ref, '--name-only'];
918
920
  break;
919
921
  case 'unstaged':
920
922
  default:
921
- diffCmd = 'git diff --name-only';
923
+ diffArgs = ['diff', '--name-only'];
922
924
  break;
923
925
  }
924
926
  let changedFiles;
925
927
  try {
926
- const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
928
+ const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
927
929
  changedFiles = output.trim().split('\n').filter(f => f.length > 0);
928
930
  }
929
931
  catch (err) {
@@ -1077,9 +1079,15 @@ export class LocalBackend {
1077
1079
  const graphFiles = new Set([sym.filePath, ...allIncoming.map(r => r.filePath)].filter(Boolean));
1078
1080
  // Simple text search across the repo for the old name (in files not already covered by graph)
1079
1081
  try {
1080
- const { execSync } = await import('child_process');
1081
- const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
1082
- const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
1082
+ const { execFileSync } = await import('child_process');
1083
+ const rgArgs = [
1084
+ '-l',
1085
+ '--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java}',
1086
+ '-t', 'code',
1087
+ `\\b${oldName}\\b`,
1088
+ '.',
1089
+ ];
1090
+ const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
1083
1091
  const files = output.trim().split('\n').filter(f => f.length > 0);
1084
1092
  for (const file of files) {
1085
1093
  const normalizedFile = file.replace(/\\/g, '/').replace(/^\.\//, '');
@@ -1408,7 +1416,11 @@ export class LocalBackend {
1408
1416
  }
1409
1417
  async disconnect() {
1410
1418
  await closeKuzu(); // close all connections
1411
- await disposeEmbedder();
1419
+ // Note: we intentionally do NOT call disposeEmbedder() here.
1420
+ // ONNX Runtime's native cleanup segfaults on macOS and some Linux configs,
1421
+ // and importing the embedder module on Node v24+ crashes if onnxruntime
1422
+ // was never loaded during the session. Since process.exit(0) follows
1423
+ // immediately after disconnect(), the OS reclaims everything. See #38, #89.
1412
1424
  this.repos.clear();
1413
1425
  this.contextCache.clear();
1414
1426
  this.initializedRepos.clear();
@@ -10,5 +10,14 @@
10
10
  * Tools: list_repos, query, cypher, context, impact, detect_changes, rename
11
11
  * Resources: repos, repo/{name}/context, repo/{name}/clusters, ...
12
12
  */
13
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
13
14
  import type { LocalBackend } from './local/local-backend.js';
15
+ /**
16
+ * Create a configured MCP Server with all handlers registered.
17
+ * Transport-agnostic — caller connects the desired transport.
18
+ */
19
+ export declare function createMCPServer(backend: LocalBackend): Server;
20
+ /**
21
+ * Start the MCP server on stdio transport (for CLI use).
22
+ */
14
23
  export declare function startMCPServer(backend: LocalBackend): Promise<void>;
@@ -54,7 +54,11 @@ function getNextStepHint(toolName, args) {
54
54
  return '';
55
55
  }
56
56
  }
57
- export async function startMCPServer(backend) {
57
+ /**
58
+ * Create a configured MCP Server with all handlers registered.
59
+ * Transport-agnostic — caller connects the desired transport.
60
+ */
61
+ export function createMCPServer(backend) {
58
62
  const server = new Server({
59
63
  name: 'gitnexus',
60
64
  version: '1.1.9',
@@ -212,7 +216,7 @@ Present the analysis as a clear risk report.`,
212
216
  Follow these steps:
213
217
  1. READ \`gitnexus://repo/${repo || '{name}'}/context\` for codebase stats
214
218
  2. READ \`gitnexus://repo/${repo || '{name}'}/clusters\` to see all functional areas
215
- 3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows
219
+ 3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows
216
220
  4. For the top 5 most important processes, READ \`gitnexus://repo/${repo || '{name}'}/process/{name}\` for step-by-step traces
217
221
  5. Generate a mermaid architecture diagram showing the major areas and their connections
218
222
  6. Write an ARCHITECTURE.md file with: overview, functional areas, key execution flows, and the mermaid diagram`,
@@ -223,6 +227,13 @@ Follow these steps:
223
227
  }
224
228
  throw new Error(`Unknown prompt: ${name}`);
225
229
  });
230
+ return server;
231
+ }
232
+ /**
233
+ * Start the MCP server on stdio transport (for CLI use).
234
+ */
235
+ export async function startMCPServer(backend) {
236
+ const server = createMCPServer(backend);
226
237
  // Connect to stdio transport
227
238
  const transport = new StdioServerTransport();
228
239
  await server.connect(transport);
@@ -4,14 +4,14 @@
4
4
  * Checks if the GitNexus index is behind the current git HEAD.
5
5
  * Returns a hint for the LLM to call analyze if stale.
6
6
  */
7
- import { execSync } from 'child_process';
7
+ import { execFileSync } from 'child_process';
8
8
  /**
9
9
  * Check how many commits the index is behind HEAD
10
10
  */
11
11
  export function checkStaleness(repoPath, lastCommit) {
12
12
  try {
13
13
  // Get count of commits between lastCommit and HEAD
14
- const result = execSync(`git rev-list --count ${lastCommit}..HEAD`, { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
14
+ const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], { cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
15
15
  const commitsBehind = parseInt(result, 10) || 0;
16
16
  if (commitsBehind > 0) {
17
17
  return {
@@ -1,8 +1,10 @@
1
1
  /**
2
- * HTTP API Server (Multi-Repo)
2
+ * HTTP API Server
3
3
  *
4
- * REST API for browser-based clients to query indexed repositories.
5
- * Uses LocalBackend for multi-repo support via the global registry
6
- * the same backend the MCP server uses.
4
+ * REST API for browser-based clients to query the local .gitnexus/ index.
5
+ * Also hosts the MCP server over StreamableHTTP for remote AI tool access.
6
+ *
7
+ * Security: binds to 127.0.0.1 by default (use --host to override).
8
+ * CORS is restricted to localhost and the deployed site.
7
9
  */
8
- export declare const createServer: (port: number) => Promise<void>;
10
+ export declare const createServer: (port: number, host?: string) => Promise<void>;
@@ -1,21 +1,26 @@
1
1
  /**
2
- * HTTP API Server (Multi-Repo)
2
+ * HTTP API Server
3
3
  *
4
- * REST API for browser-based clients to query indexed repositories.
5
- * Uses LocalBackend for multi-repo support via the global registry
6
- * the same backend the MCP server uses.
4
+ * REST API for browser-based clients to query the local .gitnexus/ index.
5
+ * Also hosts the MCP server over StreamableHTTP for remote AI tool access.
6
+ *
7
+ * Security: binds to 127.0.0.1 by default (use --host to override).
8
+ * CORS is restricted to localhost and the deployed site.
7
9
  */
8
10
  import express from 'express';
9
11
  import cors from 'cors';
10
12
  import path from 'path';
11
13
  import fs from 'fs/promises';
12
- import { LocalBackend } from '../mcp/local/local-backend.js';
14
+ import { loadMeta, listRegisteredRepos } from '../storage/repo-manager.js';
15
+ import { executeQuery, closeKuzu, withKuzuDb } from '../core/kuzu/kuzu-adapter.js';
13
16
  import { NODE_TABLES } from '../core/kuzu/schema.js';
14
- /**
15
- * Build the full knowledge graph for a repo by querying each node table
16
- * and all relationships via the backend's cypher tool.
17
- */
18
- const buildGraph = async (backend, repoName) => {
17
+ import { searchFTSFromKuzu } from '../core/search/bm25-index.js';
18
+ import { hybridSearch } from '../core/search/hybrid-search.js';
19
+ // Embedding imports are lazy (dynamic import) to avoid loading onnxruntime-node
20
+ // at server startup — crashes on unsupported Node ABI versions (#89)
21
+ import { LocalBackend } from '../mcp/local/local-backend.js';
22
+ import { mountMCPEndpoints } from './mcp-http.js';
23
+ const buildGraph = async () => {
19
24
  const nodes = [];
20
25
  for (const table of NODE_TABLES) {
21
26
  try {
@@ -35,9 +40,7 @@ const buildGraph = async (backend, repoName) => {
35
40
  else {
36
41
  query = `MATCH (n:${table}) RETURN n.id AS id, n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine, n.content AS content`;
37
42
  }
38
- const result = await backend.executeCypher(repoName, query);
39
- // cypher returns the rows directly (array), or { error } on failure
40
- const rows = Array.isArray(result) ? result : [];
43
+ const rows = await executeQuery(query);
41
44
  for (const row of rows) {
42
45
  nodes.push({
43
46
  id: row.id ?? row[0],
@@ -65,47 +68,43 @@ const buildGraph = async (backend, repoName) => {
65
68
  }
66
69
  }
67
70
  const relationships = [];
68
- try {
69
- const relResult = await backend.executeCypher(repoName, `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
70
- const relRows = Array.isArray(relResult) ? relResult : [];
71
- for (const row of relRows) {
72
- relationships.push({
73
- id: `${row.sourceId}_${row.type}_${row.targetId}`,
74
- type: row.type,
75
- sourceId: row.sourceId,
76
- targetId: row.targetId,
77
- confidence: row.confidence,
78
- reason: row.reason,
79
- step: row.step,
80
- });
81
- }
82
- }
83
- catch (err) {
84
- console.warn('GitNexus: relationship query failed:', err?.message);
71
+ const relRows = await executeQuery(`MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`);
72
+ for (const row of relRows) {
73
+ relationships.push({
74
+ id: `${row.sourceId}_${row.type}_${row.targetId}`,
75
+ type: row.type,
76
+ sourceId: row.sourceId,
77
+ targetId: row.targetId,
78
+ confidence: row.confidence,
79
+ reason: row.reason,
80
+ step: row.step,
81
+ });
85
82
  }
86
83
  return { nodes, relationships };
87
84
  };
88
- const httpStatus = (err) => {
89
- const msg = err?.message ?? '';
90
- if (msg.includes('not found') || msg.includes('No indexed'))
85
+ const statusFromError = (err) => {
86
+ const msg = String(err?.message ?? '');
87
+ if (msg.includes('No indexed repositories') || msg.includes('not found'))
91
88
  return 404;
92
89
  if (msg.includes('Multiple repositories'))
93
90
  return 400;
94
91
  return 500;
95
92
  };
96
- export const createServer = async (port) => {
97
- const backend = new LocalBackend();
98
- const hasRepos = await backend.init();
99
- if (!hasRepos) {
100
- console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.');
101
- console.warn('Run "gitnexus analyze" in a repository to index it first.');
93
+ const requestedRepo = (req) => {
94
+ const fromQuery = typeof req.query.repo === 'string' ? req.query.repo : undefined;
95
+ if (fromQuery)
96
+ return fromQuery;
97
+ if (req.body && typeof req.body === 'object' && typeof req.body.repo === 'string') {
98
+ return req.body.repo;
102
99
  }
100
+ return undefined;
101
+ };
102
+ export const createServer = async (port, host = '127.0.0.1') => {
103
103
  const app = express();
104
+ // CORS: only allow localhost origins and the deployed site.
105
+ // Non-browser requests (curl, server-to-server) have no origin and are allowed.
104
106
  app.use(cors({
105
107
  origin: (origin, callback) => {
106
- // Allow requests with no origin (curl, server-to-server), localhost, and the deployed site.
107
- // The server binds to 127.0.0.1 so only the local machine can reach it — CORS just gates
108
- // which browser-tab origins may issue the request.
109
108
  if (!origin
110
109
  || origin.startsWith('http://localhost:')
111
110
  || origin.startsWith('http://127.0.0.1:')
@@ -118,115 +117,140 @@ export const createServer = async (port) => {
118
117
  }
119
118
  }));
120
119
  app.use(express.json({ limit: '10mb' }));
121
- // ─── GET /api/repos ─────────────────────────────────────────────
122
- // List all indexed repositories
120
+ // Initialize MCP backend (multi-repo, shared across all MCP sessions)
121
+ const backend = new LocalBackend();
122
+ await backend.init();
123
+ const cleanupMcp = mountMCPEndpoints(app, backend);
124
+ // Helper: resolve a repo by name from the global registry, or default to first
125
+ const resolveRepo = async (repoName) => {
126
+ const repos = await listRegisteredRepos();
127
+ if (repos.length === 0)
128
+ return null;
129
+ if (repoName)
130
+ return repos.find(r => r.name === repoName) || null;
131
+ return repos[0]; // default to first
132
+ };
133
+ // List all registered repos
123
134
  app.get('/api/repos', async (_req, res) => {
124
135
  try {
125
- const repos = await backend.listRepos();
126
- res.json(repos);
136
+ const repos = await listRegisteredRepos();
137
+ res.json(repos.map(r => ({
138
+ name: r.name, path: r.path, indexedAt: r.indexedAt,
139
+ lastCommit: r.lastCommit, stats: r.stats,
140
+ })));
127
141
  }
128
142
  catch (err) {
129
143
  res.status(500).json({ error: err.message || 'Failed to list repos' });
130
144
  }
131
145
  });
132
- // ─── GET /api/repo?repo=X ──────────────────────────────────────
133
- // Get metadata for a specific repo
146
+ // Get repo info
134
147
  app.get('/api/repo', async (req, res) => {
135
148
  try {
136
- const repoName = req.query.repo;
137
- const repo = await backend.resolveRepo(repoName);
149
+ const entry = await resolveRepo(requestedRepo(req));
150
+ if (!entry) {
151
+ res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' });
152
+ return;
153
+ }
154
+ const meta = await loadMeta(entry.storagePath);
138
155
  res.json({
139
- name: repo.name,
140
- path: repo.repoPath,
141
- indexedAt: repo.indexedAt,
142
- lastCommit: repo.lastCommit,
143
- stats: repo.stats || {},
156
+ name: entry.name,
157
+ repoPath: entry.path,
158
+ indexedAt: meta?.indexedAt ?? entry.indexedAt,
159
+ stats: meta?.stats ?? entry.stats ?? {},
144
160
  });
145
161
  }
146
162
  catch (err) {
147
- res.status(httpStatus(err))
148
- .json({ error: err.message || 'Repository not found' });
163
+ res.status(500).json({ error: err.message || 'Failed to get repo info' });
149
164
  }
150
165
  });
151
- // ─── GET /api/graph?repo=X ─────────────────────────────────────
152
- // Full knowledge graph (all nodes + relationships)
166
+ // Get full graph
153
167
  app.get('/api/graph', async (req, res) => {
154
168
  try {
155
- const repoName = req.query.repo;
156
- // Resolve repo to validate it exists and get the name
157
- const repo = await backend.resolveRepo(repoName);
158
- const graph = await buildGraph(backend, repo.name);
169
+ const entry = await resolveRepo(requestedRepo(req));
170
+ if (!entry) {
171
+ res.status(404).json({ error: 'Repository not found' });
172
+ return;
173
+ }
174
+ const kuzuPath = path.join(entry.storagePath, 'kuzu');
175
+ const graph = await withKuzuDb(kuzuPath, async () => buildGraph());
159
176
  res.json(graph);
160
177
  }
161
178
  catch (err) {
162
- res.status(httpStatus(err))
163
- .json({ error: err.message || 'Failed to build graph' });
179
+ res.status(500).json({ error: err.message || 'Failed to build graph' });
164
180
  }
165
181
  });
166
- // ─── POST /api/query ───────────────────────────────────────────
167
- // Execute a raw Cypher query.
168
- // This endpoint is intentionally unrestricted (no query validation) because
169
- // the server binds to 127.0.0.1 only — it exposes full graph query
170
- // capabilities to local clients by design.
182
+ // Execute Cypher query
171
183
  app.post('/api/query', async (req, res) => {
172
184
  try {
173
- const repoName = (req.body.repo ?? req.query.repo);
174
185
  const cypher = req.body.cypher;
175
186
  if (!cypher) {
176
187
  res.status(400).json({ error: 'Missing "cypher" in request body' });
177
188
  return;
178
189
  }
179
- const result = await backend.executeCypher(repoName, cypher);
180
- if (result && !Array.isArray(result) && result.error) {
181
- res.status(500).json({ error: result.error });
190
+ const entry = await resolveRepo(requestedRepo(req));
191
+ if (!entry) {
192
+ res.status(404).json({ error: 'Repository not found' });
182
193
  return;
183
194
  }
195
+ const kuzuPath = path.join(entry.storagePath, 'kuzu');
196
+ const result = await withKuzuDb(kuzuPath, () => executeQuery(cypher));
184
197
  res.json({ result });
185
198
  }
186
199
  catch (err) {
187
- res.status(httpStatus(err))
188
- .json({ error: err.message || 'Query failed' });
200
+ res.status(500).json({ error: err.message || 'Query failed' });
189
201
  }
190
202
  });
191
- // ─── POST /api/search ──────────────────────────────────────────
192
- // Process-grouped semantic search
203
+ // Search
193
204
  app.post('/api/search', async (req, res) => {
194
205
  try {
195
- const repoName = (req.body.repo ?? req.query.repo);
196
206
  const query = (req.body.query ?? '').trim();
197
- const limit = req.body.limit;
198
207
  if (!query) {
199
208
  res.status(400).json({ error: 'Missing "query" in request body' });
200
209
  return;
201
210
  }
202
- const results = await backend.callTool('query', {
203
- repo: repoName,
204
- query,
205
- limit,
211
+ const entry = await resolveRepo(requestedRepo(req));
212
+ if (!entry) {
213
+ res.status(404).json({ error: 'Repository not found' });
214
+ return;
215
+ }
216
+ const kuzuPath = path.join(entry.storagePath, 'kuzu');
217
+ const parsedLimit = Number(req.body.limit ?? 10);
218
+ const limit = Number.isFinite(parsedLimit)
219
+ ? Math.max(1, Math.min(100, Math.trunc(parsedLimit)))
220
+ : 10;
221
+ const results = await withKuzuDb(kuzuPath, async () => {
222
+ const { isEmbedderReady } = await import('../core/embeddings/embedder.js');
223
+ if (isEmbedderReady()) {
224
+ const { semanticSearch } = await import('../core/embeddings/embedding-pipeline.js');
225
+ return hybridSearch(query, limit, executeQuery, semanticSearch);
226
+ }
227
+ // FTS-only fallback when embeddings aren't loaded
228
+ return searchFTSFromKuzu(query, limit);
206
229
  });
207
230
  res.json({ results });
208
231
  }
209
232
  catch (err) {
210
- res.status(httpStatus(err))
211
- .json({ error: err.message || 'Search failed' });
233
+ res.status(500).json({ error: err.message || 'Search failed' });
212
234
  }
213
235
  });
214
- // ─── GET /api/file?repo=X&path=Y ──────────────────────────────
215
- // Read a file from a resolved repo path on disk
236
+ // Read file — with path traversal guard
216
237
  app.get('/api/file', async (req, res) => {
217
238
  try {
218
- const repoName = req.query.repo;
239
+ const entry = await resolveRepo(requestedRepo(req));
240
+ if (!entry) {
241
+ res.status(404).json({ error: 'Repository not found' });
242
+ return;
243
+ }
219
244
  const filePath = req.query.path;
220
245
  if (!filePath) {
221
- res.status(400).json({ error: 'Missing "path" query parameter' });
246
+ res.status(400).json({ error: 'Missing path' });
222
247
  return;
223
248
  }
224
- const repo = await backend.resolveRepo(repoName);
225
- // Resolve the full path and validate it stays within the repo root
226
- const repoRoot = path.resolve(repo.repoPath);
249
+ // Prevent path traversal — resolve and verify the path stays within the repo root
250
+ const repoRoot = path.resolve(entry.path);
227
251
  const fullPath = path.resolve(repoRoot, filePath);
228
252
  if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
229
- res.status(403).json({ error: 'Path traversal denied: path escapes repo root' });
253
+ res.status(403).json({ error: 'Path traversal denied' });
230
254
  return;
231
255
  }
232
256
  const content = await fs.readFile(fullPath, 'utf-8');
@@ -237,87 +261,81 @@ export const createServer = async (port) => {
237
261
  res.status(404).json({ error: 'File not found' });
238
262
  }
239
263
  else {
240
- res.status(httpStatus(err))
241
- .json({ error: err.message || 'Failed to read file' });
264
+ res.status(500).json({ error: err.message || 'Failed to read file' });
242
265
  }
243
266
  }
244
267
  });
245
- // ─── GET /api/processes?repo=X ─────────────────────────────────
246
- // List all processes for a repo
268
+ // List all processes
247
269
  app.get('/api/processes', async (req, res) => {
248
270
  try {
249
- const repoName = req.query.repo;
250
- const result = await backend.queryProcesses(repoName);
271
+ const result = await backend.queryProcesses(requestedRepo(req));
251
272
  res.json(result);
252
273
  }
253
274
  catch (err) {
254
- res.status(httpStatus(err))
255
- .json({ error: err.message || 'Failed to query processes' });
275
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to query processes' });
256
276
  }
257
277
  });
258
- // ─── GET /api/process?repo=X&name=Y ───────────────────────────
259
- // Get detailed process info including steps
278
+ // Process detail
260
279
  app.get('/api/process', async (req, res) => {
261
280
  try {
262
- const repoName = req.query.repo;
263
- const name = req.query.name;
281
+ const name = String(req.query.name ?? '').trim();
264
282
  if (!name) {
265
283
  res.status(400).json({ error: 'Missing "name" query parameter' });
266
284
  return;
267
285
  }
268
- const result = await backend.queryProcessDetail(name, repoName);
269
- if (result.error) {
286
+ const result = await backend.queryProcessDetail(name, requestedRepo(req));
287
+ if (result?.error) {
270
288
  res.status(404).json({ error: result.error });
271
289
  return;
272
290
  }
273
291
  res.json(result);
274
292
  }
275
293
  catch (err) {
276
- res.status(httpStatus(err))
277
- .json({ error: err.message || 'Failed to query process detail' });
294
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to query process detail' });
278
295
  }
279
296
  });
280
- // ─── GET /api/clusters?repo=X ─────────────────────────────────
281
- // List all clusters for a repo
297
+ // List all clusters
282
298
  app.get('/api/clusters', async (req, res) => {
283
299
  try {
284
- const repoName = req.query.repo;
285
- const result = await backend.queryClusters(repoName);
300
+ const result = await backend.queryClusters(requestedRepo(req));
286
301
  res.json(result);
287
302
  }
288
303
  catch (err) {
289
- res.status(httpStatus(err))
290
- .json({ error: err.message || 'Failed to query clusters' });
304
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to query clusters' });
291
305
  }
292
306
  });
293
- // ─── GET /api/cluster?repo=X&name=Y ───────────────────────────
294
- // Get detailed cluster info including members
307
+ // Cluster detail
295
308
  app.get('/api/cluster', async (req, res) => {
296
309
  try {
297
- const repoName = req.query.repo;
298
- const name = req.query.name;
310
+ const name = String(req.query.name ?? '').trim();
299
311
  if (!name) {
300
312
  res.status(400).json({ error: 'Missing "name" query parameter' });
301
313
  return;
302
314
  }
303
- const result = await backend.queryClusterDetail(name, repoName);
304
- if (result.error) {
315
+ const result = await backend.queryClusterDetail(name, requestedRepo(req));
316
+ if (result?.error) {
305
317
  res.status(404).json({ error: result.error });
306
318
  return;
307
319
  }
308
320
  res.json(result);
309
321
  }
310
322
  catch (err) {
311
- res.status(httpStatus(err))
312
- .json({ error: err.message || 'Failed to query cluster detail' });
323
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to query cluster detail' });
313
324
  }
314
325
  });
315
- const server = app.listen(port, '127.0.0.1', () => {
316
- console.log(`GitNexus server running on http://localhost:${port}`);
317
- console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`);
326
+ // Global error handler catch anything the route handlers miss
327
+ app.use((err, _req, res, _next) => {
328
+ console.error('Unhandled error:', err);
329
+ res.status(500).json({ error: 'Internal server error' });
330
+ });
331
+ const server = app.listen(port, host, () => {
332
+ console.log(`GitNexus server running on http://${host}:${port}`);
318
333
  });
334
+ // Graceful shutdown — close Express + KuzuDB cleanly
319
335
  const shutdown = async () => {
320
336
  server.close();
337
+ await cleanupMcp();
338
+ await closeKuzu();
321
339
  await backend.disconnect();
322
340
  process.exit(0);
323
341
  };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * MCP over HTTP
3
+ *
4
+ * Mounts the GitNexus MCP server on Express using StreamableHTTP transport.
5
+ * Each connecting client gets its own stateful session; the LocalBackend
6
+ * is shared across all sessions (thread-safe — lazy KuzuDB per repo).
7
+ *
8
+ * Sessions are cleaned up on explicit close or after SESSION_TTL_MS of inactivity
9
+ * (guards against network drops that never trigger onclose).
10
+ */
11
+ import type { Express } from 'express';
12
+ import type { LocalBackend } from '../mcp/local/local-backend.js';
13
+ export declare function mountMCPEndpoints(app: Express, backend: LocalBackend): () => Promise<void>;