@esankhan3/code-search-mcp 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/graph-traversal.test.js +262 -0
  10. package/dist/__tests__/http-auth.test.js +180 -0
  11. package/dist/__tests__/http-index.test.js +125 -0
  12. package/dist/__tests__/metrics-collect.test.js +48 -0
  13. package/dist/__tests__/observability.test.js +3 -4
  14. package/dist/__tests__/remote-proxy.test.js +105 -0
  15. package/dist/__tests__/server-cli-args.test.js +146 -0
  16. package/dist/__tests__/server-lifecycle.test.js +108 -0
  17. package/dist/__tests__/tool-inputs.test.js +178 -0
  18. package/dist/backends/in-process.js +5 -2
  19. package/dist/backends/index.js +2 -2
  20. package/dist/cli/index.js +143 -76
  21. package/dist/core/config.js +106 -35
  22. package/dist/core/env-config.js +3 -5
  23. package/dist/core/project-name.js +12 -0
  24. package/dist/core/version.js +5 -0
  25. package/dist/daemon/index.js +106 -65
  26. package/dist/daemon/rpc-server.js +38 -18
  27. package/dist/index.js +113 -89
  28. package/dist/middleware/auth.js +34 -66
  29. package/dist/middleware/rate-limit.js +37 -0
  30. package/dist/observability/logger.js +10 -2
  31. package/dist/observability/metrics.js +7 -6
  32. package/dist/projects/registry.js +13 -15
  33. package/dist/resources/resources.js +17 -0
  34. package/dist/server.js +216 -129
  35. package/dist/tools/graph.js +448 -165
  36. package/dist/tools/index-tools.js +6 -2
  37. package/dist/tools/profile.js +14 -8
  38. package/dist/tools/search.js +61 -51
  39. package/dist/tools/validate.js +48 -0
  40. package/dist/transports/http-transport.js +94 -55
  41. package/dist/transports/remote-proxy.js +58 -97
  42. package/package.json +2 -2
@@ -0,0 +1,262 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { createSystemGraphSqliteWriter, getGraphStore, invalidateRetriever } from "@esankhan3/anvil-knowledge-core";
7
+ import { handleGraphTool } from "../tools/graph.js";
8
+ const MAX_NODES = 5e4;
9
+ let tmp = "";
10
+ let stashDataDir;
11
+ function ctx(projectName) {
12
+ return {
13
+ projectName,
14
+ directoryPath: null,
15
+ indexReady: true,
16
+ startedAt: 0,
17
+ indexing: {
18
+ status: "idle",
19
+ phase: null,
20
+ message: null,
21
+ percent: 0,
22
+ startedAt: null,
23
+ error: null,
24
+ lastSuccess: null,
25
+ lastDurationMs: 0,
26
+ history: []
27
+ }
28
+ };
29
+ }
30
+ const text = (r) => r?.content?.[0]?.text ?? "";
31
+ const call = async (project, name, args) => text(await handleGraphTool(name, args, ctx(project)));
32
+ const key = (name, file = "f.ts") => `r::${file}::${name}`;
33
+ async function writeGraph(project, nodes, edges, fileOf = (_n) => "f.ts") {
34
+ const kb = join(tmp, project);
35
+ mkdirSync(kb, { recursive: true });
36
+ const w = await createSystemGraphSqliteWriter(kb);
37
+ assert.ok(w, "no sqlite driver");
38
+ w.addRepoGraph("r", {
39
+ nodes: nodes.map((n) => ({ id: `${n.file ?? "f.ts"}::${n.name}`, label: n.name, type: "function", file: n.file ?? "f.ts" })),
40
+ links: edges.map((e) => ({ source: `${fileOf(e.s)}::${e.s}`, target: `${fileOf(e.t)}::${e.t}`, type: e.type, confidence: e.confidence }))
41
+ });
42
+ w.finalize();
43
+ }
44
+ async function instrument(project, methods, onRead = () => {
45
+ }) {
46
+ const store = await getGraphStore(project);
47
+ const nodes = /* @__PURE__ */ new Set();
48
+ for (const m of methods) {
49
+ const orig = store[m];
50
+ if (typeof orig !== "function") continue;
51
+ store[m] = (...a) => {
52
+ const r = orig.apply(store, a);
53
+ if (Array.isArray(r)) for (const x of r) for (const k of Array.isArray(x) ? x : [x]) nodes.add(k);
54
+ onRead();
55
+ return r;
56
+ };
57
+ }
58
+ return { nodes, restore: () => {
59
+ for (const m of methods) delete store[m];
60
+ } };
61
+ }
62
+ function expireClock() {
63
+ const real = Date.now;
64
+ Date.now = () => real() + 6e4;
65
+ return () => {
66
+ Date.now = real;
67
+ };
68
+ }
69
+ before(() => {
70
+ stashDataDir = process.env.CODE_SEARCH_DATA_DIR;
71
+ tmp = mkdtempSync(join(tmpdir(), "cs-trav-"));
72
+ process.env.CODE_SEARCH_DATA_DIR = tmp;
73
+ });
74
+ after(async () => {
75
+ await invalidateRetriever();
76
+ rmSync(tmp, { recursive: true, force: true });
77
+ if (stashDataDir === void 0) delete process.env.CODE_SEARCH_DATA_DIR;
78
+ else process.env.CODE_SEARCH_DATA_DIR = stashDataDir;
79
+ });
80
+ function rng(seed) {
81
+ return () => {
82
+ seed = seed + 1831565813 | 0;
83
+ let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
84
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
85
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
86
+ };
87
+ }
88
+ function steps(edges, direction, types) {
89
+ const adj = /* @__PURE__ */ new Map();
90
+ const add = (a, b) => {
91
+ if (!adj.has(a)) adj.set(a, /* @__PURE__ */ new Set());
92
+ adj.get(a).add(b);
93
+ };
94
+ for (const e of edges) {
95
+ if (e.confidence < 0.7 || types && !types.includes(e.type)) continue;
96
+ if (direction !== "callers") add(e.s, e.t);
97
+ if (direction !== "callees") add(e.t, e.s);
98
+ }
99
+ return adj;
100
+ }
101
+ function bfsHops(adj, from, to) {
102
+ if (from === to) return null;
103
+ const dist = /* @__PURE__ */ new Map([[from, 0]]);
104
+ let frontier = [from];
105
+ while (frontier.length > 0) {
106
+ const next = [];
107
+ for (const k of frontier) {
108
+ for (const n of adj.get(k) ?? []) {
109
+ if (dist.has(n)) continue;
110
+ dist.set(n, dist.get(k) + 1);
111
+ if (n === to) return dist.get(n);
112
+ next.push(n);
113
+ }
114
+ }
115
+ frontier = next;
116
+ }
117
+ return null;
118
+ }
119
+ const pathKeys = (t) => [...t.matchAll(/^- `([^`]+)`$/gm)].map((m) => m[1]);
120
+ describe("trace_path \u2014 shortest path", () => {
121
+ it("matches a plain BFS on random graphs: hop count, a real path, maxDepth", async () => {
122
+ const rand = rng(7);
123
+ const pick = (xs) => xs[Math.floor(rand() * xs.length)];
124
+ let paths = 0;
125
+ let none = 0;
126
+ for (let g = 0; g < 25; g++) {
127
+ const n = 8 + Math.floor(rand() * 20);
128
+ const names = Array.from({ length: n }, (_, i) => `n${i}`);
129
+ const edges = Array.from({ length: Math.floor(n * (1 + rand() * 1.5)) }, () => ({
130
+ s: pick(names),
131
+ t: pick(names),
132
+ type: rand() < 0.75 ? "calls" : "imports",
133
+ confidence: rand() < 0.85 ? 0.9 : 0.5
134
+ }));
135
+ const project = `rnd${g}`;
136
+ await writeGraph(project, names.map((name) => ({ name })), edges);
137
+ for (let q = 0; q < 16; q++) {
138
+ const from = pick(names);
139
+ const to = pick(names);
140
+ const direction = pick(["callees", "callers", "both"]);
141
+ const relations = pick([void 0, ["imports"], ["all"]]);
142
+ const maxDepth = 1 + Math.floor(rand() * 6);
143
+ const adj = steps(edges, direction, relations === void 0 ? ["calls"] : relations[0] === "all" ? null : relations);
144
+ const want = bfsHops(adj, from, to);
145
+ const t = await call(project, "trace_path", { from: key(from), to: key(to), direction, maxDepth, ...relations ? { relations } : {} });
146
+ const label = `${project}: ${from} \u2192 ${to} (${direction}, ${relations ?? "calls"}, maxDepth ${maxDepth})
147
+ ${t}`;
148
+ if (want === null || want > maxDepth) {
149
+ assert.match(t, new RegExp(`^No path from .* within ${maxDepth} hops`), label);
150
+ none++;
151
+ continue;
152
+ }
153
+ assert.equal(Number(/^# Path \((\d+) hops\)/.exec(t)?.[1]), want, label);
154
+ const keys = pathKeys(t);
155
+ assert.equal(keys.length, want + 1, label);
156
+ assert.equal(keys[0], key(from), label);
157
+ assert.equal(keys[keys.length - 1], key(to), label);
158
+ for (let i = 1; i < keys.length; i++) {
159
+ assert.ok(adj.get(keys[i - 1].split("::")[2])?.has(keys[i].split("::")[2]), `no step ${keys[i - 1]} \u2192 ${keys[i]} in ${label}`);
160
+ }
161
+ paths++;
162
+ }
163
+ }
164
+ assert.ok(paths >= 60 && none >= 60, `sample covers both outcomes (${paths} paths, ${none} none)`);
165
+ });
166
+ it("picks the shorter of two routes and honours maxDepth exactly", async () => {
167
+ const e = (s, t2) => ({ s, t: t2, type: "calls", confidence: 0.9 });
168
+ await writeGraph("routes", ["a", "b", "c", "d", "e", "x", "y"].map((name) => ({ name })), [
169
+ e("a", "b"),
170
+ e("b", "c"),
171
+ e("c", "d"),
172
+ e("d", "e"),
173
+ // 4 hops
174
+ e("a", "x"),
175
+ e("x", "y"),
176
+ e("y", "e")
177
+ // 3 hops
178
+ ]);
179
+ const t = await call("routes", "trace_path", { from: "a", to: "e" });
180
+ assert.match(t, /# Path \(3 hops\)\n\na → x → y → e/);
181
+ assert.match(await call("routes", "trace_path", { from: "a", to: "e", maxDepth: 3 }), /3 hops/);
182
+ assert.match(await call("routes", "trace_path", { from: "a", to: "e", maxDepth: 2 }), /^No path from "a" to "e" within 2 hops/);
183
+ assert.match(await call("routes", "trace_path", { from: "e", to: "a", direction: "callers" }), /# Path \(3 hops\)\n\ne ← y ← x ← a/);
184
+ assert.match(await call("routes", "trace_path", { from: "e", to: "a" }), /^No path from "e" to "a"/);
185
+ });
186
+ });
187
+ describe("traversal budget on a high-fan-out graph", () => {
188
+ const FAN = 250;
189
+ before(async () => {
190
+ const nodes = [{ name: "src" }, { name: "dst" }, { name: "sink", file: "sink.ts" }];
191
+ const edges = [];
192
+ const calls = (s, t) => edges.push({ s, t, type: "calls", confidence: 0.9 });
193
+ for (let i = 0; i < FAN; i++) {
194
+ nodes.push({ name: `c${i}` }, { name: `k${i}` });
195
+ calls("src", `c${i}`);
196
+ calls(`k${i}`, "dst");
197
+ for (let j = 0; j < FAN; j++) {
198
+ nodes.push({ name: `g${i}_${j}` });
199
+ calls(`c${i}`, `g${i}_${j}`);
200
+ calls(`g${i}_${j}`, "sink");
201
+ }
202
+ }
203
+ await writeGraph("fan", nodes, edges, (n) => n === "sink" ? "sink.ts" : "f.ts");
204
+ });
205
+ it('trace_path stops at the node cap and says so instead of "No path"', async () => {
206
+ const reads = await instrument("fan", ["neighborsOf", "neighborEdges"]);
207
+ try {
208
+ const t = await call("fan", "trace_path", { from: "src", to: "dst", maxDepth: 10 });
209
+ assert.match(t, /^No path from "src" to "dst" found within the traversal budget \(explored \d+ nodes, depth \d+ of 10/);
210
+ assert.match(t, /narrow the search .* or lower maxDepth/);
211
+ assert.ok(Number(/explored (\d+) nodes/.exec(t)?.[1]) <= MAX_NODES, t);
212
+ assert.ok(reads.nodes.size <= MAX_NODES, `read ${reads.nodes.size} distinct nodes`);
213
+ } finally {
214
+ reads.restore();
215
+ }
216
+ });
217
+ it("trace_path stops when the wall-clock budget runs out", async () => {
218
+ let restoreClock = () => {
219
+ };
220
+ let queries = 0;
221
+ const reads = await instrument("fan", ["neighborsOf", "neighborEdges"], () => {
222
+ if (++queries === 1) restoreClock = expireClock();
223
+ });
224
+ try {
225
+ const t = await call("fan", "trace_path", { from: "src", to: "dst", maxDepth: 10 });
226
+ assert.match(t, /found within the traversal budget \(explored \d+ nodes, depth 1 of 10/);
227
+ assert.equal(queries, 1);
228
+ } finally {
229
+ restoreClock();
230
+ reads.restore();
231
+ }
232
+ });
233
+ it("trace_path still finds a path into a hub within the budget", async () => {
234
+ const t = await call("fan", "trace_path", { from: "c0", to: "sink", maxDepth: 10 });
235
+ assert.match(t, /# Path \(2 hops\)\n\nc0 → g0_\d+ → sink/);
236
+ });
237
+ it("impact_analysis caps the dependents scanned per hop and marks the count as a lower bound", async () => {
238
+ const t = await call("fan", "impact_analysis", { repo: "r", file: "sink.ts", depth: 1 });
239
+ assert.match(t, new RegExp(`## Dependents: ${MAX_NODES}\\+ edges`));
240
+ assert.match(t, new RegExp(`### Hop 1 \\(${MAX_NODES}\\+\\)`));
241
+ });
242
+ it("impact_analysis stops between hops when the wall-clock budget runs out", async () => {
243
+ let restoreClock = () => {
244
+ };
245
+ let expired = false;
246
+ const reads = await instrument("fan", ["dependents"], () => {
247
+ if (!expired) {
248
+ expired = true;
249
+ restoreClock = expireClock();
250
+ }
251
+ });
252
+ try {
253
+ const t = await call("fan", "impact_analysis", { repo: "r", file: "sink.ts", depth: 3 });
254
+ assert.match(t, /### Hop 1/);
255
+ assert.doesNotMatch(t, /### Hop 2/);
256
+ assert.match(t, /traversal budget/);
257
+ } finally {
258
+ restoreClock();
259
+ reads.restore();
260
+ }
261
+ });
262
+ });
@@ -0,0 +1,180 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createHmac } from "node:crypto";
4
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { request as httpRequest } from "node:http";
7
+ import { Server } from "@modelcontextprotocol/server";
8
+ import { startHttpTransport } from "../transports/http-transport.js";
9
+ import { SlidingWindow } from "../middleware/rate-limit.js";
10
+ import { BIN, tmp, baseEnv, spawnProc, getJson, waitFor } from "./_harness.js";
11
+ const PORT = 47386;
12
+ const BASE = `http://127.0.0.1:${PORT}`;
13
+ const SECRET = "s3cr3t-hs256";
14
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
15
+ function jwt(payload, secret = SECRET) {
16
+ const data = `${b64({ alg: "HS256", typ: "JWT" })}.${b64(payload)}`;
17
+ return `${data}.${createHmac("sha256", secret).update(data).digest("base64url")}`;
18
+ }
19
+ const now = () => Math.floor(Date.now() / 1e3);
20
+ function config(over) {
21
+ return {
22
+ port: PORT,
23
+ host: "127.0.0.1",
24
+ transport: "streamable-http",
25
+ authEnabled: true,
26
+ authMode: "api-key",
27
+ authApiKeys: [],
28
+ authJwtSecret: void 0,
29
+ authJwtIssuer: "code-search-mcp",
30
+ rateLimitPerMinute: 100,
31
+ ...over
32
+ };
33
+ }
34
+ async function withServer(cfg, fn) {
35
+ const http = await startHttpTransport({
36
+ config: cfg,
37
+ createMcpServer: async () => ({ server: new Server({ name: "t", version: "0" }, { capabilities: {} }) }),
38
+ getStatus: () => ({ directoryPath: "/secret/path" })
39
+ });
40
+ try {
41
+ await fn();
42
+ } finally {
43
+ http.closeAllConnections();
44
+ await new Promise((r) => http.close(r));
45
+ }
46
+ }
47
+ function adminStatus(token) {
48
+ return new Promise((resolve, reject) => {
49
+ const req = httpRequest(`${BASE}/admin/api/status`, {
50
+ agent: false,
51
+ headers: token ? { Authorization: `Bearer ${token}` } : {}
52
+ }, (res) => {
53
+ let text = "";
54
+ res.on("data", (c) => {
55
+ text += c.toString();
56
+ });
57
+ res.on("end", () => {
58
+ let body = text;
59
+ try {
60
+ body = JSON.parse(text);
61
+ } catch {
62
+ }
63
+ resolve({ status: res.statusCode ?? 0, body, headers: { get: (h) => res.headers[h.toLowerCase()] } });
64
+ });
65
+ });
66
+ req.on("error", reject);
67
+ req.end();
68
+ });
69
+ }
70
+ describe("sliding-window limiter", () => {
71
+ it("admits at most `limit` hits in any trailing window (no boundary burst)", () => {
72
+ let t = 0;
73
+ const w = new SlidingWindow(6e4, () => t);
74
+ assert.equal(w.hit("k", 2).ok, true);
75
+ t = 59e3;
76
+ assert.equal(w.hit("k", 2).ok, true);
77
+ t = 59500;
78
+ const denied = w.hit("k", 2);
79
+ assert.equal(denied.ok, false);
80
+ assert.equal(denied.retryAfterSec, 1);
81
+ t = 60100;
82
+ assert.equal(w.hit("k", 2).ok, true, "the t=0 hit left the window");
83
+ t = 60200;
84
+ assert.equal(w.hit("k", 2).ok, false, "a fixed window would have reset here");
85
+ assert.equal(w.hit("other", 2).ok, true);
86
+ });
87
+ });
88
+ describe("JWT", () => {
89
+ it("rejects tokens without exp and before nbf; accepts a valid one", async () => {
90
+ await withServer(config({ authMode: "jwt", authJwtSecret: SECRET }), async () => {
91
+ assert.equal((await adminStatus(jwt({ sub: "a", iss: "code-search-mcp", exp: now() + 600 }))).status, 200);
92
+ assert.equal((await adminStatus(jwt({ sub: "b", iss: "code-search-mcp" }))).status, 401, "no exp");
93
+ assert.equal((await adminStatus(jwt({ sub: "c", iss: "code-search-mcp", exp: now() + 600, nbf: now() + 3600 }))).status, 401, "nbf in future");
94
+ assert.equal((await adminStatus(jwt({ sub: "d", iss: "code-search-mcp", exp: now() - 5 }))).status, 401, "expired");
95
+ });
96
+ });
97
+ it("refuses to start in jwt mode without a secret", async () => {
98
+ await assert.rejects(
99
+ () => startHttpTransport({
100
+ config: config({ authMode: "jwt", authJwtSecret: void 0 }),
101
+ createMcpServer: async () => ({ server: new Server({ name: "t", version: "0" }, { capabilities: {} }) })
102
+ }),
103
+ /CODE_SEARCH_AUTH_JWT_SECRET/
104
+ );
105
+ });
106
+ });
107
+ describe("API-key rate limiting + admin auth", () => {
108
+ it("limits per full key and sends Retry-After", async () => {
109
+ const keyA = "sharedprefix-AAAA-0001";
110
+ const keyB = "sharedprefix-BBBB-0002";
111
+ await withServer(config({ authApiKeys: [keyA, keyB], rateLimitPerMinute: 2 }), async () => {
112
+ assert.equal((await adminStatus(keyA)).status, 200);
113
+ assert.equal((await adminStatus(keyA)).status, 200);
114
+ const limited = await adminStatus(keyA);
115
+ assert.equal(limited.status, 429);
116
+ assert.ok(Number(limited.headers.get("retry-after")) >= 1, `Retry-After=${limited.headers.get("retry-after")}`);
117
+ assert.equal((await adminStatus(keyB)).status, 200, "a key sharing the prefix has its own bucket");
118
+ });
119
+ });
120
+ it("/admin/api/status requires auth when auth is enabled", async () => {
121
+ await withServer(config({ authApiKeys: ["k-1234567890"] }), async () => {
122
+ const r = await adminStatus();
123
+ assert.equal(r.status, 401);
124
+ assert.doesNotMatch(JSON.stringify(r.body), /secret\/path/);
125
+ });
126
+ });
127
+ });
128
+ describe("P8 registry on /mcp", () => {
129
+ let root;
130
+ before(() => {
131
+ root = tmp("cs-reg-");
132
+ const dir = join(root, "data", "projects", "proj");
133
+ mkdirSync(dir, { recursive: true });
134
+ writeFileSync(join(dir, "project.yaml"), [
135
+ `workspace: ${root}`,
136
+ "scopes: [team-a]",
137
+ "quotas:",
138
+ " max_queries_per_minute: 2",
139
+ ""
140
+ ].join("\n"));
141
+ });
142
+ after(() => rmSync(root, { recursive: true, force: true }));
143
+ it("enforces project scopes and the per-(identity, project) quota", async () => {
144
+ const env = baseEnv({
145
+ home: join(root, "home"),
146
+ dataDir: join(root, "data"),
147
+ extra: { CODE_SEARCH_AUTH_MODE: "jwt", CODE_SEARCH_AUTH_JWT_SECRET: SECRET, CODE_SEARCH_HOST: "127.0.0.1" }
148
+ });
149
+ const p = spawnProc(BIN.mcp, ["--serve", "--project", "proj", "--port", String(PORT)], env);
150
+ try {
151
+ await waitFor(async () => (await getJson(`${BASE}/health`)).status === 200, 2e4, `health
152
+ ${p.stderr()}`);
153
+ const mcp = (token) => fetch(`${BASE}/mcp`, {
154
+ method: "POST",
155
+ headers: {
156
+ Authorization: `Bearer ${token}`,
157
+ "Content-Type": "application/json",
158
+ Accept: "application/json, text/event-stream"
159
+ },
160
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} })
161
+ });
162
+ const teamB = jwt({ sub: "bob", iss: "code-search-mcp", exp: now() + 600, scope: "team-b" });
163
+ assert.equal((await mcp(teamB)).status, 403);
164
+ const noScope = jwt({ sub: "eve", iss: "code-search-mcp", exp: now() + 600 });
165
+ assert.equal((await mcp(noScope)).status, 403, "a token without a scope claim is not admin");
166
+ const teamA = jwt({ sub: "alice", iss: "code-search-mcp", exp: now() + 600, scope: "team-a" });
167
+ for (let i = 0; i < 2; i++) {
168
+ const r = await mcp(teamA);
169
+ assert.ok(r.status !== 403 && r.status !== 429, `request ${i} \u2192 ${r.status}`);
170
+ await r.text();
171
+ }
172
+ const over = await mcp(teamA);
173
+ assert.equal(over.status, 429);
174
+ assert.ok(Number(over.headers.get("retry-after")) >= 1);
175
+ } finally {
176
+ p.kill();
177
+ await p.exited;
178
+ }
179
+ });
180
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import {
6
+ BIN,
7
+ tmp,
8
+ makeWorkspace,
9
+ baseEnv,
10
+ spawnProc,
11
+ getJson,
12
+ waitFor,
13
+ exitWithin,
14
+ startFakeEmbedder
15
+ } from "./_harness.js";
16
+ let root;
17
+ let ws;
18
+ let slow;
19
+ before(async () => {
20
+ root = tmp("cs-idx-");
21
+ ws = makeWorkspace(root);
22
+ slow = await startFakeEmbedder(47393, { delayMs: 2e3 });
23
+ });
24
+ after(async () => {
25
+ await slow.close();
26
+ rmSync(root, { recursive: true, force: true });
27
+ });
28
+ const post = (base, body, headers = {}) => getJson(`${base}/index`, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body });
29
+ describe("POST /index", () => {
30
+ it("validates, refuses overlap and other projects, reports real edges", async () => {
31
+ const env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data1"), embedderUrl: slow.url });
32
+ const p = spawnProc(BIN.mcp, ["--serve", "--port", "47387"], env);
33
+ const base = "http://127.0.0.1:47387";
34
+ try {
35
+ await waitFor(async () => (await getJson(`${base}/health`)).status === 200, 2e4, `health
36
+ ${p.stderr()}`);
37
+ assert.equal((await post(base, "")).status, 400, "empty body");
38
+ assert.equal((await post(base, "not json")).status, 400, "invalid JSON");
39
+ assert.equal((await post(base, "[]")).status, 400, "no path");
40
+ assert.equal((await post(base, JSON.stringify({ path: join(root, "missing") }))).status, 400, "missing dir");
41
+ assert.equal((await post(base, JSON.stringify({ path: ws, pad: "x".repeat(2e5) }))).status, 413, "huge body");
42
+ const other = await post(base, JSON.stringify({ path: ws, project: "other" }));
43
+ assert.equal(other.status, 400);
44
+ assert.match(other.body.error, /serves project "default"/);
45
+ assert.equal((await getJson(`${base}/status`)).body.project, "default");
46
+ const first = post(base, JSON.stringify({ path: ws }));
47
+ await waitFor(async () => (await getJson(`${base}/status`)).body.indexing.status === "indexing", 1e4, "indexing to start");
48
+ const overlap = await post(base, JSON.stringify({ path: ws }));
49
+ assert.equal(overlap.status, 409);
50
+ const done = await first;
51
+ assert.equal(done.status, 200, JSON.stringify(done.body));
52
+ assert.equal(done.body.project, "default");
53
+ assert.equal(done.body.repos, 2);
54
+ assert.ok(done.body.crossRepoEdges >= 1, `crossRepoEdges=${done.body.crossRepoEdges}`);
55
+ const status = (await getJson(`${base}/status`)).body;
56
+ assert.equal(status.project, "default");
57
+ assert.equal(status.directoryPath, ws);
58
+ assert.equal(status.indexReady, true);
59
+ } finally {
60
+ p.kill();
61
+ await p.exited;
62
+ }
63
+ });
64
+ });
65
+ describe("cluster mode", () => {
66
+ it("workers report the primary's indexing state", async () => {
67
+ const env = baseEnv({
68
+ home: join(root, "home"),
69
+ dataDir: join(root, "data2"),
70
+ embedderUrl: slow.url,
71
+ extra: { CODE_SEARCH_WORKERS: "2" }
72
+ });
73
+ const p = spawnProc(BIN.mcp, ["--serve", "--port", "47397"], env);
74
+ const base = "http://127.0.0.1:47397";
75
+ try {
76
+ await waitFor(async () => (await getJson(`${base}/health`)).status === 200, 2e4, `health
77
+ ${p.stderr()}`);
78
+ const indexed = post(base, JSON.stringify({ path: ws }));
79
+ await waitFor(async () => (await getJson(`${base}/status`)).body.indexing.status === "indexing", 1e4, `worker sees indexing
80
+ ${p.stderr()}`);
81
+ const done = await indexed;
82
+ assert.equal(done.status, 200, JSON.stringify(done.body));
83
+ await waitFor(async () => {
84
+ for (let i = 0; i < 6; i++) {
85
+ const s = (await getJson(`${base}/status`)).body;
86
+ if (s.indexing.status !== "idle" || !s.indexReady || !s.indexing.lastSuccess) return false;
87
+ }
88
+ return true;
89
+ }, 5e3, "every worker to report the finished index");
90
+ assert.equal((await getJson(`${base}/admin/api/status`)).body.indexing.status, "idle");
91
+ } finally {
92
+ p.child.kill("SIGTERM");
93
+ await exitWithin(p, 5e3);
94
+ }
95
+ });
96
+ it("enforces one rate limit for the whole server", async () => {
97
+ const env = baseEnv({
98
+ home: join(root, "home"),
99
+ dataDir: join(root, "data3"),
100
+ extra: {
101
+ CODE_SEARCH_WORKERS: "2",
102
+ CODE_SEARCH_AUTH_MODE: "api-key",
103
+ CODE_SEARCH_AUTH_API_KEYS: "cluster-key-0001",
104
+ CODE_SEARCH_RATE_LIMIT_PER_MINUTE: "4",
105
+ CODE_SEARCH_HOST: "127.0.0.1"
106
+ }
107
+ });
108
+ const p = spawnProc(BIN.mcp, ["--serve", "--port", "47398"], env);
109
+ const base = "http://127.0.0.1:47398";
110
+ try {
111
+ await waitFor(async () => (await getJson(`${base}/health`)).status === 200, 2e4, `health
112
+ ${p.stderr()}`);
113
+ await waitFor(() => (p.stderr().match(/Server running for/g) ?? []).length >= 2, 2e4, "two workers");
114
+ const statuses = [];
115
+ for (let i = 0; i < 10; i++) {
116
+ statuses.push((await getJson(`${base}/admin/api/status`, { headers: { Authorization: "Bearer cluster-key-0001" } })).status);
117
+ }
118
+ assert.equal(statuses.filter((s) => s === 200).length, 4, statuses.join(","));
119
+ assert.equal(statuses.filter((s) => s === 429).length, 6, statuses.join(","));
120
+ } finally {
121
+ p.child.kill("SIGTERM");
122
+ await exitWithin(p, 5e3);
123
+ }
124
+ });
125
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { rmSync } from "node:fs";
4
+ import { getBlobStore } from "@esankhan3/anvil-knowledge-core";
5
+ import { collectIndexMetrics } from "../server.js";
6
+ import { registry } from "../observability/metrics.js";
7
+ import { tmp } from "./_harness.js";
8
+ let dataDir;
9
+ before(() => {
10
+ dataDir = tmp("cs-metrics-");
11
+ process.env.CODE_SEARCH_DATA_DIR = dataDir;
12
+ });
13
+ after(() => rmSync(dataDir, { recursive: true, force: true }));
14
+ const KC = { embedding: { provider: "ollama" }, chunking: { maxTokens: 500 }, retrieval: { maxChunks: 8, maxTokens: 12e3 } };
15
+ describe("collectIndexMetrics", () => {
16
+ it("reports real index age + chunks and clears gauges whose blob vanished", async () => {
17
+ const blobs = getBlobStore("p", KC);
18
+ await blobs.putJson("index_stats.json", {
19
+ repos: [{ name: "a", chunkCount: 3, language: "ts" }, { name: "b", chunkCount: 5, language: "ts" }],
20
+ lastIndexed: new Date(Date.now() - 42e3).toISOString()
21
+ });
22
+ await blobs.putJson("vector_index.json", { rowsAddedSinceTrain: 5, trainedRows: 10, numUnindexedRows: 7, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
23
+ await collectIndexMetrics("p", KC);
24
+ let out = registry.render();
25
+ const age = Number(out.match(/^code_search_index_age_seconds\{project="p"\} (\d+)$/m)?.[1]);
26
+ assert.ok(age >= 42 && age < 60, `age=${age}`);
27
+ assert.match(out, /^code_search_index_chunks_total\{project="p",repo="a"\} 3$/m);
28
+ assert.match(out, /^code_search_index_chunks_total\{project="p",repo="b"\} 5$/m);
29
+ assert.match(out, /^code_search_vector_index_unindexed_rows\{project="p"\} 7$/m);
30
+ assert.match(out, /^code_search_vector_index_growth_ratio\{project="p"\} 0\.5$/m);
31
+ await collectIndexMetrics("p", KC, new Date(Date.now() - 2e3).toISOString());
32
+ assert.ok(Number(registry.render().match(/^code_search_index_age_seconds\{project="p"\} (\d+)$/m)?.[1]) < 10);
33
+ await blobs.delete("vector_index.json");
34
+ await blobs.putJson("index_stats.json", { repos: [{ name: "a", chunkCount: 4, language: "ts" }], lastIndexed: (/* @__PURE__ */ new Date()).toISOString() });
35
+ await collectIndexMetrics("p", KC);
36
+ out = registry.render();
37
+ assert.doesNotMatch(out, /code_search_vector_index_unindexed_rows\{/);
38
+ assert.doesNotMatch(out, /code_search_vector_index_growth_ratio\{/);
39
+ assert.doesNotMatch(out, /repo="b"/);
40
+ assert.match(out, /^code_search_index_chunks_total\{project="p",repo="a"\} 4$/m);
41
+ });
42
+ it("declares no metric that nothing records", () => {
43
+ const out = registry.render();
44
+ for (const dead of ["embeddings_calls_total", "llm_calls_total", "reranker_cache_hits_total"]) {
45
+ assert.doesNotMatch(out, new RegExp(`code_search_${dead}`));
46
+ }
47
+ });
48
+ });
@@ -58,12 +58,11 @@ describe("Logger (P7)", () => {
58
58
  assert.equal(lines.length, 1);
59
59
  assert.match(lines[0], /"kept"/);
60
60
  });
61
- it("falls back to text mode when structured=false", () => {
61
+ it("passes text through unchanged when structured=false", () => {
62
62
  const lines = [];
63
63
  const log = new Logger({ structured: false, write: (l) => lines.push(l) });
64
64
  log.error("boom");
65
- assert.equal(lines.length, 1);
66
- assert.match(lines[0], /ERROR/);
67
- assert.match(lines[0], /boom/);
65
+ log.info("ready", { component: "code-search-mcp" });
66
+ assert.deepEqual(lines, ["boom", "[code-search-mcp] ready"]);
68
67
  });
69
68
  });