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 <noreply@anthropic.com>
This commit is contained in:
Nico Prieto 2026-02-25 11:38:28 +01:00
parent dc66bb83ef
commit df2674cb1a
11 changed files with 868 additions and 471 deletions

View file

@ -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<GraphCanvasHandle>(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<string, string>();
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<string, string>();
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 = () => {
<DropZone
onFileSelect={handleFileSelect}
onGitClone={handleGitClone}
backendRepos={backend.repos}
isBackendConnected={backend.isConnected}
backendUrl={backend.backendUrl}
onSelectBackendRepo={handleSelectBackendRepo}
onServerConnect={async (result, serverUrl) => {
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 (
<div className="flex flex-col h-screen bg-void overflow-hidden">
<Header onFocusNode={handleFocusNode} />
<Header onFocusNode={handleFocusNode} availableRepos={availableRepos} onSwitchRepo={switchRepo} />
<main className="flex-1 flex min-h-0">
{/* 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}
/>
</div>

View file

@ -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<string | null>(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<AbortController | null>(null);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
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 (
<div className="flex items-center justify-center min-h-screen p-8 bg-void">
{/* Background gradient effects */}
@ -161,25 +221,18 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
GitHub URL
</button>
<button
onClick={() => { setActiveTab('local'); setError(null); }}
onClick={() => { setActiveTab('server'); setError(null); }}
className={`
flex-1 flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg
text-sm font-medium transition-all duration-200
${activeTab === 'local'
${activeTab === 'server'
? 'bg-accent text-white shadow-md'
: isBackendConnected
? 'text-text-secondary hover:text-text-primary hover:bg-elevated'
: 'text-text-muted cursor-not-allowed opacity-50'
: 'text-text-secondary hover:text-text-primary hover:bg-elevated'
}
`}
disabled={!isBackendConnected}
title={!isBackendConnected ? 'Start gitnexus serve to connect' : undefined}
>
<Server className="w-4 h-4" />
Local Server
{isBackendConnected && (
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
)}
<Globe className="w-4 h-4" />
Server
</button>
</div>
@ -195,7 +248,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
<>
<div
className={`
relative p-16
relative p-16
bg-surface border-2 border-dashed rounded-3xl
transition-all duration-300 cursor-pointer
${isDragging
@ -282,7 +335,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
data-1p-ignore="true"
data-form-type="other"
className="
w-full px-4 py-3
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
@ -308,7 +361,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
data-1p-ignore="true"
data-form-type="other"
className="
w-full pl-10 pr-10 py-3
w-full pl-10 pr-10 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
@ -329,9 +382,9 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
onClick={handleGitClone}
disabled={isCloning || !githubUrl.trim()}
className="
w-full flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
w-full flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
@ -371,7 +424,7 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
{/* Security note */}
{githubToken && (
<p className="mt-3 text-xs text-text-muted text-center">
🔒 Token stays in your browser only, never sent to any server
Token stays in your browser only, never sent to any server
</p>
)}
@ -387,14 +440,131 @@ export const DropZone = ({ onFileSelect, onGitClone, backendRepos, isBackendConn
</div>
)}
{/* Local Server Tab */}
{activeTab === 'local' && isBackendConnected && backendRepos && onSelectBackendRepo && (
<BackendRepoSelector
repos={backendRepos}
onSelectRepo={onSelectBackendRepo}
backendUrl={backendUrl ?? 'http://localhost:4747'}
isConnected={isBackendConnected}
/>
{/* Server Tab */}
{activeTab === 'server' && (
<div className="p-8 bg-surface border border-border-default rounded-3xl">
{/* Icon */}
<div className="mx-auto w-20 h-20 mb-6 flex items-center justify-center bg-gradient-to-br from-accent to-emerald-600 rounded-2xl shadow-lg">
<Globe className="w-10 h-10 text-white" />
</div>
{/* Text */}
<h2 className="text-xl font-semibold text-text-primary text-center mb-2">
Connect to Server
</h2>
<p className="text-sm text-text-secondary text-center mb-6">
Load a pre-built knowledge graph from a running GitNexus server
</p>
{/* Inputs */}
<div className="space-y-3" data-form-type="other">
<input
type="url"
name="server-url-input"
value={serverUrl}
onChange={(e) => 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
"
/>
<div className="flex gap-2">
<button
onClick={handleServerConnect}
disabled={isConnecting}
className="
flex-1 flex items-center justify-center gap-2
px-4 py-3
bg-accent hover:bg-accent/90
text-white font-medium rounded-xl
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
"
>
{isConnecting ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
{serverProgress.phase === 'validating'
? 'Validating...'
: serverProgress.phase === 'downloading'
? serverProgressPercent !== null
? `Downloading... ${serverProgressPercent}%`
: `Downloading... ${formatBytes(serverProgress.downloaded)}`
: serverProgress.phase === 'extracting'
? 'Processing...'
: 'Connecting...'
}
</>
) : (
<>
Connect
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
{isConnecting && (
<button
onClick={handleCancelConnect}
className="
flex items-center justify-center
px-4 py-3
bg-red-500/20 hover:bg-red-500/30
text-red-400 font-medium rounded-xl
transition-all duration-200
"
>
<X className="w-5 h-5" />
</button>
)}
</div>
</div>
{/* Progress bar */}
{isConnecting && serverProgress.phase === 'downloading' && (
<div className="mt-4">
<div className="h-2 bg-elevated rounded-full overflow-hidden">
<div
className={`h-full bg-accent transition-all duration-300 ease-out ${
serverProgressPercent === null ? 'animate-pulse' : ''
}`}
style={{
width: serverProgressPercent !== null
? `${serverProgressPercent}%`
: '100%',
}}
/>
</div>
{serverProgress.total && (
<p className="mt-1 text-xs text-text-muted text-center">
{formatBytes(serverProgress.downloaded)} / {formatBytes(serverProgress.total)}
</p>
)}
</div>
)}
{/* Hints */}
<div className="mt-4 flex items-center justify-center gap-3 text-xs text-text-muted">
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
Pre-indexed
</span>
<span className="px-3 py-1.5 bg-elevated border border-border-subtle rounded-md">
No WASM needed
</span>
</div>
</div>
)}
</div>
</div>

View file

@ -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<string, string> = {
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<HTMLDivElement>(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) => {
<span className="font-semibold text-[15px] tracking-tight">GitNexus</span>
</div>
{/* Project badge */}
{/* Project badge / Repo selector dropdown */}
{projectName && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary">
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[200px]">{projectName}</span>
</div>
)}
<div className="relative" ref={repoDropdownRef}>
<button
onClick={() => availableRepos.length >= 2 && setIsRepoDropdownOpen(prev => !prev)}
className={`flex items-center gap-2 px-3 py-1.5 bg-surface border border-border-subtle rounded-lg text-sm text-text-secondary transition-colors ${availableRepos.length >= 2 ? 'hover:bg-hover cursor-pointer' : ''}`}
>
<span className="w-1.5 h-1.5 bg-node-function rounded-full animate-pulse" />
<span className="truncate max-w-[200px]">{projectName}</span>
{availableRepos.length >= 2 && (
<ChevronDown className={`w-3.5 h-3.5 text-text-muted transition-transform ${isRepoDropdownOpen ? 'rotate-180' : ''}`} />
)}
</button>
{isBackendMode && (
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-green-500/10 border border-green-500/30 rounded-lg text-xs text-green-400">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
Local
{/* Repo dropdown */}
{isRepoDropdownOpen && availableRepos.length >= 2 && (
<div className="absolute top-full left-0 mt-1 w-72 bg-surface border border-border-subtle rounded-lg shadow-xl overflow-hidden z-50">
{availableRepos.map((repo) => {
const isCurrent = repo.name === projectName;
return (
<button
key={repo.name}
onClick={() => {
if (!isCurrent && onSwitchRepo) {
onSwitchRepo(repo.name);
}
setIsRepoDropdownOpen(false);
}}
className={`w-full px-4 py-3 flex items-center gap-3 text-left transition-colors ${isCurrent ? 'bg-accent/10 border-l-2 border-accent' : 'hover:bg-hover border-l-2 border-transparent'}`}
>
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${isCurrent ? 'bg-node-function animate-pulse' : 'bg-text-muted'}`} />
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-accent' : 'text-text-primary'}`}>
{repo.name}
</div>
<div className="text-xs text-text-muted mt-0.5">
{repo.stats?.nodes ?? '?'} nodes &middot; {repo.stats?.files ?? '?'} files
</div>
</div>
</button>
);
})}
</div>
)}
</div>
)}
</div>

View file

@ -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<void>;
// Worker API (shared across app)
runPipeline: (file: File, onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
runPipelineFromFiles: (files: FileEntry[], onProgress: (p: PipelineProgress) => void, clusteringConfig?: ProviderConfig) => Promise<PipelineResult>;
@ -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<AppState | null>(null);
@ -277,6 +279,10 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
// Project info
const [projectName, setProjectName] = useState<string>('');
// Multi-repo switching
const [serverBaseUrl, setServerBaseUrl] = useState<string | null>(null);
const [availableRepos, setAvailableRepos] = useState<RepoSummary[]>([]);
// Embedding state
const [embeddingStatus, setEmbeddingStatus] = useState<EmbeddingStatus>('idle');
const [embeddingProgress, setEmbeddingProgress] = useState<EmbeddingProgress | null>(null);
@ -298,11 +304,7 @@ export const AppStateProvider = ({ children }: { children: ReactNode }) => {
const [isCodePanelOpen, setCodePanelOpen] = useState(false);
const [codeReferenceFocus, setCodeReferenceFocus] = useState<CodeReferenceFocus | null>(null);
// Backend mode
const [isBackendMode, setIsBackendMode] = useState(false);
const [backendRepo, setBackendRepo] = useState<string | null>(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<any[]> => {
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<boolean> => {
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<void> => {
// 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<void> => {
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<void> => {
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<string, string>();
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 (

View file

@ -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<string, string>;
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<RepoSummary[]> {
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<ServerRepoInfo> {
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<string, string> {
const contents: Record<string, string> = {};
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<ConnectToServerResult> {
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 };
}

View file

@ -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",

View file

@ -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;

View file

@ -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<void> => {
} catch {}
db = null;
}
currentDbPath = null;
};
export const isKuzuReady = (): boolean => conn !== null && db !== null;

View file

@ -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<string, any> | undefined
}
}
export async function startMCPServer(backend: LocalBackend): Promise<void> {
/**
* 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<void> {
// 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<void> {
// 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<void> {
const server = createMCPServer(backend);
// Connect to stdio transport
const transport = new StdioServerTransport();
await server.connect(transport);

View file

@ -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}`);
});
};

View file

@ -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<string, MCPSession>();
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');
}