@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.
- package/README.md +114 -54
- package/dist/__tests__/_harness.js +194 -0
- package/dist/__tests__/config-cli.test.js +110 -0
- package/dist/__tests__/config-resolver.test.js +1 -1
- package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
- package/dist/__tests__/daemon-runtime.test.js +145 -0
- package/dist/__tests__/graph-tools-store.test.js +314 -0
- package/dist/__tests__/graph-tools.test.js +11 -4
- package/dist/__tests__/graph-traversal.test.js +262 -0
- package/dist/__tests__/http-auth.test.js +180 -0
- package/dist/__tests__/http-index.test.js +125 -0
- package/dist/__tests__/metrics-collect.test.js +48 -0
- package/dist/__tests__/observability.test.js +3 -4
- package/dist/__tests__/remote-proxy.test.js +105 -0
- package/dist/__tests__/server-cli-args.test.js +146 -0
- package/dist/__tests__/server-lifecycle.test.js +108 -0
- package/dist/__tests__/tool-inputs.test.js +178 -0
- package/dist/backends/in-process.js +5 -2
- package/dist/backends/index.js +2 -2
- package/dist/cli/index.js +143 -76
- package/dist/core/config.js +106 -35
- package/dist/core/env-config.js +3 -5
- package/dist/core/project-name.js +12 -0
- package/dist/core/version.js +5 -0
- package/dist/daemon/index.js +106 -65
- package/dist/daemon/rpc-server.js +38 -18
- package/dist/index.js +113 -89
- package/dist/middleware/auth.js +34 -66
- package/dist/middleware/rate-limit.js +37 -0
- package/dist/observability/logger.js +10 -2
- package/dist/observability/metrics.js +7 -6
- package/dist/projects/registry.js +13 -15
- package/dist/resources/resources.js +17 -0
- package/dist/server.js +216 -129
- package/dist/tools/graph.js +448 -165
- package/dist/tools/index-tools.js +6 -2
- package/dist/tools/profile.js +14 -8
- package/dist/tools/search.js +61 -51
- package/dist/tools/validate.js +48 -0
- package/dist/transports/http-transport.js +94 -55
- package/dist/transports/remote-proxy.js +58 -97
- package/package.json +2 -2
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
6
|
+
import {
|
|
7
|
+
BIN,
|
|
8
|
+
tmp,
|
|
9
|
+
makeWorkspace,
|
|
10
|
+
baseEnv,
|
|
11
|
+
spawnProc,
|
|
12
|
+
getJson,
|
|
13
|
+
waitFor,
|
|
14
|
+
exitWithin,
|
|
15
|
+
startFakeEmbedder
|
|
16
|
+
} from "./_harness.js";
|
|
17
|
+
const PKG_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")).version;
|
|
18
|
+
let root;
|
|
19
|
+
let ws;
|
|
20
|
+
let slow;
|
|
21
|
+
let fast;
|
|
22
|
+
before(async () => {
|
|
23
|
+
root = tmp("cs-life-");
|
|
24
|
+
ws = makeWorkspace(root);
|
|
25
|
+
slow = await startFakeEmbedder(47391, { delayMs: 2500 });
|
|
26
|
+
fast = await startFakeEmbedder(47392);
|
|
27
|
+
});
|
|
28
|
+
after(async () => {
|
|
29
|
+
await slow.close();
|
|
30
|
+
await fast.close();
|
|
31
|
+
rmSync(root, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
describe("HTTP server lifecycle", () => {
|
|
34
|
+
it("answers /health and /ready during the startup index, then serves", async () => {
|
|
35
|
+
const env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data1"), embedderUrl: slow.url });
|
|
36
|
+
const p = spawnProc(BIN.mcp, ["--local", ws, "--serve", "--port", "47383"], env);
|
|
37
|
+
const base = "http://127.0.0.1:47383";
|
|
38
|
+
try {
|
|
39
|
+
await waitFor(async () => (await getJson(`${base}/health`)).status === 200, 2e4, `health
|
|
40
|
+
${p.stderr()}`);
|
|
41
|
+
const early = await getJson(`${base}/ready`);
|
|
42
|
+
assert.equal(early.status, 503, "not ready while the first index is still embedding");
|
|
43
|
+
assert.equal(early.body.ready, false);
|
|
44
|
+
await waitFor(async () => (await getJson(`${base}/ready`)).status === 200, 6e4, `ready
|
|
45
|
+
${p.stderr()}`);
|
|
46
|
+
const version = await getJson(`${base}/version`);
|
|
47
|
+
assert.equal(version.body.version, PKG_VERSION);
|
|
48
|
+
const admin = await getJson(`${base}/admin/api/status`);
|
|
49
|
+
assert.equal(admin.body.version, PKG_VERSION);
|
|
50
|
+
const client = new Client({ name: "lifecycle-test", version: "1.0.0" });
|
|
51
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${base}/mcp`)));
|
|
52
|
+
assert.equal(client.getServerVersion()?.version, PKG_VERSION);
|
|
53
|
+
const templates = (await client.listResourceTemplates()).resourceTemplates.map((t) => t.uriTemplate).sort();
|
|
54
|
+
assert.deepEqual(templates, ["code-search://repo/{name}/graph", "code-search://repo/{name}/profile"]);
|
|
55
|
+
await client.close();
|
|
56
|
+
const metrics = await (await fetch(`${base}/metrics`)).text();
|
|
57
|
+
const age = metrics.match(/^code_search_index_age_seconds\{project="default"\} (\d+)$/m);
|
|
58
|
+
assert.ok(age, `index_age missing:
|
|
59
|
+
${metrics}`);
|
|
60
|
+
assert.ok(Number(age[1]) < 120, `index_age=${age[1]}`);
|
|
61
|
+
assert.doesNotMatch(metrics, /phase=/);
|
|
62
|
+
assert.match(metrics, /^code_search_index_chunks_total\{project="default",repo="orders"\} [1-9]\d*$/m);
|
|
63
|
+
p.child.kill("SIGTERM");
|
|
64
|
+
const exit = await exitWithin(p, 5e3);
|
|
65
|
+
assert.notEqual(exit, "timeout", "SIGTERM must terminate the HTTP server");
|
|
66
|
+
} finally {
|
|
67
|
+
p.kill();
|
|
68
|
+
await p.exited;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it("structured logs + reindex interval from the config file", async () => {
|
|
72
|
+
const env = baseEnv({
|
|
73
|
+
home: join(root, "home"),
|
|
74
|
+
dataDir: join(root, "data2"),
|
|
75
|
+
embedderUrl: fast.url,
|
|
76
|
+
extra: { CODE_SEARCH_STRUCTURED_LOGS: "1" }
|
|
77
|
+
});
|
|
78
|
+
const p = spawnProc(BIN.mcp, ["--serve", ws, "--port", "47384", "--indexing.reindex-interval-ms", "1h"], env);
|
|
79
|
+
try {
|
|
80
|
+
await waitFor(async () => (await getJson("http://127.0.0.1:47384/ready")).status === 200, 6e4, `ready
|
|
81
|
+
${p.stderr()}`);
|
|
82
|
+
const lines = p.stderr().split("\n").filter(Boolean);
|
|
83
|
+
assert.ok(!lines.some((l) => l.startsWith("[code-search-mcp]")), `plain-text server lines:
|
|
84
|
+
${p.stderr()}`);
|
|
85
|
+
const json = lines.filter((l) => l.startsWith("{")).map((l) => JSON.parse(l));
|
|
86
|
+
assert.ok(json.some((e) => e.component === "code-search-mcp" && e.msg === "Auto-reindex every 60m"), p.stderr());
|
|
87
|
+
assert.ok(json.some((e) => e.component === "auto-index" && e.level === "info"), "index progress goes through the logger");
|
|
88
|
+
} finally {
|
|
89
|
+
p.kill();
|
|
90
|
+
await p.exited;
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("stdio server lifecycle", () => {
|
|
95
|
+
it("SIGTERM terminates --local stdio mode", async () => {
|
|
96
|
+
const env = baseEnv({ home: join(root, "home"), dataDir: join(root, "data3"), embedderUrl: fast.url });
|
|
97
|
+
const p = spawnProc(BIN.mcp, ["--local", ws], env);
|
|
98
|
+
try {
|
|
99
|
+
await p.waitForStderr(/Server running for "ws" \(stdio\)/, 6e4);
|
|
100
|
+
p.child.kill("SIGTERM");
|
|
101
|
+
const exit = await exitWithin(p, 5e3);
|
|
102
|
+
assert.notEqual(exit, "timeout", "SIGTERM must terminate the stdio server");
|
|
103
|
+
} finally {
|
|
104
|
+
p.kill();
|
|
105
|
+
await p.exited;
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
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 { VectorStore, invalidateRetriever, resetLlmConfig } from "@esankhan3/anvil-knowledge-core";
|
|
7
|
+
import { handleGraphTool, registerGraphTools } from "../tools/graph.js";
|
|
8
|
+
import { handleSearchTool, registerSearchTools } from "../tools/search.js";
|
|
9
|
+
import { handleProfileTool } from "../tools/profile.js";
|
|
10
|
+
import { resetServerConfig } from "../core/env-config.js";
|
|
11
|
+
import { registry } from "../observability/metrics.js";
|
|
12
|
+
const PROJECT = "inputs";
|
|
13
|
+
let tmp = "";
|
|
14
|
+
const ENV_KEYS = [
|
|
15
|
+
"CODE_SEARCH_DATA_DIR",
|
|
16
|
+
"CODE_SEARCH_EMBEDDING_PROVIDER",
|
|
17
|
+
"CODE_SEARCH_OLLAMA_HOST",
|
|
18
|
+
"CODE_SEARCH_EMBEDDING_DIMENSIONS",
|
|
19
|
+
"CODE_SEARCH_RERANKER_PROVIDER",
|
|
20
|
+
"ANVIL_LLM_MODE",
|
|
21
|
+
"CODE_SEARCH_LLM_MODE"
|
|
22
|
+
];
|
|
23
|
+
const stash = {};
|
|
24
|
+
function ctx(indexReady = true) {
|
|
25
|
+
return {
|
|
26
|
+
projectName: PROJECT,
|
|
27
|
+
directoryPath: null,
|
|
28
|
+
indexReady,
|
|
29
|
+
startedAt: 0,
|
|
30
|
+
indexing: {
|
|
31
|
+
status: "idle",
|
|
32
|
+
phase: null,
|
|
33
|
+
message: null,
|
|
34
|
+
percent: 0,
|
|
35
|
+
startedAt: null,
|
|
36
|
+
error: null,
|
|
37
|
+
lastSuccess: null,
|
|
38
|
+
lastDurationMs: 0,
|
|
39
|
+
history: []
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const text = (r) => r?.content?.[0]?.text ?? "";
|
|
44
|
+
function chunk(id, entityName, content) {
|
|
45
|
+
return {
|
|
46
|
+
id,
|
|
47
|
+
filePath: `src/${id}.ts`,
|
|
48
|
+
repoName: "app",
|
|
49
|
+
project: PROJECT,
|
|
50
|
+
startLine: 1,
|
|
51
|
+
endLine: 3,
|
|
52
|
+
content,
|
|
53
|
+
contextPrefix: "",
|
|
54
|
+
contextualizedContent: content,
|
|
55
|
+
language: "typescript",
|
|
56
|
+
entityType: "function",
|
|
57
|
+
entityName,
|
|
58
|
+
tokens: 10,
|
|
59
|
+
imports: [],
|
|
60
|
+
exports: [],
|
|
61
|
+
embedding: [1, 0, 0, 0]
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
before(async () => {
|
|
65
|
+
for (const k of ENV_KEYS) stash[k] = process.env[k];
|
|
66
|
+
tmp = mkdtempSync(join(tmpdir(), "cs-inputs-"));
|
|
67
|
+
process.env.CODE_SEARCH_DATA_DIR = tmp;
|
|
68
|
+
process.env.CODE_SEARCH_EMBEDDING_PROVIDER = "ollama";
|
|
69
|
+
process.env.CODE_SEARCH_OLLAMA_HOST = "http://127.0.0.1:47361";
|
|
70
|
+
process.env.CODE_SEARCH_EMBEDDING_DIMENSIONS = "4";
|
|
71
|
+
process.env.CODE_SEARCH_RERANKER_PROVIDER = "none";
|
|
72
|
+
process.env.ANVIL_LLM_MODE = "none";
|
|
73
|
+
process.env.CODE_SEARCH_LLM_MODE = "none";
|
|
74
|
+
resetServerConfig();
|
|
75
|
+
resetLlmConfig();
|
|
76
|
+
mkdirSync(join(tmp, PROJECT), { recursive: true });
|
|
77
|
+
const store = new VectorStore(join(tmp, PROJECT, "lancedb"));
|
|
78
|
+
await store.init();
|
|
79
|
+
await store.upsertChunks([
|
|
80
|
+
chunk("a", "chargeCustomer", "export function chargeCustomer(amount: number) { return gateway.charge(amount); }"),
|
|
81
|
+
chunk("b", "refundCustomer", "export function refundCustomer(id: string) { return gateway.refund(id); }")
|
|
82
|
+
]);
|
|
83
|
+
await store.ensureFtsIndex();
|
|
84
|
+
});
|
|
85
|
+
after(async () => {
|
|
86
|
+
await invalidateRetriever();
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
88
|
+
for (const k of ENV_KEYS) {
|
|
89
|
+
if (stash[k] === void 0) delete process.env[k];
|
|
90
|
+
else process.env[k] = stash[k];
|
|
91
|
+
}
|
|
92
|
+
resetServerConfig();
|
|
93
|
+
resetLlmConfig();
|
|
94
|
+
});
|
|
95
|
+
describe("input validation", () => {
|
|
96
|
+
const cases = [
|
|
97
|
+
["search", "search_code", {}, /query/],
|
|
98
|
+
["search", "search_code", { query: " " }, /query/],
|
|
99
|
+
["search", "search_code", { query: 5 }, /query.*string/],
|
|
100
|
+
["search", "search_code", { query: "x", maxResults: 0 }, /maxResults/],
|
|
101
|
+
["search", "search_exact", { query: "x", repos: 5 }, /"repos" must be a non-empty array of strings or a non-empty string/],
|
|
102
|
+
["search", "search_exact", { query: "x", repos: { app: true } }, /repos/],
|
|
103
|
+
["search", "search_exact", { query: "x", repos: [] }, /repos/],
|
|
104
|
+
["search", "search_exact", { query: "x", repos: "" }, /repos/],
|
|
105
|
+
["search", "search_exact", { query: "x", repos: " , " }, /repos.*at least one/],
|
|
106
|
+
["search", "get_code_snippet", {}, /id|repo/],
|
|
107
|
+
["graph", "detect_changes", {}, /repo/],
|
|
108
|
+
["graph", "trace_path", { from: "a", direction: "sideways" }, /direction.*callees, callers, both/],
|
|
109
|
+
["graph", "trace_path", { from: "a", maxDepth: 0 }, /maxDepth/],
|
|
110
|
+
["graph", "trace_path", { from: "a", maxDepth: 11 }, /maxDepth/],
|
|
111
|
+
["graph", "find_callers", { function: "a", relations: ["friends"] }, /relations/],
|
|
112
|
+
["graph", "impact_analysis", { repo: "app" }, /file/],
|
|
113
|
+
["graph", "search_graph", { limit: "ten" }, /limit/],
|
|
114
|
+
["graph", "find_dead_code", { repo: "app", limit: -1 }, /limit/]
|
|
115
|
+
];
|
|
116
|
+
for (const [kind, name, args, re] of cases) {
|
|
117
|
+
it(`${name} ${JSON.stringify(args)} \u2192 isError`, async () => {
|
|
118
|
+
const r = kind === "search" ? await handleSearchTool(name, args, ctx()) : await handleGraphTool(name, args, ctx());
|
|
119
|
+
assert.equal(r?.isError, true, text(r));
|
|
120
|
+
assert.match(text(r), re);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
it("get_repo_profile without repo \u2192 isError", async () => {
|
|
124
|
+
const r = await handleProfileTool("get_repo_profile", {}, ctx());
|
|
125
|
+
assert.equal(r?.isError, true);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe("search_exact without an embedder", () => {
|
|
129
|
+
it("serves BM25 even while the index is not ready", async () => {
|
|
130
|
+
const r = await handleSearchTool("search_exact", { query: "refundCustomer" }, ctx(false));
|
|
131
|
+
assert.notEqual(r.isError, true, text(r));
|
|
132
|
+
assert.match(text(r), /refundCustomer/);
|
|
133
|
+
});
|
|
134
|
+
it("accepts repos as an array, one name, or a comma-separated list \u2014 and applies it", async () => {
|
|
135
|
+
for (const repos of [["app"], "app", "other, app"]) {
|
|
136
|
+
const r = await handleSearchTool("search_exact", { query: "refundCustomer", repos }, ctx());
|
|
137
|
+
assert.notEqual(r.isError, true, `${JSON.stringify(repos)}: ${text(r)}`);
|
|
138
|
+
assert.match(text(r), /refundCustomer/, JSON.stringify(repos));
|
|
139
|
+
}
|
|
140
|
+
assert.match(text(await handleSearchTool("search_exact", { query: "refundCustomer", repos: "other" }, ctx())), /No results/);
|
|
141
|
+
});
|
|
142
|
+
it("records the query metrics", async () => {
|
|
143
|
+
await handleSearchTool("search_exact", { query: "chargeCustomer" }, ctx());
|
|
144
|
+
const body = registry.render();
|
|
145
|
+
assert.match(body, /code_search_queries_total\{[^}]*mode="bm25"[^}]*\} \d+/);
|
|
146
|
+
assert.match(body, /code_search_query_duration_seconds_count\{mode="bm25"\} \d+/);
|
|
147
|
+
});
|
|
148
|
+
it("hybrid search reports an embedder failure as an error and counts it", async () => {
|
|
149
|
+
const r = await handleSearchTool("search_code", { query: "charge customer" }, ctx());
|
|
150
|
+
assert.equal(r.isError, true);
|
|
151
|
+
assert.match(registry.render(), /code_search_errors_total\{kind="search"\} \d+/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
describe("hints", () => {
|
|
155
|
+
it("index-not-ready does not point at a reindex tool", async () => {
|
|
156
|
+
const t = text(await handleSearchTool("search_semantic", { query: "x" }, ctx(false)));
|
|
157
|
+
assert.match(t, /not ready/i);
|
|
158
|
+
assert.doesNotMatch(t, /reindex tool/);
|
|
159
|
+
});
|
|
160
|
+
it("missing profile with LLM off says profiles need an LLM", async () => {
|
|
161
|
+
const t = text(await handleProfileTool("get_repo_profile", { repo: "app" }, ctx()));
|
|
162
|
+
assert.match(t, /LLM/);
|
|
163
|
+
assert.doesNotMatch(t, /Run reindex/);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
describe("descriptors match behaviour", () => {
|
|
167
|
+
const search = Object.fromEntries(registerSearchTools().map((t) => [t.name, t]));
|
|
168
|
+
const graph = Object.fromEntries(registerGraphTools().map((t) => [t.name, t]));
|
|
169
|
+
it("search_semantic / search_exact advertise repos", () => {
|
|
170
|
+
assert.ok(search.search_semantic.inputSchema.properties.repos);
|
|
171
|
+
assert.ok(search.search_exact.inputSchema.properties.repos);
|
|
172
|
+
});
|
|
173
|
+
it("trace_path is not described as confidence-weighted; search_graph name is a regex", () => {
|
|
174
|
+
assert.doesNotMatch(graph.trace_path.description, /confidence-weighted/);
|
|
175
|
+
assert.match(graph.search_graph.inputSchema.properties.name.description, /regex/i);
|
|
176
|
+
assert.ok(graph.find_callers.inputSchema.properties.relations);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import {
|
|
4
4
|
getRetriever,
|
|
5
|
+
invalidateRetriever,
|
|
5
6
|
indexFromPath,
|
|
6
7
|
getKnowledgeBasePath,
|
|
7
8
|
KnowledgeIndexer,
|
|
@@ -60,7 +61,7 @@ class InProcessBackend {
|
|
|
60
61
|
}
|
|
61
62
|
async status() {
|
|
62
63
|
const indexer = new KnowledgeIndexer();
|
|
63
|
-
const stats = await indexer.getStats(this.project);
|
|
64
|
+
const stats = await indexer.getStats(this.project, this.knowledge);
|
|
64
65
|
return {
|
|
65
66
|
totalChunks: stats.totalChunks,
|
|
66
67
|
repos: stats.repos,
|
|
@@ -79,6 +80,7 @@ class InProcessBackend {
|
|
|
79
80
|
force: opts?.force,
|
|
80
81
|
config: this.knowledge
|
|
81
82
|
});
|
|
83
|
+
await invalidateRetriever(this.project);
|
|
82
84
|
return this.status();
|
|
83
85
|
}
|
|
84
86
|
async invalidate(paths) {
|
|
@@ -91,10 +93,11 @@ class InProcessBackend {
|
|
|
91
93
|
const stats = await store.getStats();
|
|
92
94
|
if (!stats || stats.rowCount === 0) return;
|
|
93
95
|
const indexer = new KnowledgeIndexer();
|
|
94
|
-
const summary = await indexer.getStats(this.project);
|
|
96
|
+
const summary = await indexer.getStats(this.project, this.knowledge);
|
|
95
97
|
for (const r of summary.repos) {
|
|
96
98
|
await store.deleteFileChunks(this.project, r.name, paths);
|
|
97
99
|
}
|
|
100
|
+
await invalidateRetriever(this.project);
|
|
98
101
|
}
|
|
99
102
|
async close() {
|
|
100
103
|
}
|
package/dist/backends/index.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { InProcessBackend } from "./in-process.js";
|
|
4
4
|
import { DaemonBackend } from "./daemon-client.js";
|
|
5
|
+
import { getComponentLogger } from "../observability/logger.js";
|
|
5
6
|
export * from "./types.js";
|
|
6
7
|
import { InProcessBackend as InProcessBackend2 } from "./in-process.js";
|
|
7
8
|
import { DaemonBackend as DaemonBackend2 } from "./daemon-client.js";
|
|
@@ -16,8 +17,7 @@ async function pickBackend(cfg) {
|
|
|
16
17
|
if (cfg.preferDaemon && socketPath && existsSync(socketPath)) {
|
|
17
18
|
const daemon = new DaemonBackend({ ...cfg, socketPath });
|
|
18
19
|
if (await daemon.ping()) return daemon;
|
|
19
|
-
|
|
20
|
-
`);
|
|
20
|
+
getComponentLogger("code-search").warn(`daemon socket exists at ${socketPath} but ping failed; using in-process backend`);
|
|
21
21
|
}
|
|
22
22
|
return new InProcessBackend(cfg);
|
|
23
23
|
}
|