From df2674cb1a049b3d8d9c9650201b71fdde66cd37 Mon Sep 17 00:00:00 2001 From: Nico Prieto Date: Wed, 25 Feb 2026 11:38:28 +0100 Subject: [PATCH] feat: add remote server connection mode and multi-repo switching Replace the old local-only backend mode with a new server connection flow that lets the web UI connect to any running GitNexus server, download the pre-built knowledge graph, and explore it without WASM. - Add server-connection service with streaming download and progress - Replace DropZone backend tab with server connect UI (URL input, progress bar, cancel) - Add repo switcher dropdown in Header when multiple repos are indexed - Mount MCP server over StreamableHTTP at /api/mcp for remote AI tool access - Refactor api.ts to query KuzuDB directly instead of routing through LocalBackend - Fix KuzuDB adapter to support switching between databases (close old before opening new) - Extract createMCPServer() from startMCPServer() for transport-agnostic reuse - Support ?server= query param for bookmarkable auto-connect Co-Authored-By: Claude Opus 4.6 --- gitnexus-web/src/App.tsx | 162 ++++--- gitnexus-web/src/components/DropZone.tsx | 268 +++++++++-- gitnexus-web/src/components/Header.tsx | 67 ++- gitnexus-web/src/hooks/useAppState.tsx | 149 ++++--- .../src/services/server-connection.ts | 155 +++++++ gitnexus/package-lock.json | 4 +- gitnexus/src/cli/analyze.ts | 2 +- gitnexus/src/core/kuzu/kuzu-adapter.ts | 15 +- gitnexus/src/mcp/server.ts | 35 +- gitnexus/src/server/api.ts | 419 ++++++------------ gitnexus/src/server/mcp-http.ts | 63 +++ 11 files changed, 868 insertions(+), 471 deletions(-) create mode 100644 gitnexus-web/src/services/server-connection.ts create mode 100644 gitnexus/src/server/mcp-http.ts diff --git a/gitnexus-web/src/App.tsx b/gitnexus-web/src/App.tsx index 6cb18cc3c..2de2a4a34 100644 --- a/gitnexus-web/src/App.tsx +++ b/gitnexus-web/src/App.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { AppStateProvider, useAppState } from './hooks/useAppState'; import { DropZone } from './components/DropZone'; import { LoadingOverlay } from './components/LoadingOverlay'; @@ -11,9 +11,8 @@ import { FileTreePanel } from './components/FileTreePanel'; import { CodeReferencesPanel } from './components/CodeReferencesPanel'; import { FileEntry } from './services/zip'; import { getActiveProviderConfig } from './core/llm/settings-service'; -import { useBackend } from './hooks/useBackend'; -import { fetchGraph } from './services/backend'; import { createKnowledgeGraph } from './core/graph/graph'; +import { connectToServer, fetchRepos, normalizeServerUrl, type ConnectToServerResult } from './services/server-connection'; const AppContent = () => { const { @@ -36,12 +35,13 @@ const AppContent = () => { codeReferences, selectedNode, isCodePanelOpen, - setBackendMode, - setBackendRepo, + serverBaseUrl, + setServerBaseUrl, + availableRepos, + setAvailableRepos, + switchRepo, } = useAppState(); - const backend = useBackend(); - const graphCanvasRef = useRef(null); const handleFileSelect = useCallback(async (file: File) => { @@ -132,63 +132,105 @@ const AppContent = () => { } }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, runPipelineFromFiles, startEmbeddings, initializeAgent]); - const handleFocusNode = useCallback((nodeId: string) => { - graphCanvasRef.current?.focusNode(nodeId); - }, []); + const handleServerConnect = useCallback((result: ConnectToServerResult) => { + // Extract project name from repoPath + const repoPath = result.repoInfo.repoPath; + const projectName = repoPath.split('/').pop() || 'server-project'; + setProjectName(projectName); - const handleSelectBackendRepo = useCallback(async (repoName: string) => { + // Build KnowledgeGraph from server data (bypasses WASM pipeline entirely) + const graph = createKnowledgeGraph(); + for (const node of result.nodes) { + graph.addNode(node); + } + for (const rel of result.relationships) { + graph.addRelationship(rel); + } + setGraph(graph); + + // Set file contents from extracted File node content + const fileMap = new Map(); + for (const [path, content] of Object.entries(result.fileContents)) { + fileMap.set(path, content); + } + setFileContents(fileMap); + + // Transition directly to exploring view + setViewMode('exploring'); + + // Initialize agent if LLM is configured + if (getActiveProviderConfig()) { + initializeAgent(projectName); + } + + // Auto-start embeddings + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + }, [setViewMode, setGraph, setFileContents, setProjectName, initializeAgent, startEmbeddings]); + + // Auto-connect when ?server query param is present (bookmarkable shortcut) + const autoConnectRan = useRef(false); + useEffect(() => { + if (autoConnectRan.current) return; + const params = new URLSearchParams(window.location.search); + if (!params.has('server')) return; + autoConnectRan.current = true; + + // Clean the URL so a refresh won't re-trigger + const cleanUrl = window.location.pathname + window.location.hash; + window.history.replaceState(null, '', cleanUrl); + + setProgress({ phase: 'extracting', percent: 0, message: 'Connecting to server...', detail: 'Validating server' }); setViewMode('loading'); - setProjectName(repoName); - setProgress({ phase: 'extracting', percent: 50, message: 'Loading from server...', detail: 'Fetching graph data' }); - try { - const graphData = await fetchGraph(repoName); + const serverUrl = params.get('server') || window.location.origin; - // Build KnowledgeGraph from server data - const graph = createKnowledgeGraph(); - for (const node of graphData.nodes) { - graph.addNode(node as any); + const baseUrl = normalizeServerUrl(serverUrl); + + connectToServer(serverUrl, (phase, downloaded, total) => { + if (phase === 'validating') { + setProgress({ phase: 'extracting', percent: 5, message: 'Connecting to server...', detail: 'Validating server' }); + } else if (phase === 'downloading') { + const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` }); + } else if (phase === 'extracting') { + setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' }); } - for (const rel of graphData.relationships) { - graph.addRelationship(rel as any); - } - setGraph(graph); + }).then(async (result) => { + handleServerConnect(result); - // Extract file contents from File nodes (content is in node properties) - const contents = new Map(); - for (const node of graphData.nodes) { - const n = node as any; - if (n.label === 'File' && n.properties?.content && n.properties?.filePath) { - contents.set(n.properties.filePath, n.properties.content); - } + // Store server URL and fetch available repos for the repo switcher + setServerBaseUrl(baseUrl); + try { + const repos = await fetchRepos(baseUrl); + setAvailableRepos(repos); + } catch (e) { + console.warn('Failed to fetch repo list:', e); } - setFileContents(contents); - - // Enter backend mode - setBackendMode(true); - setBackendRepo(repoName); - backend.selectRepo(repoName); - setProgress(null); - setViewMode('exploring'); - - // Initialize agent if LLM configured - if (getActiveProviderConfig()) { - initializeAgent(repoName); - } - } catch (error) { - console.error('Backend load error:', error); + }).catch((err) => { + console.error('Auto-connect failed:', err); setProgress({ phase: 'error', percent: 0, - message: 'Error loading from server', - detail: error instanceof Error ? error.message : 'Unknown error', + message: 'Failed to connect to server', + detail: err instanceof Error ? err.message : 'Unknown error', }); setTimeout(() => { setViewMode('onboarding'); setProgress(null); }, 3000); - } - }, [setViewMode, setGraph, setFileContents, setProgress, setProjectName, setBackendMode, setBackendRepo, backend, initializeAgent]); + }); + }, [handleServerConnect, setProgress, setViewMode, setServerBaseUrl, setAvailableRepos]); + + const handleFocusNode = useCallback((nodeId: string) => { + graphCanvasRef.current?.focusNode(nodeId); + }, []); // Handle settings saved - refresh and reinitialize agent // NOTE: Must be defined BEFORE any conditional returns (React hooks rule) @@ -203,10 +245,19 @@ const AppContent = () => { { + handleServerConnect(result); + if (serverUrl) { + const baseUrl = normalizeServerUrl(serverUrl); + setServerBaseUrl(baseUrl); + try { + const repos = await fetchRepos(baseUrl); + setAvailableRepos(repos); + } catch (e) { + console.warn('Failed to fetch repo list:', e); + } + } + }} /> ); } @@ -218,7 +269,7 @@ const AppContent = () => { // Exploring view return (
-
+
{/* Left Panel - File Tree */} @@ -247,9 +298,6 @@ const AppContent = () => { isOpen={isSettingsPanelOpen} onClose={() => setSettingsPanelOpen(false)} onSettingsSaved={handleSettingsSaved} - backendUrl={backend.backendUrl} - isBackendConnected={backend.isConnected} - onBackendUrlChange={backend.setBackendUrl} />
diff --git a/gitnexus-web/src/components/DropZone.tsx b/gitnexus-web/src/components/DropZone.tsx index dc3c82e2a..fa7857668 100644 --- a/gitnexus-web/src/components/DropZone.tsx +++ b/gitnexus-web/src/components/DropZone.tsx @@ -1,22 +1,24 @@ -import { useState, useCallback, useEffect, useRef, DragEvent } from 'react'; -import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Server } from 'lucide-react'; +import { useState, useCallback, useRef, DragEvent } from 'react'; +import { Upload, FileArchive, Github, Loader2, ArrowRight, Key, Eye, EyeOff, Globe, X } from 'lucide-react'; import { cloneRepository, parseGitHubUrl } from '../services/git-clone'; +import { connectToServer, type ConnectToServerResult } from '../services/server-connection'; import { FileEntry } from '../services/zip'; -import { BackendRepo } from '../services/backend'; -import { BackendRepoSelector } from './BackendRepoSelector'; interface DropZoneProps { onFileSelect: (file: File) => void; onGitClone?: (files: FileEntry[]) => void; - backendRepos?: BackendRepo[]; - isBackendConnected?: boolean; - backendUrl?: string; - onSelectBackendRepo?: (repoName: string) => void; + onServerConnect?: (result: ConnectToServerResult, serverUrl?: string) => void; } -export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConnected, backendUrl, onSelectBackendRepo }: DropZoneProps) => { +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export const DropZone = ({ onFileSelect, onGitClone, onServerConnect }: DropZoneProps) => { const [isDragging, setIsDragging] = useState(false); - const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'local'>('zip'); + const [activeTab, setActiveTab] = useState<'zip' | 'github' | 'server'>('zip'); const [githubUrl, setGithubUrl] = useState(''); const [githubToken, setGithubToken] = useState(''); const [showToken, setShowToken] = useState(false); @@ -24,13 +26,17 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn const [cloneProgress, setCloneProgress] = useState({ phase: '', percent: 0 }); const [error, setError] = useState(null); - const hasAutoSwitched = useRef(false); - useEffect(() => { - if (!hasAutoSwitched.current && isBackendConnected && backendRepos && backendRepos.length > 0) { - setActiveTab('local'); - hasAutoSwitched.current = true; - } - }, [isBackendConnected, backendRepos]); + // Server tab state + const [serverUrl, setServerUrl] = useState(() => + localStorage.getItem('gitnexus-server-url') || '' + ); + const [isConnecting, setIsConnecting] = useState(false); + const [serverProgress, setServerProgress] = useState<{ + phase: string; + downloaded: number; + total: number | null; + }>({ phase: '', downloaded: 0, total: null }); + const abortControllerRef = useRef(null); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); @@ -92,10 +98,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn const files = await cloneRepository( githubUrl, (phase, percent) => setCloneProgress({ phase, percent }), - githubToken || undefined // Pass token if provided + githubToken || undefined ); - // Clear token from memory after successful clone setGithubToken(''); if (onGitClone) { @@ -104,12 +109,11 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn } catch (err) { console.error('Clone failed:', err); const message = err instanceof Error ? err.message : 'Failed to clone repository'; - // Provide helpful error for auth failures if (message.includes('401') || message.includes('403') || message.includes('Authentication')) { if (!githubToken) { - setError('🔒 This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.'); + setError('This looks like a private repo. Add a GitHub PAT (Personal Access Token) to access it.'); } else { - setError('🔑 Authentication failed. Check your token permissions (needs repo access).'); + setError('Authentication failed. Check your token permissions (needs repo access).'); } } else if (message.includes('404') || message.includes('not found')) { setError('Repository not found. Check the URL or it might be private (needs PAT).'); @@ -121,6 +125,62 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn } }; + const handleServerConnect = async () => { + const urlToUse = serverUrl.trim() || window.location.origin; + if (!urlToUse) { + setError('Please enter a server URL'); + return; + } + + // Persist URL to localStorage + localStorage.setItem('gitnexus-server-url', serverUrl); + + setError(null); + setIsConnecting(true); + setServerProgress({ phase: 'validating', downloaded: 0, total: null }); + + const abortController = new AbortController(); + abortControllerRef.current = abortController; + + try { + const result = await connectToServer( + urlToUse, + (phase, downloaded, total) => { + setServerProgress({ phase, downloaded, total }); + }, + abortController.signal + ); + + if (onServerConnect) { + onServerConnect(result, urlToUse); + } + } catch (err) { + if ((err as Error).name === 'AbortError') { + // User cancelled + return; + } + console.error('Server connect failed:', err); + const message = err instanceof Error ? err.message : 'Failed to connect to server'; + if (message.includes('Failed to fetch') || message.includes('NetworkError')) { + setError('Cannot reach server. Check the URL and ensure the server is running.'); + } else { + setError(message); + } + } finally { + setIsConnecting(false); + abortControllerRef.current = null; + } + }; + + const handleCancelConnect = () => { + abortControllerRef.current?.abort(); + setIsConnecting(false); + }; + + const serverProgressPercent = serverProgress.total + ? Math.round((serverProgress.downloaded / serverProgress.total) * 100) + : null; + return (
{/* Background gradient effects */} @@ -161,25 +221,18 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn GitHub URL
@@ -195,7 +248,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn <>
- 🔒 Token stays in your browser only, never sent to any server + Token stays in your browser only, never sent to any server

)} @@ -387,14 +440,131 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
)} - {/* Local Server Tab */} - {activeTab === 'local' && isBackendConnected && backendRepos && onSelectBackendRepo && ( - + {/* Server Tab */} + {activeTab === 'server' && ( +
+ {/* Icon */} +
+ +
+ + {/* Text */} +

+ Connect to Server +

+

+ Load a pre-built knowledge graph from a running GitNexus server +

+ + {/* Inputs */} +
+ setServerUrl(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isConnecting && handleServerConnect()} + placeholder={window.location.origin} + disabled={isConnecting} + autoComplete="off" + data-lpignore="true" + data-1p-ignore="true" + data-form-type="other" + className=" + w-full px-4 py-3 + bg-elevated border border-border-default rounded-xl + text-text-primary placeholder-text-muted + focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent + disabled:opacity-50 disabled:cursor-not-allowed + transition-all duration-200 + " + /> + +
+ + + {isConnecting && ( + + )} +
+
+ + {/* Progress bar */} + {isConnecting && serverProgress.phase === 'downloading' && ( +
+
+
+
+ {serverProgress.total && ( +

+ {formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)} +

+ )} +
+ )} + + {/* Hints */} +
+ + Pre-indexed + + + No WASM needed + +
+
)}
diff --git a/gitnexus-web/src/components/Header.tsx b/gitnexus-web/src/components/Header.tsx index 465a6b9d8..64edd0077 100644 --- a/gitnexus-web/src/components/Header.tsx +++ b/gitnexus-web/src/components/Header.tsx @@ -1,5 +1,6 @@ -import { Search, Settings, HelpCircle, Sparkles, Github, Star } from 'lucide-react'; +import { Search, Settings, HelpCircle, Sparkles, Github, Star, ChevronDown } from 'lucide-react'; import { useAppState } from '../hooks/useAppState'; +import type { RepoSummary } from '../services/server-connection'; import { useState, useMemo, useRef, useEffect, useCallback } from 'react'; import { GraphNode } from '../core/graph/types'; import { EmbeddingStatus } from './EmbeddingStatus'; @@ -19,9 +20,11 @@ const NODE_TYPE_COLORS: Record = { interface HeaderProps { onFocusNode?: (nodeId: string) => void; + availableRepos?: RepoSummary[]; + onSwitchRepo?: (repoName: string) => void; } -export const Header = ({ onFocusNode }: HeaderProps) => { +export const Header = ({ onFocusNode, availableRepos = [], onSwitchRepo }: HeaderProps) => { const { projectName, graph, @@ -29,8 +32,9 @@ export const Header = ({ onFocusNode }: HeaderProps) => { isRightPanelOpen, rightPanelTab, setSettingsPanelOpen, - isBackendMode, } = useAppState(); + const [isRepoDropdownOpen, setIsRepoDropdownOpen] = useState(false); + const repoDropdownRef = useRef(null); const [searchQuery, setSearchQuery] = useState(''); const [isSearchOpen, setIsSearchOpen] = useState(false); const [selectedIndex, setSelectedIndex] = useState(0); @@ -50,12 +54,15 @@ export const Header = ({ onFocusNode }: HeaderProps) => { .slice(0, 10); // Limit to 10 results }, [graph, searchQuery]); - // Handle clicking outside to close dropdown + // Handle clicking outside to close dropdowns useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(e.target as Node)) { setIsSearchOpen(false); } + if (repoDropdownRef.current && !repoDropdownRef.current.contains(e.target as Node)) { + setIsRepoDropdownOpen(false); + } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); @@ -117,18 +124,50 @@ export const Header = ({ onFocusNode }: HeaderProps) => { GitNexus - {/* Project badge */} + {/* Project badge / Repo selector dropdown */} {projectName && ( -
- - {projectName} -
- )} +
+ - {isBackendMode && ( -
- - Local + {/* Repo dropdown */} + {isRepoDropdownOpen && availableRepos.length >= 2 && ( +
+ {availableRepos.map((repo) => { + const isCurrent = repo.name === projectName; + return ( + + ); + })} +
+ )}
)}
diff --git a/gitnexus-web/src/hooks/useAppState.tsx b/gitnexus-web/src/hooks/useAppState.tsx index b24040157..233a710ee 100644 --- a/gitnexus-web/src/hooks/useAppState.tsx +++ b/gitnexus-web/src/hooks/useAppState.tsx @@ -11,7 +11,8 @@ import type { LLMSettings, ProviderConfig, AgentStreamChunk, ChatMessage, ToolCa import { loadSettings, getActiveProviderConfig, saveSettings } from '../core/llm/settings-service'; import type { AgentMessage } from '../core/llm/agent'; import { DEFAULT_VISIBLE_EDGES, type EdgeType } from '../lib/constants'; -import { runCypherQuery, getBackendUrl } from '../services/backend'; +import type { RepoSummary, ConnectToServerResult } from '../services/server-connection'; +import { fetchRepos, connectToServer } from '../services/server-connection'; export type ViewMode = 'onboarding' | 'loading' | 'exploring'; export type RightPanelTab = 'code' | 'chat'; @@ -112,6 +113,13 @@ interface AppState { projectName: string; setProjectName: (name: string) => void; + // Multi-repo switching + serverBaseUrl: string | null; + setServerBaseUrl: (url: string | null) => void; + availableRepos: RepoSummary[]; + setAvailableRepos: (repos: RepoSummary[]) => void; + switchRepo: (repoName: string) => Promise; + // Worker API (shared across app) runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise; @@ -161,12 +169,6 @@ interface AppState { clearAICodeReferences: () => void; clearCodeReferences: () => void; codeReferenceFocus: CodeReferenceFocus | null; - - // Backend mode - isBackendMode: boolean; - backendRepo: string | null; - setBackendMode: (mode: boolean) => void; - setBackendRepo: (repo: string | null) => void; } const AppStateContext = createContext(null); @@ -277,6 +279,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { // Project info const [projectName, setProjectName] = useState(''); + // Multi-repo switching + const [serverBaseUrl, setServerBaseUrl] = useState(null); + const [availableRepos, setAvailableRepos] = useState([]); + // Embedding state const [embeddingStatus, setEmbeddingStatus] = useState('idle'); const [embeddingProgress, setEmbeddingProgress] = useState(null); @@ -298,11 +304,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { const [isCodePanelOpen, setCodePanelOpen] = useState(false); const [codeReferenceFocus, setCodeReferenceFocus] = useState(null); - // Backend mode - const [isBackendMode, setIsBackendMode] = useState(false); - const [backendRepo, setBackendRepo] = useState(null); - - const normalizePath = useCallback((p: string) => { + const normalizePath = useCallback((p: string) => { return p.replace(/\\/g, '/').replace(/^\.?\//, ''); }, []); @@ -465,16 +467,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }, []); const runQuery = useCallback(async (cypher: string): Promise => { - if (isBackendMode && backendRepo) { - return runCypherQuery(backendRepo, cypher); - } const api = apiRef.current; if (!api) throw new Error('Worker not initialized'); return api.runQuery(cypher); - }, [isBackendMode, backendRepo]); + }, []); const isDatabaseReady = useCallback(async (): Promise => { - if (isBackendMode) return true; // backend handles DB const api = apiRef.current; if (!api) return false; try { @@ -482,13 +480,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } catch { return false; } - }, [isBackendMode]); + }, []); // Embedding methods const startEmbeddings = useCallback(async (forceDevice?: 'webgpu' | 'wasm'): Promise => { - // Embeddings require the WASM worker DB — skip in backend mode - if (isBackendMode) return; - const api = apiRef.current; if (!api) throw new Error('Worker not initialized'); @@ -530,7 +525,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } throw error; } - }, [isBackendMode]); + }, []); const semanticSearch = useCallback(async ( query: string, @@ -571,39 +566,25 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { }, []); const initializeAgent = useCallback(async (overrideProjectName?: string): Promise => { - const config = getActiveProviderConfig(); - if (!config) { - setAgentError('Please configure an LLM provider in settings'); - return; - } - const api = apiRef.current; if (!api) { setAgentError('Worker not initialized'); return; } + const config = getActiveProviderConfig(); + if (!config) { + setAgentError('Please configure an LLM provider in settings'); + return; + } + setIsAgentInitializing(true); setAgentError(null); try { + // Use override if provided (for fresh loads), fallback to state (for re-init) const effectiveProjectName = overrideProjectName || projectName || 'project'; - let result: { success: boolean; error?: string }; - - if (isBackendMode && backendRepo) { - // Backend mode: pass HTTP config + file contents to worker - const entries = Array.from(fileContents.entries()); - result = await api.initializeBackendAgent( - config, - getBackendUrl(), - backendRepo, - entries, - effectiveProjectName, - ); - } else { - result = await api.initializeAgent(config, effectiveProjectName); - } - + const result = await api.initializeAgent(config, effectiveProjectName); if (result.success) { setIsAgentReady(true); setAgentError(null); @@ -621,7 +602,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { } finally { setIsAgentInitializing(false); } - }, [projectName, isBackendMode, backendRepo, fileContents]); + }, [projectName]); const sendChatMessage = useCallback(async (message: string): Promise => { const api = apiRef.current; @@ -991,6 +972,73 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setAgentError(null); }, []); + // Switch to a different repo on the connected server + const switchRepo = useCallback(async (repoName: string) => { + if (!serverBaseUrl) return; + + setProgress({ phase: 'extracting', percent: 0, message: 'Switching repository...', detail: `Loading ${repoName}` }); + setViewMode('loading'); + + // Clear stale graph state from previous repo (highlights, selections, blast radius) + // Without this, sigma reducers dim ALL nodes/edges because old node IDs don't match + setHighlightedNodeIds(new Set()); + clearAIToolHighlights(); + clearBlastRadius(); + setSelectedNode(null); + setQueryResult(null); + setCodeReferences([]); + setCodePanelOpen(false); + setCodeReferenceFocus(null); + + try { + const result: ConnectToServerResult = await connectToServer(serverBaseUrl, (phase, downloaded, total) => { + if (phase === 'validating') { + setProgress({ phase: 'extracting', percent: 5, message: 'Switching repository...', detail: 'Validating' }); + } else if (phase === 'downloading') { + const pct = total ? Math.round((downloaded / total) * 90) + 5 : 50; + const mb = (downloaded / (1024 * 1024)).toFixed(1); + setProgress({ phase: 'extracting', percent: pct, message: 'Downloading graph...', detail: `${mb} MB downloaded` }); + } else if (phase === 'extracting') { + setProgress({ phase: 'extracting', percent: 97, message: 'Processing...', detail: 'Extracting file contents' }); + } + }, undefined, repoName); + + // Reuse the same handleServerConnect logic inline + const repoPath = result.repoInfo.repoPath; + const pName = result.repoInfo.name || repoPath.split('/').pop() || 'server-project'; + setProjectName(pName); + + const graph = createKnowledgeGraph(); + for (const node of result.nodes) graph.addNode(node); + for (const rel of result.relationships) graph.addRelationship(rel); + setGraph(graph); + + const fileMap = new Map(); + for (const [p, c] of Object.entries(result.fileContents)) fileMap.set(p, c); + setFileContents(fileMap); + + setViewMode('exploring'); + + if (getActiveProviderConfig()) initializeAgent(pName); + + startEmbeddings().catch((err) => { + if (err?.name === 'WebGPUNotAvailableError' || err?.message?.includes('WebGPU')) { + startEmbeddings('wasm').catch(console.warn); + } else { + console.warn('Embeddings auto-start failed:', err); + } + }); + } catch (err) { + console.error('Repo switch failed:', err); + setProgress({ + phase: 'error', percent: 0, + message: 'Failed to switch repository', + detail: err instanceof Error ? err.message : 'Unknown error', + }); + setTimeout(() => { setViewMode('exploring'); setProgress(null); }, 3000); + } + }, [serverBaseUrl, setProgress, setViewMode, setProjectName, setGraph, setFileContents, initializeAgent, startEmbeddings, setHighlightedNodeIds, clearAIToolHighlights, clearBlastRadius, setSelectedNode, setQueryResult, setCodeReferences, setCodePanelOpen, setCodeReferenceFocus]); + const removeCodeReference = useCallback((id: string) => { setCodeReferences(prev => { const ref = prev.find(r => r.id === id); @@ -1084,6 +1132,12 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { setProgress, projectName, setProjectName, + // Multi-repo switching + serverBaseUrl, + setServerBaseUrl, + availableRepos, + setAvailableRepos, + switchRepo, runPipeline, runPipelineFromFiles, runQuery, @@ -1124,11 +1178,6 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => { clearAICodeReferences, clearCodeReferences, codeReferenceFocus, - // Backend mode - isBackendMode, - backendRepo, - setBackendMode: setIsBackendMode, - setBackendRepo, }; return ( diff --git a/gitnexus-web/src/services/server-connection.ts b/gitnexus-web/src/services/server-connection.ts new file mode 100644 index 000000000..7687a1fef --- /dev/null +++ b/gitnexus-web/src/services/server-connection.ts @@ -0,0 +1,155 @@ +import { GraphNode, GraphRelationship } from '../core/graph/types'; + +export interface RepoSummary { + name: string; + path: string; + indexedAt: string; + lastCommit: string; + stats: { + files: number; + nodes: number; + edges: number; + communities: number; + processes: number; + }; +} + +export interface ServerRepoInfo { + name: string; + repoPath: string; + indexedAt: string; + stats: { + files: number; + nodes: number; + edges: number; + communities: number; + processes: number; + }; +} + +export interface ConnectToServerResult { + nodes: GraphNode[]; + relationships: GraphRelationship[]; + fileContents: Record; + repoInfo: ServerRepoInfo; +} + +export function normalizeServerUrl(input: string): string { + let url = input.trim(); + + // Strip trailing slashes + url = url.replace(/\/+$/, ''); + + // Add protocol if missing + if (!url.startsWith('http://') && !url.startsWith('https://')) { + if (url.startsWith('localhost') || url.startsWith('127.0.0.1')) { + url = `http://${url}`; + } else { + url = `https://${url}`; + } + } + + // Add /api if not already present + if (!url.endsWith('/api')) { + url = `${url}/api`; + } + + return url; +} + +export async function fetchRepos(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/repos`); + if (!response.ok) throw new Error(`Server returned ${response.status}`); + return response.json(); +} + +export async function fetchRepoInfo(baseUrl: string, repoName?: string): Promise { + const url = repoName ? `${baseUrl}/repo?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/repo`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Server returned ${response.status}: ${response.statusText}`); + } + return response.json(); +} + +export async function fetchGraph( + baseUrl: string, + onProgress?: (downloaded: number, total: number | null) => void, + signal?: AbortSignal, + repoName?: string +): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> { + const url = repoName ? `${baseUrl}/graph?repo=${encodeURIComponent(repoName)}` : `${baseUrl}/graph`; + const response = await fetch(url, { signal }); + if (!response.ok) { + throw new Error(`Server returned ${response.status}: ${response.statusText}`); + } + + const contentLength = response.headers.get('Content-Length'); + const total = contentLength ? parseInt(contentLength, 10) : null; + + if (!response.body) { + const data = await response.json(); + return data; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let downloaded = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + chunks.push(value); + downloaded += value.length; + onProgress?.(downloaded, total); + } + + const combined = new Uint8Array(downloaded); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + const text = new TextDecoder().decode(combined); + return JSON.parse(text); +} + +export function extractFileContents(nodes: GraphNode[]): Record { + const contents: Record = {}; + for (const node of nodes) { + if (node.label === 'File' && (node.properties as any).content) { + contents[node.properties.filePath] = (node.properties as any).content; + } + } + return contents; +} + +export async function connectToServer( + url: string, + onProgress?: (phase: string, downloaded: number, total: number | null) => void, + signal?: AbortSignal, + repoName?: string +): Promise { + const baseUrl = normalizeServerUrl(url); + + // Phase 1: Validate server + onProgress?.('validating', 0, null); + const repoInfo = await fetchRepoInfo(baseUrl, repoName); + + // Phase 2: Download graph + onProgress?.('downloading', 0, null); + const { nodes, relationships } = await fetchGraph( + baseUrl, + (downloaded, total) => onProgress?.('downloading', downloaded, total), + signal, + repoName + ); + + // Phase 3: Extract file contents + onProgress?.('extracting', 0, null); + const fileContents = extractFileContents(nodes); + + return { nodes, relationships, fileContents, repoInfo }; +} diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 86415b4d9..53f1951c3 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitnexus", - "version": "1.1.9", + "version": "1.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitnexus", - "version": "1.1.9", + "version": "1.2.8", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@huggingface/transformers": "^3.0.0", diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index a22a8dad5..f5158737e 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -93,7 +93,7 @@ export const analyzeCommand = async ( const origLog = console.log.bind(console); const origWarn = console.warn.bind(console); const origError = console.error.bind(console); - const barLog = (...args: any[]) => origLog(args.map(a => (typeof a === 'string' ? a : String(a))).join(' ')); + const barLog = (...args: any[]) => { const msg = args.map(a => (typeof a === 'string' ? a : String(a))).join(' '); if (typeof (bar as any).log === 'function') { (bar as any).log(msg + '\n'); } }; console.log = barLog; console.warn = barLog; console.error = barLog; diff --git a/gitnexus/src/core/kuzu/kuzu-adapter.ts b/gitnexus/src/core/kuzu/kuzu-adapter.ts index 9988b56ed..e676cd553 100644 --- a/gitnexus/src/core/kuzu/kuzu-adapter.ts +++ b/gitnexus/src/core/kuzu/kuzu-adapter.ts @@ -13,11 +13,22 @@ import { generateAllCSVs } from './csv-generator.js'; let db: kuzu.Database | null = null; let conn: kuzu.Connection | null = null; +let currentDbPath: string | null = null; const normalizeCopyPath = (filePath: string): string => filePath.replace(/\\/g, '/'); export const initKuzu = async (dbPath: string) => { - if (conn) return { db, conn }; + // If already connected to the SAME database, reuse + if (conn && currentDbPath === dbPath) return { db, conn }; + + // Different database requested — close the old one first + if (conn || db) { + try { if (conn) await conn.close(); } catch {} + try { if (db) await db.close(); } catch {} + conn = null; + db = null; + currentDbPath = null; + } // kuzu v0.11 stores the database as a single file (not a directory). // If the path already exists, it must be a valid kuzu database file. @@ -58,6 +69,7 @@ export const initKuzu = async (dbPath: string) => { } } + currentDbPath = dbPath; return { db, conn }; }; @@ -525,6 +537,7 @@ export const closeKuzu = async (): Promise => { } catch {} db = null; } + currentDbPath = null; }; export const isKuzuReady = (): boolean => conn !== null && db !== null; diff --git a/gitnexus/src/mcp/server.ts b/gitnexus/src/mcp/server.ts index 577585624..dc6a2fa0f 100644 --- a/gitnexus/src/mcp/server.ts +++ b/gitnexus/src/mcp/server.ts @@ -1,12 +1,12 @@ /** * MCP Server (Multi-Repo) - * + * * Model Context Protocol server that runs on stdio. * External AI tools (Cursor, Claude) spawn this process and * communicate via stdin/stdout using the MCP protocol. - * + * * Supports multiple indexed repositories via the global registry. - * + * * Tools: list_repos, query, cypher, context, impact, detect_changes, rename * Resources: repos, repo/{name}/context, repo/{name}/clusters, ... */ @@ -28,10 +28,10 @@ import { getResourceDefinitions, getResourceTemplates, readResource } from './re /** * Next-step hints appended to tool responses. - * + * * Agents often stop after one tool call. These hints guide them to the * logical next action, creating a self-guiding workflow without hooks. - * + * * Design: Each hint is a short, actionable instruction (not a suggestion). * The hint references the specific tool/resource to use next. */ @@ -75,7 +75,11 @@ function getNextStepHint(toolName: string, args: Record | undefined } } -export async function startMCPServer(backend: LocalBackend): Promise { +/** + * Create a configured MCP Server with all handlers registered. + * Transport-agnostic — caller connects the desired transport. + */ +export function createMCPServer(backend: LocalBackend): Server { const server = new Server( { name: 'gitnexus', @@ -119,7 +123,7 @@ export async function startMCPServer(backend: LocalBackend): Promise { // Handle read resource request server.setRequestHandler(ReadResourceRequestSchema, async (request) => { const { uri } = request.params; - + try { const content = await readResource(uri, backend); return { @@ -209,7 +213,7 @@ export async function startMCPServer(backend: LocalBackend): Promise { // Handle get prompt request server.setRequestHandler(GetPromptRequestSchema, async (request) => { const { name, arguments: args } = request.params; - + if (name === 'detect_impact') { const scope = args?.scope || 'all'; const baseRef = args?.base_ref || ''; @@ -233,7 +237,7 @@ Present the analysis as a clear risk report.`, ], }; } - + if (name === 'generate_map') { const repo = args?.repo || ''; return { @@ -247,7 +251,7 @@ Present the analysis as a clear risk report.`, Follow these steps: 1. READ \`gitnexus://repo/${repo || '{name}'}/context\` for codebase stats 2. READ \`gitnexus://repo/${repo || '{name}'}/clusters\` to see all functional areas -3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows +3. READ \`gitnexus://repo/${repo || '{name}'}/processes\` to see all execution flows 4. For the top 5 most important processes, READ \`gitnexus://repo/${repo || '{name}'}/process/{name}\` for step-by-step traces 5. Generate a mermaid architecture diagram showing the major areas and their connections 6. Write an ARCHITECTURE.md file with: overview, functional areas, key execution flows, and the mermaid diagram`, @@ -256,10 +260,19 @@ Follow these steps: ], }; } - + throw new Error(`Unknown prompt: ${name}`); }); + return server; +} + +/** + * Start the MCP server on stdio transport (for CLI use). + */ +export async function startMCPServer(backend: LocalBackend): Promise { + const server = createMCPServer(backend); + // Connect to stdio transport const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index fbe0ebcd6..6681f4fdf 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -1,29 +1,27 @@ /** - * HTTP API Server (Multi-Repo) + * HTTP API Server * - * REST API for browser-based clients to query indexed repositories. - * Uses LocalBackend for multi-repo support via the global registry — - * the same backend the MCP server uses. + * REST API for browser-based clients to query the local .gitnexus/ index. + * Also hosts the MCP server over StreamableHTTP for remote AI tool access. */ import express from 'express'; import cors from 'cors'; import path from 'path'; import fs from 'fs/promises'; -import { LocalBackend } from '../mcp/local/local-backend.js'; +import { findRepo, loadMeta, listRegisteredRepos } from '../storage/repo-manager.js'; +import { initKuzu, executeQuery } from '../core/kuzu/kuzu-adapter.js'; import { NODE_TABLES } from '../core/kuzu/schema.js'; import { GraphNode, GraphRelationship } from '../core/graph/types.js'; +import { searchFTSFromKuzu } from '../core/search/bm25-index.js'; +import { hybridSearch } from '../core/search/hybrid-search.js'; +import { semanticSearch } from '../core/embeddings/embedding-pipeline.js'; +import { isEmbedderReady } from '../core/embeddings/embedder.js'; +import { LocalBackend } from '../mcp/local/local-backend.js'; +import { mountMCPEndpoints } from './mcp-http.js'; -/** - * Build the full knowledge graph for a repo by querying each node table - * and all relationships via the backend's cypher tool. - */ -const buildGraph = async ( - backend: LocalBackend, - repoName: string, -): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { +const buildGraph = async (): Promise<{ nodes: GraphNode[]; relationships: GraphRelationship[] }> => { const nodes: GraphNode[] = []; - for (const table of NODE_TABLES) { try { let query = ''; @@ -39,10 +37,7 @@ const buildGraph = async ( 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`; } - const result = await backend.executeCypher(repoName, query); - // cypher returns the rows directly (array), or { error } on failure - const rows = Array.isArray(result) ? result : []; - + const rows = await executeQuery(query); for (const row of rows) { nodes.push({ id: row.id ?? row[0], @@ -70,283 +65,135 @@ const buildGraph = async ( } const relationships: GraphRelationship[] = []; - try { - const relResult = await backend.executeCypher( - repoName, - `MATCH (a)-[r:CodeRelation]->(b) RETURN a.id AS sourceId, b.id AS targetId, r.type AS type, r.confidence AS confidence, r.reason AS reason, r.step AS step`, - ); - const relRows = Array.isArray(relResult) ? relResult : []; - - for (const row of relRows) { - relationships.push({ - id: `${row.sourceId}_${row.type}_${row.targetId}`, - type: row.type, - sourceId: row.sourceId, - targetId: row.targetId, - confidence: row.confidence, - reason: row.reason, - step: row.step, - }); - } - } catch (err: any) { - console.warn('GitNexus: relationship query failed:', err?.message); + 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` + ); + for (const row of relRows) { + relationships.push({ + id: `${row.sourceId}_${row.type}_${row.targetId}`, + type: row.type, + sourceId: row.sourceId, + targetId: row.targetId, + confidence: row.confidence, + reason: row.reason, + step: row.step, + }); } return { nodes, relationships }; }; -const httpStatus = (err: any): number => { - const msg = err?.message ?? ''; - if (msg.includes('not found') || msg.includes('No indexed')) return 404; - if (msg.includes('Multiple repositories')) return 400; - return 500; -}; - export const createServer = async (port: number) => { - const backend = new LocalBackend(); - const hasRepos = await backend.init(); - - if (!hasRepos) { - console.warn('GitNexus: No indexed repositories found. The server will start but most endpoints will return errors.'); - console.warn('Run "gitnexus analyze" in a repository to index it first.'); - } - const app = express(); - app.use(cors({ - origin: (origin, callback) => { - // Allow requests with no origin (curl, server-to-server), localhost, and the deployed site. - // The server binds to 127.0.0.1 so only the local machine can reach it — CORS just gates - // which browser-tab origins may issue the request. - if ( - !origin - || origin.startsWith('http://localhost:') - || origin.startsWith('http://127.0.0.1:') - || origin === 'https://gitnexus.vercel.app' - ) { - callback(null, true); - } else { - callback(new Error('Not allowed by CORS')); - } - } - })); + app.use(cors()); app.use(express.json({ limit: '10mb' })); - // ─── GET /api/repos ───────────────────────────────────────────── - // List all indexed repositories - app.get('/api/repos', async (_req, res) => { - try { - const repos = await backend.listRepos(); - res.json(repos); - } catch (err: any) { - res.status(500).json({ error: err.message || 'Failed to list repos' }); - } - }); + // Initialize MCP backend (multi-repo, shared across all MCP sessions) + const backend = new LocalBackend(); + await backend.init(); + mountMCPEndpoints(app, backend); - // ─── GET /api/repo?repo=X ────────────────────────────────────── - // Get metadata for a specific repo - app.get('/api/repo', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const repo = await backend.resolveRepo(repoName); - res.json({ - name: repo.name, - path: repo.repoPath, - indexedAt: repo.indexedAt, - lastCommit: repo.lastCommit, - stats: repo.stats || {}, - }); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Repository not found' }); - } - }); - - // ─── GET /api/graph?repo=X ───────────────────────────────────── - // Full knowledge graph (all nodes + relationships) - app.get('/api/graph', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - // Resolve repo to validate it exists and get the name - const repo = await backend.resolveRepo(repoName); - const graph = await buildGraph(backend, repo.name); - res.json(graph); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to build graph' }); - } - }); - - // ─── POST /api/query ─────────────────────────────────────────── - // Execute a raw Cypher query. - // This endpoint is intentionally unrestricted (no query validation) because - // the server binds to 127.0.0.1 only — it exposes full graph query - // capabilities to local clients by design. - app.post('/api/query', async (req, res) => { - try { - const repoName = (req.body.repo ?? req.query.repo) as string | undefined; - const cypher = req.body.cypher as string; - - if (!cypher) { - res.status(400).json({ error: 'Missing "cypher" in request body' }); - return; - } - - const result = await backend.callTool('cypher', { repo: repoName, query: cypher }); - if (result && !Array.isArray(result) && result.error) { - res.status(500).json({ error: result.error }); - return; - } - res.json({ result }); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Query failed' }); - } - }); - - // ─── POST /api/search ────────────────────────────────────────── - // Process-grouped semantic search - app.post('/api/search', async (req, res) => { - try { - const repoName = (req.body.repo ?? req.query.repo) as string | undefined; - const query = (req.body.query ?? '').trim(); - const limit = req.body.limit as number | undefined; - - if (!query) { - res.status(400).json({ error: 'Missing "query" in request body' }); - return; - } - - const results = await backend.callTool('query', { - repo: repoName, - query, - limit, - }); - res.json({ results }); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Search failed' }); - } - }); - - // ─── GET /api/file?repo=X&path=Y ────────────────────────────── - // Read a file from a resolved repo path on disk - app.get('/api/file', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const filePath = req.query.path as string; - - if (!filePath) { - res.status(400).json({ error: 'Missing "path" query parameter' }); - return; - } - - const repo = await backend.resolveRepo(repoName); - - // Resolve the full path and validate it stays within the repo root - const repoRoot = path.resolve(repo.repoPath); - const fullPath = path.resolve(repoRoot, filePath); - - if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) { - res.status(403).json({ error: 'Path traversal denied: path escapes repo root' }); - return; - } - - const content = await fs.readFile(fullPath, 'utf-8'); - res.json({ content }); - } catch (err: any) { - if (err.code === 'ENOENT') { - res.status(404).json({ error: 'File not found' }); - } else { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to read file' }); - } - } - }); - - // ─── GET /api/processes?repo=X ───────────────────────────────── - // List all processes for a repo - app.get('/api/processes', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const result = await backend.queryProcesses(repoName); - res.json(result); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to query processes' }); - } - }); - - // ─── GET /api/process?repo=X&name=Y ─────────────────────────── - // Get detailed process info including steps - app.get('/api/process', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const name = req.query.name as string; - - if (!name) { - res.status(400).json({ error: 'Missing "name" query parameter' }); - return; - } - - const result = await backend.queryProcessDetail(name, repoName); - if (result.error) { - res.status(404).json({ error: result.error }); - return; - } - res.json(result); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to query process detail' }); - } - }); - - // ─── GET /api/clusters?repo=X ───────────────────────────────── - // List all clusters for a repo - app.get('/api/clusters', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const result = await backend.queryClusters(repoName); - res.json(result); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to query clusters' }); - } - }); - - // ─── GET /api/cluster?repo=X&name=Y ─────────────────────────── - // Get detailed cluster info including members - app.get('/api/cluster', async (req, res) => { - try { - const repoName = req.query.repo as string | undefined; - const name = req.query.name as string; - - if (!name) { - res.status(400).json({ error: 'Missing "name" query parameter' }); - return; - } - - const result = await backend.queryClusterDetail(name, repoName); - if (result.error) { - res.status(404).json({ error: result.error }); - return; - } - res.json(result); - } catch (err: any) { - res.status(httpStatus(err)) - .json({ error: err.message || 'Failed to query cluster detail' }); - } - }); - - const server = app.listen(port, '127.0.0.1', () => { - console.log(`GitNexus server running on http://localhost:${port}`); - console.log(`Serving ${hasRepos ? 'all indexed repositories' : 'no repositories (run gitnexus analyze first)'}`); - }); - - const shutdown = async () => { - server.close(); - await backend.disconnect(); - process.exit(0); + // Helper: resolve a repo by name from the global registry, or default to first + const resolveRepo = async (repoName?: string) => { + const repos = await listRegisteredRepos(); + if (repos.length === 0) return null; + if (repoName) return repos.find(r => r.name === repoName) || null; + return repos[0]; // default to first }; - process.once('SIGINT', shutdown); - process.once('SIGTERM', shutdown); + + // List all registered repos + app.get('/api/repos', async (_req, res) => { + const repos = await listRegisteredRepos(); + res.json(repos.map(r => ({ + name: r.name, path: r.path, indexedAt: r.indexedAt, + lastCommit: r.lastCommit, stats: r.stats, + }))); + }); + + // Get repo info + app.get('/api/repo', async (req, res) => { + const entry = await resolveRepo(req.query.repo as string | undefined); + if (!entry) { + res.status(404).json({ error: 'Repository not found. Run: gitnexus analyze' }); + return; + } + const meta = await loadMeta(entry.storagePath); + res.json({ + name: entry.name, + repoPath: entry.path, + indexedAt: meta?.indexedAt ?? entry.indexedAt, + stats: meta?.stats ?? entry.stats ?? {}, + }); + }); + + // Get full graph + app.get('/api/graph', async (req, res) => { + const entry = await resolveRepo(req.query.repo as string | undefined); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + const kuzuPath = path.join(entry.storagePath, 'kuzu'); + await initKuzu(kuzuPath); + const graph = await buildGraph(); + res.json(graph); + }); + + // Execute Cypher query + app.post('/api/query', async (req, res) => { + const entry = await resolveRepo(req.query.repo as string | undefined); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + const kuzuPath = path.join(entry.storagePath, 'kuzu'); + await initKuzu(kuzuPath); + const result = await executeQuery(req.body.cypher); + res.json({ result }); + }); + + // Search + app.post('/api/search', async (req, res) => { + const entry = await resolveRepo(req.query.repo as string | undefined); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + const kuzuPath = path.join(entry.storagePath, 'kuzu'); + await initKuzu(kuzuPath); + + const query = req.body.query ?? ''; + const limit = req.body.limit ?? 10; + + if (isEmbedderReady()) { + const results = await hybridSearch(query, limit, executeQuery, semanticSearch); + res.json({ results }); + return; + } + + // FTS-only fallback when embeddings aren't loaded + const results = await searchFTSFromKuzu(query, limit); + res.json({ results }); + }); + + // Read file + app.get('/api/file', async (req, res) => { + const entry = await resolveRepo(req.query.repo as string | undefined); + if (!entry) { + res.status(404).json({ error: 'Repository not found' }); + return; + } + const filePath = req.query.path as string; + if (!filePath) { + res.status(400).json({ error: 'Missing path' }); + return; + } + const fullPath = path.join(entry.path, filePath); + const content = await fs.readFile(fullPath, 'utf-8'); + res.json({ content }); + }); + + app.listen(port, () => { + console.log(`GitNexus server running on http://localhost:${port}`); + }); }; diff --git a/gitnexus/src/server/mcp-http.ts b/gitnexus/src/server/mcp-http.ts new file mode 100644 index 000000000..d8c0576cd --- /dev/null +++ b/gitnexus/src/server/mcp-http.ts @@ -0,0 +1,63 @@ +/** + * MCP over HTTP + * + * Mounts the GitNexus MCP server on Express using StreamableHTTP transport. + * Each connecting client gets its own stateful session; the LocalBackend + * is shared across all sessions (thread-safe — lazy KuzuDB per repo). + */ + +import type { Express, Request, Response } from 'express'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { createMCPServer } from '../mcp/server.js'; +import type { LocalBackend } from '../mcp/local/local-backend.js'; +import { randomUUID } from 'crypto'; + +interface MCPSession { + server: Server; + transport: StreamableHTTPServerTransport; +} + +export function mountMCPEndpoints(app: Express, backend: LocalBackend): void { + const sessions = new Map(); + + app.all('/api/mcp', async (req: Request, res: Response) => { + const sessionId = req.headers['mcp-session-id'] as string | undefined; + + if (sessionId && sessions.has(sessionId)) { + // Existing session — delegate to its transport + const session = sessions.get(sessionId)!; + await session.transport.handleRequest(req, res, req.body); + } else if (sessionId) { + // Unknown/expired session ID — tell client to re-initialize (per MCP spec) + res.status(404).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Session not found. Re-initialize.' }, + id: null, + }); + } else if (req.method === 'POST') { + // No session ID — new client initializing + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + const server = createMCPServer(backend); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + + if (transport.sessionId) { + sessions.set(transport.sessionId, { server, transport }); + transport.onclose = () => { + sessions.delete(transport.sessionId!); + }; + } + } else { + res.status(400).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'No valid session. Send a POST to initialize.' }, + id: null, + }); + } + }); + + console.log('MCP HTTP endpoints mounted at /api/mcp'); +}