mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
refactor: make mcp standalone-only, remove legacy browser bridge
This commit is contained in:
parent
fcbb6f9e92
commit
1ae08ee9fc
8 changed files with 1376 additions and 579 deletions
983
gitnexus-mcp/package-lock.json
generated
983
gitnexus-mcp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
110
gitnexus-mcp/src/core/embedder.ts
Normal file
110
gitnexus-mcp/src/core/embedder.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue