@esankhan3/code-search-mcp 0.1.0 → 0.3.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.
- package/README.md +174 -19
- package/dist/__tests__/config-resolver.test.js +153 -0
- package/dist/__tests__/daemon-rpc.test.js +100 -0
- package/dist/__tests__/observability.test.js +69 -0
- package/dist/__tests__/projects.test.js +141 -0
- package/dist/backends/daemon-client.js +87 -0
- package/dist/backends/in-process.js +104 -0
- package/dist/backends/index.js +29 -0
- package/dist/backends/types.js +0 -0
- package/dist/cli/index.js +231 -0
- package/dist/core/config.js +372 -0
- package/dist/core/env-config.js +45 -43
- package/dist/daemon/index.js +119 -0
- package/dist/daemon/rpc-server.js +99 -0
- package/dist/daemon/watcher.js +105 -0
- package/dist/index.js +7 -0
- package/dist/observability/logger.js +66 -0
- package/dist/observability/metrics.js +138 -0
- package/dist/projects/registry.js +146 -0
- package/dist/server.js +4 -0
- package/dist/transports/http-transport.js +38 -0
- package/package.json +18 -6
- package/scripts/smoke/run-smoke.sh +167 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { existsSync, unlinkSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
class RpcServer extends EventEmitter {
|
|
6
|
+
socketPath;
|
|
7
|
+
handlers;
|
|
8
|
+
server = null;
|
|
9
|
+
clients = /* @__PURE__ */ new Set();
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
super();
|
|
12
|
+
this.socketPath = opts.socketPath;
|
|
13
|
+
this.handlers = opts.handlers;
|
|
14
|
+
}
|
|
15
|
+
async start() {
|
|
16
|
+
if (process.platform !== "win32" && existsSync(this.socketPath)) {
|
|
17
|
+
try {
|
|
18
|
+
unlinkSync(this.socketPath);
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
mkdirSync(dirname(this.socketPath), { recursive: true });
|
|
23
|
+
await new Promise((resolveP, rejectP) => {
|
|
24
|
+
this.server = createServer((sock) => this.handleClient(sock));
|
|
25
|
+
this.server.on("error", (err) => {
|
|
26
|
+
this.emit("error", err);
|
|
27
|
+
rejectP(err);
|
|
28
|
+
});
|
|
29
|
+
this.server.listen(this.socketPath, () => {
|
|
30
|
+
this.emit("listening", this.socketPath);
|
|
31
|
+
resolveP();
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
async stop() {
|
|
36
|
+
for (const c of this.clients) {
|
|
37
|
+
try {
|
|
38
|
+
c.destroy();
|
|
39
|
+
} catch {
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
this.clients.clear();
|
|
43
|
+
if (this.server) {
|
|
44
|
+
await new Promise((r) => this.server.close(() => r()));
|
|
45
|
+
this.server = null;
|
|
46
|
+
}
|
|
47
|
+
if (process.platform !== "win32" && existsSync(this.socketPath)) {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(this.socketPath);
|
|
50
|
+
} catch {
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
handleClient(sock) {
|
|
55
|
+
this.clients.add(sock);
|
|
56
|
+
let buf = "";
|
|
57
|
+
sock.setNoDelay(true);
|
|
58
|
+
sock.on("end", () => this.clients.delete(sock));
|
|
59
|
+
sock.on("error", () => this.clients.delete(sock));
|
|
60
|
+
sock.on("data", async (chunk) => {
|
|
61
|
+
buf += chunk.toString("utf-8");
|
|
62
|
+
let idx = buf.indexOf("\n");
|
|
63
|
+
while (idx >= 0) {
|
|
64
|
+
const line = buf.slice(0, idx);
|
|
65
|
+
buf = buf.slice(idx + 1);
|
|
66
|
+
await this.handleLine(sock, line);
|
|
67
|
+
idx = buf.indexOf("\n");
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async handleLine(sock, line) {
|
|
72
|
+
if (!line.trim()) return;
|
|
73
|
+
let req;
|
|
74
|
+
try {
|
|
75
|
+
req = JSON.parse(line);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
sock.write(this.errorEnvelope(null, -32700, err instanceof Error ? err.message : String(err)));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const handler = this.handlers[req.method];
|
|
81
|
+
if (!handler) {
|
|
82
|
+
sock.write(this.errorEnvelope(req.id ?? null, -32601, `Method not found: ${req.method}`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const result = await handler(req.params);
|
|
87
|
+
sock.write(JSON.stringify({ jsonrpc: "2.0", id: req.id ?? null, result }) + "\n");
|
|
88
|
+
} catch (err) {
|
|
89
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
90
|
+
sock.write(this.errorEnvelope(req.id ?? null, -32e3, msg));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
errorEnvelope(id, code, message) {
|
|
94
|
+
return JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }) + "\n";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export {
|
|
98
|
+
RpcServer
|
|
99
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { watch, statSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
const HAS_RECURSIVE = process.platform === "darwin" || process.platform === "win32";
|
|
5
|
+
class Watcher extends EventEmitter {
|
|
6
|
+
workspaceDir;
|
|
7
|
+
ignore;
|
|
8
|
+
debounceMs;
|
|
9
|
+
watchers = [];
|
|
10
|
+
pending = /* @__PURE__ */ new Set();
|
|
11
|
+
timer = null;
|
|
12
|
+
started = false;
|
|
13
|
+
constructor(opts) {
|
|
14
|
+
super();
|
|
15
|
+
this.workspaceDir = opts.workspaceDir;
|
|
16
|
+
this.ignore = opts.ignorePatterns;
|
|
17
|
+
this.debounceMs = Math.max(50, opts.debounceMs);
|
|
18
|
+
}
|
|
19
|
+
start() {
|
|
20
|
+
if (this.started) return;
|
|
21
|
+
this.started = true;
|
|
22
|
+
if (HAS_RECURSIVE) {
|
|
23
|
+
const w = watch(this.workspaceDir, { recursive: true }, (_event, filename) => {
|
|
24
|
+
if (!filename) return;
|
|
25
|
+
this.queue(filename.toString());
|
|
26
|
+
});
|
|
27
|
+
w.on("error", (err) => this.emit("error", err));
|
|
28
|
+
this.watchers.push(w);
|
|
29
|
+
} else {
|
|
30
|
+
this.walkAndWatch(this.workspaceDir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
stop() {
|
|
34
|
+
if (!this.started) return;
|
|
35
|
+
this.started = false;
|
|
36
|
+
if (this.timer) clearTimeout(this.timer);
|
|
37
|
+
this.timer = null;
|
|
38
|
+
for (const w of this.watchers) {
|
|
39
|
+
try {
|
|
40
|
+
w.close();
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
this.watchers = [];
|
|
45
|
+
this.pending.clear();
|
|
46
|
+
}
|
|
47
|
+
onBatch(listener) {
|
|
48
|
+
return this.on("batch", listener);
|
|
49
|
+
}
|
|
50
|
+
walkAndWatch(dir) {
|
|
51
|
+
if (this.shouldIgnore(dir)) return;
|
|
52
|
+
try {
|
|
53
|
+
const w = watch(dir, (_event, filename) => {
|
|
54
|
+
if (!filename) return;
|
|
55
|
+
const full = join(dir, filename.toString());
|
|
56
|
+
this.queue(relative(this.workspaceDir, full));
|
|
57
|
+
try {
|
|
58
|
+
if (statSync(full).isDirectory()) this.walkAndWatch(full);
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
w.on("error", (err) => this.emit("error", err));
|
|
63
|
+
this.watchers.push(w);
|
|
64
|
+
for (const entry of readdirSync(dir)) {
|
|
65
|
+
const full = join(dir, entry);
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(full).isDirectory()) this.walkAndWatch(full);
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
queue(rel) {
|
|
75
|
+
if (this.shouldIgnore(rel)) return;
|
|
76
|
+
this.pending.add(join(this.workspaceDir, rel));
|
|
77
|
+
if (this.timer) clearTimeout(this.timer);
|
|
78
|
+
this.timer = setTimeout(() => this.flush(), this.debounceMs);
|
|
79
|
+
}
|
|
80
|
+
flush() {
|
|
81
|
+
const batch = { changed: new Set(this.pending), removed: /* @__PURE__ */ new Set() };
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
this.timer = null;
|
|
84
|
+
for (const p of batch.changed) {
|
|
85
|
+
try {
|
|
86
|
+
statSync(p);
|
|
87
|
+
} catch {
|
|
88
|
+
batch.removed.add(p);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const r of batch.removed) batch.changed.delete(r);
|
|
92
|
+
this.emit("batch", batch);
|
|
93
|
+
}
|
|
94
|
+
shouldIgnore(path) {
|
|
95
|
+
for (const pat of this.ignore) {
|
|
96
|
+
if (path.includes(`/${pat}/`) || path.endsWith(`/${pat}`) || path.startsWith(`${pat}/`) || path === pat) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
Watcher
|
|
105
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { existsSync } from "node:fs";
|
|
4
|
+
import { printConfig, resolveCodeSearchConfig, parseCliFlags } from "./core/config.js";
|
|
4
5
|
const args = process.argv.slice(2);
|
|
6
|
+
if (args.includes("--print-config")) {
|
|
7
|
+
const { patch } = parseCliFlags(args.filter((a) => a !== "--print-config"));
|
|
8
|
+
const cfg = resolveCodeSearchConfig({ cli: patch });
|
|
9
|
+
process.stdout.write(printConfig(cfg) + "\n");
|
|
10
|
+
process.exit(0);
|
|
11
|
+
}
|
|
5
12
|
let mode = "remote";
|
|
6
13
|
let remoteUrl = null;
|
|
7
14
|
let apiKey;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40 };
|
|
2
|
+
class Logger {
|
|
3
|
+
opts;
|
|
4
|
+
constructor(opts) {
|
|
5
|
+
this.opts = {
|
|
6
|
+
structured: opts.structured,
|
|
7
|
+
level: opts.level ?? "info",
|
|
8
|
+
write: opts.write ?? ((line) => process.stderr.write(line + "\n"))
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
debug(msg, fields) {
|
|
12
|
+
this.emit("debug", msg, fields);
|
|
13
|
+
}
|
|
14
|
+
info(msg, fields) {
|
|
15
|
+
this.emit("info", msg, fields);
|
|
16
|
+
}
|
|
17
|
+
warn(msg, fields) {
|
|
18
|
+
this.emit("warn", msg, fields);
|
|
19
|
+
}
|
|
20
|
+
error(msg, fields) {
|
|
21
|
+
this.emit("error", msg, fields);
|
|
22
|
+
}
|
|
23
|
+
/** Bind a component name so every line carries it. */
|
|
24
|
+
child(component) {
|
|
25
|
+
return new Logger({
|
|
26
|
+
structured: this.opts.structured,
|
|
27
|
+
level: this.opts.level,
|
|
28
|
+
write: (line) => this.opts.write(line)
|
|
29
|
+
}).withComponent(component);
|
|
30
|
+
}
|
|
31
|
+
/** Internal — return a logger that prefixes the component field. */
|
|
32
|
+
withComponent(component) {
|
|
33
|
+
const self = this;
|
|
34
|
+
return new Proxy(self, {
|
|
35
|
+
get(target, prop) {
|
|
36
|
+
if (prop === "emit") {
|
|
37
|
+
return (level, msg, fields) => self.emit(level, msg, { component, ...fields });
|
|
38
|
+
}
|
|
39
|
+
return target[prop];
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
emit(level, msg, fields) {
|
|
44
|
+
if (LEVEL_RANK[level] < LEVEL_RANK[this.opts.level]) return;
|
|
45
|
+
if (this.opts.structured) {
|
|
46
|
+
const entry = { level, msg, ts: (/* @__PURE__ */ new Date()).toISOString(), ...fields };
|
|
47
|
+
this.opts.write(JSON.stringify(entry));
|
|
48
|
+
} else {
|
|
49
|
+
const comp = fields?.component ? `[${fields.component}]` : "";
|
|
50
|
+
this.opts.write(`${level.toUpperCase()} ${comp} ${msg}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
let _default = null;
|
|
55
|
+
function getLogger() {
|
|
56
|
+
if (!_default) _default = new Logger({ structured: false });
|
|
57
|
+
return _default;
|
|
58
|
+
}
|
|
59
|
+
function configureLogger(opts) {
|
|
60
|
+
_default = new Logger(opts);
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
Logger,
|
|
64
|
+
configureLogger,
|
|
65
|
+
getLogger
|
|
66
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
function formatLabels(labels) {
|
|
2
|
+
if (!labels) return "";
|
|
3
|
+
const parts = [];
|
|
4
|
+
for (const [k, v] of Object.entries(labels)) {
|
|
5
|
+
parts.push(`${k}="${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`);
|
|
6
|
+
}
|
|
7
|
+
return parts.length ? `{${parts.join(",")}}` : "";
|
|
8
|
+
}
|
|
9
|
+
function labelKey(labels) {
|
|
10
|
+
if (!labels) return "";
|
|
11
|
+
const keys = Object.keys(labels).sort();
|
|
12
|
+
return keys.map((k) => `${k}=${labels[k]}`).join("|");
|
|
13
|
+
}
|
|
14
|
+
class Counter {
|
|
15
|
+
name;
|
|
16
|
+
help;
|
|
17
|
+
buckets = /* @__PURE__ */ new Map();
|
|
18
|
+
constructor(name, help) {
|
|
19
|
+
this.name = name;
|
|
20
|
+
this.help = help;
|
|
21
|
+
}
|
|
22
|
+
inc(labels, value = 1) {
|
|
23
|
+
const key = labelKey(labels);
|
|
24
|
+
const existing = this.buckets.get(key);
|
|
25
|
+
if (existing) existing.value += value;
|
|
26
|
+
else this.buckets.set(key, { labels, value });
|
|
27
|
+
}
|
|
28
|
+
render() {
|
|
29
|
+
const lines = [];
|
|
30
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
31
|
+
lines.push(`# TYPE ${this.name} counter`);
|
|
32
|
+
for (const { labels, value } of this.buckets.values()) {
|
|
33
|
+
lines.push(`${this.name}${formatLabels(labels)} ${value}`);
|
|
34
|
+
}
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
class Gauge {
|
|
39
|
+
name;
|
|
40
|
+
help;
|
|
41
|
+
buckets = /* @__PURE__ */ new Map();
|
|
42
|
+
constructor(name, help) {
|
|
43
|
+
this.name = name;
|
|
44
|
+
this.help = help;
|
|
45
|
+
}
|
|
46
|
+
set(value, labels) {
|
|
47
|
+
this.buckets.set(labelKey(labels), { labels, value });
|
|
48
|
+
}
|
|
49
|
+
render() {
|
|
50
|
+
const lines = [];
|
|
51
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
52
|
+
lines.push(`# TYPE ${this.name} gauge`);
|
|
53
|
+
for (const { labels, value } of this.buckets.values()) {
|
|
54
|
+
lines.push(`${this.name}${formatLabels(labels)} ${value}`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const DEFAULT_BUCKETS = [5e-3, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10];
|
|
60
|
+
class Histogram {
|
|
61
|
+
name;
|
|
62
|
+
help;
|
|
63
|
+
bucketBounds;
|
|
64
|
+
buckets = /* @__PURE__ */ new Map();
|
|
65
|
+
constructor(name, help, bucketBounds = DEFAULT_BUCKETS) {
|
|
66
|
+
this.name = name;
|
|
67
|
+
this.help = help;
|
|
68
|
+
this.bucketBounds = bucketBounds;
|
|
69
|
+
}
|
|
70
|
+
observe(value, labels) {
|
|
71
|
+
const key = labelKey(labels);
|
|
72
|
+
let entry = this.buckets.get(key);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = {
|
|
75
|
+
labels,
|
|
76
|
+
counts: new Array(this.bucketBounds.length).fill(0),
|
|
77
|
+
sum: 0,
|
|
78
|
+
total: 0
|
|
79
|
+
};
|
|
80
|
+
this.buckets.set(key, entry);
|
|
81
|
+
}
|
|
82
|
+
for (let i = 0; i < this.bucketBounds.length; i++) {
|
|
83
|
+
if (value <= this.bucketBounds[i]) entry.counts[i]++;
|
|
84
|
+
}
|
|
85
|
+
entry.sum += value;
|
|
86
|
+
entry.total++;
|
|
87
|
+
}
|
|
88
|
+
render() {
|
|
89
|
+
const lines = [];
|
|
90
|
+
lines.push(`# HELP ${this.name} ${this.help}`);
|
|
91
|
+
lines.push(`# TYPE ${this.name} histogram`);
|
|
92
|
+
for (const entry of this.buckets.values()) {
|
|
93
|
+
let cumulative = 0;
|
|
94
|
+
for (let i = 0; i < this.bucketBounds.length; i++) {
|
|
95
|
+
cumulative = entry.counts[i];
|
|
96
|
+
const labels = { ...entry.labels, le: this.bucketBounds[i] };
|
|
97
|
+
lines.push(`${this.name}_bucket${formatLabels(labels)} ${cumulative}`);
|
|
98
|
+
}
|
|
99
|
+
lines.push(`${this.name}_bucket${formatLabels({ ...entry.labels, le: "+Inf" })} ${entry.total}`);
|
|
100
|
+
lines.push(`${this.name}_sum${formatLabels(entry.labels)} ${entry.sum}`);
|
|
101
|
+
lines.push(`${this.name}_count${formatLabels(entry.labels)} ${entry.total}`);
|
|
102
|
+
}
|
|
103
|
+
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
class Registry {
|
|
107
|
+
collectors = /* @__PURE__ */ new Map();
|
|
108
|
+
register(metric) {
|
|
109
|
+
if (!this.collectors.has(metric.name)) this.collectors.set(metric.name, metric);
|
|
110
|
+
return this.collectors.get(metric.name);
|
|
111
|
+
}
|
|
112
|
+
render() {
|
|
113
|
+
const parts = [];
|
|
114
|
+
for (const c of this.collectors.values()) parts.push(c.render());
|
|
115
|
+
return parts.join("\n") + "\n";
|
|
116
|
+
}
|
|
117
|
+
reset() {
|
|
118
|
+
this.collectors.clear();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const registry = new Registry();
|
|
122
|
+
const metrics = {
|
|
123
|
+
queriesTotal: registry.register(new Counter("code_search_queries_total", "Total search queries by mode + repo.")),
|
|
124
|
+
queryDuration: registry.register(new Histogram("code_search_query_duration_seconds", "Query latency by mode.")),
|
|
125
|
+
indexChunks: registry.register(new Gauge("code_search_index_chunks_total", "Indexed chunks by repo.")),
|
|
126
|
+
indexAge: registry.register(new Gauge("code_search_index_age_seconds", "Seconds since last index by repo.")),
|
|
127
|
+
embeddingCalls: registry.register(new Counter("code_search_embeddings_calls_total", "Embedding API calls by provider.")),
|
|
128
|
+
llmCalls: registry.register(new Counter("code_search_llm_calls_total", "LLM API calls by phase.")),
|
|
129
|
+
errors: registry.register(new Counter("code_search_errors_total", "Errors by kind.")),
|
|
130
|
+
rerankerCacheHits: registry.register(new Counter("code_search_reranker_cache_hits_total", "Rerank cache lookups by outcome."))
|
|
131
|
+
};
|
|
132
|
+
export {
|
|
133
|
+
Counter,
|
|
134
|
+
Gauge,
|
|
135
|
+
Histogram,
|
|
136
|
+
metrics,
|
|
137
|
+
registry
|
|
138
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { readdirSync, existsSync, statSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
resolveCodeSearchConfig
|
|
5
|
+
} from "../core/config.js";
|
|
6
|
+
const DEFAULT_QUOTAS = {
|
|
7
|
+
maxQueriesPerMinute: 100,
|
|
8
|
+
maxEmbeddingCostUsd: 0,
|
|
9
|
+
maxLlmCostUsd: 0
|
|
10
|
+
};
|
|
11
|
+
function parseSimpleYaml(src) {
|
|
12
|
+
const obj = {};
|
|
13
|
+
const lines = src.split(/\r?\n/);
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < lines.length) {
|
|
16
|
+
const line = lines[i].replace(/#.*$/, "").replace(/\s+$/, "");
|
|
17
|
+
if (!line.trim()) {
|
|
18
|
+
i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const top = /^(\w+):\s*(.*)$/.exec(line);
|
|
22
|
+
if (!top) {
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const key = top[1];
|
|
27
|
+
const valRaw = top[2];
|
|
28
|
+
if (!valRaw) {
|
|
29
|
+
const sub = {};
|
|
30
|
+
i++;
|
|
31
|
+
while (i < lines.length) {
|
|
32
|
+
const sline = lines[i].replace(/#.*$/, "").replace(/\s+$/, "");
|
|
33
|
+
if (!sline) {
|
|
34
|
+
i++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const indent = sline.length - sline.trimStart().length;
|
|
38
|
+
if (indent < 2) break;
|
|
39
|
+
const m = /^\s*(\w+):\s*(.*)$/.exec(sline);
|
|
40
|
+
if (!m) {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
sub[m[1]] = coerce(m[2]);
|
|
45
|
+
i++;
|
|
46
|
+
}
|
|
47
|
+
obj[key] = sub;
|
|
48
|
+
} else {
|
|
49
|
+
obj[key] = coerce(valRaw);
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return obj;
|
|
54
|
+
}
|
|
55
|
+
function coerce(raw) {
|
|
56
|
+
let v = raw.trim();
|
|
57
|
+
if (v.startsWith('"') && v.endsWith('"') || v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1);
|
|
58
|
+
if (v === "true") return true;
|
|
59
|
+
if (v === "false") return false;
|
|
60
|
+
if (/^-?\d+$/.test(v)) return parseInt(v, 10);
|
|
61
|
+
if (/^-?\d+\.\d+$/.test(v)) return parseFloat(v);
|
|
62
|
+
if (v.startsWith("[") && v.endsWith("]")) return v.slice(1, -1).split(",").map((s) => coerce(s.trim()));
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
function readProjectYaml(path) {
|
|
66
|
+
if (!existsSync(path)) return {};
|
|
67
|
+
try {
|
|
68
|
+
return parseSimpleYaml(readFileSync(path, "utf-8"));
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
class ProjectRegistry {
|
|
74
|
+
constructor(dataDir) {
|
|
75
|
+
this.dataDir = dataDir;
|
|
76
|
+
}
|
|
77
|
+
projects = /* @__PURE__ */ new Map();
|
|
78
|
+
/** Re-read the projects/ directory. */
|
|
79
|
+
reload() {
|
|
80
|
+
this.projects.clear();
|
|
81
|
+
const projectsDir = join(this.dataDir, "projects");
|
|
82
|
+
if (!existsSync(projectsDir)) return [];
|
|
83
|
+
for (const name of readdirSync(projectsDir)) {
|
|
84
|
+
const dir = join(projectsDir, name);
|
|
85
|
+
try {
|
|
86
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
87
|
+
} catch {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const yamlPath = join(dir, "project.yaml");
|
|
91
|
+
const meta = readProjectYaml(yamlPath);
|
|
92
|
+
if (!meta.workspace) continue;
|
|
93
|
+
const workspaceDir = String(meta.workspace);
|
|
94
|
+
const quotas = {
|
|
95
|
+
maxQueriesPerMinute: meta.quotas?.max_queries_per_minute ?? meta.quotas?.maxQueriesPerMinute ?? DEFAULT_QUOTAS.maxQueriesPerMinute,
|
|
96
|
+
maxEmbeddingCostUsd: meta.quotas?.max_embedding_cost_usd ?? meta.quotas?.maxEmbeddingCostUsd ?? DEFAULT_QUOTAS.maxEmbeddingCostUsd,
|
|
97
|
+
maxLlmCostUsd: meta.quotas?.max_llm_cost_usd ?? meta.quotas?.maxLlmCostUsd ?? DEFAULT_QUOTAS.maxLlmCostUsd
|
|
98
|
+
};
|
|
99
|
+
const config = resolveCodeSearchConfig({ workspaceDir });
|
|
100
|
+
this.projects.set(name, {
|
|
101
|
+
name,
|
|
102
|
+
workspaceDir,
|
|
103
|
+
repos: Array.isArray(meta.repos) ? meta.repos : [],
|
|
104
|
+
scopes: Array.isArray(meta.scopes) ? meta.scopes : [],
|
|
105
|
+
config,
|
|
106
|
+
quotas
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return [...this.projects.values()];
|
|
110
|
+
}
|
|
111
|
+
list() {
|
|
112
|
+
return [...this.projects.values()];
|
|
113
|
+
}
|
|
114
|
+
get(name) {
|
|
115
|
+
return this.projects.get(name);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function projectAccessAllowed(project, identityScopes) {
|
|
119
|
+
if (project.scopes.length === 0) return true;
|
|
120
|
+
if (identityScopes.includes("*")) return true;
|
|
121
|
+
for (const s of identityScopes) {
|
|
122
|
+
if (project.scopes.includes(s)) return true;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const projectRateBuckets = /* @__PURE__ */ new Map();
|
|
127
|
+
function checkProjectQuota(project, identity) {
|
|
128
|
+
if (project.quotas.maxQueriesPerMinute <= 0) return { ok: true };
|
|
129
|
+
const key = `${identity}|${project.name}`;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const bucket = projectRateBuckets.get(key);
|
|
132
|
+
if (!bucket || now > bucket.resetAt) {
|
|
133
|
+
projectRateBuckets.set(key, { count: 1, resetAt: now + 6e4 });
|
|
134
|
+
return { ok: true };
|
|
135
|
+
}
|
|
136
|
+
if (bucket.count >= project.quotas.maxQueriesPerMinute) {
|
|
137
|
+
return { ok: false, reason: `quota exceeded for project="${project.name}": ${project.quotas.maxQueriesPerMinute}/min` };
|
|
138
|
+
}
|
|
139
|
+
bucket.count++;
|
|
140
|
+
return { ok: true };
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
ProjectRegistry,
|
|
144
|
+
checkProjectQuota,
|
|
145
|
+
projectAccessAllowed
|
|
146
|
+
};
|
package/dist/server.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerResources, handleResource } from "./resources/resources.js";
|
|
|
16
16
|
import { getKnowledgeBasePath } from "@esankhan3/anvil-knowledge-core";
|
|
17
17
|
import { indexFromPath } from "@esankhan3/anvil-knowledge-core";
|
|
18
18
|
import { loadServerConfig } from "./core/env-config.js";
|
|
19
|
+
import { toKnowledgeConfig } from "./core/config.js";
|
|
19
20
|
import { startHttpTransport } from "./transports/http-transport.js";
|
|
20
21
|
function createMcpServerInstance(ctx) {
|
|
21
22
|
const server = new Server(
|
|
@@ -185,9 +186,12 @@ async function trackedIndex(ctx, project, dirPath, opts) {
|
|
|
185
186
|
ctx.indexing.startedAt = Date.now();
|
|
186
187
|
ctx.indexing.error = null;
|
|
187
188
|
pushHistory(ctx, "start", `${label}: started for "${project}" at ${dirPath}`);
|
|
189
|
+
const unified = loadServerConfig().__unified;
|
|
190
|
+
const knowledgeConfig = toKnowledgeConfig(unified);
|
|
188
191
|
try {
|
|
189
192
|
const stats = await indexFromPath(project, dirPath, {
|
|
190
193
|
force: opts?.force,
|
|
194
|
+
config: knowledgeConfig,
|
|
191
195
|
onProgress: (m) => {
|
|
192
196
|
ctx.indexing.message = m;
|
|
193
197
|
console.error(`[${label}] ${m}`);
|
|
@@ -2,9 +2,11 @@ import { createServer } from "node:http";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
4
|
import { createAuthMiddleware } from "../middleware/auth.js";
|
|
5
|
+
import { registry, metrics } from "../observability/metrics.js";
|
|
5
6
|
async function startHttpTransport(opts) {
|
|
6
7
|
const { config, createMcpServer, onReady, getHealth } = opts;
|
|
7
8
|
const authenticate = createAuthMiddleware(config);
|
|
9
|
+
const startedAt = Date.now();
|
|
8
10
|
const sessions = /* @__PURE__ */ new Map();
|
|
9
11
|
const SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
10
12
|
const MAX_SESSIONS = 100;
|
|
@@ -37,6 +39,42 @@ async function startHttpTransport(opts) {
|
|
|
37
39
|
res.end(JSON.stringify(status, null, 2));
|
|
38
40
|
return;
|
|
39
41
|
}
|
|
42
|
+
if (path === "/version" && req.method === "GET") {
|
|
43
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
44
|
+
res.end(JSON.stringify({
|
|
45
|
+
name: "code-search-mcp",
|
|
46
|
+
version: "0.1.0",
|
|
47
|
+
node: process.version,
|
|
48
|
+
platform: process.platform
|
|
49
|
+
}));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (path === "/ready" && req.method === "GET") {
|
|
53
|
+
const health = getHealth?.() ?? {};
|
|
54
|
+
const ready = health.indexReady === true;
|
|
55
|
+
res.writeHead(ready ? 200 : 503, { "Content-Type": "application/json" });
|
|
56
|
+
res.end(JSON.stringify({ ready, ...health }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (path === "/metrics" && req.method === "GET") {
|
|
60
|
+
const status = opts.getStatus?.() ?? {};
|
|
61
|
+
const indexing = status.indexing;
|
|
62
|
+
metrics.indexAge.set(0, { phase: indexing?.phase ?? "idle" });
|
|
63
|
+
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
|
64
|
+
res.end(registry.render());
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (path === "/admin/api/status" && req.method === "GET") {
|
|
68
|
+
const status = opts.getStatus?.() ?? {};
|
|
69
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
70
|
+
res.end(JSON.stringify({
|
|
71
|
+
version: "0.1.0",
|
|
72
|
+
uptime: Math.floor((Date.now() - startedAt) / 1e3),
|
|
73
|
+
sessions: sessions.size,
|
|
74
|
+
...status
|
|
75
|
+
}, null, 2));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
40
78
|
if (path === "/index" && req.method === "POST") {
|
|
41
79
|
if (config.authEnabled) {
|
|
42
80
|
const identity = authenticate(req, res);
|
package/package.json
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esankhan3/code-search-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for multi-repo code search — GitHub org or local path, zero config",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"bin": {
|
|
8
|
-
"code-search-mcp": "./dist/index.js"
|
|
8
|
+
"code-search-mcp": "./dist/index.js",
|
|
9
|
+
"code-search": "./dist/cli/index.js",
|
|
10
|
+
"code-search-daemon": "./dist/daemon/index.js"
|
|
9
11
|
},
|
|
10
12
|
"exports": {
|
|
11
13
|
".": "./dist/index.js"
|
|
12
14
|
},
|
|
13
15
|
"files": [
|
|
14
|
-
"dist"
|
|
16
|
+
"dist",
|
|
17
|
+
"scripts/smoke"
|
|
15
18
|
],
|
|
16
19
|
"scripts": {
|
|
17
20
|
"build": "node build.mjs",
|
|
18
|
-
"dev": "tsc -b --watch"
|
|
21
|
+
"dev": "tsc -b --watch",
|
|
22
|
+
"test": "node --test dist/__tests__/*.test.js",
|
|
23
|
+
"smoke": "bash scripts/smoke/run-smoke.sh"
|
|
19
24
|
},
|
|
20
25
|
"dependencies": {
|
|
21
|
-
"@esankhan3/anvil-agent-core": "^0.
|
|
22
|
-
"@esankhan3/anvil-knowledge-core": "^0.
|
|
26
|
+
"@esankhan3/anvil-agent-core": "^0.3.0",
|
|
27
|
+
"@esankhan3/anvil-knowledge-core": "^0.3.0",
|
|
23
28
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
24
29
|
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"@lancedb/lancedb-darwin-arm64": "0.27.2",
|
|
32
|
+
"@lancedb/lancedb-darwin-x64": "0.27.2",
|
|
33
|
+
"@lancedb/lancedb-linux-x64-gnu": "0.27.2",
|
|
34
|
+
"@lancedb/lancedb-linux-arm64-gnu": "0.27.2",
|
|
35
|
+
"@lancedb/lancedb-win32-x64-msvc": "0.27.2"
|
|
36
|
+
},
|
|
25
37
|
"devDependencies": {
|
|
26
38
|
"@types/node": "^22.15.2"
|
|
27
39
|
},
|