refactor: make mcp standalone-only, remove legacy browser bridge

This commit is contained in:
abhigyanpatwari 2026-02-03 22:19:44 +05:30
parent fcbb6f9e92
commit 1ae08ee9fc
8 changed files with 1376 additions and 579 deletions

File diff suppressed because it is too large Load diff

View file

@ -30,6 +30,7 @@
"prepublishOnly": "npm run build"
},
"dependencies": {
"@huggingface/transformers": "^3.5.1",
"@modelcontextprotocol/sdk": "^1.0.0",
"kuzu": "^0.11.0",
"minisearch": "^7.1.0",

View file

@ -1,36 +0,0 @@
/**
* Bridge Protocol Types
*
* JSON-RPC-like protocol for communication between bridge and browser.
*/
export interface BridgeMessage {
id: string;
type?: 'register_peer' | 'tool_call' | 'tool_result' | 'agent_info' | 'handshake' | 'handshake_ack' | 'context';
method?: string;
params?: any;
result?: any;
error?: {
code?: number;
message: string;
};
agentName?: string;
peerId?: string;
}
export type ToolCallRequest = BridgeMessage & { method: string };
export type ToolCallResponse = BridgeMessage & ({ result: any } | { error: any });
/**
* Check if message is a request (has method)
*/
export function isRequest(msg: BridgeMessage): msg is ToolCallRequest {
return typeof msg.method === 'string';
}
/**
* Check if message is a response (has result or error)
*/
export function isResponse(msg: BridgeMessage): msg is ToolCallResponse {
return 'result' in msg || 'error' in msg;
}

View file

@ -1,397 +0,0 @@
import { WebSocketServer, WebSocket } from 'ws';
import { createServer as createNetServer } from 'net';
import { BridgeMessage, isRequest, isResponse } from './protocol.js';
import { v4 as uuidv4 } from 'uuid';
/**
* Codebase context sent from the GitNexus browser app
*/
export interface CodebaseContext {
projectName: string;
stats: {
fileCount: number;
functionCount: number;
classCount: number;
interfaceCount: number;
methodCount: number;
};
hotspots: Array<{
name: string;
type: string;
filePath: string;
connections: number;
}>;
folderTree: string;
}
/**
* Check if a Port is available
*/
async function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = createNetServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port);
});
}
export class WebSocketBridge {
private wss: WebSocketServer | null = null; // Used if we are the Hub
private client: WebSocket | null = null; // Used if we are a Peer (connecting to Hub), OR if we are Hub (clients connecting to us)
// Hub State
private browserClient: WebSocket | null = null;
private peerClients: Map<string, WebSocket> = new Map();
// Common State
private pendingRequests: Map<string, { resolve: (val: any) => void, reject: (err: any) => void }> = new Map();
private requestId = 0;
private started = false;
private _context: any | null = null; // CodebaseContext
private contextListeners: Set<(context: any | null) => void> = new Set();
private agentName: string;
private isHub = false;
private port = 54319;
constructor(port: number = 54319, agentName?: string) {
this.port = port;
this.agentName = agentName || process.env.GITNEXUS_AGENT || this.detectAgent();
}
private detectAgent(): string {
if (process.env.CURSOR_SESSION_ID) return 'Cursor';
if (process.env.CLAUDE_CODE) return 'Claude Code';
if (process.env.WINDSURF_SESSION) return 'Windsurf';
return 'Unknown Agent';
}
async start(): Promise<boolean> {
const available = await isPortAvailable(this.port);
if (available) {
return this.startAsHub();
} else {
return this.startAsPeer();
}
}
// -------------------------------------------------------------------------
// Hub Implementation (Master)
// -------------------------------------------------------------------------
private async startAsHub(): Promise<boolean> {
console.error(`Starting as MCP Hub on port ${this.port}`);
this.isHub = true;
return new Promise((resolve) => {
this.wss = new WebSocketServer({ port: this.port });
this.wss.on('connection', (ws, req) => {
// Security: Origin check could go here if req.headers.origin available
ws.on('message', (data) => this.handleHubMessage(ws, data));
ws.on('close', () => this.handleHubDisconnect(ws));
ws.on('error', (err) => console.error('Hub client error:', err));
});
this.wss.on('listening', () => {
this.started = true;
resolve(true);
});
this.wss.on('error', (err) => {
console.error('Hub server error:', err);
resolve(false);
});
});
}
private handleHubMessage(ws: WebSocket, data: any) {
try {
const msg: BridgeMessage = JSON.parse(data.toString());
if (msg.type === 'handshake') {
// Peer verifying we are GitNexus
ws.send(JSON.stringify({ type: 'handshake_ack', id: msg.id }));
return;
}
if (msg.type === 'register_peer') {
// Peer registering itself
const peerId = uuidv4();
this.peerClients.set(peerId, ws);
(ws as any).peerId = peerId;
(ws as any).agentName = msg.agentName;
console.error(`Peer connected: ${msg.agentName} (${peerId})`);
// Forward current context to new peer if available
if (this._context) {
ws.send(JSON.stringify({ type: 'context', params: this._context }));
}
return;
}
// Handle Context updates (from Browser)
if (msg.type === 'context') {
// Browser identified itself (implicitly)
if (this.browserClient !== ws) {
if (this.browserClient) this.browserClient.close();
this.browserClient = ws;
console.error('Browser connected to Hub');
}
this._context = msg.params;
this.notifyContextListeners();
// Broadcast context to all peers
this.broadcastToPeers(msg);
return;
}
// Handle Tool Calls (Peer/Hub -> Browser)
if (isRequest(msg)) {
// If it came from a ws client (Peer), validation needed?
// We assume it's destined for the Browser
if (this.browserClient && this.browserClient.readyState === WebSocket.OPEN) {
// Attach agent info if missing (for UI)
if (!msg.agentName && (ws as any).agentName) {
msg.agentName = (ws as any).agentName;
}
// Attach peerId so we can route response back
if (!msg.peerId && (ws as any).peerId) {
msg.peerId = (ws as any).peerId;
}
this.browserClient.send(JSON.stringify(msg));
} else {
// Browser not connected, fail
if (msg.id) {
ws.send(JSON.stringify({
id: msg.id,
error: { message: "Browser not connected. Open GitNexus." }
}));
}
}
return;
}
// Handle Tool Results (Browser -> Peer/Hub)
if (isResponse(msg)) {
// Route to the correct peer
if (msg.peerId && this.peerClients.has(msg.peerId)) {
const peer = this.peerClients.get(msg.peerId);
if (peer?.readyState === WebSocket.OPEN) {
peer.send(JSON.stringify(msg));
}
} else {
// It might be for Us (the Hub)
this.handleResponseLocal(msg);
}
return;
}
} catch (e) {
console.error('Hub: Failed to parse message', e);
}
}
private handleHubDisconnect(ws: WebSocket) {
if (ws === this.browserClient) {
console.error('Browser disconnected from Hub');
this.browserClient = null;
this._context = null;
this.notifyContextListeners();
} else {
const peerId = (ws as any).peerId;
if (peerId) {
this.peerClients.delete(peerId);
console.error(`Peer disconnected: ${peerId}`);
}
}
}
private broadcastToPeers(msg: any) {
for (const client of this.peerClients.values()) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(msg));
}
}
}
// -------------------------------------------------------------------------
// Peer Implementation (Spoke)
// -------------------------------------------------------------------------
private async startAsPeer(): Promise<boolean> {
console.error(`Port ${this.port} busy. Attempting to connect as Peer...`);
return new Promise((resolve) => {
const ws = new WebSocket(`ws://localhost:${this.port}`);
const timeout = setTimeout(() => {
console.error('Handshake timeout. Port is busy by unknown app.');
ws.close();
resolve(false);
}, 1000);
ws.on('open', () => {
// Send Handshake
ws.send(JSON.stringify({ type: 'handshake', id: 'init' }));
});
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
// Handshake success?
if (msg.type === 'handshake_ack') {
clearTimeout(timeout);
console.error('Handshake successful. Joining as Peer.');
// Register ourselves
ws.send(JSON.stringify({
type: 'register_peer',
agentName: this.agentName
}));
this.client = ws;
this.started = true;
resolve(true);
return;
}
// Normal messages from Hub
this.handlePeerMessage(msg);
} catch (e) {
// ignore garbage
}
});
ws.on('error', (err) => {
console.error('Peer connection error:', err);
resolve(false);
});
// If connection fails immediately
ws.on('close', () => {
if (!this.started) resolve(false);
else {
this.client = null;
this._context = null;
this.notifyContextListeners();
}
});
});
}
private handlePeerMessage(msg: BridgeMessage) {
if (msg.type === 'context') {
this._context = msg.params;
this.notifyContextListeners();
return;
}
if (isResponse(msg)) {
this.handleResponseLocal(msg);
}
}
// -------------------------------------------------------------------------
// Shared / Public API
// -------------------------------------------------------------------------
private handleResponseLocal(msg: any) {
if (msg.id && this.pendingRequests.has(msg.id)) {
const { resolve, reject } = this.pendingRequests.get(msg.id)!;
this.pendingRequests.delete(msg.id);
if (msg.error) {
// We'll reject the promise so caller knows
reject(new Error(msg.error.message));
} else {
resolve(msg.result);
}
}
}
get isConnected(): boolean {
if (this.isHub) {
return this.browserClient !== null && this.browserClient.readyState === WebSocket.OPEN;
} else {
return this.client !== null && this.client.readyState === WebSocket.OPEN;
}
}
get context(): any {
return this._context;
}
onContextChange(listener: (context: any) => void) {
this.contextListeners.add(listener);
return () => this.contextListeners.delete(listener);
}
private notifyContextListeners() {
this.contextListeners.forEach((listener) => listener(this._context));
}
async callTool(method: string, params: any): Promise<any> {
if (!this.isConnected) {
if (this.isHub) throw new Error('GitNexus Browser not connected.');
else throw new Error('GitNexus Hub disonnected.');
}
const id = `req_${++this.requestId}`;
return new Promise((resolve, reject) => {
this.pendingRequests.set(id, { resolve, reject });
const msg: BridgeMessage = {
id,
method,
params,
agentName: this.agentName,
// type is implicitly request because of method
};
if (this.isHub) {
// Send directly to browser
if (this.browserClient && this.browserClient.readyState === WebSocket.OPEN) {
this.browserClient.send(JSON.stringify(msg));
} else {
this.pendingRequests.delete(id);
reject(new Error('Browser not connected'));
}
} else {
// Send to Hub (who forwards to browser)
if (this.client && this.client.readyState === WebSocket.OPEN) {
this.client.send(JSON.stringify(msg));
} else {
this.pendingRequests.delete(id);
reject(new Error('Hub disconnected'));
}
}
setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error('Request timeout'));
}
}, 30000);
});
}
close() {
this.wss?.close();
this.client?.close();
}
disconnect() {
this.close();
}
}

View file

@ -1,45 +1,102 @@
/**
* Serve Command
*
* Starts the MCP server with hybrid mode:
* 1. First tries local .gitnexus/ index (standalone mode)
* 2. Falls back to WebSocket bridge if browser is running
* Starts the MCP server in standalone mode using local .gitnexus/ index.
*
* Auto-detects repository by trying (in order):
* 1. GITNEXUS_CWD env var (explicit override)
* 2. process.cwd() (IDE working directory)
* 3. VSCODE_WORKSPACE_FOLDER env var
*/
import { startMCPServer } from '../mcp/server.js';
import { WebSocketBridge } from '../bridge/websocket-server.js';
import { LocalBackend } from '../local/local-backend.js';
import { LocalBackend, findRepo } from '../local/local-backend.js';
import path from 'path';
import fs from 'fs/promises';
interface ServeOptions {
port: string;
}
export async function serveCommand(options: ServeOptions) {
const port = parseInt(options.port, 10);
// Use GITNEXUS_CWD env var if set, otherwise use process.cwd()
const cwd = process.env.GITNEXUS_CWD || process.cwd();
/**
* Get candidate paths to search for .gitnexus/ folder
*/
function getCandidatePaths(): string[] {
const candidates: string[] = [];
// Try local backend first (standalone mode)
const local = new LocalBackend();
const hasLocalIndex = await local.init(cwd);
if (hasLocalIndex) {
console.error(`GitNexus: Using local index at ${local.storagePath}`);
await startMCPServer(local);
return;
// 1. Explicit override (highest priority)
if (process.env.GITNEXUS_CWD) {
candidates.push(process.env.GITNEXUS_CWD);
}
// No local index - fall back to browser bridge
console.error('GitNexus: No local .gitnexus/ found, starting browser bridge...');
// 2. Current working directory
candidates.push(process.cwd());
const bridge = new WebSocketBridge(port);
const started = await bridge.start();
if (!started) {
console.error(`Failed to start GitNexus browser bridge on port ${port}.`);
console.error('Run "gitnexus analyze" to index this repository for standalone mode.');
process.exit(1);
// 3. VS Code workspace folders (if available via env)
if (process.env.VSCODE_WORKSPACE_FOLDER) {
candidates.push(process.env.VSCODE_WORKSPACE_FOLDER);
}
await startMCPServer(bridge);
// Deduplicate while preserving order
return [...new Set(candidates.map(p => path.resolve(p)))];
}
/**
* Find a git repository root by walking up the directory tree
*/
async function findGitRoot(startPath: string): Promise<string | null> {
let current = path.resolve(startPath);
const root = path.parse(current).root;
while (current !== root) {
try {
const gitPath = path.join(current, '.git');
const stat = await fs.stat(gitPath);
if (stat.isDirectory()) return current;
} catch {}
current = path.dirname(current);
}
return null;
}
export async function serveCommand(_options: ServeOptions) {
// Try multiple candidate paths to find .gitnexus/
const candidates = getCandidatePaths();
for (const candidate of candidates) {
const repo = await findRepo(candidate);
if (repo) {
const local = new LocalBackend();
await local.init(candidate);
console.error(`GitNexus: Found index at ${repo.storagePath}`);
await startMCPServer(local);
return;
}
}
// No index found - give helpful error message
for (const candidate of candidates) {
const gitRoot = await findGitRoot(candidate);
if (gitRoot) {
console.error('');
console.error('╔════════════════════════════════════════════════════╗');
console.error('║ GitNexus: Repository Not Indexed ║');
console.error('╠════════════════════════════════════════════════════╣');
console.error(`║ Found git repo: ${gitRoot.slice(0, 35).padEnd(35)}`);
console.error('║ ║');
console.error('║ To enable AI code understanding, run: ║');
console.error('║ ║');
console.error('║ npx gitnexus-cli analyze ║');
console.error('║ ║');
console.error('║ Then restart your IDE. ║');
console.error('╚════════════════════════════════════════════════════╝');
console.error('');
process.exit(1);
}
}
// No git repo found
console.error('GitNexus: No git repository found.');
console.error(`Searched: ${candidates.join(', ')}`);
process.exit(1);
}

View file

@ -0,0 +1,110 @@
/**
* Embedder Module (Read-Only)
*
* Singleton factory for transformers.js embedding pipeline.
* For MCP, we only need to compute query embeddings, not batch embed.
*/
import { pipeline, env, type FeatureExtractionPipeline } from '@huggingface/transformers';
// Model config
const MODEL_ID = 'Snowflake/snowflake-arctic-embed-xs';
const EMBEDDING_DIMS = 384;
// Module-level state for singleton pattern
let embedderInstance: FeatureExtractionPipeline | null = null;
let isInitializing = false;
let initPromise: Promise<FeatureExtractionPipeline> | null = null;
/**
* Initialize the embedding model (lazy, on first search)
*/
export const initEmbedder = async (): Promise<FeatureExtractionPipeline> => {
if (embedderInstance) {
return embedderInstance;
}
if (isInitializing && initPromise) {
return initPromise;
}
isInitializing = true;
initPromise = (async () => {
try {
env.allowLocalModels = false;
console.error('GitNexus: Loading embedding model (first search may take a moment)...');
// Try WebGPU first (Windows DirectX12), fall back to CPU
const devicesToTry: Array<'webgpu' | 'cpu'> = ['webgpu', 'cpu'];
for (const device of devicesToTry) {
try {
embedderInstance = await (pipeline as any)(
'feature-extraction',
MODEL_ID,
{
device: device,
dtype: 'fp32',
}
);
console.error(`GitNexus: Embedding model loaded (${device})`);
return embedderInstance!;
} catch {
if (device === 'cpu') throw new Error('Failed to load embedding model');
}
}
throw new Error('No suitable device found');
} catch (error) {
isInitializing = false;
initPromise = null;
embedderInstance = null;
throw error;
} finally {
isInitializing = false;
}
})();
return initPromise;
};
/**
* Check if embedder is ready
*/
export const isEmbedderReady = (): boolean => embedderInstance !== null;
/**
* Embed a query text for semantic search
*/
export const embedQuery = async (query: string): Promise<number[]> => {
const embedder = await initEmbedder();
const result = await embedder(query, {
pooling: 'mean',
normalize: true,
});
return Array.from(result.data as ArrayLike<number>);
};
/**
* Get embedding dimensions
*/
export const getEmbeddingDims = (): number => EMBEDDING_DIMS;
/**
* Cleanup embedder
*/
export const disposeEmbedder = async (): Promise<void> => {
if (embedderInstance) {
try {
if ('dispose' in embedderInstance && typeof embedderInstance.dispose === 'function') {
await embedderInstance.dispose();
}
} catch {}
embedderInstance = null;
initPromise = null;
}
};

View file

@ -9,6 +9,7 @@ import fs from 'fs/promises';
import path from 'path';
import { initKuzu, executeQuery, closeKuzu, isKuzuReady } from '../core/kuzu-adapter.js';
import { loadBM25Index, searchBM25, isBM25Ready } from '../core/bm25-index.js';
import { embedQuery, getEmbeddingDims, disposeEmbedder } from '../core/embedder.js';
export interface RepoMeta {
repoPath: string;
@ -46,7 +47,18 @@ function getStoragePaths(repoPath: string) {
async function loadMeta(storagePath: string): Promise<RepoMeta | null> {
try {
// Verify both meta.json and kuzu exist for a valid index
const metaPath = path.join(storagePath, 'meta.json');
const kuzuPath = path.join(storagePath, 'kuzu');
// Check kuzu exists (can be file or directory depending on how it was saved)
try {
await fs.stat(kuzuPath);
} catch {
return null; // kuzu doesn't exist
}
// Load and parse meta.json
const raw = await fs.readFile(metaPath, 'utf-8');
return JSON.parse(raw) as RepoMeta;
} catch {
@ -205,88 +217,204 @@ export class LocalBackend {
].join('\n');
}
private async search(params: { query: string; limit?: number; depth?: string }): Promise<any> {
private async search(params: { query: string; limit?: number; depth?: string; groupByProcess?: boolean }): Promise<any> {
await this.ensureInitialized();
const limit = params.limit || 10;
const query = params.query;
const depth = params.depth || 'definitions';
// BM25 keyword search
const bm25Results = isBM25Ready() ? searchBM25(query, limit * 2) : [];
// Run BM25 and semantic search in parallel
const [bm25Results, semanticResults] = await Promise.all([
this.bm25Search(query, limit * 2),
this.semanticSearch(query, limit * 2),
]);
if (bm25Results.length === 0) {
return { message: 'No results found', query, bm25Ready: isBM25Ready() };
// Merge and deduplicate results using reciprocal rank fusion
const scoreMap = new Map<string, { score: number; source: string; data: any }>();
// BM25 results
for (let i = 0; i < bm25Results.length; i++) {
const result = bm25Results[i];
const key = result.filePath;
const rrfScore = 1 / (60 + i); // RRF formula with k=60
const existing = scoreMap.get(key);
if (existing) {
existing.score += rrfScore;
existing.source = 'hybrid';
} else {
scoreMap.set(key, { score: rrfScore, source: 'bm25', data: result });
}
}
// Get node details from kuzu for top results
// Semantic results
for (let i = 0; i < semanticResults.length; i++) {
const result = semanticResults[i];
const key = result.filePath;
const rrfScore = 1 / (60 + i);
const existing = scoreMap.get(key);
if (existing) {
existing.score += rrfScore;
existing.source = 'hybrid';
} else {
scoreMap.set(key, { score: rrfScore, source: 'semantic', data: result });
}
}
// Sort by fused score and take top results
const merged = Array.from(scoreMap.entries())
.sort((a, b) => b[1].score - a[1].score)
.slice(0, limit);
// Enrich with graph data
const results: any[] = [];
for (const bm25Result of bm25Results.slice(0, limit)) {
for (const [_, item] of merged) {
const result = item.data;
result.searchSource = item.source;
result.fusedScore = item.score;
// Add relationships if depth is 'full' and we have a node ID
if (depth === 'full' && result.nodeId) {
try {
const relQuery = `
MATCH (n {id: '${result.nodeId.replace(/'/g, "''")}'})-[r:CodeRelation]->(m)
RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath
LIMIT 5
`;
const rels = await executeQuery(relQuery);
result.connections = rels.map((rel: any) => ({
type: rel.type || rel[0],
name: rel.targetName || rel[1],
path: rel.targetPath || rel[2],
}));
} catch {
result.connections = [];
}
}
results.push(result);
}
return results;
}
/**
* BM25 keyword search helper
*/
private async bm25Search(query: string, limit: number): Promise<any[]> {
if (!isBM25Ready()) return [];
const bm25Results = searchBM25(query, limit);
const results: any[] = [];
for (const bm25Result of bm25Results) {
const fileName = bm25Result.filePath.split('/').pop() || bm25Result.filePath;
try {
// Use CONTAINS to match file paths (handles relative vs full paths)
const fileName = bm25Result.filePath.split('/').pop() || bm25Result.filePath;
const symbolQuery = `
MATCH (n)
WHERE n.filePath CONTAINS '${fileName.replace(/'/g, "''")}'
RETURN n.id AS id, n.name AS name, labels(n)[0] AS type, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine
LIMIT 5
LIMIT 3
`;
const symbols = await executeQuery(symbolQuery);
if (symbols.length > 0) {
for (const sym of symbols) {
const result: any = {
results.push({
nodeId: sym.id || sym[0],
name: sym.name || sym[1],
type: sym.type || sym[2],
filePath: sym.filePath || sym[3],
startLine: sym.startLine || sym[4],
endLine: sym.endLine || sym[5],
score: bm25Result.score,
};
// Add relationships if depth is 'full'
if (depth === 'full') {
const relQuery = `
MATCH (n {id: '${(sym.id || sym[0]).replace(/'/g, "''")}' })-[r:CodeRelation]->(m)
RETURN r.type AS type, m.name AS targetName, m.filePath AS targetPath
LIMIT 5
`;
try {
const rels = await executeQuery(relQuery);
result.connections = rels.map((rel: any) => ({
type: rel.type || rel[0],
name: rel.targetName || rel[1],
path: rel.targetPath || rel[2],
}));
} catch {
result.connections = [];
}
}
results.push(result);
bm25Score: bm25Result.score,
});
}
} else {
// No symbols found in kuzu, return file info from BM25
results.push({
name: fileName,
type: 'File',
filePath: bm25Result.filePath,
score: bm25Result.score,
bm25Score: bm25Result.score,
});
}
} catch {
// On kuzu error, still return BM25 result
results.push({
name: bm25Result.filePath.split('/').pop(),
name: fileName,
type: 'File',
filePath: bm25Result.filePath,
score: bm25Result.score,
bm25Score: bm25Result.score,
});
}
}
return results.slice(0, limit);
return results;
}
/**
* Semantic vector search helper
*/
private async semanticSearch(query: string, limit: number): Promise<any[]> {
try {
// Embed the query
const queryVec = await embedQuery(query);
const dims = getEmbeddingDims();
const queryVecStr = `[${queryVec.join(',')}]`;
// Query vector index
const vectorQuery = `
CALL QUERY_VECTOR_INDEX('CodeEmbedding', 'code_embedding_idx',
CAST(${queryVecStr} AS FLOAT[${dims}]), ${limit})
YIELD node AS emb, distance
WITH emb, distance
WHERE distance < 0.6
RETURN emb.nodeId AS nodeId, distance
ORDER BY distance
`;
const embResults = await executeQuery(vectorQuery);
if (embResults.length === 0) return [];
// Get metadata for each result
const results: any[] = [];
for (const embRow of embResults) {
const nodeId = embRow.nodeId ?? embRow[0];
const distance = embRow.distance ?? embRow[1];
// Extract label from node ID
const labelEndIdx = nodeId.indexOf(':');
const label = labelEndIdx > 0 ? nodeId.substring(0, labelEndIdx) : 'Unknown';
try {
const nodeQuery = label === 'File'
? `MATCH (n:File {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath`
: `MATCH (n:${label} {id: '${nodeId.replace(/'/g, "''")}'}) RETURN n.name AS name, n.filePath AS filePath, n.startLine AS startLine, n.endLine AS endLine`;
const nodeRows = await executeQuery(nodeQuery);
if (nodeRows.length > 0) {
const nodeRow = nodeRows[0];
results.push({
nodeId,
name: nodeRow.name ?? nodeRow[0] ?? '',
type: label,
filePath: nodeRow.filePath ?? nodeRow[1] ?? '',
distance,
startLine: label !== 'File' ? (nodeRow.startLine ?? nodeRow[2]) : undefined,
endLine: label !== 'File' ? (nodeRow.endLine ?? nodeRow[3]) : undefined,
});
}
} catch {}
}
return results;
} catch (err: any) {
// Semantic search unavailable (no embeddings or model not loaded)
console.error('GitNexus: Semantic search unavailable -', err.message);
return [];
}
}
private async cypher(params: { query: string }): Promise<any> {
@ -580,8 +708,9 @@ export class LocalBackend {
};
}
disconnect(): void {
async disconnect(): Promise<void> {
closeKuzu();
await disposeEmbedder();
this.repo = null;
this._context = null;
this.initialized = false;

View file

@ -2,12 +2,10 @@
* MCP Server
*
* Model Context Protocol server that runs on stdio.
* External AI tools (Cursor, Claude Code) spawn this process and
* External AI tools (Cursor, Claude) spawn this process and
* communicate via stdin/stdout using the MCP protocol.
*
* Exposes:
* - Tools: search, cypher, blastRadius, highlight
* - Resources: codebase context (stats, hotspots, folder tree)
* Tools: context, search, cypher, overview, explore, impact, analyze
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
@ -19,93 +17,50 @@ import {
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { GITNEXUS_TOOLS } from './tools.js';
import type { CodebaseContext } from '../bridge/websocket-server.js';
// Interface for anything that can call tools (DaemonClient or WebSocketBridge)
interface ToolCaller {
callTool(method: string, params: any): Promise<any>;
disconnect?(): void;
context?: CodebaseContext | null;
onContextChange?: (listener: (context: CodebaseContext | null) => void) => () => void;
}
import type { LocalBackend, CodebaseContext } from '../local/local-backend.js';
/**
* Format context as markdown for the resource
*/
function formatContextAsMarkdown(context: CodebaseContext): string {
const { projectName, stats, hotspots, folderTree } = context;
const { projectName, stats } = context;
const lines: string[] = [];
lines.push(`# GitNexus: ${projectName}`);
lines.push('');
lines.push('This codebase is currently loaded in GitNexus. Use the tools below to explore it.');
lines.push('## Stats');
lines.push(`- Files: ${stats.fileCount}`);
lines.push(`- Functions: ${stats.functionCount}`);
if (stats.communityCount > 0) lines.push(`- Communities: ${stats.communityCount}`);
if (stats.processCount > 0) lines.push(`- Processes: ${stats.processCount}`);
lines.push('');
// Stats
lines.push('## 📊 Statistics');
lines.push(`- **Files**: ${stats.fileCount}`);
lines.push(`- **Functions**: ${stats.functionCount}`);
if (stats.classCount > 0) lines.push(`- **Classes**: ${stats.classCount}`);
if (stats.interfaceCount > 0) lines.push(`- **Interfaces**: ${stats.interfaceCount}`);
if (stats.methodCount > 0) lines.push(`- **Methods**: ${stats.methodCount}`);
lines.push('## Available Tools');
lines.push('');
lines.push('- **context**: Codebase overview and stats');
lines.push('- **search**: Hybrid semantic + keyword search');
lines.push('- **cypher**: Execute Cypher queries on graph');
lines.push('- **overview**: List communities and processes');
lines.push('- **explore**: Deep dive on symbol/cluster/process');
lines.push('- **impact**: Change impact analysis');
lines.push('- **analyze**: Index/re-index repository');
lines.push('');
// Hotspots
if (hotspots.length > 0) {
lines.push('## 🔥 Hotspots (Most Connected Nodes)');
lines.push('');
hotspots.forEach(h => {
lines.push(`- \`${h.name}\` (${h.type}) — ${h.connections} connections — ${h.filePath}`);
});
lines.push('');
}
// Folder tree
if (folderTree) {
lines.push('## 📁 Project Structure');
lines.push('```');
lines.push(projectName + '/');
lines.push(folderTree);
lines.push('```');
lines.push('');
}
// Usage hints
lines.push('## 🛠️ Available Tools');
lines.push('## Graph Schema');
lines.push('');
lines.push('- **search**: Semantic + keyword search across codebase');
lines.push('- **cypher**: Execute Cypher queries on knowledge graph');
lines.push('- **grep**: Regex pattern search in files');
lines.push('- **read**: Read file contents');
lines.push('- **explore**: Deep dive on symbol, cluster, or process');
lines.push('- **overview**: Codebase map (all clusters + processes)');
lines.push('- **impact**: Analyze change impact (upstream/downstream)');
lines.push('- **highlight**: Visualize nodes in graph');
lines.push('**Nodes**: File, Function, Class, Interface, Method, Community, Process');
lines.push('');
lines.push('## 📝 Graph Schema');
lines.push('');
lines.push('**Node Types**: File, Folder, Function, Class, Interface, Method, Community, Process');
lines.push('');
lines.push('**Relation**: `CodeRelation` with `type` property:');
lines.push('- CALLS, IMPORTS, EXTENDS, IMPLEMENTS, CONTAINS, DEFINES');
lines.push('- MEMBER_OF (symbol → community), STEP_IN_PROCESS (symbol → process)');
lines.push('');
lines.push('**Example Cypher Queries**:');
lines.push('```cypher');
lines.push('MATCH (f:Function) RETURN f.name LIMIT 10');
lines.push("MATCH (f:File)-[:CodeRelation {type: 'IMPORTS'}]->(g:File) RETURN f.name, g.name");
lines.push("MATCH (s)-[:CodeRelation {type: 'MEMBER_OF'}]->(c:Community) RETURN c.label, count(s)");
lines.push('```');
lines.push('**Relations**: CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS');
return lines.join('\n');
}
export async function startMCPServer(client: ToolCaller): Promise<void> {
export async function startMCPServer(backend: LocalBackend): Promise<void> {
const server = new Server(
{
name: 'gitnexus',
version: '0.1.0',
version: '0.2.0',
},
{
capabilities: {
@ -117,7 +72,7 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
// Handle list resources request
server.setRequestHandler(ListResourcesRequestSchema, async () => {
const context = client.context;
const context = backend.context;
if (!context) {
return { resources: [] };
@ -128,7 +83,7 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
{
uri: 'gitnexus://codebase/context',
name: `GitNexus: ${context.projectName}`,
description: `Codebase context for ${context.projectName} (${context.stats.fileCount} files, ${context.stats.functionCount} functions)`,
description: `Codebase context for ${context.projectName} (${context.stats.fileCount} files)`,
mimeType: 'text/markdown',
},
],
@ -140,7 +95,7 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
const { uri } = request.params;
if (uri === 'gitnexus://codebase/context') {
const context = client.context;
const context = backend.context;
if (!context) {
return {
@ -148,7 +103,7 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
{
uri,
mimeType: 'text/plain',
text: 'No codebase loaded. Open GitNexus in your browser and load a repository.',
text: 'No codebase loaded.',
},
],
};
@ -182,8 +137,7 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
const { name, arguments: args } = request.params;
try {
// Forward the tool call to the browser via daemon
const result = await client.callTool(name, args);
const result = await backend.callTool(name, args);
return {
content: [
@ -213,13 +167,13 @@ export async function startMCPServer(client: ToolCaller): Promise<void> {
// Handle graceful shutdown
process.on('SIGINT', async () => {
client.disconnect?.();
await backend.disconnect();
await server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
client.disconnect?.();
await backend.disconnect();
await server.close();
process.exit(0);
});