@esankhan3/code-search-mcp 1.0.5 → 1.1.0

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 (41) 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__/http-auth.test.js +180 -0
  10. package/dist/__tests__/http-index.test.js +125 -0
  11. package/dist/__tests__/metrics-collect.test.js +48 -0
  12. package/dist/__tests__/observability.test.js +3 -4
  13. package/dist/__tests__/remote-proxy.test.js +105 -0
  14. package/dist/__tests__/server-cli-args.test.js +146 -0
  15. package/dist/__tests__/server-lifecycle.test.js +108 -0
  16. package/dist/__tests__/tool-inputs.test.js +178 -0
  17. package/dist/backends/in-process.js +5 -2
  18. package/dist/backends/index.js +2 -2
  19. package/dist/cli/index.js +143 -76
  20. package/dist/core/config.js +106 -35
  21. package/dist/core/env-config.js +3 -5
  22. package/dist/core/project-name.js +12 -0
  23. package/dist/core/version.js +5 -0
  24. package/dist/daemon/index.js +106 -65
  25. package/dist/daemon/rpc-server.js +38 -18
  26. package/dist/index.js +113 -89
  27. package/dist/middleware/auth.js +34 -66
  28. package/dist/middleware/rate-limit.js +37 -0
  29. package/dist/observability/logger.js +10 -2
  30. package/dist/observability/metrics.js +7 -6
  31. package/dist/projects/registry.js +13 -15
  32. package/dist/resources/resources.js +17 -0
  33. package/dist/server.js +216 -129
  34. package/dist/tools/graph.js +353 -133
  35. package/dist/tools/index-tools.js +3 -2
  36. package/dist/tools/profile.js +14 -8
  37. package/dist/tools/search.js +61 -51
  38. package/dist/tools/validate.js +48 -0
  39. package/dist/transports/http-transport.js +94 -55
  40. package/dist/transports/remote-proxy.js +58 -97
  41. package/package.json +1 -1
@@ -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
  });
@@ -0,0 +1,105 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createServer } from "node:http";
4
+ import { join } from "node:path";
5
+ import { rmSync } from "node:fs";
6
+ import { Client } from "@modelcontextprotocol/client";
7
+ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
8
+ import { BIN, tmp, baseEnv } from "./_harness.js";
9
+ const PORT = 47389;
10
+ const GOOD_KEY = "good-key-1234";
11
+ let remote;
12
+ let root;
13
+ before(async () => {
14
+ root = tmp("cs-proxy-");
15
+ remote = createServer((req, res) => {
16
+ if (req.url === "/health") {
17
+ res.writeHead(200, { "Content-Type": "application/json" });
18
+ res.end(JSON.stringify({ status: "ok", project: "remote", indexReady: true }));
19
+ return;
20
+ }
21
+ let body = "";
22
+ req.on("data", (c) => {
23
+ body += c.toString();
24
+ });
25
+ req.on("end", () => {
26
+ if (req.headers.authorization !== `Bearer ${GOOD_KEY}`) {
27
+ res.writeHead(401, { "Content-Type": "application/json" });
28
+ res.end(JSON.stringify({ error: "Invalid API key" }));
29
+ return;
30
+ }
31
+ const msg = JSON.parse(body);
32
+ if (msg.method === "initialize") {
33
+ res.writeHead(200, { "Content-Type": "application/json" });
34
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "fake", version: "0" } } }));
35
+ return;
36
+ }
37
+ if (msg.method === "resources/list") {
38
+ res.writeHead(200, { "Content-Type": "application/json" });
39
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found: resources/list" } }));
40
+ return;
41
+ }
42
+ const reply = JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { tools: [{ name: "remote_tool", inputSchema: { type: "object" } }] } });
43
+ const half = Math.floor(reply.length / 2);
44
+ res.writeHead(200, { "Content-Type": "text/event-stream" });
45
+ res.end(
46
+ `event: message
47
+ data: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/message", params: { level: "info", data: "hi" } })}
48
+
49
+ event: message
50
+ data: ${JSON.stringify({ jsonrpc: "2.0", id: 999, result: { tools: [] } })}
51
+
52
+ event: message
53
+ data: ${reply.slice(0, half)}
54
+ data: ${reply.slice(half)}
55
+
56
+ `
57
+ );
58
+ });
59
+ });
60
+ await new Promise((r) => remote.listen(PORT, "127.0.0.1", () => r()));
61
+ });
62
+ after(async () => {
63
+ remote.closeAllConnections();
64
+ await new Promise((r) => remote.close(r));
65
+ rmSync(root, { recursive: true, force: true });
66
+ });
67
+ async function proxyClient(key) {
68
+ const client = new Client({ name: "proxy-test", version: "1.0.0" });
69
+ const env = baseEnv({ home: join(root, "home") });
70
+ await client.connect(new StdioClientTransport({
71
+ command: process.execPath,
72
+ args: [BIN.mcp, "--remote", `http://127.0.0.1:${PORT}`, "--api-key", key],
73
+ env,
74
+ stderr: "pipe"
75
+ }));
76
+ return client;
77
+ }
78
+ describe("remote proxy", () => {
79
+ it("surfaces upstream auth failures as JSON-RPC errors", async () => {
80
+ const client = await proxyClient("wrong-key");
81
+ try {
82
+ await assert.rejects(() => client.listTools(), /401/);
83
+ await assert.rejects(() => client.listResources(), /401/);
84
+ } finally {
85
+ await client.close();
86
+ }
87
+ });
88
+ it("relays a remote JSON-RPC error", async () => {
89
+ const client = await proxyClient(GOOD_KEY);
90
+ try {
91
+ await assert.rejects(() => client.listResources(), /Method not found: resources\/list/);
92
+ } finally {
93
+ await client.close();
94
+ }
95
+ });
96
+ it("picks the SSE event answering its request id", async () => {
97
+ const client = await proxyClient(GOOD_KEY);
98
+ try {
99
+ const { tools } = await client.listTools();
100
+ assert.deepEqual(tools.map((t) => t.name), ["remote_tool"]);
101
+ } finally {
102
+ await client.close();
103
+ }
104
+ });
105
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, before, after } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { writeFileSync, existsSync, rmSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import {
6
+ BIN,
7
+ tmp,
8
+ makeWorkspace,
9
+ baseEnv,
10
+ spawnProc,
11
+ run,
12
+ getJson,
13
+ waitFor,
14
+ startFakeEmbedder
15
+ } from "./_harness.js";
16
+ let root;
17
+ let ws;
18
+ let embedder;
19
+ let env;
20
+ before(async () => {
21
+ root = tmp("cs-args-");
22
+ ws = makeWorkspace(root);
23
+ embedder = await startFakeEmbedder(47390);
24
+ env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data"), embedderUrl: embedder.url });
25
+ });
26
+ after(async () => {
27
+ await embedder.close();
28
+ rmSync(root, { recursive: true, force: true });
29
+ });
30
+ async function printConfig(args, cwd, extra = {}) {
31
+ const r = await run(BIN.mcp, ["--print-config", ...args], { ...env, ...extra }, cwd);
32
+ assert.equal(r.code, 0, r.stderr);
33
+ return { cfg: JSON.parse(r.stdout), stderr: r.stderr };
34
+ }
35
+ describe("code-search-mcp argv", () => {
36
+ it("--print-config reports dotted flags and --port under server.port", async () => {
37
+ const { cfg } = await printConfig(["--serve", "--port", "47381", "--retrieval.max-chunks", "12"]);
38
+ assert.equal(cfg.server.port, 47381);
39
+ assert.equal(cfg.retrieval.maxChunks, 12);
40
+ assert.equal(cfg.port, void 0);
41
+ });
42
+ it("--print-config flags an invalid reindex interval as disabled", async () => {
43
+ const { cfg, stderr } = await printConfig([], void 0, { CODE_SEARCH_REINDEX_INTERVAL: "90s" });
44
+ assert.equal(cfg.indexing.reindexIntervalMs, 0);
45
+ assert.match(stderr, /invalid reindex interval "90s"/i);
46
+ });
47
+ it("--print-config applies the cwd .code-search.yaml layer", async () => {
48
+ const dir = tmp("cs-wsy-");
49
+ writeFileSync(join(dir, ".code-search.yaml"), "retrieval:\n max_chunks: 17\n");
50
+ const { cfg } = await printConfig([], dir);
51
+ assert.equal(cfg.retrieval.maxChunks, 17);
52
+ const cli = await run(BIN.cli, ["--print-config"], env, dir);
53
+ assert.equal(JSON.parse(cli.stdout).retrieval.maxChunks, 17);
54
+ });
55
+ it("serve mode accepts dotted flags, --serve <path>, and any mode-flag order", async () => {
56
+ const variants = [
57
+ ["--serve", ws, "--port", "47381", "--retrieval.max-chunks", "12"],
58
+ ["--serve", "--local", ws, "--port", "47381"],
59
+ ["--local", ws, "--serve", "--port", "47381"]
60
+ ];
61
+ for (const args of variants) {
62
+ const p = spawnProc(BIN.mcp, args, env);
63
+ try {
64
+ await waitFor(async () => (await getJson("http://127.0.0.1:47381/health")).status === 200, 2e4, `health for ${args.join(" ")}
65
+ ${p.stderr()}`);
66
+ const status = await getJson("http://127.0.0.1:47381/status");
67
+ assert.equal(status.body.directoryPath, ws, args.join(" "));
68
+ assert.match(p.stderr(), /LLM: disabled/);
69
+ } finally {
70
+ p.kill();
71
+ await p.exited;
72
+ }
73
+ }
74
+ });
75
+ it("--transport sse fails clearly instead of starting another transport", async () => {
76
+ const r = await run(BIN.mcp, ["--serve", "--port", "47382", "--transport", "sse"], env);
77
+ assert.equal(r.code, 2);
78
+ assert.match(r.stderr, /SSE transport is not supported/);
79
+ });
80
+ it("unknown flags exit 2", async () => {
81
+ const r = await run(BIN.mcp, ["--serve", "--retrieval.max-chunkz", "3"], env);
82
+ assert.equal(r.code, 2);
83
+ assert.match(r.stderr, /unknown flag.*--retrieval\.max-chunkz/i);
84
+ });
85
+ });
86
+ describe("code-search subcommands", () => {
87
+ it("index --project p <dir> indexes <dir> as p and reports cross-repo edges", async () => {
88
+ const r = await run(BIN.cli, ["index", "--project", "wsx", ws], env);
89
+ assert.equal(r.code, 0, r.stderr);
90
+ const out = JSON.parse(r.stdout);
91
+ assert.equal(out.project, "wsx");
92
+ assert.equal(out.repos, 2);
93
+ assert.ok(out.crossRepoEdges >= 1, `crossRepoEdges=${out.crossRepoEdges}`);
94
+ });
95
+ it("query takes the positional text even after flags", async () => {
96
+ const r = await run(BIN.cli, ["query", "--project", "wsx", "--mode", "bm25", "--format", "json", "--no-daemon", "welcome email"], env);
97
+ assert.equal(r.code, 0, r.stderr);
98
+ assert.equal(JSON.parse(r.stdout).query, "welcome email");
99
+ });
100
+ it("rejects unknown flags and invalid --format / --top-k with exit 2", async () => {
101
+ for (const args of [
102
+ ["query", "--retrieval.max-chunkz", "3", "x"],
103
+ ["query", "--format", "xml", "x"],
104
+ ["query", "--top-k", "abc", "x"],
105
+ ["query", "--top-k", "0", "x"],
106
+ ["index", "--top-k", "3", ws],
107
+ ["status", "--bogus"]
108
+ ]) {
109
+ const r = await run(BIN.cli, args, env);
110
+ assert.equal(r.code, 2, `${args.join(" ")} \u2192 ${r.code}
111
+ ${r.stderr}`);
112
+ }
113
+ });
114
+ it("status reports where the KB lives and never creates dirs for unknown projects", async () => {
115
+ const r = await run(BIN.cli, ["status", "--project", "nope"], env);
116
+ assert.equal(r.code, 0, r.stderr);
117
+ const s = JSON.parse(r.stdout);
118
+ assert.equal(s.indexed, false);
119
+ assert.equal(s.daemonAlive, false);
120
+ assert.equal(existsSync(join(root, "data", "nope")), false);
121
+ const ok = JSON.parse((await run(BIN.cli, ["status", "--project", "wsx"], env)).stdout);
122
+ assert.equal(ok.indexed, true);
123
+ assert.equal(ok.kbPath, join(root, "data", "wsx"));
124
+ });
125
+ it("storage.dataDir from the CLI moves the KB", async () => {
126
+ const { CODE_SEARCH_DATA_DIR: _drop, ...noDataDir } = env;
127
+ const dd = join(root, "dd2");
128
+ const r = await run(BIN.cli, ["index", ws, "--project", "moved", "--storage.data-dir", dd], noDataDir);
129
+ assert.equal(r.code, 0, r.stderr);
130
+ assert.ok(existsSync(join(dd, "moved", "index_stats.json")), "KB written under --storage.data-dir");
131
+ assert.equal(existsSync(join(root, "home", ".anvil", "knowledge-base", "moved")), false);
132
+ const s = JSON.parse((await run(BIN.cli, ["status", "--project", "moved", "--storage.data-dir", dd], noDataDir)).stdout);
133
+ assert.equal(s.dataDir, dd);
134
+ assert.equal(s.indexed, true);
135
+ });
136
+ it("bare `code-search mcp` serves the cwd over stdio (as its help says)", async () => {
137
+ const p = spawnProc(BIN.cli, ["mcp", "--project", "wsx"], env, ws);
138
+ try {
139
+ await p.waitForStderr(/Server running for "wsx" \(stdio\)/);
140
+ assert.doesNotMatch(p.stderr(), /No remote server configured/);
141
+ } finally {
142
+ p.kill();
143
+ await p.exited;
144
+ }
145
+ });
146
+ });