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.
- package/dist/cli/ai-context.js +23 -52
- package/dist/cli/analyze.js +4 -1
- package/dist/cli/index.js +1 -0
- package/dist/cli/mcp.js +11 -22
- package/dist/cli/serve.d.ts +1 -0
- package/dist/cli/serve.js +2 -1
- package/dist/cli/setup.js +2 -2
- package/dist/cli/wiki.js +6 -2
- package/dist/config/supported-languages.d.ts +2 -1
- package/dist/config/supported-languages.js +1 -1
- package/dist/core/embeddings/embedder.js +40 -1
- package/dist/core/graph/types.d.ts +2 -0
- package/dist/core/ingestion/entry-point-scoring.js +26 -1
- package/dist/core/ingestion/filesystem-walker.js +3 -3
- package/dist/core/ingestion/framework-detection.d.ts +12 -4
- package/dist/core/ingestion/framework-detection.js +105 -5
- package/dist/core/ingestion/import-processor.js +77 -0
- package/dist/core/ingestion/parsing-processor.js +51 -9
- package/dist/core/ingestion/process-processor.js +7 -1
- package/dist/core/ingestion/tree-sitter-queries.d.ts +1 -0
- package/dist/core/ingestion/tree-sitter-queries.js +361 -282
- package/dist/core/ingestion/utils.js +6 -0
- package/dist/core/ingestion/workers/parse-worker.d.ts +3 -0
- package/dist/core/ingestion/workers/parse-worker.js +192 -1
- package/dist/core/kuzu/csv-generator.js +4 -2
- package/dist/core/kuzu/kuzu-adapter.d.ts +9 -0
- package/dist/core/kuzu/kuzu-adapter.js +68 -9
- package/dist/core/kuzu/schema.d.ts +6 -6
- package/dist/core/kuzu/schema.js +8 -0
- package/dist/core/tree-sitter/parser-loader.js +2 -0
- package/dist/core/wiki/generator.js +2 -2
- package/dist/mcp/local/local-backend.js +25 -13
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.js +13 -2
- package/dist/mcp/staleness.js +2 -2
- package/dist/server/api.d.ts +7 -5
- package/dist/server/api.js +145 -127
- package/dist/server/mcp-http.d.ts +13 -0
- package/dist/server/mcp-http.js +100 -0
- package/package.json +2 -1
- package/skills/gitnexus-cli.md +82 -0
- package/skills/{debugging.md → gitnexus-debugging.md} +12 -8
- package/skills/{exploring.md → gitnexus-exploring.md} +10 -7
- package/skills/gitnexus-guide.md +64 -0
- package/skills/{impact-analysis.md → gitnexus-impact-analysis.md} +14 -11
- 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
|
-
|
|
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 {
|
|
905
|
-
// Build git diff
|
|
906
|
-
let
|
|
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
|
-
|
|
911
|
+
diffArgs = ['diff', '--staged', '--name-only'];
|
|
910
912
|
break;
|
|
911
913
|
case 'all':
|
|
912
|
-
|
|
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
|
-
|
|
919
|
+
diffArgs = ['diff', params.base_ref, '--name-only'];
|
|
918
920
|
break;
|
|
919
921
|
case 'unstaged':
|
|
920
922
|
default:
|
|
921
|
-
|
|
923
|
+
diffArgs = ['diff', '--name-only'];
|
|
922
924
|
break;
|
|
923
925
|
}
|
|
924
926
|
let changedFiles;
|
|
925
927
|
try {
|
|
926
|
-
const output =
|
|
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 {
|
|
1081
|
-
const
|
|
1082
|
-
|
|
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
|
-
|
|
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();
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -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>;
|
package/dist/mcp/server.js
CHANGED
|
@@ -54,7 +54,11 @@ function getNextStepHint(toolName, args) {
|
|
|
54
54
|
return '';
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
-
|
|
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);
|
package/dist/mcp/staleness.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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 {
|
package/dist/server/api.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP API Server
|
|
2
|
+
* HTTP API Server
|
|
3
3
|
*
|
|
4
|
-
* REST API for browser-based clients to query
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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>;
|
package/dist/server/api.js
CHANGED
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTTP API Server
|
|
2
|
+
* HTTP API Server
|
|
3
3
|
*
|
|
4
|
-
* REST API for browser-based clients to query
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
89
|
-
const msg = err?.message ?? '';
|
|
90
|
-
if (msg.includes('
|
|
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
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
122
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
137
|
-
|
|
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:
|
|
140
|
-
|
|
141
|
-
indexedAt:
|
|
142
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
180
|
-
if (
|
|
181
|
-
res.status(
|
|
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(
|
|
188
|
-
.json({ error: err.message || 'Query failed' });
|
|
200
|
+
res.status(500).json({ error: err.message || 'Query failed' });
|
|
189
201
|
}
|
|
190
202
|
});
|
|
191
|
-
//
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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(
|
|
211
|
-
.json({ error: err.message || 'Search failed' });
|
|
233
|
+
res.status(500).json({ error: err.message || 'Search failed' });
|
|
212
234
|
}
|
|
213
235
|
});
|
|
214
|
-
//
|
|
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
|
|
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
|
|
246
|
+
res.status(400).json({ error: 'Missing path' });
|
|
222
247
|
return;
|
|
223
248
|
}
|
|
224
|
-
|
|
225
|
-
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
259
|
-
// Get detailed process info including steps
|
|
278
|
+
// Process detail
|
|
260
279
|
app.get('/api/process', async (req, res) => {
|
|
261
280
|
try {
|
|
262
|
-
const
|
|
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,
|
|
269
|
-
if (result
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
//
|
|
294
|
-
// Get detailed cluster info including members
|
|
307
|
+
// Cluster detail
|
|
295
308
|
app.get('/api/cluster', async (req, res) => {
|
|
296
309
|
try {
|
|
297
|
-
const
|
|
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,
|
|
304
|
-
if (result
|
|
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(
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
console.
|
|
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>;
|