Merge pull request #657 from DeusData/feat/mcp-protocol-2025-11-25

feat(mcp): add structured tool metadata
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-06-28 22:08:57 +02:00
commit 4ab00df63f
35 files changed
+1867 -312

No files matched your search

+15 -12
View File
@@ -3,16 +3,17 @@ import { GraphTab } from "./components/GraphTab";
import { StatsTab } from "./components/StatsTab";
import { ControlTab } from "./components/ControlTab";
import type { TabId } from "./lib/types";
const TABS: { id: TabId; label: string }[] = [
{ id: "graph", label: "Graph" },
{ id: "stats", label: "Projects" },
{ id: "control", label: "Control" },
];
import { useUiMessages } from "./lib/i18n";
export function App() {
const t = useUiMessages();
const [activeTab, setActiveTab] = useState<TabId>("stats");
const [selectedProject, setSelectedProject] = useState<string | null>(null);
const tabs: { id: TabId; label: string }[] = [
{ id: "graph", label: t.tabs.graph },
{ id: "stats", label: t.tabs.projects },
{ id: "control", label: t.tabs.control },
];
return (
<div className="h-screen flex flex-col bg-background text-foreground">
@@ -28,17 +29,17 @@ export function App() {
{/* Tabs inline in header */}
<nav className="flex items-center gap-0.5">
{TABS.map((t) => (
{tabs.map((tab) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id)}
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-3 py-1 rounded-md text-[12px] font-medium transition-all ${
activeTab === t.id
activeTab === tab.id
? "bg-primary/15 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-white/[0.04]"
}`}
>
{t.label}
{tab.label}
</button>
))}
</nav>
@@ -46,7 +47,9 @@ export function App() {
{selectedProject && (
<div className="flex items-center gap-2 px-3 py-1 rounded-lg bg-white/[0.04] border border-border/30">
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">Graph</span>
<span className="text-[10px] text-foreground/30 uppercase tracking-wider">
{t.graph.selectedLabel}
</span>
<span className="text-[11px] text-primary font-mono truncate max-w-[300px]">
{selectedProject}
</span>
+19 -15
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { ProcessInfo } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
/* ── Gauge component ────────────────────────────────────── */
@@ -30,6 +31,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
proc: ProcessInfo; selected: boolean;
onSelect: () => void; onKill: () => void;
}) {
const t = useUiMessages();
return (
<button
onClick={onSelect}
@@ -46,7 +48,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
PID {proc.pid}
</span>
{proc.is_self && (
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">THIS</span>
<span className="text-[9px] px-1.5 py-0.5 rounded bg-primary/15 text-primary font-medium">{t.control.thisProcess}</span>
)}
</div>
{!proc.is_self && (
@@ -54,7 +56,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
onClick={(e) => { e.stopPropagation(); onKill(); }}
className="px-2 py-1 rounded-lg text-[10px] text-foreground/20 hover:text-destructive hover:bg-destructive/10 transition-all"
>
Kill
{t.control.kill}
</button>
)}
</div>
@@ -69,7 +71,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.rss_mb.toFixed(0)} MB</p>
</div>
<div>
<p className="text-[9px] text-foreground/20 uppercase">Uptime</p>
<p className="text-[9px] text-foreground/20 uppercase">{t.control.uptime}</p>
<p className="text-[13px] font-semibold tabular-nums text-foreground/70">{proc.elapsed}</p>
</div>
</div>
@@ -82,6 +84,7 @@ function ProcessCard({ proc, selected, onSelect, onKill }: {
/* ── Log viewer ─────────────────────────────────────────── */
function LogViewer() {
const t = useUiMessages();
const [lines, setLines] = useState<string[]>([]);
useEffect(() => {
@@ -100,13 +103,13 @@ function LogViewer() {
return (
<div className="rounded-xl border border-border/30 bg-black/30 overflow-hidden">
<div className="px-4 py-2 border-b border-border/20">
<span className="text-[11px] font-medium text-foreground/40">Process Logs</span>
<span className="text-[11px] font-medium text-foreground/40">{t.control.processLogs}</span>
<span className="text-[10px] text-foreground/15 ml-2">{lines.length} lines</span>
</div>
<ScrollArea className="h-[400px]">
<div className="p-3 font-mono text-[10px] leading-relaxed">
{lines.length === 0 ? (
<p className="text-foreground/15 text-center py-8">No logs yet</p>
<p className="text-foreground/15 text-center py-8">{t.control.noLogs}</p>
) : (
lines.map((line, i) => {
const isErr = line.includes("level=error");
@@ -132,6 +135,7 @@ function LogViewer() {
/* ── Main Control Tab ───────────────────────────────────── */
export function ControlTab() {
const t = useUiMessages();
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
const [selfMetrics, setSelfMetrics] = useState({ rss_mb: 0, user_cpu: 0, sys_cpu: 0 });
const [selectedPid, setSelectedPid] = useState<number | null>(null);
@@ -156,7 +160,7 @@ export function ControlTab() {
}, [fetchProcesses]);
const killProcess = useCallback(async (pid: number) => {
if (!confirm(`Kill process ${pid}?`)) return;
if (!confirm(t.control.killConfirm(pid))) return;
try {
await fetch("/api/process-kill", {
method: "POST",
@@ -165,7 +169,7 @@ export function ControlTab() {
});
setTimeout(fetchProcesses, 1000);
} catch { /* ignore */ }
}, [fetchProcesses]);
}, [fetchProcesses, t.control]);
/* Aggregates */
const totalCpu = processes.reduce((s, p) => s + p.cpu, 0);
@@ -174,32 +178,32 @@ export function ControlTab() {
return (
<ScrollArea className="h-full">
<div className="p-8 max-w-4xl mx-auto">
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">Control Panel</h2>
<h2 className="text-[15px] font-semibold text-foreground/80 mb-6">{t.control.panel}</h2>
{/* Aggregate gauges */}
<div className="flex gap-4 mb-8">
<Gauge label="Total CPU" value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label="Total RAM" value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label="Processes" value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label="Self RAM" value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
<Gauge label={t.control.totalCpu} value={totalCpu} max={100 * processes.length || 100} unit="%" color="text-foreground/80" />
<Gauge label={t.control.totalRam} value={totalRam} max={4096} unit="MB" color="text-foreground/80" />
<Gauge label={t.control.processes} value={processes.length} max={10} unit="" color="text-primary" />
<Gauge label={t.control.selfRam} value={selfMetrics.rss_mb} max={2048} unit="MB" color="text-primary" />
</div>
{/* Process grid */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h3 className="text-[13px] font-medium text-foreground/50">
Active Processes
{t.control.activeProcesses}
</h3>
<button
onClick={fetchProcesses}
className="text-[11px] text-primary/60 hover:text-primary transition-colors"
>
Refresh
{t.common.refresh}
</button>
</div>
{processes.length === 0 ? (
<p className="text-foreground/20 text-[12px] text-center py-8">No processes found</p>
<p className="text-foreground/20 text-[12px] text-center py-8">{t.control.noProcesses}</p>
) : (
<div className="grid grid-cols-2 gap-3">
{processes.map((p) => (
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { GRAPH_CANVAS_DPR } from "./GraphScene";
describe("GraphScene render limits", () => {
it("caps the high-DPI WebGL backing store below the MSAA failure range", () => {
expect(GRAPH_CANVAS_DPR[0]).toBe(1);
expect(GRAPH_CANVAS_DPR[1]).toBeLessThanOrEqual(1.5);
});
});
+9 -3
View File
@@ -45,6 +45,8 @@ function CameraAnimator({ target }: { target: CameraTarget | null }) {
/* ── Idle auto-rotation ──────────────────────────────────── */
const IDLE_TIMEOUT_MS = 60_000;
export const GRAPH_CANVAS_DPR: [number, number] = [1, 1.5];
export const GRAPH_COMPOSER_MULTISAMPLING = 0;
function IdleAutoRotate({
controlsRef,
@@ -108,8 +110,12 @@ export function GraphScene({
<Canvas
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
style={{ background: "#06090f" }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: false }}
dpr={GRAPH_CANVAS_DPR}
gl={{
antialias: false,
alpha: false,
powerPreference: "high-performance",
}}
>
<color attach="background" args={["#06090f"]} />
<ambientLight intensity={0.5} />
@@ -176,7 +182,7 @@ export function GraphScene({
<CameraAnimator target={cameraTarget} />
<IdleAutoRotate controlsRef={controlsRef} />
<EffectComposer>
<EffectComposer multisampling={GRAPH_COMPOSER_MULTISAMPLING}>
<Bloom
luminanceThreshold={0.3}
luminanceSmoothing={0.7}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { formatGraphLimitNotice } from "./GraphTab";
import type { GraphData } from "../lib/types";
describe("formatGraphLimitNotice", () => {
it("reports when the graph response is truncated for render safety", () => {
const data = {
nodes: Array.from({ length: 2000 }, (_, id) => ({
id,
x: 0,
y: 0,
z: 0,
label: "Function",
name: `fn${id}`,
size: 1,
color: "#ffffff",
})),
edges: [],
total_nodes: 43729,
} satisfies GraphData;
expect(formatGraphLimitNotice(data)).toBe(
"Showing 2,000 of 43,729 nodes. Use filters to narrow.",
);
});
it("stays quiet when the full graph is rendered", () => {
const data = {
nodes: [],
edges: [],
total_nodes: 0,
} satisfies GraphData;
expect(formatGraphLimitNotice(data)).toBeNull();
});
});
+9
View File
@@ -29,6 +29,11 @@ interface GraphTabProps {
project: string | null;
}
export function formatGraphLimitNotice(data: GraphData | null): string | null {
if (!data || data.total_nodes <= data.nodes.length) return null;
return `Showing ${data.nodes.length.toLocaleString()} of ${data.total_nodes.toLocaleString()} nodes. Use filters to narrow.`;
}
export function GraphTab({ project }: GraphTabProps) {
const { data, loading, error, fetchOverview } = useGraphData();
const [highlightedIds, setHighlightedIds] = useState<Set<number> | null>(null);
@@ -38,6 +43,7 @@ export function GraphTab({ project }: GraphTabProps) {
const [showLabels, setShowLabels] = useState(true);
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
const limitNotice = formatGraphLimitNotice(data);
/* Filter state — all enabled by default */
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
@@ -282,6 +288,9 @@ export function GraphTab({ project }: GraphTabProps) {
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
+7 -3
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { GraphNode } from "../lib/types";
import { useUiMessages } from "../lib/i18n";
interface SidebarProps {
nodes: GraphNode[];
@@ -105,6 +106,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: {
}
export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
const t = useUiMessages();
const [search, setSearch] = useState("");
const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]);
@@ -122,7 +124,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="relative">
<input
type="text"
placeholder="Search..."
placeholder={t.graph.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all"
@@ -134,7 +136,9 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
<div className="py-1">
{filtered ? (
filtered.length === 0 ? (
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">No matches</p>
<p className="text-foreground/20 text-[12px] px-4 py-6 text-center">
{t.common.noMatches}
</p>
) : (
filtered.map((n) => (
<button
@@ -160,7 +164,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
onClick={() => onSelectPath("", new Set())}
className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
>
Clear selection
{t.graph.clearSelection}
</button>
</div>
)}
+95
View File
@@ -0,0 +1,95 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StatsTab } from "./StatsTab";
function mockProjectsFetch(extra?: (url: string, init?: RequestInit) => Response | undefined) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const overridden = extra?.(url, init);
if (overridden) return overridden;
if (url === "/rpc") {
return new Response(JSON.stringify({
result: { content: [{ text: JSON.stringify({ projects: [] }) }] },
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url.startsWith("/api/ui-config")) {
return new Response(JSON.stringify({ lang: "en" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.startsWith("/api/browse")) {
return new Response(JSON.stringify({
path: "/home/dev",
parent: "/home",
dirs: ["alpha", "beta"],
roots: ["/", "D:/"],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (url === "/api/index") {
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return new Response("{}", { status: 200 });
});
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
describe("StatsTab index modal", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("submits a custom path and project name", async () => {
let submitted: unknown = null;
mockProjectsFetch((url, init) => {
if (url === "/api/index") {
submitted = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ status: "indexing", slot: 0 }), {
status: 202,
headers: { "Content-Type": "application/json" },
});
}
return undefined;
});
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByLabelText("Repository path"), {
target: { value: "D:\\work\\信租风控通后端" },
});
fireEvent.change(screen.getByLabelText("Project name"), {
target: { value: "信租风控通后端" },
});
fireEvent.click(screen.getByRole("button", { name: "Index This Folder" }));
await waitFor(() => {
expect(submitted).toEqual({
root_path: "D:\\work\\信租风控通后端",
project_name: "信租风控通后端",
});
});
});
it("filters picker rows and exposes quick row indexing", async () => {
mockProjectsFetch();
render(<StatsTab onSelectProject={() => {}} />);
fireEvent.click(await screen.findByRole("button", { name: "Index your first repository" }));
fireEvent.change(await screen.findByPlaceholderText("Filter folders"), {
target: { value: "bet" },
});
expect(screen.queryByText("alpha")).not.toBeInTheDocument();
expect(screen.getByText("beta")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Index beta" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse D:/" })).toBeInTheDocument();
});
});
+151 -44
View File
@@ -1,7 +1,8 @@
import { useMemo, useState, useCallback, useEffect } from "react";
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useProjects } from "../hooks/useProjects";
import { colorForLabel } from "../lib/colors";
import { useUiMessages } from "../lib/i18n";
interface StatsTabProps {
onSelectProject: (project: string) => void;
@@ -10,6 +11,7 @@ interface StatsTabProps {
/* ── Glowy health dot ───────────────────────────────────── */
function HealthDot({ name }: { name: string }) {
const t = useUiMessages();
const [status, setStatus] = useState<"loading" | "healthy" | "corrupt" | "missing">("loading");
const [info, setInfo] = useState("");
@@ -34,9 +36,9 @@ function HealthDot({ name }: { name: string }) {
status === "corrupt" ? "#f87171" : "#555";
const label =
status === "healthy" ? "Database healthy" :
status === "missing" ? "Database missing" :
status === "corrupt" ? "Database unhealthy" : "Checking...";
status === "healthy" ? t.projects.healthHealthy :
status === "missing" ? t.projects.healthMissing :
status === "corrupt" ? t.projects.healthCorrupt : t.projects.healthChecking;
return (
<div className="group relative inline-flex items-center">
@@ -64,6 +66,7 @@ function HealthDot({ name }: { name: string }) {
/* ── ADR button + modal ─────────────────────────────────── */
function AdrButton({ project }: { project: string }) {
const t = useUiMessages();
const [hasAdr, setHasAdr] = useState<boolean | null>(null);
const [open, setOpen] = useState(false);
const [content, setContent] = useState("");
@@ -117,13 +120,13 @@ function AdrButton({ project }: { project: string }) {
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl p-6 w-full max-w-2xl shadow-2xl max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-[15px] font-semibold text-foreground/90">Architecture Decision Record</h3>
<h3 className="text-[15px] font-semibold text-foreground/90">{t.adr.title}</h3>
<p className="text-[11px] text-foreground/30 font-mono mt-0.5">{project}</p>
</div>
<button onClick={() => setOpen(false)} className="text-foreground/20 hover:text-foreground/50 text-[16px] p-1">×</button>
</div>
{updatedAt && (
<p className="text-[10px] text-foreground/20 mb-3">Last updated: {updatedAt}</p>
<p className="text-[10px] text-foreground/20 mb-3">{t.adr.lastUpdated}: {updatedAt}</p>
)}
<textarea
value={content}
@@ -139,12 +142,12 @@ function AdrButton({ project }: { project: string }) {
}}
className="px-3 py-2 rounded-lg text-[12px] text-destructive/60 hover:text-destructive hover:bg-destructive/10 font-medium transition-all"
>
Delete
{t.common.delete}
</button>
)}
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
<button onClick={() => setOpen(false)} className="px-4 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={save} disabled={saving} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{saving ? "Saving..." : "Save"}
{saving ? t.common.saving : t.common.save}
</button>
</div>
</div>
@@ -156,16 +159,30 @@ function AdrButton({ project }: { project: string }) {
/* ── Create Index Modal ─────────────────────────────────── */
function joinPath(base: string, dir: string): string {
if (!base || base === "/") return `/${dir}`;
if (/^[A-Za-z]:[\\/]?$/.test(base)) return `${base[0]}:/${dir}`;
const slash = base.includes("\\") && !base.includes("/") ? "\\" : "/";
return `${base.replace(/[\\/]+$/, "")}${slash}${dir}`;
}
function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const t = useUiMessages();
const [currentPath, setCurrentPath] = useState("");
const [dirs, setDirs] = useState<string[]>([]);
const [roots, setRoots] = useState<string[]>(["/"]);
const [parentPath, setParentPath] = useState("");
const [projectName, setProjectName] = useState("");
const [filter, setFilter] = useState("");
const [activeIndex, setActiveIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const filterRef = useRef<HTMLInputElement>(null);
const browse = useCallback(async (path?: string) => {
setLoading(true);
setError(null);
try {
const q = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/browse${q}`);
@@ -173,18 +190,30 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
if (data.error) throw new Error(data.error);
setCurrentPath(data.path ?? "");
setDirs((data.dirs ?? []).sort());
setRoots(data.roots ?? ["/"]);
setParentPath(data.parent ?? "/");
} catch (e) { setError(e instanceof Error ? e.message : "Browse failed"); }
finally { setLoading(false); }
}, []);
useEffect(() => { browse(); }, [browse]);
useEffect(() => { filterRef.current?.focus(); }, []);
const submit = async () => {
if (!currentPath) return;
const filteredDirs = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return dirs;
return dirs.filter((d) => d.toLowerCase().includes(q));
}, [dirs, filter]);
useEffect(() => { setActiveIndex(0); }, [filter, currentPath]);
const submit = async (path = currentPath) => {
if (!path) return;
setSubmitting(true); setError(null);
try {
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ root_path: currentPath }) });
const body: { root_path: string; project_name?: string } = { root_path: path };
if (projectName.trim()) body.project_name = projectName.trim();
const res = await fetch("/api/index", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? "Failed");
onCreated(); onClose();
@@ -192,17 +221,78 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
finally { setSubmitting(false); }
};
const onFilterKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(filteredDirs.length - 1, 0)));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && filteredDirs.length > 0) {
e.preventDefault();
const dir = filteredDirs.length === 1 ? filteredDirs[0] : filteredDirs[activeIndex];
if (filteredDirs.length === 1) void submit(joinPath(currentPath, dir));
else void browse(joinPath(currentPath, dir));
}
};
/* Breadcrumb segments */
const segments = currentPath.split("/").filter(Boolean);
const displayPath = currentPath.replace(/\\/g, "/");
const segments = displayPath.split("/").filter(Boolean);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-lg shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(70vh, 550px)" }} onClick={(e) => e.stopPropagation()}>
<div className="relative bg-[#0e2028] border border-border/40 rounded-2xl w-full max-w-2xl shadow-2xl flex flex-col overflow-hidden" style={{ height: "min(82vh, 680px)" }} onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="px-5 pt-5 pb-3 shrink-0">
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">Select Repository Folder</h3>
<p className="text-[12px] text-foreground/30">Navigate to the project root and click "Index This Folder".</p>
<h3 className="text-[15px] font-semibold text-foreground/90 mb-1">{t.index.selectRepositoryFolder}</h3>
<p className="text-[12px] text-foreground/30">{t.index.instructions}</p>
</div>
<div className="px-5 pb-3 grid grid-cols-[1fr_220px] gap-3 shrink-0">
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.repositoryPath}</span>
<input
aria-label={t.index.repositoryPath}
value={currentPath}
onChange={(e) => setCurrentPath(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground font-mono outline-none focus:border-primary/40"
/>
</label>
<label className="block">
<span className="block text-[10px] uppercase tracking-widest text-foreground/25 mb-1">{t.index.projectName}</span>
<input
aria-label={t.index.projectName}
value={projectName}
placeholder={t.index.projectNamePlaceholder}
onChange={(e) => setProjectName(e.target.value)}
className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
</label>
</div>
<div className="px-5 pb-3 flex items-center gap-2 shrink-0">
<input
ref={filterRef}
value={filter}
placeholder={t.index.filterFolders}
onChange={(e) => setFilter(e.target.value)}
onKeyDown={onFilterKeyDown}
className="flex-1 bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20"
/>
<div className="flex items-center gap-1">
{roots.map((root) => (
<button
key={root}
aria-label={t.index.browseRoot(root)}
onClick={() => browse(root)}
className="px-2.5 py-2 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/45 font-mono transition-all"
>
{root}
</button>
))}
</div>
</div>
{/* Breadcrumb */}
@@ -235,19 +325,34 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
</button>
)}
{loading ? (
<p className="text-foreground/20 text-[12px] text-center py-8">Loading...</p>
) : dirs.length === 0 ? (
<p className="text-foreground/15 text-[12px] text-center py-8">No subdirectories</p>
<p className="text-foreground/20 text-[12px] text-center py-8">{t.common.loading}</p>
) : filteredDirs.length === 0 ? (
<p className="text-foreground/15 text-[12px] text-center py-8">{t.index.noSubdirectories}</p>
) : (
dirs.map((d) => (
<button
filteredDirs.map((d, i) => (
<div
key={d}
onClick={() => browse(`${currentPath}/${d}`)}
className="flex items-center gap-2 w-full text-left px-3 py-1.5 rounded-lg hover:bg-white/[0.04] text-[12px] text-foreground/60 transition-colors group"
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-[12px] transition-colors group ${
i === activeIndex ? "bg-white/[0.05]" : "hover:bg-white/[0.04]"
}`}
>
<span className="text-foreground/20 group-hover:text-foreground/40">📁</span>
<span className="truncate">{d}</span>
</button>
<button
aria-label={t.index.browseRoot(d)}
onClick={() => browse(joinPath(currentPath, d))}
className="flex min-w-0 flex-1 items-center gap-2 text-left text-foreground/60"
>
<span className="text-foreground/20 group-hover:text-foreground/40">/</span>
<span className="truncate">{d}</span>
</button>
<button
aria-label={t.index.indexDirectory(d)}
onClick={() => submit(joinPath(currentPath, d))}
disabled={submitting}
className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 px-2 py-1 rounded-md bg-primary/15 hover:bg-primary/25 text-primary text-[10px] font-medium transition-all disabled:opacity-30"
>
{t.index.indexThisFolder}
</button>
</div>
))
)}
</div>
@@ -259,9 +364,9 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
<div className="flex items-center justify-between">
<p className="text-[11px] text-foreground/25 font-mono truncate max-w-[250px]">{currentPath}</p>
<div className="flex gap-2 shrink-0">
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">Cancel</button>
<button onClick={submit} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{submitting ? "Starting..." : "Index This Folder"}
<button onClick={onClose} className="px-3 py-2 rounded-lg text-[12px] text-foreground/40 hover:bg-white/[0.04] font-medium transition-all">{t.common.cancel}</button>
<button onClick={() => submit()} disabled={submitting || !currentPath} className="px-4 py-2 rounded-lg bg-primary/20 hover:bg-primary/30 text-primary text-[12px] font-medium transition-all disabled:opacity-30">
{submitting ? t.index.starting : t.index.indexThisFolder}
</button>
</div>
</div>
@@ -274,6 +379,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
/* ── Index Progress ─────────────────────────────────────── */
function IndexProgress({ onDone }: { onDone: () => void }) {
const t = useUiMessages();
const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
useEffect(() => {
const poll = setInterval(async () => {
@@ -293,7 +399,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
<div key={j.slot} className="flex items-center gap-3">
<div className="w-4 h-4 border-2 border-primary/30 border-t-primary rounded-full animate-spin shrink-0" />
<div>
<p className="text-[12px] text-primary font-medium">Indexing in progress</p>
<p className="text-[12px] text-primary font-medium">{t.projects.indexingInProgress}</p>
<p className="text-[11px] text-foreground/30 font-mono">{j.path}</p>
</div>
</div>
@@ -305,6 +411,7 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
/* ── Main Stats Tab ─────────────────────────────────────── */
export function StatsTab({ onSelectProject }: StatsTabProps) {
const t = useUiMessages();
const { projects, loading, error, refresh } = useProjects();
const [showModal, setShowModal] = useState(false);
const [indexing, setIndexing] = useState(false);
@@ -319,9 +426,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
}, [projects]);
const deleteProject = useCallback(async (name: string) => {
if (!confirm(`Delete index for "${name}"?`)) return;
if (!confirm(t.projects.deleteConfirm(name))) return;
try { await fetch(`/api/project?name=${encodeURIComponent(name)}`, { method: "DELETE" }); refresh(); } catch { /* */ }
}, [refresh]);
}, [refresh, t.projects]);
return (
<ScrollArea className="h-full">
@@ -329,9 +436,9 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{projects.length > 0 && (
<div className="flex gap-4 mb-8">
{[
{ label: "Projects", value: aggregate.projects, color: "text-primary" },
{ label: "Nodes", value: aggregate.nodes, color: "text-foreground/80" },
{ label: "Edges", value: aggregate.edges, color: "text-foreground/80" },
{ label: t.tabs.projects, value: aggregate.projects, color: "text-primary" },
{ label: t.projects.nodes, value: aggregate.nodes, color: "text-foreground/80" },
{ label: t.projects.edges, value: aggregate.edges, color: "text-foreground/80" },
].map((s) => (
<div key={s.label} className="flex-1 rounded-xl border border-border/30 bg-white/[0.02] p-4">
<p className="text-[10px] text-foreground/25 uppercase tracking-widest mb-1">{s.label}</p>
@@ -344,10 +451,10 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{indexing && <IndexProgress onDone={() => { setIndexing(false); refresh(); }} />}
<div className="flex items-center justify-between mb-6">
<h2 className="text-[15px] font-semibold text-foreground/80">Indexed Projects</h2>
<h2 className="text-[15px] font-semibold text-foreground/80">{t.projects.indexedProjects}</h2>
<div className="flex items-center gap-2">
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ New Index</button>
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : "Refresh"}</button>
<button onClick={() => setShowModal(true)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">+ {t.index.newIndex}</button>
<button onClick={refresh} disabled={loading} className="px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[12px] text-foreground/40 font-medium transition-all disabled:opacity-30">{loading ? "..." : t.common.refresh}</button>
</div>
</div>
@@ -355,8 +462,8 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
{!loading && projects.length === 0 && !error && (
<div className="text-center py-20">
<p className="text-foreground/25 text-[13px] mb-2">No indexed projects</p>
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">Index your first repository</button>
<p className="text-foreground/25 text-[13px] mb-2">{t.projects.noIndexedProjects}</p>
<button onClick={() => setShowModal(true)} className="px-4 py-2 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.indexFirstRepository}</button>
</div>
)}
@@ -376,15 +483,15 @@ export function StatsTab({ onSelectProject }: StatsTabProps) {
</div>
<div className="flex items-center gap-1.5 shrink-0">
<AdrButton project={p.project.name} />
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">View Graph</button>
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title="Delete index">✕</button>
<button onClick={() => onSelectProject(p.project.name)} className="px-3 py-1.5 rounded-lg bg-primary/15 hover:bg-primary/25 text-primary text-[12px] font-medium transition-all">{t.projects.viewGraph}</button>
<button onClick={() => deleteProject(p.project.name)} className="px-2 py-1.5 rounded-lg hover:bg-destructive/10 text-foreground/20 hover:text-destructive text-[12px] transition-all" title={t.projects.deleteTitle}>✕</button>
</div>
</div>
{p.schema && (
<>
<div className="flex gap-6 text-[12px] text-foreground/30 mb-3">
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> nodes</span>
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> edges</span>
<span><strong className="text-foreground/55 tabular-nums">{totalNodes.toLocaleString()}</strong> {t.projects.nodes}</span>
<span><strong className="text-foreground/55 tabular-nums">{totalEdges.toLocaleString()}</strong> {t.projects.edges}</span>
</div>
<div className="flex flex-wrap gap-1">
{p.schema.node_labels?.map((l) => (
+26
View File
@@ -0,0 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchLayout, GRAPH_RENDER_NODE_LIMIT } from "./useGraphData";
describe("fetchLayout", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses the safe graph render cap by default", async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ nodes: [], edges: [], total_nodes: 0 }),
}));
vi.stubGlobal("fetch", fetchMock);
await fetchLayout("large-project");
expect(GRAPH_RENDER_NODE_LIMIT).toBe(2000);
expect(fetchMock).toHaveBeenCalledTimes(1);
const calls = fetchMock.mock.calls as unknown as Array<[string]>;
const [url] = calls[0];
expect(url).toBe(
"/api/layout?project=large-project&max_nodes=2000",
);
});
});
+6 -4
View File
@@ -9,9 +9,11 @@ interface UseGraphDataResult {
fetchDetail: (project: string, centerNode: string) => void;
}
async function fetchLayout(
export const GRAPH_RENDER_NODE_LIMIT = 2000;
export async function fetchLayout(
project: string,
maxNodes = 50000,
maxNodes = GRAPH_RENDER_NODE_LIMIT,
): Promise<GraphData> {
const params = new URLSearchParams({ project, max_nodes: String(maxNodes) });
const res = await fetch(`/api/layout?${params}`);
@@ -33,7 +35,7 @@ export function useGraphData(): UseGraphDataResult {
setLoading(true);
setError(null);
try {
const result = await fetchLayout(project, 50000);
const result = await fetchLayout(project);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
@@ -48,7 +50,7 @@ export function useGraphData(): UseGraphDataResult {
setError(null);
try {
/* TODO: detail level with center_node filtering */
const result = await fetchLayout(project, 50000);
const result = await fetchLayout(project);
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch layout");
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { detectLanguage, messages } from "./i18n";
describe("i18n", () => {
it("detects Chinese from Accept-Language and falls back to English", () => {
expect(detectLanguage("zh-CN,zh;q=0.9,en;q=0.8")).toBe("zh");
expect(detectLanguage("de-DE,de;q=0.9")).toBe("en");
});
it("keeps UI chrome messages in the catalog", () => {
expect(messages.zh.tabs.projects).toBe("项目");
expect(messages.zh.index.newIndex).toBe("新建索引");
expect(messages.en.index.repositoryPath).toBe("Repository path");
});
});
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
export type UiLanguage = "en" | "zh";
export const messages = {
en: {
tabs: {
graph: "Graph",
projects: "Projects",
control: "Control",
},
common: {
cancel: "Cancel",
refresh: "Refresh",
loading: "Loading...",
save: "Save",
saving: "Saving...",
delete: "Delete",
noMatches: "No matches",
},
graph: {
selectedLabel: "Graph",
search: "Search...",
clearSelection: "Clear selection",
},
projects: {
indexedProjects: "Indexed Projects",
noIndexedProjects: "No indexed projects",
indexFirstRepository: "Index your first repository",
viewGraph: "View Graph",
nodes: "nodes",
edges: "edges",
deleteTitle: "Delete index",
deleteConfirm: (name: string) => `Delete index for "${name}"?`,
healthHealthy: "Database healthy",
healthMissing: "Database missing",
healthCorrupt: "Database unhealthy",
healthChecking: "Checking...",
indexingInProgress: "Indexing in progress",
},
index: {
newIndex: "New Index",
selectRepositoryFolder: "Select Repository Folder",
instructions: "Navigate to the project root and click \"Index This Folder\".",
repositoryPath: "Repository path",
projectName: "Project name",
projectNamePlaceholder: "Optional display name",
filterFolders: "Filter folders",
noSubdirectories: "No subdirectories",
indexThisFolder: "Index This Folder",
starting: "Starting...",
browseRoot: (path: string) => `Browse ${path}`,
indexDirectory: (name: string) => `Index ${name}`,
},
adr: {
title: "Architecture Decision Record",
lastUpdated: "Last updated",
},
control: {
panel: "Control Panel",
totalCpu: "Total CPU",
totalRam: "Total RAM",
processes: "Processes",
selfRam: "Self RAM",
activeProcesses: "Active Processes",
processLogs: "Process Logs",
noProcesses: "No processes found",
noLogs: "No logs yet",
kill: "Kill",
thisProcess: "THIS",
uptime: "Uptime",
killConfirm: (pid: number) => `Kill process ${pid}?`,
},
},
zh: {
tabs: {
graph: "图谱",
projects: "项目",
control: "控制",
},
common: {
cancel: "取消",
refresh: "刷新",
loading: "加载中...",
save: "保存",
saving: "保存中...",
delete: "删除",
noMatches: "无匹配结果",
},
graph: {
selectedLabel: "图谱",
search: "搜索...",
clearSelection: "清除选择",
},
projects: {
indexedProjects: "已索引项目",
noIndexedProjects: "暂无已索引项目",
indexFirstRepository: "索引第一个仓库",
viewGraph: "查看图谱",
nodes: "节点",
edges: "边",
deleteTitle: "删除索引",
deleteConfirm: (name: string) => `删除 "${name}" 的索引?`,
healthHealthy: "数据库正常",
healthMissing: "数据库缺失",
healthCorrupt: "数据库异常",
healthChecking: "检查中...",
indexingInProgress: "正在索引",
},
index: {
newIndex: "新建索引",
selectRepositoryFolder: "选择仓库目录",
instructions: "导航到项目根目录,然后点击“索引此目录”。",
repositoryPath: "仓库路径",
projectName: "项目名称",
projectNamePlaceholder: "可选显示名称",
filterFolders: "筛选目录",
noSubdirectories: "没有子目录",
indexThisFolder: "索引此目录",
starting: "启动中...",
browseRoot: (path: string) => `浏览 ${path}`,
indexDirectory: (name: string) => `索引 ${name}`,
},
adr: {
title: "架构决策记录",
lastUpdated: "最后更新",
},
control: {
panel: "控制面板",
totalCpu: "总 CPU",
totalRam: "总内存",
processes: "进程",
selfRam: "自身内存",
activeProcesses: "活动进程",
processLogs: "进程日志",
noProcesses: "未找到进程",
noLogs: "暂无日志",
kill: "结束",
thisProcess: "本进程",
uptime: "运行时间",
killConfirm: (pid: number) => `结束进程 ${pid}?`,
},
},
} as const;
export type UiMessages = (typeof messages)[UiLanguage];
export function detectLanguage(acceptLanguage?: string | null, override?: string | null): UiLanguage {
if (override === "zh" || override === "en") return override;
if (!acceptLanguage) return "en";
const normalized = acceptLanguage.toLowerCase();
return normalized.includes("zh-cn") || normalized.includes("zh") ? "zh" : "en";
}
let cachedLanguage: UiLanguage = "en";
let languageLoaded = false;
let languageRequest: Promise<UiLanguage> | null = null;
const languageListeners = new Set<(lang: UiLanguage) => void>();
function loadUiLanguage(): Promise<UiLanguage> {
if (languageLoaded) return Promise.resolve(cachedLanguage);
if (languageRequest) return languageRequest;
languageRequest = fetch("/api/ui-config")
.then((r) => r.json())
.then((data) => detectLanguage(null, data?.lang))
.catch(() => detectLanguage(navigator.language))
.then((lang) => {
cachedLanguage = lang;
languageLoaded = true;
for (const listener of languageListeners) listener(lang);
return lang;
})
.finally(() => {
languageRequest = null;
});
return languageRequest;
}
export function useUiMessages(): UiMessages {
const [lang, setLang] = useState<UiLanguage>(cachedLanguage);
useEffect(() => {
let cancelled = false;
languageListeners.add(setLang);
void loadUiLanguage().then((nextLang) => {
if (!cancelled) setLang(nextLang);
});
return () => {
cancelled = true;
languageListeners.delete(setLang);
};
}, []);
return messages[lang];
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.tsx","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/rpc.ts","./src/components/controltab.tsx","./src/components/edgelines.tsx","./src/components/errorboundary.tsx","./src/components/filterpanel.tsx","./src/components/graphscene.test.ts","./src/components/graphscene.tsx","./src/components/graphtab.test.ts","./src/components/graphtab.tsx","./src/components/nodecloud.tsx","./src/components/nodedetailpanel.tsx","./src/components/nodelabels.tsx","./src/components/nodetooltip.tsx","./src/components/projectcard.tsx","./src/components/resizehandle.tsx","./src/components/sidebar.tsx","./src/components/statstab.test.tsx","./src/components/statstab.tsx","./src/components/tabbar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/input.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/separator.tsx","./src/hooks/usegraphdata.test.ts","./src/hooks/usegraphdata.ts","./src/hooks/useprojects.ts","./src/lib/colors.ts","./src/lib/i18n.test.ts","./src/lib/i18n.ts","./src/lib/types.ts","./src/lib/utils.ts"],"version":"5.9.3"}
+4
View File
@@ -2715,6 +2715,8 @@ int cbm_cmd_config(int argc, char **argv) {
"Enable auto-indexing on MCP session start");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_AUTO_INDEX_LIMIT, "50000",
"Max files for auto-indexing new projects");
printf(" %-25s default=%-10s %s\n", CBM_CONFIG_UI_LANG, "auto",
"Pin graph UI language: en, zh, or auto");
return 0;
}
@@ -2740,6 +2742,8 @@ int cbm_cmd_config(int argc, char **argv) {
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX, "false"));
printf(" %-25s = %-10s\n", CBM_CONFIG_AUTO_INDEX_LIMIT,
cbm_config_get(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "50000"));
printf(" %-25s = %-10s\n", CBM_CONFIG_UI_LANG,
cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto"));
} else if (strcmp(argv[0], "get") == 0) {
if (argc < MIN_ARGC_GET) {
(void)fprintf(stderr, "Usage: config get <key>\n");
+1
View File
@@ -274,6 +274,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key);
/* Well-known config keys */
#define CBM_CONFIG_AUTO_INDEX "auto_index"
#define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit"
#define CBM_CONFIG_UI_LANG "ui-lang"
/* ── Subcommands (wired from main.c) ─────────────────────────── */
+240 -53
View File
@@ -12,47 +12,72 @@
#include <string.h>
static CBMLogLevel g_log_level = CBM_LOG_INFO;
static CBMLogFormat g_log_format = CBM_LOG_FORMAT_TEXT;
static cbm_log_sink_fn g_log_sink = NULL;
static CBMLogSinkMode g_log_sink_mode = CBM_LOG_SINK_REPLACE;
/* CBM_LOG_LEVEL support — distilled from #414 (closes #413, thanks @santanusinha). */
void cbm_log_init_from_env(void) {
/* getenv() is safe here: this runs at startup before any thread is created,
* so there is no concurrent setenv() to race against. */
const char *raw = getenv("CBM_LOG_LEVEL");
if (!raw || raw[0] == '\0') {
return; /* unset/empty: keep the current (default) level — fail-open */
}
/* Textual form, case-insensitive. Index of each name == its enum value. */
static const char *const names[] = {"debug", "info", "warn", "error", "none"};
char lower[8];
size_t i = 0;
for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
lower[i] = (char)tolower((unsigned char)raw[i]);
}
lower[i] = '\0';
if (raw[i] == '\0') { /* fully consumed — candidate textual match */
for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
if (strcmp(lower, names[lvl]) == 0) {
cbm_log_set_level((CBMLogLevel)lvl);
return;
if (raw && raw[0] != '\0') {
/* Textual form, case-insensitive. Index of each name == its enum value. */
static const char *const names[] = {"debug", "info", "warn", "error", "none"};
char lower[8];
size_t i = 0;
for (; i < sizeof(lower) - 1 && raw[i] != '\0'; i++) {
lower[i] = (char)tolower((unsigned char)raw[i]);
}
lower[i] = '\0';
if (raw[i] == '\0') { /* fully consumed — candidate textual match */
for (size_t lvl = 0; lvl < sizeof(names) / sizeof(names[0]); lvl++) {
if (strcmp(lower, names[lvl]) == 0) {
cbm_log_set_level((CBMLogLevel)lvl);
goto parse_format;
}
}
}
/* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
char *end = NULL;
long n = strtol(raw, &end, CBM_DECIMAL_BASE);
if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
cbm_log_set_level((CBMLogLevel)n);
}
}
/* Numeric form: 0=debug .. 4=none, matching CBMLogLevel. */
char *end = NULL;
long n = strtol(raw, &end, CBM_DECIMAL_BASE);
if (end != raw && *end == '\0' && n >= CBM_LOG_DEBUG && n <= CBM_LOG_NONE) {
cbm_log_set_level((CBMLogLevel)n);
/* Unrecognised value: leave the level unchanged (fail-open). */
parse_format:;
const char *fmt = getenv("CBM_LOG_FORMAT");
if (fmt && fmt[0] != '\0') {
char lower_fmt[8];
size_t i = 0;
for (; i < sizeof(lower_fmt) - 1 && fmt[i] != '\0'; i++) {
lower_fmt[i] = (char)tolower((unsigned char)fmt[i]);
}
lower_fmt[i] = '\0';
if (fmt[i] == '\0' && strcmp(lower_fmt, "json") == 0) {
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
} else if (fmt[i] == '\0' && strcmp(lower_fmt, "text") == 0) {
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
}
return;
}
/* Unrecognised value: leave the level unchanged (fail-open). */
/* Format is intentionally explicit-only. Logs stay local to stderr and the
* optional in-process sink; deployment environment variables must not
* silently change the operator-selected output shape. */
}
void cbm_log_set_sink(cbm_log_sink_fn fn) {
cbm_log_set_sink_ex(fn, CBM_LOG_SINK_REPLACE);
}
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode) {
g_log_sink = fn;
g_log_sink_mode = mode;
}
void cbm_log_set_level(CBMLogLevel level) {
@@ -63,6 +88,14 @@ CBMLogLevel cbm_log_get_level(void) {
return g_log_level;
}
void cbm_log_set_format(CBMLogFormat format) {
g_log_format = format;
}
CBMLogFormat cbm_log_get_format(void) {
return g_log_format;
}
static const char *level_str(CBMLogLevel level) {
switch (level) {
case CBM_LOG_DEBUG:
@@ -78,54 +111,208 @@ static const char *level_str(CBMLogLevel level) {
}
}
static void append_char(char *buf, size_t bufsz, size_t *pos, char ch) {
if (*pos < bufsz - 1) {
buf[*pos] = ch;
}
(*pos)++;
}
static void append_raw(char *buf, size_t bufsz, size_t *pos, const char *s) {
if (!s) {
return;
}
while (*s) {
append_char(buf, bufsz, pos, *s++);
}
}
static void append_text_atom(char *buf, size_t bufsz, size_t *pos, const char *s) {
if (!s) {
return;
}
while (*s) {
unsigned char ch = (unsigned char)*s++;
if (ch <= ' ' || ch == 0x7f) {
append_char(buf, bufsz, pos, '_');
} else {
append_char(buf, bufsz, pos, (char)ch);
}
}
}
static void append_json_string(char *buf, size_t bufsz, size_t *pos, const char *s) {
append_char(buf, bufsz, pos, '"');
if (s) {
while (*s) {
unsigned char ch = (unsigned char)*s++;
switch (ch) {
case '"':
append_raw(buf, bufsz, pos, "\\\"");
break;
case '\\':
append_raw(buf, bufsz, pos, "\\\\");
break;
case '\b':
append_raw(buf, bufsz, pos, "\\b");
break;
case '\f':
append_raw(buf, bufsz, pos, "\\f");
break;
case '\n':
append_raw(buf, bufsz, pos, "\\n");
break;
case '\r':
append_raw(buf, bufsz, pos, "\\r");
break;
case '\t':
append_raw(buf, bufsz, pos, "\\t");
break;
default:
if (ch < 0x20) {
static const char hex[] = "0123456789abcdef";
append_raw(buf, bufsz, pos, "\\u00");
append_char(buf, bufsz, pos, hex[ch >> 4]);
append_char(buf, bufsz, pos, hex[ch & 0xf]);
} else {
append_char(buf, bufsz, pos, (char)ch);
}
break;
}
}
}
append_char(buf, bufsz, pos, '"');
}
static void finish_line(char *buf, size_t bufsz, size_t pos) {
if (bufsz == 0) {
return;
}
if (pos >= bufsz) {
buf[bufsz - 1] = '\0';
} else {
buf[pos] = '\0';
}
}
static void emit_line(const char *line) {
if (g_log_sink) {
g_log_sink(line);
if (g_log_sink_mode == CBM_LOG_SINK_REPLACE) {
return;
}
}
(void)fprintf(stderr, "%s\n", line);
}
void cbm_log(CBMLogLevel level, const char *msg, ...) {
if (level < g_log_level) {
return;
}
/* Build the log line into a buffer ONCE — no double va_list iteration */
char line_buf[CBM_SZ_512];
int pos =
snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s", level_str(level), msg ? msg : "");
char line_buf[CBM_SZ_4K];
size_t pos = 0;
va_list args;
va_start(args, msg);
for (;;) {
const char *key = va_arg(args, const char *);
if (!key) {
break;
if (g_log_format == CBM_LOG_FORMAT_JSON) {
append_raw(line_buf, sizeof(line_buf), &pos, "{\"level\":");
append_json_string(line_buf, sizeof(line_buf), &pos, level_str(level));
append_raw(line_buf, sizeof(line_buf), &pos, ",\"event\":");
append_json_string(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
for (;;) {
const char *key = va_arg(args, const char *);
if (!key) {
break;
}
const char *val = va_arg(args, const char *);
append_char(line_buf, sizeof(line_buf), &pos, ',');
append_json_string(line_buf, sizeof(line_buf), &pos, key);
append_char(line_buf, sizeof(line_buf), &pos, ':');
append_json_string(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
const char *val = va_arg(args, const char *);
if (!val) {
val = "";
}
if ((size_t)pos < sizeof(line_buf) - SKIP_ONE) {
pos += snprintf(line_buf + pos, sizeof(line_buf) - (size_t)pos, " %s=%s", key, val);
append_char(line_buf, sizeof(line_buf), &pos, '}');
} else {
append_raw(line_buf, sizeof(line_buf), &pos, "level=");
append_text_atom(line_buf, sizeof(line_buf), &pos, level_str(level));
append_raw(line_buf, sizeof(line_buf), &pos, " msg=");
append_text_atom(line_buf, sizeof(line_buf), &pos, msg ? msg : "");
for (;;) {
const char *key = va_arg(args, const char *);
if (!key) {
break;
}
const char *val = va_arg(args, const char *);
append_char(line_buf, sizeof(line_buf), &pos, ' ');
append_text_atom(line_buf, sizeof(line_buf), &pos, key);
append_char(line_buf, sizeof(line_buf), &pos, '=');
append_text_atom(line_buf, sizeof(line_buf), &pos, val ? val : "");
}
}
va_end(args);
/* When a sink is registered it takes over all output (exclusive).
* Otherwise write structured log to stderr. */
if (g_log_sink) {
g_log_sink(line_buf);
} else {
(void)fprintf(stderr, "%s\n", line_buf);
}
finish_line(line_buf, sizeof(line_buf), pos);
emit_line(line_buf);
}
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value) {
if (level < g_log_level) {
char value_buf[CBM_SZ_32];
snprintf(value_buf, sizeof(value_buf), "%" PRId64, value);
cbm_log(level, msg, key ? key : "?", value_buf, NULL);
}
static void copy_path_without_query(const char *path, char *out, size_t outsz) {
if (!out || outsz == 0) {
return;
}
out[0] = '\0';
if (!path) {
return;
}
size_t n = 0;
while (path[n] && path[n] != '?' && path[n] != '#' && n < outsz - 1) {
out[n] = path[n];
n++;
}
out[n] = '\0';
}
char line_buf[CBM_SZ_256];
snprintf(line_buf, sizeof(line_buf), "level=%s msg=%s %s=%" PRId64, level_str(level),
msg ? msg : "", key ? key : "?", value);
if (g_log_sink) {
g_log_sink(line_buf);
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
int64_t duration_us) {
char duration_ms[CBM_SZ_32];
snprintf(duration_ms, sizeof(duration_ms), "%" PRId64, duration_us / 1000);
if (tool_name && tool_name[0] != '\0') {
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
"method", method ? method : "", "tool", tool_name, "status",
is_error ? "error" : "ok", "duration_ms", duration_ms, NULL);
} else {
(void)fprintf(stderr, "%s\n", line_buf);
cbm_log(is_error ? CBM_LOG_WARN : CBM_LOG_INFO, "mcp.request", "protocol", "jsonrpc",
"method", method ? method : "", "status", is_error ? "error" : "ok", "duration_ms",
duration_ms, NULL);
}
}
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
int64_t duration_ms, size_t request_bytes, size_t response_bytes) {
char safe_path[CBM_SZ_1K];
char status_buf[CBM_SZ_16];
char duration_buf[CBM_SZ_32];
char request_buf[CBM_SZ_32];
char response_buf[CBM_SZ_32];
copy_path_without_query(path, safe_path, sizeof(safe_path));
snprintf(status_buf, sizeof(status_buf), "%d", status);
snprintf(duration_buf, sizeof(duration_buf), "%" PRId64, duration_ms);
snprintf(request_buf, sizeof(request_buf), "%zu", request_bytes);
snprintf(response_buf, sizeof(response_buf), "%zu", response_bytes);
CBMLogLevel level = CBM_LOG_INFO;
if (status >= 500) {
level = CBM_LOG_ERROR;
} else if (status >= 400) {
level = CBM_LOG_WARN;
}
cbm_log(level, "http.request", "component", component ? component : "", "method",
method ? method : "", "path", safe_path, "status", status_buf, "duration_ms",
duration_buf, "request_bytes", request_buf, "response_bytes", response_buf, NULL);
}
+30 -3
View File
@@ -3,7 +3,8 @@
*
* Design:
* - All output goes to stderr (stdout is reserved for MCP JSON-RPC)
* - Structured format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
* - Structured text format: "level=info msg=pass.timing pass=defs elapsed_ms=42"
* - Optional JSON format for local structured parsing
* - Levels: DEBUG, INFO, WARN, ERROR
* - Level filtering at runtime via cbm_log_set_level() or the
* CBM_LOG_LEVEL env var (see cbm_log_init_from_env)
@@ -12,7 +13,9 @@
#ifndef CBM_LOG_H
#define CBM_LOG_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
typedef enum {
CBM_LOG_DEBUG = 0,
@@ -22,11 +25,23 @@ typedef enum {
CBM_LOG_NONE = 4 /* disable all logging */
} CBMLogLevel;
typedef enum {
CBM_LOG_FORMAT_TEXT = 0,
CBM_LOG_FORMAT_JSON = 1,
} CBMLogFormat;
typedef enum {
CBM_LOG_SINK_REPLACE = 0,
CBM_LOG_SINK_TEE = 1,
} CBMLogSinkMode;
/* Apply the CBM_LOG_LEVEL environment variable to the runtime log level.
* Accepts (case-insensitive) "debug", "info", "warn", "error", "none", or
* the numeric equivalents 0..4 matching CBMLogLevel. Unknown, empty, or
* unset values leave the level unchanged (fail-open). Call once at startup,
* before any threads or log statements. Distilled from #414 (closes #413). */
* unset values leave the level unchanged (fail-open).
*
* Also applies CBM_LOG_FORMAT=text|json. If unset, the current format is left
* unchanged. Call once at startup before any threads or log lines. */
void cbm_log_init_from_env(void);
/* Set minimum log level (default: INFO). */
@@ -35,6 +50,10 @@ void cbm_log_set_level(CBMLogLevel level);
/* Get current log level. */
CBMLogLevel cbm_log_get_level(void);
/* Set/get output format. Default is text. */
void cbm_log_set_format(CBMLogFormat format);
CBMLogFormat cbm_log_get_format(void);
/* Core logging function. msg is a short semantic tag.
* Variadic args are key-value pairs: (const char *key, const char *value)...
* Terminated by NULL key.
@@ -57,8 +76,16 @@ void cbm_log(CBMLogLevel level, const char *msg, ...);
/* Log with integer value (avoids sprintf for common case). */
void cbm_log_int(CBMLogLevel level, const char *msg, const char *key, int64_t value);
/* Operational event helpers. They deliberately avoid request bodies, headers,
* arguments, and query strings. */
void cbm_log_mcp_request(const char *method, const char *tool_name, bool is_error,
int64_t duration_us);
void cbm_log_http_request(const char *component, const char *method, const char *path, int status,
int64_t duration_ms, size_t request_bytes, size_t response_bytes);
/* Optional log sink callback — called with the formatted log line. */
typedef void (*cbm_log_sink_fn)(const char *line);
void cbm_log_set_sink(cbm_log_sink_fn fn);
void cbm_log_set_sink_ex(cbm_log_sink_fn fn, CBMLogSinkMode mode);
#endif /* CBM_LOG_H */
+1 -1
View File
@@ -429,7 +429,7 @@ int main(int argc, char **argv) {
* No more untracked heap blind spots. */
/* Store binary path for subprocess spawning + hook log sink */
cbm_http_server_set_binary_path(argv[0]);
cbm_log_set_sink(cbm_ui_log_append);
cbm_log_set_sink_ex(cbm_ui_log_append, CBM_LOG_SINK_TEE);
cbm_log_info("server.start", "version", CBM_VERSION);
cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */
+193 -37
View File
@@ -32,6 +32,7 @@ enum {
MCP_URI_PREFIX = 7, /* strlen("file://") */
MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */
MCP_RETURN_2 = 2,
MCP_TOOLS_PAGE_SIZE = 8,
};
#define MCP_MS_TO_US 1000LL
#define MCP_S_TO_US 1000000LL
@@ -249,29 +250,66 @@ char *cbm_mcp_text_result(const char *text, bool is_error) {
yyjson_mut_val *content = yyjson_mut_arr(doc);
yyjson_mut_val *item = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, item, "type", "text");
yyjson_mut_obj_add_str(doc, item, "text", text);
yyjson_mut_obj_add_str(doc, item, "text", text ? text : "");
yyjson_mut_arr_add_val(content, item);
yyjson_mut_obj_add_val(doc, root, "content", content);
if (is_error) {
yyjson_mut_obj_add_bool(doc, root, "isError", true);
if (!is_error && text) {
yyjson_doc *structured_doc = yyjson_read(text, strlen(text), 0);
if (structured_doc) {
yyjson_val *structured_root = yyjson_doc_get_root(structured_doc);
if (yyjson_is_obj(structured_root)) {
yyjson_mut_val *structured = yyjson_val_mut_copy(doc, structured_root);
yyjson_mut_obj_add_val(doc, root, "structuredContent", structured);
}
yyjson_doc_free(structured_doc);
}
}
yyjson_mut_obj_add_bool(doc, root, "isError", is_error);
char *out = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return out;
}
bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
const char *active_id_str) {
if (!params_json) {
return false;
}
yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
if (!doc) {
return false;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *request_id = yyjson_obj_get(root, "requestId");
bool matches = false;
if (request_id) {
if (active_id_str) {
matches =
yyjson_is_str(request_id) && strcmp(yyjson_get_str(request_id), active_id_str) == 0;
} else {
matches = yyjson_is_int(request_id) && yyjson_get_int(request_id) == active_id;
}
}
yyjson_doc_free(doc);
return matches;
}
/* ── Tool definitions ─────────────────────────────────────────── */
typedef struct {
const char *name;
const char *title;
const char *description;
const char *input_schema; /* JSON string */
} tool_def_t;
static const tool_def_t TOOLS[] = {
{"index_repository",
{"index_repository", "Index repository",
"Index a repository into the knowledge graph. "
"Special mode 'cross-repo-intelligence': skip extraction, only match Routes/Channels "
"across projects to create CROSS_HTTP_CALLS/CROSS_ASYNC_CALLS/CROSS_CHANNEL edges. "
@@ -287,12 +325,15 @@ static const tool_def_t TOOLS[] = {
"\"target_projects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},"
"\"description\":\"Projects to search for cross-repo links (cross-repo-intelligence mode). "
"Use [\\\"*\\\"] for all indexed projects. Run list_projects to see available projects.\"},"
"\"name\":{\"type\":\"string\",\"description\":"
"\"Override the derived project name. Non-ASCII bytes are encoded and unsafe path characters "
"are normalized.\"},"
"\"persistence\":{\"type\":\"boolean\",\"default\":false,\"description\":"
"\"Write compressed artifact to .codebase-memory/graph.db.zst for team sharing. "
"Teammates can bootstrap from the artifact instead of full re-indexing.\"}"
"},\"required\":[\"repo_path\"]}"},
{"search_graph",
{"search_graph", "Search graph",
"Search the code knowledge graph for functions, classes, routes, and variables. Use INSTEAD "
"OF grep/glob when finding code definitions, implementations, or relationships. Three search "
"modes: (1) query='update settings' for BM25 ranked full-text search with camelCase "
@@ -329,7 +370,7 @@ static const tool_def_t TOOLS[] = {
"increment offset by limit and re-call while has_more is true.\"}},"
"\"required\":[\"project\"]}"},
{"query_graph",
{"query_graph", "Query graph",
"Execute a Cypher query against the knowledge graph for complex multi-hop patterns, "
"aggregations, and cross-service analysis. The response includes 'total' (returned "
"row count). There is a hard 100k row ceiling — for broad queries add LIMIT in the "
@@ -352,7 +393,7 @@ static const tool_def_t TOOLS[] = {
"ceiling. No offset support — use search_graph for paginated browsing.\"}},"
"\"required\":[\"query\",\"project\"]}"},
{"trace_path",
{"trace_path", "Trace path",
"Trace paths through the code graph. Modes: calls (callers/callees), data_flow (value "
"propagation with args at each hop), cross_service (through HTTP/async Route nodes). "
"Use INSTEAD OF grep for callers, dependencies, impact analysis, or data flow tracing.",
@@ -373,7 +414,7 @@ static const tool_def_t TOOLS[] = {
"filtered out. When true, test nodes are included with is_test=true marker."
"\"}},\"required\":[\"function_name\",\"project\"]}"},
{"get_code_snippet",
{"get_code_snippet", "Get code snippet",
"Read source code for a function/class/symbol. IMPORTANT: First call search_graph to find the "
"exact qualified_name, then pass it here. This is a read tool, not a search tool. Accepts "
"full qualified_name (exact match) or short function name (returns suggestions if ambiguous).",
@@ -382,11 +423,12 @@ static const tool_def_t TOOLS[] = {
"\"type\":\"string\"},\"include_neighbors\":{"
"\"type\":\"boolean\",\"default\":false}},\"required\":[\"qualified_name\",\"project\"]}"},
{"get_graph_schema", "Get the schema of the knowledge graph (node labels, edge types)",
{"get_graph_schema", "Get graph schema",
"Get the schema of the knowledge graph (node labels, edge types)",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
{"get_architecture",
{"get_architecture", "Get architecture",
"Get high-level architecture overview — packages, services, dependencies, and project "
"structure at a glance. Includes 'clusters': Leiden community detection over the call/import "
"graph, surfacing the de-facto modules (each with a label, member count, cohesion score, "
@@ -399,7 +441,7 @@ static const tool_def_t TOOLS[] = {
"\"aspects\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
"}"},
{"search_code",
{"search_code", "Search code",
"Graph-augmented code search. Finds text patterns via grep, then enriches results with "
"the knowledge graph: deduplicates matches into containing functions, ranks by structural "
"importance (definitions first, popular functions next, tests last). "
@@ -423,17 +465,17 @@ static const tool_def_t TOOLS[] = {
"offset parameter — raise limit or narrow with file_pattern / path_filter to see more."
"\",\"default\":10}},\"required\":[\"pattern\",\"project\"]}"},
{"list_projects", "List all indexed projects", "{\"type\":\"object\",\"properties\":{}}"},
{"delete_project", "Delete a project from the index",
{"list_projects", "List projects", "List all indexed projects",
"{\"type\":\"object\",\"properties\":{}}"},
{"delete_project", "Delete project", "Delete a project from the index",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
{"index_status", "Get the indexing status of a project",
{"index_status", "Index status", "Get the indexing status of a project",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
{"detect_changes", "Detect code changes and their impact",
{"detect_changes", "Detect changes", "Detect code changes and their impact",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"scope\":{\"type\":"
"\"string\"},\"depth\":{\"type\":\"integer\",\"default\":2},\"base_branch\":{\"type\":"
"\"string\",\"default\":\"main\"},\"since\":{\"type\":\"string\",\"description\":"
@@ -441,13 +483,13 @@ static const tool_def_t TOOLS[] = {
"\"required\":"
"[\"project\"]}"},
{"manage_adr", "Create or update Architecture Decision Records",
{"manage_adr", "Manage ADR", "Create or update Architecture Decision Records",
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":"
"\"string\",\"enum\":[\"get\",\"update\",\"sections\"]},\"content\":{\"type\":\"string\"},"
"\"sections\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"project\"]"
"}"},
{"ingest_traces", "Ingest runtime traces to enhance the knowledge graph",
{"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph",
"{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":"
"\"object\"}},\"project\":{\"type\":"
"\"string\"}},\"required\":[\"traces\",\"project\"]}"},
@@ -455,37 +497,111 @@ static const tool_def_t TOOLS[] = {
static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]);
char *cbm_mcp_tools_list(void) {
static const char MCP_TOOL_OUTPUT_SCHEMA[] = "{\"type\":\"object\",\"additionalProperties\":true}";
static void mcp_add_json_schema(yyjson_mut_doc *doc, yyjson_mut_val *obj, const char *key,
const char *schema_json) {
yyjson_doc *schema_doc = yyjson_read(schema_json, strlen(schema_json), 0);
if (schema_doc) {
yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
if (schema) {
yyjson_mut_obj_add_val(doc, obj, key, schema);
}
yyjson_doc_free(schema_doc);
}
}
static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i) {
yyjson_mut_val *tool = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
yyjson_mut_obj_add_str(doc, tool, "title", TOOLS[i].title);
yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
mcp_add_json_schema(doc, tool, "inputSchema", TOOLS[i].input_schema);
mcp_add_json_schema(doc, tool, "outputSchema", MCP_TOOL_OUTPUT_SCHEMA);
yyjson_mut_arr_add_val(tools, tool);
}
static char *cbm_mcp_tools_list_range(int offset, int limit, bool include_next_cursor) {
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = yyjson_mut_obj(doc);
yyjson_mut_doc_set_root(doc, root);
yyjson_mut_val *tools = yyjson_mut_arr(doc);
for (int i = 0; i < TOOL_COUNT; i++) {
yyjson_mut_val *tool = yyjson_mut_obj(doc);
yyjson_mut_obj_add_str(doc, tool, "name", TOOLS[i].name);
yyjson_mut_obj_add_str(doc, tool, "description", TOOLS[i].description);
if (offset < 0) {
offset = 0;
}
if (offset > TOOL_COUNT) {
offset = TOOL_COUNT;
}
if (limit < 0 || limit > TOOL_COUNT) {
limit = TOOL_COUNT;
}
/* Parse input schema JSON and embed */
yyjson_doc *schema_doc =
yyjson_read(TOOLS[i].input_schema, strlen(TOOLS[i].input_schema), 0);
if (schema_doc) {
yyjson_mut_val *schema = yyjson_val_mut_copy(doc, yyjson_doc_get_root(schema_doc));
yyjson_mut_obj_add_val(doc, tool, "inputSchema", schema);
yyjson_doc_free(schema_doc);
}
int end = offset + limit;
if (end > TOOL_COUNT) {
end = TOOL_COUNT;
}
yyjson_mut_arr_add_val(tools, tool);
for (int i = offset; i < end; i++) {
mcp_add_tool_def(doc, tools, i);
}
yyjson_mut_obj_add_val(doc, root, "tools", tools);
if (include_next_cursor && end < TOOL_COUNT) {
char cursor[32];
snprintf(cursor, sizeof(cursor), "%d", end);
yyjson_mut_obj_add_strcpy(doc, root, "nextCursor", cursor);
}
char *out = yy_doc_to_str(doc);
yyjson_mut_doc_free(doc);
return out;
}
char *cbm_mcp_tools_list(void) {
return cbm_mcp_tools_list_range(0, TOOL_COUNT, false);
}
static int mcp_tools_cursor_offset(const char *params_json) {
if (!params_json) {
return 0;
}
yyjson_doc *doc = yyjson_read(params_json, strlen(params_json), 0);
if (!doc) {
return 0;
}
int offset = 0;
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *cursor = root ? yyjson_obj_get(root, "cursor") : NULL;
if (cursor) {
offset = TOOL_COUNT;
if (yyjson_is_str(cursor)) {
const char *cursor_str = yyjson_get_str(cursor);
if (cursor_str && *cursor_str != '\0') {
char *endptr = NULL;
errno = 0;
long parsed = strtol(cursor_str, &endptr, 10);
if (endptr && *endptr == '\0' && errno == 0 && parsed >= 0) {
offset = parsed > TOOL_COUNT ? TOOL_COUNT : (int)parsed;
}
}
}
}
yyjson_doc_free(doc);
return offset;
}
static char *cbm_mcp_tools_list_page(const char *params_json) {
return cbm_mcp_tools_list_range(mcp_tools_cursor_offset(params_json), MCP_TOOLS_PAGE_SIZE,
true);
}
/* Supported protocol versions, newest first. The server picks the newest
* version that it shares with the client (per MCP spec version negotiation). */
static const char *SUPPORTED_PROTOCOL_VERSIONS[] = {
@@ -531,6 +647,7 @@ char *cbm_mcp_initialize_response(const char *params_json) {
yyjson_mut_val *caps = yyjson_mut_obj(doc);
yyjson_mut_val *tools_cap = yyjson_mut_obj(doc);
yyjson_mut_obj_add_bool(doc, tools_cap, "listChanged", false);
yyjson_mut_obj_add_val(doc, caps, "tools", tools_cap);
yyjson_mut_obj_add_val(doc, root, "capabilities", caps);
@@ -644,6 +761,7 @@ struct cbm_mcp_server {
/* Active pipeline tracking for cancellation support */
cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */
int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */
char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */
};
cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) {
@@ -703,6 +821,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) {
cbm_store_close(srv->store);
}
free(srv->current_project);
free(srv->active_request_id_str);
free(srv);
}
@@ -2918,15 +3037,18 @@ static bool build_index_success_response(cbm_mcp_server_t *srv, yyjson_mut_doc *
static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
char *repo_path = cbm_mcp_get_string_arg(args, "repo_path");
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
char *name_override = cbm_mcp_get_string_arg(args, "name");
cbm_normalize_path_sep(repo_path);
if (!repo_path) {
free(mode_str);
free(name_override);
return cbm_mcp_text_result("repo_path is required", true);
}
if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) {
free(mode_str);
free(name_override);
char *result = handle_cross_repo_mode(repo_path, args);
free(repo_path);
return result;
@@ -2944,9 +3066,17 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
cbm_pipeline_t *p = cbm_pipeline_new(repo_path, NULL, mode);
if (!p) {
free(name_override);
free(repo_path);
return cbm_mcp_text_result("failed to create pipeline", true);
}
if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) {
cbm_pipeline_free(p);
free(name_override);
free(repo_path);
return cbm_mcp_text_result("invalid project name", true);
}
free(name_override);
cbm_pipeline_set_persistence(p, persistence);
char *project_name = heap_strdup(cbm_pipeline_project_name(p));
@@ -4962,18 +5092,21 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
/* Notifications (no id) → handle cancellation, then no response */
if (!req.has_id) {
if (req.method && strcmp(req.method, "notifications/cancelled") == 0) {
/* MCP cancellation: cancel the active pipeline if request ID matches */
if (srv->active_pipeline) {
if (srv->active_pipeline &&
cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id,
srv->active_request_id_str)) {
cbm_pipeline_cancel(srv->active_pipeline);
cbm_log_info("mcp.cancelled", "request_id_active",
srv->active_request_id > 0 ? "yes" : "none");
cbm_log_info("mcp.cancelled", "match", "true");
}
}
cbm_jsonrpc_request_free(&req);
return NULL;
}
struct timespec req_t0;
cbm_clock_gettime(CLOCK_MONOTONIC, &req_t0);
char *result_json = NULL;
bool request_logged = false;
if (strcmp(req.method, "initialize") == 0) {
result_json = cbm_mcp_initialize_response(req.params_raw);
@@ -4983,21 +5116,31 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
} else if (strcmp(req.method, "ping") == 0) {
result_json = heap_strdup("{}");
} else if (strcmp(req.method, "tools/list") == 0) {
result_json = cbm_mcp_tools_list();
result_json = cbm_mcp_tools_list_page(req.params_raw);
} else if (strcmp(req.method, "tools/call") == 0) {
char *tool_name = req.params_raw ? cbm_mcp_get_tool_name(req.params_raw) : NULL;
char *tool_args =
req.params_raw ? cbm_mcp_get_arguments(req.params_raw) : heap_strdup("{}");
srv->active_request_id = req.id;
free(srv->active_request_id_str);
srv->active_request_id_str = req.id_str ? heap_strdup(req.id_str) : NULL;
struct timespec t0;
cbm_clock_gettime(CLOCK_MONOTONIC, &t0);
result_json = cbm_mcp_handle_tool(srv, tool_name, tool_args);
srv->active_request_id = CBM_NOT_FOUND;
free(srv->active_request_id_str);
srv->active_request_id_str = NULL;
struct timespec t1;
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
long long dur_us = ((long long)(t1.tv_sec - t0.tv_sec) * MCP_S_TO_US) +
((long long)(t1.tv_nsec - t0.tv_nsec) / MCP_MS_TO_US);
bool is_err = (result_json != NULL) && (strstr(result_json, "\"isError\":true") != NULL);
cbm_diag_record_query(dur_us, is_err);
long long request_dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
cbm_log_mcp_request(req.method, tool_name, is_err, request_dur_us);
request_logged = true;
result_json = inject_update_notice(srv, result_json);
free(tool_name);
@@ -5013,10 +5156,23 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) {
.error_json = err_obj,
};
char *err = cbm_jsonrpc_format_response(&err_resp);
struct timespec t1;
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
cbm_log_mcp_request(req.method, NULL, true, dur_us);
cbm_jsonrpc_request_free(&req);
return err;
}
if (!request_logged) {
struct timespec t1;
cbm_clock_gettime(CLOCK_MONOTONIC, &t1);
long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) +
((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US);
cbm_log_mcp_request(req.method, NULL, false, dur_us);
}
cbm_jsonrpc_response_t resp = {
.id = req.id,
.id_str = req.id_str,
+4
View File
@@ -54,6 +54,10 @@ char *cbm_jsonrpc_format_error(int64_t id, int code, const char *message);
/* Format an MCP tool result with text content. Returns heap-allocated JSON. */
char *cbm_mcp_text_result(const char *text, bool is_error);
/* Return true when notifications/cancelled params target the active request. */
bool cbm_mcp_cancel_request_matches(const char *params_json, int64_t active_id,
const char *active_id_str);
/* Format the tools/list response. Returns heap-allocated JSON. */
char *cbm_mcp_tools_list(void);
+4 -4
View File
@@ -363,10 +363,10 @@ char *cbm_project_name_from_path(const char *abs_path) {
/* Normalize path separators */
cbm_normalize_path_sep(path);
/* Map every character cbm_validate_project_name would reject. The
* validator (used by resolve_store via project_db_path) allows only
* [A-Za-z0-9._-], so anything else — path separators, ':', spaces, '@',
* '+', … — must be normalized here. Otherwise a repo like
/* Map every character that is unsafe for portable project DB names. We
* keep derived names in [A-Za-z0-9._-], so anything else — path
* separators, ':', spaces, '@', '+', … — must be normalized here.
* Otherwise a repo like
* "/home/u/my project" yields the name "home-u-my project": indexing
* creates the DB and it shows in list_projects, but resolve_store rejects
* the space and reports project-not-found (#349).
+22
View File
@@ -27,6 +27,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P
#include "foundation/platform.h"
#include "foundation/compat_fs.h"
#include "foundation/log.h"
#include "foundation/str_util.h"
#include "foundation/hash_table.h"
#include "foundation/compat.h"
#include "foundation/compat_thread.h"
@@ -175,6 +176,27 @@ void cbm_pipeline_set_persistence(cbm_pipeline_t *p, bool enabled) {
}
}
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name) {
if (!p || !name || !name[0]) {
return false;
}
char *normalized = cbm_project_name_from_path(name);
if (!normalized) {
return false;
}
if (!cbm_validate_project_name(normalized)) {
free(normalized);
return false;
}
free(p->project_name);
p->project_name = normalized;
free(p->branch_qn);
p->branch_qn = cbm_git_context_branch_qn(p->project_name, &p->git_ctx);
return true;
}
void cbm_pipeline_free(cbm_pipeline_t *p) {
if (!p) {
return;
+3
View File
@@ -63,6 +63,9 @@ void cbm_pipeline_cancel(cbm_pipeline_t *p);
* owned by the pipeline. Valid until cbm_pipeline_free(). */
const char *cbm_pipeline_project_name(const cbm_pipeline_t *p);
/* Override the derived project name with a sanitized user-provided label. */
bool cbm_pipeline_set_project_name(cbm_pipeline_t *p, const char *name);
/* Get the index mode (CBM_MODE_FULL, CBM_MODE_MODERATE, CBM_MODE_FAST). */
int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
+180 -26
View File
@@ -19,10 +19,12 @@
#include "ui/layout3d.h"
#include "mcp/mcp.h"
#include "store/store.h"
#include "cli/cli.h"
/* pipeline.h no longer needed — indexing runs as subprocess */
#include "foundation/log.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/compat_fs.h"
#include "foundation/str_util.h"
#include "foundation/compat_thread.h"
@@ -39,6 +41,7 @@
#include <process.h>
#include <psapi.h> /* GetProcessMemoryInfo */
#else
#include <sys/stat.h>
#include <unistd.h>
#include <sys/wait.h>
#endif
@@ -79,6 +82,33 @@ static void update_cors(const cbm_http_req_t *req) {
snprintf(g_cors_json, sizeof(g_cors_json), "%sContent-Type: application/json\r\n", g_cors);
}
static const char *detect_ui_lang(const char *accept_language) {
if (accept_language && (strstr(accept_language, "zh-CN") || strstr(accept_language, "zh"))) {
return "zh";
}
return "en";
}
static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) {
const char *lang = NULL;
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
if (cfg) {
const char *pinned = cbm_config_get(cfg, CBM_CONFIG_UI_LANG, "auto");
if (strcmp(pinned, "zh") == 0 || strcmp(pinned, "en") == 0) {
lang = pinned;
}
}
char lang_buf[8];
snprintf(lang_buf, sizeof(lang_buf), "%s", lang ? lang : detect_ui_lang(req->accept_language));
if (cfg) {
cbm_config_close(cfg);
}
cbm_http_replyf(c, 200, g_cors_json, "{\"lang\":\"%s\"}", lang_buf);
}
/* ── Server state ─────────────────────────────────────────────── */
struct cbm_http_server {
@@ -396,6 +426,26 @@ static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) {
#include <dirent.h>
static void append_roots_json(char *buf, size_t bufsz, int *pos) {
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, ",\"roots\":[");
#ifdef _WIN32
DWORD drives = GetLogicalDrives();
int count = 0;
for (int i = 0; i < 26; i++) {
if (!(drives & (1u << i))) {
continue;
}
if (count++ > 0) {
buf[(*pos)++] = ',';
}
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"%c:/\"", 'A' + i);
}
#else
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "\"/\"");
#endif
*pos += snprintf(buf + *pos, bufsz - (size_t)*pos, "]");
}
/* GET /api/browse?path=/some/dir — list subdirectories for file picker */
static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
char path[1024] = {0};
@@ -481,7 +531,9 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) {
{
char esc_parent[2048];
cbm_json_escape(esc_parent, (int)sizeof(esc_parent), parent);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"}", esc_parent);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "],\"parent\":\"%s\"", esc_parent);
append_roots_json(buf, sizeof(buf), &pos);
pos += snprintf(buf + pos, sizeof(buf) - (size_t)pos, "}");
}
cbm_http_replyf(c, 200, g_cors_json, "%s", buf);
}
@@ -596,9 +648,107 @@ static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) {
static char g_binary_path[1024] = {0};
static bool copy_path(char *out, size_t outsz, const char *path) {
if (!out || outsz == 0 || !path || !path[0]) {
return false;
}
int n = snprintf(out, outsz, "%s", path);
return n > 0 && (size_t)n < outsz;
}
#ifndef _WIN32
static bool is_executable_file(const char *path) {
struct stat st;
return path && stat(path, &st) == 0 && S_ISREG(st.st_mode) && access(path, X_OK) == 0;
}
static bool resolve_from_path(const char *name, char *out, size_t outsz) {
const char *path = getenv("PATH");
if (!name || !name[0] || strchr(name, '/') || !path || !path[0]) {
return false;
}
const char *cur = path;
while (*cur) {
const char *colon = strchr(cur, ':');
size_t dir_len = colon ? (size_t)(colon - cur) : strlen(cur);
if (dir_len > 0 && dir_len < 900) {
char candidate[1024];
int n = snprintf(candidate, sizeof(candidate), "%.*s/%s", (int)dir_len, cur, name);
if (n > 0 && (size_t)n < sizeof(candidate) && is_executable_file(candidate)) {
return copy_path(out, outsz, candidate);
}
}
if (!colon) {
break;
}
cur = colon + 1;
}
return false;
}
static bool resolve_self_executable(char *out, size_t outsz) {
#if defined(__APPLE__)
char buf[1024];
uint32_t sz = sizeof(buf);
if (_NSGetExecutablePath(buf, &sz) == 0 && buf[0]) {
return copy_path(out, outsz, buf);
}
return false;
#else
char buf[1024];
ssize_t len = readlink("/proc/self/exe", buf, sizeof(buf) - 1);
if (len > 0) {
buf[len] = '\0';
return copy_path(out, outsz, buf);
}
return false;
#endif
}
#else
static bool resolve_self_executable(char *out, size_t outsz) {
char buf[1024];
DWORD n = GetModuleFileNameA(NULL, buf, (DWORD)sizeof(buf));
if (n > 0 && n < sizeof(buf)) {
return copy_path(out, outsz, buf);
}
return false;
}
#endif
bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz) {
if (!out || outsz == 0) {
return false;
}
out[0] = '\0';
#ifndef _WIN32
if (argv0 && strchr(argv0, '/') && is_executable_file(argv0)) {
return copy_path(out, outsz, argv0);
}
if (resolve_from_path(argv0, out, outsz)) {
return true;
}
#else
if (argv0 && argv0[0]) {
DWORD attrs = GetFileAttributesA(argv0);
if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) {
return copy_path(out, outsz, argv0);
}
}
#endif
if (resolve_self_executable(out, outsz)) {
return true;
}
return copy_path(out, outsz, argv0);
}
void cbm_http_server_set_binary_path(const char *path) {
if (path) {
snprintf(g_binary_path, sizeof(g_binary_path), "%s", path);
if (!cbm_http_server_resolve_binary_path(path, g_binary_path, sizeof(g_binary_path))) {
g_binary_path[0] = '\0';
}
}
}
@@ -611,36 +761,24 @@ static void *index_thread_fn(void *arg) {
const char *bin = g_binary_path;
char self_path[1024] = {0};
if (!bin[0]) {
#ifdef _WIN32
GetModuleFileNameA(NULL, self_path, sizeof(self_path));
#elif defined(__APPLE__)
uint32_t sz = sizeof(self_path);
_NSGetExecutablePath(self_path, &sz);
#else
ssize_t len = readlink("/proc/self/exe", self_path, sizeof(self_path) - 1);
if (len > 0)
self_path[len] = '\0';
#endif
cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path));
bin = self_path[0] ? self_path : "codebase-memory-mcp";
}
char log_file[256];
/* JSON-escape root_path to prevent injection via double-quotes or backslashes */
/* JSON-escape root_path and optional project name. */
char escaped_path[2048];
{
const char *s = job->root_path;
size_t j = 0;
for (; *s && j < sizeof(escaped_path) - 2; s++) {
if (*s == '"' || *s == '\\') {
escaped_path[j++] = '\\';
}
escaped_path[j++] = *s;
}
escaped_path[j] = '\0';
}
cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path);
char escaped_name[512];
cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name);
char json_arg[4096];
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
if (job->project_name[0]) {
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path,
escaped_name);
} else {
snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path);
}
#ifdef _WIN32
snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log",
@@ -766,7 +904,7 @@ static void *index_thread_fn(void *arg) {
return NULL;
}
/* POST /api/index — body: {"root_path": "/abs/path"} → starts background indexing */
/* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */
static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
if (req->body_len == 0 || req->body_len > 4096) {
cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}");
@@ -786,6 +924,8 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
return;
}
const char *rpath = yyjson_get_str(v_path);
yyjson_val *v_project_name = yyjson_obj_get(root, "project_name");
const char *project_name = yyjson_is_str(v_project_name) ? yyjson_get_str(v_project_name) : "";
/* Check path exists */
if (!cbm_is_dir(rpath)) {
@@ -811,6 +951,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) {
index_job_t *job = &g_index_jobs[slot];
snprintf(job->root_path, sizeof(job->root_path), "%s", rpath);
snprintf(job->project_name, sizeof(job->project_name), "%s", project_name);
job->error_msg[0] = '\0';
atomic_store(&job->status, 1);
yyjson_doc_free(doc);
@@ -1266,6 +1407,12 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c,
return;
}
/* GET /api/ui-config → language and local UI preferences */
if (is_get && cbm_http_path_match(req->path, "/api/ui-config")) {
handle_ui_config(c, req);
return;
}
/* DELETE /api/project → delete a project's .db file */
if (is_delete && cbm_http_path_match(req->path, "/api/project*")) {
handle_delete_project(c, req);
@@ -1400,15 +1547,22 @@ void cbm_http_server_run(cbm_http_server_t *srv) {
if (!conn)
continue; /* timeout — re-check stop flag */
uint64_t request_start_ms = cbm_now_ms();
cbm_http_req_t req;
int rc = cbm_httpd_read_request(conn, &req);
if (rc == 0) {
dispatch_request(srv, conn, &req);
cbm_log_http_request("graph_ui", req.method, req.path, cbm_http_conn_status(conn),
(int64_t)(cbm_now_ms() - request_start_ms), req.body_len,
cbm_http_conn_response_bytes(conn));
cbm_http_req_free(&req);
} else if (rc > 0) {
/* Parse/transport error with a known HTTP status (400/408/411/413/431).
* No CORS reflection here — the request was never parsed. */
cbm_http_replyf(conn, rc, "", "bad request");
cbm_log_http_request("graph_ui", "", "", cbm_http_conn_status(conn),
(int64_t)(cbm_now_ms() - request_start_ms), 0,
cbm_http_conn_response_bytes(conn));
}
cbm_httpd_conn_close(conn);
}
+4
View File
@@ -11,6 +11,7 @@
#define CBM_UI_HTTP_SERVER_H
#include <stdbool.h>
#include <stddef.h>
typedef struct cbm_http_server cbm_http_server_t;
@@ -47,4 +48,7 @@ void cbm_ui_log_append(const char *line);
/* Set the binary path for subprocess spawning (call from main). */
void cbm_http_server_set_binary_path(const char *path);
/* Resolve argv[0] into an executable path suitable for subprocess spawning. */
bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t outsz);
#endif /* CBM_UI_HTTP_SERVER_H */
+14
View File
@@ -52,6 +52,8 @@ struct cbm_httpd {
struct cbm_http_conn {
cbm_sock_t fd;
int recv_deadline_ms;
int response_status;
size_t response_bytes;
};
/* ── Small platform helpers ───────────────────────────────────── */
@@ -215,6 +217,14 @@ void cbm_httpd_conn_close(cbm_http_conn_t *c) {
free(c);
}
int cbm_http_conn_status(const cbm_http_conn_t *c) {
return c ? c->response_status : 0;
}
size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c) {
return c ? c->response_bytes : 0;
}
/* ── Head parsing ─────────────────────────────────────────────── */
static bool header_name_is(const char *line, size_t name_len, const char *name) {
@@ -332,6 +342,8 @@ int cbm_http_parse_head(const char *data, size_t len, cbm_http_req_t *req, size_
if (header_name_is(p, nlen, "origin")) {
copy_header_value(colon + 1, eol, req->origin, sizeof(req->origin));
} else if (header_name_is(p, nlen, "accept-language")) {
copy_header_value(colon + 1, eol, req->accept_language, sizeof(req->accept_language));
} else if (header_name_is(p, nlen, "transfer-encoding")) {
/* Chunked (or any transfer coding) is not supported. */
return 411;
@@ -506,6 +518,8 @@ void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_header
size_t len) {
if (!c)
return;
c->response_status = status;
c->response_bytes = len;
char head[1024];
int hn = snprintf(head, sizeof(head),
"HTTP/1.1 %d %s\r\n"
+5 -2
View File
@@ -42,13 +42,14 @@ typedef struct cbm_httpd cbm_httpd_t; /* listener */
typedef struct cbm_http_conn cbm_http_conn_t; /* accepted connection */
/* A parsed request. `path` and `query` are raw (NOT percent-decoded).
* `origin` is the Origin header value ("" when absent) — the only header
* the routing layer consumes. `body` is heap-allocated, NUL-terminated. */
* `origin` and `accept_language` are the header values consumed by the
* routing layer ("" when absent). `body` is heap-allocated, NUL-terminated. */
typedef struct {
char method[16];
char path[2048];
char query[2048];
char origin[256];
char accept_language[256];
char *body;
size_t body_len;
} cbm_http_req_t;
@@ -95,6 +96,8 @@ void cbm_http_replyf(cbm_http_conn_t *c, int status, const char *extra_headers,
void cbm_http_reply_buf(cbm_http_conn_t *c, int status, const char *extra_headers, const void *data,
size_t len);
int cbm_http_conn_status(const cbm_http_conn_t *c);
size_t cbm_http_conn_response_bytes(const cbm_http_conn_t *c);
void cbm_httpd_conn_close(cbm_http_conn_t *c);
/* ── Pure helpers (unit-tested without sockets) ───────────────── */
+29 -3
View File
@@ -17,13 +17,15 @@
#include <yyjson/yyjson.h>
#include <math.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
/* ── Constants ────────────────────────────────────────────────── */
#define DEFAULT_MAX_NODES 50000
#define DEFAULT_MAX_NODES 2000
#define HARD_MAX_NODES 10000
#define BH_THETA 1.2f
/* Local optimization: gentle, preserves structure */
@@ -112,6 +114,31 @@ static float rand_float(uint32_t *seed) {
return (float)((*seed >> 16) & 0x7FFF) / 32768.0f - 0.5f;
}
static int render_node_limit(void) {
const char *raw = getenv("CBM_UI_MAX_RENDER_NODES");
if (!raw || !raw[0]) {
return DEFAULT_MAX_NODES;
}
errno = 0;
char *end = NULL;
long v = strtol(raw, &end, 10);
if (errno != 0 || end == raw || *end != '\0' || v <= 0) {
return DEFAULT_MAX_NODES;
}
if (v > HARD_MAX_NODES) {
return HARD_MAX_NODES;
}
return (int)v;
}
static int clamp_max_nodes(int requested) {
int cap = render_node_limit();
if (requested <= 0 || requested > cap) {
return cap;
}
return requested;
}
/* ── Barnes-Hut Octree ────────────────────────────────────────── */
typedef struct octree_node {
@@ -389,8 +416,7 @@ cbm_layout_result_t *cbm_layout_compute(cbm_store_t *store, const char *project,
int radius, int max_nodes) {
if (!store || !project)
return NULL;
if (max_nodes <= 0)
max_nodes = DEFAULT_MAX_NODES;
max_nodes = clamp_max_nodes(max_nodes);
(void)center_node;
(void)radius;
(void)level;
+14
View File
@@ -478,6 +478,19 @@ TEST(project_name_always_validator_safe_issue349) {
PASS();
}
TEST(project_name_encodes_unicode_segments_issue571) {
char *got = cbm_project_name_from_path(
"/Users/yunxin/Desktop/\xe5\xbc\x80\xe5\x8f\x91/"
"\xe5\x90\x8e\xe7\xab\xaf/"
"\xe4\xbf\xa1\xe7\xa7\x9f\xe9\xa3\x8e\xe6\x8e\xa7\xe9\x80\x9a\xe5\x90\x8e\xe7\xab\xaf");
ASSERT_NOT_NULL(got);
ASSERT_STR_EQ(got, "Users-yunxin-Desktop-e5bc80e58f91-e5908ee7abaf-"
"e4bfa1e7a79fe9a38ee68ea7e9809ae5908ee7abaf");
ASSERT_TRUE(cbm_validate_project_name(got));
free(got);
PASS();
}
/* ================================================================
* Suite
* ================================================================ */
@@ -579,6 +592,7 @@ SUITE(fqn) {
RUN_TEST(project_name_already_dashed);
RUN_TEST(project_name_deep_path);
RUN_TEST(project_name_always_validator_safe_issue349);
RUN_TEST(project_name_encodes_unicode_segments_issue571);
RUN_TEST(project_name_colon_only);
RUN_TEST(project_name_backslash_only);
RUN_TEST(project_name_consecutive_colons);
+143
View File
@@ -11,7 +11,12 @@
* RPC dispatch, transport limits, receive deadline, clean shutdown.
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/log.h"
#include "../src/foundation/platform.h"
#include "../src/cli/cli.h"
#include "../src/ui/http_server.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "ui/httpd.h"
@@ -20,6 +25,9 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/stat.h>
#endif
#ifdef _WIN32
#include <winsock2.h>
@@ -37,6 +45,18 @@ typedef int th_sock_t;
#define TH_SOCK_BAD (-1)
#endif
static char httpd_log_buf[8192];
static void httpd_capture_log(const char *line) {
size_t used = strlen(httpd_log_buf);
size_t avail = sizeof(httpd_log_buf) - used;
if (avail <= 1)
return;
int n = snprintf(httpd_log_buf + used, avail, "%s\n", line ? line : "");
if (n < 0 || (size_t)n >= avail)
httpd_log_buf[sizeof(httpd_log_buf) - 1] = '\0';
}
/* ── Raw-socket test client ───────────────────────────────────── */
static th_sock_t th_connect(int port) {
@@ -312,6 +332,41 @@ TEST(httpd_path_match_matrix) {
PASS();
}
TEST(httpd_resolves_bare_binary_path_from_path) {
#ifdef _WIN32
PASS();
#else
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_bin_XXXXXX");
char *td = cbm_mkdtemp(tmpdir);
ASSERT_NOT_NULL(td);
char exe[512];
snprintf(exe, sizeof(exe), "%s/codebase-memory-mcp", td);
FILE *f = fopen(exe, "w");
ASSERT_NOT_NULL(f);
fputs("#!/bin/sh\nexit 0\n", f);
fclose(f);
ASSERT_EQ(chmod(exe, 0755), 0);
char *old_path = getenv("PATH") ? strdup(getenv("PATH")) : NULL;
cbm_setenv("PATH", td, 1);
char resolved[1024];
ASSERT_TRUE(
cbm_http_server_resolve_binary_path("codebase-memory-mcp", resolved, sizeof(resolved)));
ASSERT_STR_EQ(resolved, exe);
if (old_path) {
cbm_setenv("PATH", old_path, 1);
free(old_path);
} else {
cbm_unsetenv("PATH");
}
PASS();
#endif
}
/* ── Transport integration (listener only) ────────────────────── */
TEST(httpd_listen_ephemeral_port) {
@@ -507,6 +562,61 @@ TEST(ui_server_browse_traversal_probe) {
PASS();
}
TEST(ui_server_ui_config_detects_zh_accept_language) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: zh-CN,zh;q=0.9,en;q=0.8\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
PASS();
}
TEST(ui_server_ui_config_prefers_config_lang) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_cfg_XXXXXX");
char *td = cbm_mkdtemp(tmpdir);
ASSERT_NOT_NULL(td);
char *old_home = getenv("HOME") ? strdup(getenv("HOME")) : NULL;
cbm_setenv("HOME", td, 1);
char cache_dir[1024];
snprintf(cache_dir, sizeof(cache_dir), "%s", cbm_resolve_cache_dir());
cbm_config_t *cfg = cbm_config_open(cache_dir);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_UI_LANG, "zh"), 0);
cbm_config_close(cfg);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /api/ui-config HTTP/1.1\r\n"
"Accept-Language: en-US,en;q=0.9\r\n"
"\r\n",
resp, sizeof(resp));
ASSERT_TRUE(n > 0);
ASSERT_EQ(th_status(resp), 200);
ASSERT_NOT_NULL(strstr(resp, "\"lang\":\"zh\""));
th_server_stop(&ts);
if (old_home) {
cbm_setenv("HOME", old_home, 1);
free(old_home);
}
PASS();
}
TEST(ui_server_slow_request_hits_deadline) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
@@ -535,6 +645,35 @@ TEST(ui_server_slow_request_hits_deadline) {
PASS();
}
TEST(ui_server_access_log_redacts_query) {
httpd_log_buf[0] = '\0';
CBMLogLevel prev_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(httpd_capture_log, CBM_LOG_SINK_REPLACE);
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
char resp[4096];
int n = th_http(cbm_http_server_port(ts.srv),
"GET /definitely/not/here?token=secret HTTP/1.1\r\n\r\n", resp, sizeof(resp));
ASSERT_GT(n, 0);
ASSERT_EQ(th_status(resp), 404);
th_server_stop(&ts);
cbm_log_set_sink(NULL);
cbm_log_set_level(prev_level);
ASSERT_NOT_NULL(strstr(httpd_log_buf, "msg=http.request"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "component=graph_ui"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "method=GET"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "path=/definitely/not/here"));
ASSERT_NOT_NULL(strstr(httpd_log_buf, "status=404"));
ASSERT_NULL(strstr(httpd_log_buf, "token"));
ASSERT_NULL(strstr(httpd_log_buf, "secret"));
PASS();
}
TEST(ui_server_stop_joins_cleanly) {
th_server_t ts;
ASSERT_EQ(th_server_start(&ts), 0);
@@ -562,6 +701,7 @@ SUITE(httpd) {
RUN_TEST(httpd_query_param_decode);
RUN_TEST(httpd_query_param_edge_cases);
RUN_TEST(httpd_path_match_matrix);
RUN_TEST(httpd_resolves_bare_binary_path_from_path);
/* Transport */
RUN_TEST(httpd_listen_ephemeral_port);
@@ -577,6 +717,9 @@ SUITE(httpd) {
RUN_TEST(ui_server_encoded_slash_not_routed);
RUN_TEST(ui_server_nul_in_target_rejected);
RUN_TEST(ui_server_browse_traversal_probe);
RUN_TEST(ui_server_ui_config_detects_zh_accept_language);
RUN_TEST(ui_server_ui_config_prefers_config_lang);
RUN_TEST(ui_server_slow_request_hits_deadline);
RUN_TEST(ui_server_access_log_redacts_query);
RUN_TEST(ui_server_stop_joins_cleanly);
}
+105
View File
@@ -22,9 +22,14 @@ static inline bool cbm_str_contains_raw(const char *s, const char *sub) {
}
static char log_buf[4096];
static char sink_buf[4096];
static int saved_stderr;
static int pipe_fds[2];
static void test_log_sink(const char *line) {
snprintf(sink_buf, sizeof(sink_buf), "%s", line ? line : "");
}
static void capture_start(void) {
fflush(stderr);
saved_stderr = dup(STDERR_FILENO);
@@ -112,6 +117,100 @@ TEST(log_int_helper) {
PASS();
}
TEST(log_json_output) {
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
capture_start();
cbm_log_info("test.msg", "key1", "val1", "key2", "line\nbreak");
const char *output = capture_end();
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_level(CBM_LOG_INFO);
ASSERT(cbm_str_contains_raw(output, "\"level\":\"info\""));
ASSERT(cbm_str_contains_raw(output, "\"event\":\"test.msg\""));
ASSERT(cbm_str_contains_raw(output, "\"key1\":\"val1\""));
ASSERT(cbm_str_contains_raw(output, "\"key2\":\"line\\nbreak\""));
PASS();
}
TEST(log_text_sanitizes_control_chars) {
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
capture_start();
cbm_log_info("test\nmsg", "key", "line\r\nbreak\tvalue");
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
ASSERT(cbm_str_contains_raw(output, "msg=test_msg"));
ASSERT(cbm_str_contains_raw(output, "key=line__break_value"));
ASSERT_EQ(output[strlen(output) - 1], '\n');
ASSERT_NULL(strchr(output, '\r'));
PASS();
}
TEST(log_sink_tee_keeps_stderr) {
sink_buf[0] = '\0';
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(test_log_sink, CBM_LOG_SINK_TEE);
capture_start();
cbm_log_info("tee.msg", "key", "val");
const char *output = capture_end();
cbm_log_set_sink(NULL);
cbm_log_set_level(CBM_LOG_INFO);
ASSERT(cbm_str_contains_raw(output, "msg=tee.msg"));
ASSERT(cbm_str_contains_raw(sink_buf, "msg=tee.msg"));
PASS();
}
TEST(log_operational_helpers) {
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
capture_start();
cbm_log_mcp_request("tools/call", "search_graph", false, 1250);
cbm_log_http_request("graph_ui", "GET", "/api/layout", 200, 7, 0, 42);
const char *output = capture_end();
cbm_log_set_level(CBM_LOG_INFO);
ASSERT(cbm_str_contains_raw(output, "msg=mcp.request"));
ASSERT(cbm_str_contains_raw(output, "protocol=jsonrpc"));
ASSERT(cbm_str_contains_raw(output, "method=tools/call"));
ASSERT(cbm_str_contains_raw(output, "tool=search_graph"));
ASSERT(cbm_str_contains_raw(output, "msg=http.request"));
ASSERT(cbm_str_contains_raw(output, "method=GET"));
ASSERT(cbm_str_contains_raw(output, "path=/api/layout"));
ASSERT(cbm_str_contains_raw(output, "status=200"));
PASS();
}
TEST(log_format_from_env) {
cbm_setenv("CBM_LOG_FORMAT", "json", 1);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
cbm_setenv("CBM_LOG_FORMAT", "text", 1);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
cbm_unsetenv("CBM_LOG_FORMAT");
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
PASS();
}
TEST(log_format_unset_keeps_current) {
cbm_unsetenv("CBM_LOG_FORMAT");
cbm_log_set_format(CBM_LOG_FORMAT_JSON);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_JSON);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_init_from_env();
ASSERT_EQ(cbm_log_get_format(), CBM_LOG_FORMAT_TEXT);
PASS();
}
/* CBM_LOG_LEVEL parsing — distilled from #414 (closes #413). */
TEST(log_level_from_env_textual) {
cbm_setenv("CBM_LOG_LEVEL", "error", 1);
@@ -191,6 +290,12 @@ SUITE(log) {
RUN_TEST(log_filtered_by_level);
RUN_TEST(log_error_output);
RUN_TEST(log_int_helper);
RUN_TEST(log_json_output);
RUN_TEST(log_text_sanitizes_control_chars);
RUN_TEST(log_sink_tee_keeps_stderr);
RUN_TEST(log_operational_helpers);
RUN_TEST(log_format_from_env);
RUN_TEST(log_format_unset_keeps_current);
RUN_TEST(log_level_from_env_textual);
RUN_TEST(log_level_from_env_numeric);
RUN_TEST(log_level_from_env_invalid_ignored);
+115 -89
View File
@@ -10,6 +10,30 @@ int tf_skip_count = 0;
#include "test_framework.h"
#include <sqlite3.h>
#include <stdbool.h>
#include <string.h>
static int g_suite_argc = 0;
static char **g_suite_argv = NULL;
static bool suite_requested(const char *name) {
if (g_suite_argc <= 1) {
return true;
}
for (int i = 1; i < g_suite_argc; i++) {
if (strcmp(g_suite_argv[i], name) == 0) {
return true;
}
}
return false;
}
#define RUN_SELECTED_SUITE(name) \
do { \
if (suite_requested(#name)) { \
RUN_SUITE(name); \
} \
} while (0)
/* Forward declarations of suite functions */
extern void suite_arena(void);
@@ -106,155 +130,157 @@ extern void suite_dump_verify_io(void);
* caches at thread teardown (pass_parallel.c). */
extern void cbm_kind_in_set_free_cache(void);
int main(void) {
int main(int argc, char **argv) {
g_suite_argc = argc;
g_suite_argv = argv;
printf("\n codebase-memory-mcp C test suite\n");
/* Foundation */
RUN_SUITE(arena);
RUN_SUITE(hash_table);
RUN_SUITE(dyn_array);
RUN_SUITE(str_intern);
RUN_SUITE(log);
RUN_SUITE(str_util);
RUN_SUITE(platform);
RUN_SUITE(dump_verify);
RUN_SELECTED_SUITE(arena);
RUN_SELECTED_SUITE(hash_table);
RUN_SELECTED_SUITE(dyn_array);
RUN_SELECTED_SUITE(str_intern);
RUN_SELECTED_SUITE(log);
RUN_SELECTED_SUITE(str_util);
RUN_SELECTED_SUITE(platform);
RUN_SELECTED_SUITE(dump_verify);
/* Existing C code regression tests */
RUN_SUITE(ac);
RUN_SUITE(extraction);
RUN_SUITE(extraction_inheritance);
RUN_SUITE(extraction_imports);
RUN_SUITE(grammar_regression);
RUN_SUITE(grammar_labels);
RUN_SUITE(grammar_imports);
RUN_SELECTED_SUITE(ac);
RUN_SELECTED_SUITE(extraction);
RUN_SELECTED_SUITE(extraction_inheritance);
RUN_SELECTED_SUITE(extraction_imports);
RUN_SELECTED_SUITE(grammar_regression);
RUN_SELECTED_SUITE(grammar_labels);
RUN_SELECTED_SUITE(grammar_imports);
/* Store (M5) */
RUN_SUITE(store_nodes);
RUN_SUITE(store_edges);
RUN_SUITE(store_search);
RUN_SUITE(store_bulk);
RUN_SUITE(store_pragmas);
RUN_SUITE(store_checkpoint);
RUN_SUITE(dump_verify_io);
RUN_SELECTED_SUITE(store_nodes);
RUN_SELECTED_SUITE(store_edges);
RUN_SELECTED_SUITE(store_search);
RUN_SELECTED_SUITE(store_bulk);
RUN_SELECTED_SUITE(store_pragmas);
RUN_SELECTED_SUITE(store_checkpoint);
RUN_SELECTED_SUITE(dump_verify_io);
/* Cypher (M6) */
RUN_SUITE(cypher);
RUN_SELECTED_SUITE(cypher);
/* MCP Server (M9) */
RUN_SUITE(mcp);
RUN_SELECTED_SUITE(mcp);
/* Discover (M2) */
RUN_SUITE(language);
RUN_SUITE(userconfig);
RUN_SUITE(gitignore);
RUN_SUITE(discover);
RUN_SELECTED_SUITE(language);
RUN_SELECTED_SUITE(userconfig);
RUN_SELECTED_SUITE(gitignore);
RUN_SELECTED_SUITE(discover);
/* Graph Buffer (M7) */
RUN_SUITE(graph_buffer);
RUN_SELECTED_SUITE(graph_buffer);
/* Pipeline (M8) */
RUN_SUITE(registry);
RUN_SUITE(pipeline);
RUN_SUITE(fqn);
RUN_SUITE(route_canon);
RUN_SUITE(path_alias);
RUN_SELECTED_SUITE(registry);
RUN_SELECTED_SUITE(pipeline);
RUN_SELECTED_SUITE(fqn);
RUN_SELECTED_SUITE(route_canon);
RUN_SELECTED_SUITE(path_alias);
/* Watcher (M10) */
RUN_SUITE(watcher);
RUN_SELECTED_SUITE(watcher);
/* LZ4 + zstd + SQLite writer */
RUN_SUITE(lz4);
RUN_SUITE(zstd);
RUN_SUITE(sqlite_writer);
RUN_SELECTED_SUITE(lz4);
RUN_SELECTED_SUITE(zstd);
RUN_SELECTED_SUITE(sqlite_writer);
/* Persistent artifact export/import */
RUN_SUITE(artifact);
RUN_SELECTED_SUITE(artifact);
/* LSP resolvers */
RUN_SUITE(scope);
RUN_SUITE(type_rep);
RUN_SUITE(go_lsp);
RUN_SUITE(c_lsp);
RUN_SUITE(php_lsp);
RUN_SUITE(cs_lsp);
RUN_SUITE(cs_lsp_bench);
RUN_SUITE(py_lsp);
RUN_SUITE(kotlin_lsp);
RUN_SUITE(rust_lsp);
RUN_SUITE(py_lsp_bench);
RUN_SUITE(py_lsp_stress);
RUN_SUITE(py_lsp_scale);
RUN_SUITE(ts_lsp);
RUN_SUITE(java_lsp);
RUN_SUITE(java_lsp_coverage);
RUN_SELECTED_SUITE(scope);
RUN_SELECTED_SUITE(type_rep);
RUN_SELECTED_SUITE(go_lsp);
RUN_SELECTED_SUITE(c_lsp);
RUN_SELECTED_SUITE(php_lsp);
RUN_SELECTED_SUITE(cs_lsp);
RUN_SELECTED_SUITE(cs_lsp_bench);
RUN_SELECTED_SUITE(py_lsp);
RUN_SELECTED_SUITE(kotlin_lsp);
RUN_SELECTED_SUITE(rust_lsp);
RUN_SELECTED_SUITE(py_lsp_bench);
RUN_SELECTED_SUITE(py_lsp_stress);
RUN_SELECTED_SUITE(py_lsp_scale);
RUN_SELECTED_SUITE(ts_lsp);
RUN_SELECTED_SUITE(java_lsp);
RUN_SELECTED_SUITE(java_lsp_coverage);
/* Architecture + ADR + Louvain */
RUN_SUITE(store_arch);
RUN_SELECTED_SUITE(store_arch);
/* HTTP link */
/* Traces helpers */
RUN_SUITE(traces);
RUN_SELECTED_SUITE(traces);
/* Config link */
RUN_SUITE(configlink);
RUN_SELECTED_SUITE(configlink);
/* Infrastructure scanning */
RUN_SUITE(infrascan);
RUN_SELECTED_SUITE(infrascan);
/* CLI (install, update, config) */
RUN_SUITE(cli);
RUN_SELECTED_SUITE(cli);
/* System info + worker pool (parallelism) */
RUN_SUITE(system_info);
RUN_SUITE(worker_pool);
RUN_SELECTED_SUITE(system_info);
RUN_SELECTED_SUITE(worker_pool);
/* Parallel pipeline */
RUN_SUITE(parallel);
RUN_SELECTED_SUITE(parallel);
/* mem + arena + slab integration */
RUN_SUITE(mem);
RUN_SELECTED_SUITE(mem);
/* UI (config, embedded assets, layout) */
RUN_SUITE(ui);
RUN_SELECTED_SUITE(ui);
/* UI HTTP server (transport + routing) */
RUN_SUITE(httpd);
RUN_SELECTED_SUITE(httpd);
/* Security defenses */
RUN_SUITE(security);
RUN_SELECTED_SUITE(security);
/* YAML parser */
RUN_SUITE(yaml);
RUN_SELECTED_SUITE(yaml);
/* SimHash / SIMILAR_TO */
RUN_SUITE(simhash);
RUN_SELECTED_SUITE(simhash);
/* Stack overflow regression (GitHub #199) */
RUN_SUITE(stack_overflow);
RUN_SELECTED_SUITE(stack_overflow);
/* Integration (end-to-end) */
RUN_SUITE(integration);
RUN_SELECTED_SUITE(integration);
/* Per-language graph contracts (node/edge types, attribution, no-crash) */
RUN_SUITE(lang_contract);
RUN_SUITE(edge_imports);
RUN_SUITE(edge_structural);
RUN_SUITE(lsp_resolution_probe);
RUN_SUITE(node_creation_probe);
RUN_SUITE(edge_types_probe);
RUN_SUITE(convergence_probe);
RUN_SUITE(matrix_known_classes);
RUN_SUITE(matrix_new_constructs);
RUN_SUITE(grammar_probe_a);
RUN_SUITE(grammar_probe_b);
RUN_SUITE(grammar_probe_c);
RUN_SUITE(grammar_probe_d);
RUN_SUITE(grammar_probe_e);
RUN_SUITE(grammar_probe_f);
RUN_SUITE(grammar_probe_g);
RUN_SELECTED_SUITE(lang_contract);
RUN_SELECTED_SUITE(edge_imports);
RUN_SELECTED_SUITE(edge_structural);
RUN_SELECTED_SUITE(lsp_resolution_probe);
RUN_SELECTED_SUITE(node_creation_probe);
RUN_SELECTED_SUITE(edge_types_probe);
RUN_SELECTED_SUITE(convergence_probe);
RUN_SELECTED_SUITE(matrix_known_classes);
RUN_SELECTED_SUITE(matrix_new_constructs);
RUN_SELECTED_SUITE(grammar_probe_a);
RUN_SELECTED_SUITE(grammar_probe_b);
RUN_SELECTED_SUITE(grammar_probe_c);
RUN_SELECTED_SUITE(grammar_probe_d);
RUN_SELECTED_SUITE(grammar_probe_e);
RUN_SELECTED_SUITE(grammar_probe_f);
RUN_SELECTED_SUITE(grammar_probe_g);
RUN_SUITE(incremental);
RUN_SELECTED_SUITE(incremental);
/* Release process-lifetime caches so LeakSanitizer reports no leaks. */
cbm_kind_in_set_free_cache();
+120 -12
View File
@@ -5,6 +5,7 @@
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */
#include "../src/foundation/log.h"
#include "test_framework.h"
#include <mcp/mcp.h>
#include <store/store.h>
@@ -14,6 +15,12 @@
#include <stdbool.h>
#include <stdio.h>
static char mcp_log_buf[4096];
static void mcp_capture_log(const char *line) {
snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : "");
}
/* ══════════════════════════════════════════════════════════════════
* JSON-RPC PARSING
* ══════════════════════════════════════════════════════════════════ */
@@ -144,6 +151,7 @@ TEST(mcp_initialize_response) {
ASSERT_NOT_NULL(strstr(json, "codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(json, "capabilities"));
ASSERT_NOT_NULL(strstr(json, "tools"));
ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false"));
ASSERT_NOT_NULL(strstr(json, "2025-11-25"));
free(json);
@@ -188,6 +196,27 @@ TEST(mcp_tools_list) {
PASS();
}
TEST(mcp_tools_list_latest_metadata) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\""));
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\""));
ASSERT_NOT_NULL(strstr(json, "\"outputSchema\":{\"type\":\"object\""));
ASSERT_NOT_NULL(strstr(json, "\"additionalProperties\":true"));
free(json);
PASS();
}
TEST(mcp_index_repository_declares_name_override_issue571) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"index_repository\""));
ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\""));
ASSERT_NOT_NULL(strstr(json, "Non-ASCII bytes are encoded"));
free(json);
PASS();
}
TEST(mcp_tools_array_schemas_have_items) {
/* VS Code 1.112+ rejects array schemas without "items" (see
* https://github.com/microsoft/vscode/issues/248810).
@@ -223,11 +252,32 @@ TEST(mcp_text_result) {
ASSERT_NOT_NULL(strstr(json, "\"type\":\"text\""));
/* The text value is JSON-escaped inside the "text" field */
ASSERT_NOT_NULL(strstr(json, "total"));
ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"total\":5}"));
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
ASSERT_NULL(strstr(json, "\"isError\":true"));
free(json);
PASS();
}
TEST(mcp_text_result_skips_structured_content_for_plain_text) {
char *json = cbm_mcp_text_result("plain text", false);
ASSERT_NOT_NULL(json);
ASSERT_NULL(strstr(json, "\"structuredContent\""));
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
free(json);
PASS();
}
TEST(mcp_cancel_matches_request_id) {
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", 7, NULL));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":8}", 7, NULL));
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-1\"}", -1, "call-1"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-2\"}", -1, "call-1"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", -1, "7"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{}", 7, NULL));
PASS();
}
TEST(mcp_text_result_error) {
char *json = cbm_mcp_text_result("something failed", true);
ASSERT_NOT_NULL(json);
@@ -348,6 +398,58 @@ TEST(server_handle_tools_list) {
PASS();
}
TEST(server_handle_tools_list_paginates) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":200,\"method\":\"tools/list\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":200"));
ASSERT_NOT_NULL(strstr(resp, "\"nextCursor\":\"8\""));
ASSERT_NOT_NULL(strstr(resp, "index_repository"));
ASSERT_NULL(strstr(resp, "manage_adr"));
free(resp);
resp = cbm_mcp_server_handle(
srv,
"{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{\"cursor\":\"8\"}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":201"));
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_logs_request_without_params) {
mcp_log_buf[0] = '\0';
CBMLogLevel prev_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
"\"params\":{\"token\":\"secret\"}}");
ASSERT_NOT_NULL(resp);
free(resp);
cbm_mcp_server_free(srv);
cbm_log_set_sink(NULL);
cbm_log_set_level(prev_level);
ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "protocol=jsonrpc"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=tools/list"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
ASSERT_NULL(strstr(mcp_log_buf, "token"));
ASSERT_NULL(strstr(mcp_log_buf, "secret"));
PASS();
}
TEST(server_handle_unknown_method) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -506,11 +608,11 @@ TEST(tool_search_graph_query_honors_file_pattern_issue552) {
"FROM nodes;"),
CBM_STORE_OK);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
"\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
"\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -841,11 +943,11 @@ TEST(tool_get_architecture_path_scoping) {
ASSERT_NOT_NULL(inner_root);
ASSERT_NOT_NULL(strstr(inner_root, "Django"));
char *resp_scoped = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
"\"aspects\":[\"packages\"]}}}");
char *resp_scoped =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
"\"aspects\":[\"packages\"]}}}");
ASSERT_NOT_NULL(resp_scoped);
char *inner_scoped = extract_text_content(resp_scoped);
ASSERT_NOT_NULL(inner_scoped);
@@ -1156,7 +1258,7 @@ TEST(tool_manage_adr_get_with_existing_adr) {
/* ADR content must appear in response */
ASSERT_NOT_NULL(strstr(resp, "PURPOSE"));
/* Must not be an error */
ASSERT_NULL(strstr(resp, "isError"));
ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
/* Clean up */
@@ -1202,7 +1304,7 @@ TEST(tool_manage_adr_unified_backend_issue256) {
"\"mode\":\"get\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend."));
ASSERT_NULL(strstr(resp, "isError"));
ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
cbm_mcp_server_free(srv);
@@ -2324,8 +2426,12 @@ SUITE(mcp) {
/* MCP protocol helpers */
RUN_TEST(mcp_initialize_response);
RUN_TEST(mcp_tools_list);
RUN_TEST(mcp_tools_list_latest_metadata);
RUN_TEST(mcp_index_repository_declares_name_override_issue571);
RUN_TEST(mcp_tools_array_schemas_have_items);
RUN_TEST(mcp_text_result);
RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);
RUN_TEST(mcp_cancel_matches_request_id);
RUN_TEST(mcp_text_result_error);
/* Argument extraction */
@@ -2354,6 +2460,8 @@ SUITE(mcp) {
RUN_TEST(server_handle_initialize);
RUN_TEST(server_handle_initialized_notification);
RUN_TEST(server_handle_tools_list);
RUN_TEST(server_handle_tools_list_paginates);
RUN_TEST(server_handle_logs_request_without_params);
RUN_TEST(server_handle_unknown_method);
/* Server handle — edge cases */
+41
View File
@@ -317,6 +317,46 @@ TEST(layout_respects_max_nodes) {
PASS();
}
TEST(layout_clamps_render_cap_from_env) {
cbm_store_t *store = cbm_store_open_memory();
ASSERT_NOT_NULL(store);
const char *old_raw = getenv("CBM_UI_MAX_RENDER_NODES");
char *old_cap = old_raw ? strdup(old_raw) : NULL;
cbm_setenv("CBM_UI_MAX_RENDER_NODES", "25", 1);
cbm_store_upsert_project(store, "test", "/tmp/test");
for (int i = 0; i < 40; i++) {
char name[32], qn[64];
snprintf(name, sizeof(name), "fn%d", i);
snprintf(qn, sizeof(qn), "test::fn%d", i);
cbm_node_t n = {.project = "test",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "a.c",
.start_line = i,
.end_line = i + 1};
cbm_store_upsert_node(store, &n);
}
cbm_layout_result_t *r = cbm_layout_compute(store, "test", CBM_LAYOUT_OVERVIEW, NULL, 0, 50000);
ASSERT_NOT_NULL(r);
ASSERT_LTE(r->node_count, 25);
ASSERT_EQ(r->total_nodes, 40);
cbm_layout_free(r);
cbm_store_close(store);
if (old_cap) {
cbm_setenv("CBM_UI_MAX_RENDER_NODES", old_cap, 1);
free(old_cap);
} else {
cbm_unsetenv("CBM_UI_MAX_RENDER_NODES");
}
PASS();
}
TEST(layout_deterministic) {
cbm_store_t *store = cbm_store_open_memory();
ASSERT_NOT_NULL(store);
@@ -433,6 +473,7 @@ SUITE(ui) {
RUN_TEST(layout_single_node);
RUN_TEST(layout_two_connected);
RUN_TEST(layout_respects_max_nodes);
RUN_TEST(layout_clamps_render_cap_from_env);
RUN_TEST(layout_deterministic);
RUN_TEST(layout_to_json);
RUN_TEST(layout_null_inputs);