@esankhan3/code-search-mcp 1.0.6 → 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
package/dist/index.js CHANGED
@@ -1,51 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { resolve } from "node:path";
2
+ import { resolve, basename, join } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
- import { printConfig, resolveCodeSearchConfig, parseCliFlags } from "./core/config.js";
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
- }
12
- let mode = "remote";
13
- let remoteUrl = null;
14
- let apiKey;
15
- let source = null;
16
- let projectName = null;
17
- let githubToken;
18
- let force = false;
19
- let port;
20
- let transport;
21
- let authMode;
22
- for (let i = 0; i < args.length; i++) {
23
- if (args[i] === "--remote" && args[i + 1]) {
24
- mode = "remote";
25
- remoteUrl = args[++i];
26
- } else if (args[i] === "--local") {
27
- mode = "local";
28
- if (args[i + 1] && !args[i + 1].startsWith("--")) {
29
- source = args[++i];
30
- }
31
- } else if (args[i] === "--serve") {
32
- mode = "serve";
33
- } else if (args[i] === "--api-key" && args[i + 1]) {
34
- apiKey = args[++i];
35
- } else if (args[i] === "--project" && args[i + 1]) {
36
- projectName = args[++i];
37
- } else if (args[i] === "--token" && args[i + 1]) {
38
- githubToken = args[++i];
39
- } else if (args[i] === "--force") {
40
- force = true;
41
- } else if (args[i] === "--port" && args[i + 1]) {
42
- port = args[++i];
43
- } else if (args[i] === "--transport" && args[i + 1]) {
44
- transport = args[++i];
45
- } else if (args[i] === "--auth" && args[i + 1]) {
46
- authMode = args[++i];
47
- } else if (args[i] === "--help" || args[i] === "-h") {
48
- console.error(`
4
+ import { homedir } from "node:os";
5
+ import { printConfig, resolveCodeSearchConfig, parseCliFlags, exportDataDir } from "./core/config.js";
6
+ import { loadServerConfig } from "./core/env-config.js";
7
+ import { validateProjectName } from "./core/project-name.js";
8
+ import { configureLogger, getComponentLogger } from "./observability/logger.js";
9
+ const log = getComponentLogger("code-search-mcp");
10
+ const HELP = `
49
11
  Code Search MCP \u2014 multi-repo code intelligence via MCP
50
12
 
51
13
  REMOTE MODE (default \u2014 connects to a deployed server):
@@ -70,7 +32,7 @@ REMOTE MODE (default \u2014 connects to a deployed server):
70
32
  }
71
33
  }
72
34
 
73
- LOCAL MODE (self-hosted \u2014 indexes repos locally):
35
+ LOCAL MODE (self-hosted \u2014 indexes repos locally, serves over stdio):
74
36
  code-search-mcp --local /path/to/repos Index local directory
75
37
  code-search-mcp --local github:org-name Clone from GitHub org
76
38
  code-search-mcp --local --project my-project Serve existing index
@@ -78,45 +40,111 @@ LOCAL MODE (self-hosted \u2014 indexes repos locally):
78
40
  Options:
79
41
  --project <name> Project name (default: derived from source)
80
42
  --token <token> GitHub token (or GITHUB_TOKEN env)
81
- --force Force full re-index
43
+ --force Force a full re-index at startup
82
44
 
83
- SERVER MODE (deploy as HTTP service):
84
- code-search-mcp --serve Start HTTP server
85
- code-search-mcp --serve --port 3100 --auth api-key
45
+ SERVER MODE (deploy as HTTP service \u2014 Streamable HTTP on /mcp):
46
+ code-search-mcp --serve [path] Start HTTP server (project "default")
47
+ code-search-mcp --serve /repos --port 3100 --auth api-key
48
+ (--local <path> --serve and --serve --local <path> are equivalent)
86
49
 
87
50
  Options:
88
- --port <port> HTTP port (default: 3100)
89
- --auth <mode> none | api-key | jwt
90
- --transport <mode> streamable-http | sse
51
+ --port <port> HTTP port (default: 3100) = --server.port
52
+ --host <host> Bind address = --server.host
53
+ --auth <mode> none | api-key | jwt = --auth.mode
54
+ --project <name> Project name (default: "default")
91
55
 
92
56
  Environment:
93
- CODE_SEARCH_TRANSPORT streamable-http | sse
94
57
  CODE_SEARCH_PORT HTTP port (default: 3100)
95
58
  CODE_SEARCH_AUTH_MODE none | api-key | jwt
96
59
  CODE_SEARCH_AUTH_API_KEYS Comma-separated API keys
97
60
  CODE_SEARCH_EMBEDDING_PROVIDER auto | codestral | openai | voyage | ollama | custom
98
61
  CODE_SEARCH_EMBEDDING_API_KEY Unified embedding API key
99
62
  CODE_SEARCH_DATA_DIR Data directory override
100
- `);
101
- process.exit(0);
102
- } else if (!args[i].startsWith("-")) {
103
- const arg = args[i];
104
- if (arg.startsWith("http://") || arg.startsWith("https://")) {
105
- mode = "remote";
106
- remoteUrl = arg;
107
- } else {
108
- mode = "local";
109
- source = arg;
110
- }
63
+
64
+ CONFIG (every mode):
65
+ --<dotted.path> <value> Any CodeSearchConfig leaf, e.g. --retrieval.max-chunks 12
66
+ --print-config Print the resolved config (secrets redacted) and exit
67
+ `;
68
+ function usageError(msg) {
69
+ log.error(`${msg} \u2014 see --help`);
70
+ process.exit(2);
71
+ }
72
+ const { patch, rest, unknown } = parseCliFlags(process.argv.slice(2));
73
+ if (unknown.length) usageError(`unknown flag(s): ${unknown.join(", ")}`);
74
+ let printOnly = false;
75
+ let serve = false;
76
+ let local = false;
77
+ let remoteUrl = null;
78
+ let apiKey;
79
+ let source = null;
80
+ let projectName = null;
81
+ let githubToken;
82
+ let force = false;
83
+ for (let i = 0; i < rest.length; i++) {
84
+ const arg = rest[i];
85
+ const value = () => {
86
+ const v = rest[i + 1];
87
+ if (v === void 0 || v.startsWith("--")) usageError(`${arg} needs a value`);
88
+ i++;
89
+ return v;
90
+ };
91
+ const optionalPath = () => rest[i + 1] !== void 0 && !rest[i + 1].startsWith("-") ? rest[++i] : null;
92
+ switch (arg) {
93
+ case "--print-config":
94
+ printOnly = true;
95
+ break;
96
+ case "--remote":
97
+ remoteUrl = value();
98
+ break;
99
+ case "--local":
100
+ local = true;
101
+ source = optionalPath() ?? source;
102
+ break;
103
+ case "--serve":
104
+ serve = true;
105
+ source = optionalPath() ?? source;
106
+ break;
107
+ case "--api-key":
108
+ apiKey = value();
109
+ break;
110
+ case "--project":
111
+ projectName = value();
112
+ break;
113
+ case "--token":
114
+ githubToken = value();
115
+ break;
116
+ case "--force":
117
+ force = true;
118
+ break;
119
+ case "--help":
120
+ case "-h":
121
+ process.stderr.write(HELP);
122
+ process.exit(0);
123
+ default:
124
+ if (arg.startsWith("-")) usageError(`unknown flag: ${arg}`);
125
+ if (/^https?:\/\//.test(arg)) remoteUrl = arg;
126
+ else source = arg;
111
127
  }
112
128
  }
129
+ const mode = serve ? "serve" : remoteUrl ? "remote" : local || source ? "local" : "remote";
130
+ const workspaceDir = source && !source.startsWith("github:") ? resolve(source) : process.cwd();
131
+ const preview = resolveCodeSearchConfig({ cli: patch, workspaceDir });
132
+ if (preview.server.transport === "sse") {
133
+ usageError("SSE transport is not supported \u2014 the server speaks Streamable HTTP on /mcp (drop --transport / CODE_SEARCH_TRANSPORT)");
134
+ }
135
+ if (mode === "serve") patch.server = { ...patch.server, transport: "streamable-http" };
136
+ if (printOnly) {
137
+ process.stdout.write(printConfig(resolveCodeSearchConfig({ cli: patch, workspaceDir })) + "\n");
138
+ process.exit(0);
139
+ }
140
+ configureLogger({ structured: preview.telemetry.structuredLogs });
113
141
  (async () => {
114
142
  try {
115
143
  if (mode === "remote") {
116
144
  const serverUrl = remoteUrl ?? process.env.CODE_SEARCH_SERVER ?? process.env.CODE_SEARCH_REMOTE_URL;
117
145
  const key = apiKey ?? process.env.CODE_SEARCH_API_KEY ?? process.env.CODE_SEARCH_AUTH_API_KEY;
118
146
  if (!serverUrl) {
119
- console.error(`[code-search-mcp] No remote server configured.
147
+ log.error(`No remote server configured.
120
148
 
121
149
  Set CODE_SEARCH_SERVER environment variable:
122
150
  export CODE_SEARCH_SERVER=https://your-server:3100
@@ -136,14 +164,8 @@ For help:
136
164
  await startRemoteProxy({ serverUrl, apiKey: key });
137
165
  return;
138
166
  }
139
- if (mode === "serve") {
140
- if (port) process.env.CODE_SEARCH_PORT = port;
141
- if (authMode) process.env.CODE_SEARCH_AUTH_MODE = authMode;
142
- process.env.CODE_SEARCH_TRANSPORT = transport === "sse" ? "sse" : "streamable-http";
143
- const { startServer: startServer2 } = await import("./server.js");
144
- await startServer2(projectName ?? "default", source ? resolve(source) : null);
145
- return;
146
- }
167
+ const config = loadServerConfig({ cli: patch, workspaceDir });
168
+ exportDataDir(config.__unified);
147
169
  let directoryPath = null;
148
170
  if (source?.startsWith("github:")) {
149
171
  const { cloneOrUpdateOrg } = await import("./sources/github-org.js");
@@ -151,34 +173,36 @@ For help:
151
173
  const slashIdx = spec.indexOf("/");
152
174
  const org = slashIdx > 0 ? spec.slice(0, slashIdx) : spec;
153
175
  const pattern = slashIdx > 0 ? spec.slice(slashIdx + 1) : void 0;
154
- if (!projectName) projectName = org;
155
- console.error(`[code-search-mcp] Cloning repos from github:${org}${pattern ? `/${pattern}` : ""}...`);
176
+ if (!projectName && mode === "local") projectName = org;
177
+ log.info(`Cloning repos from github:${org}${pattern ? `/${pattern}` : ""}...`);
156
178
  const repos = await cloneOrUpdateOrg(org, {
157
179
  pattern,
158
180
  token: githubToken,
159
- onProgress: (m) => console.error(m)
181
+ onProgress: (m) => log.info(m)
160
182
  });
161
- console.error(`[code-search-mcp] ${repos.length} repos ready`);
162
- const { homedir } = await import("node:os");
163
- const { join } = await import("node:path");
183
+ log.info(`${repos.length} repos ready`);
164
184
  directoryPath = join(homedir(), ".code-search", org);
165
185
  } else if (source) {
166
186
  directoryPath = resolve(source);
167
187
  if (!existsSync(directoryPath)) {
168
- console.error(`[code-search-mcp] Directory not found: ${directoryPath}`);
188
+ log.error(`Directory not found: ${directoryPath}`);
169
189
  process.exit(1);
170
190
  }
171
- if (!projectName) {
172
- projectName = directoryPath.split("/").filter(Boolean).pop() || "project";
173
- }
174
- } else if (!projectName) {
191
+ } else if (local && !projectName) {
175
192
  directoryPath = process.cwd();
176
- projectName = directoryPath.split("/").filter(Boolean).pop() || "project";
193
+ }
194
+ if (!projectName) {
195
+ projectName = mode === "serve" ? "default" : directoryPath && basename(directoryPath) || "project";
196
+ }
197
+ try {
198
+ validateProjectName(projectName);
199
+ } catch (err) {
200
+ usageError(err.message);
177
201
  }
178
202
  const { startServer } = await import("./server.js");
179
- await startServer(projectName, directoryPath);
203
+ await startServer(projectName, directoryPath, { force });
180
204
  } catch (err) {
181
- console.error(`[code-search-mcp] Fatal:`, err);
205
+ log.error(`Fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
182
206
  process.exit(1);
183
207
  }
184
208
  })();
@@ -1,22 +1,5 @@
1
- import { createHmac, timingSafeEqual } from "node:crypto";
2
- const rateBuckets = /* @__PURE__ */ new Map();
3
- function checkRateLimit(identity, maxPerMinute) {
4
- const now = Date.now();
5
- const bucket = rateBuckets.get(identity);
6
- if (!bucket || now > bucket.resetAt) {
7
- rateBuckets.set(identity, { count: 1, resetAt: now + 6e4 });
8
- return true;
9
- }
10
- if (bucket.count >= maxPerMinute) return false;
11
- bucket.count++;
12
- return true;
13
- }
14
- setInterval(() => {
15
- const now = Date.now();
16
- for (const [key, bucket] of rateBuckets) {
17
- if (now > bucket.resetAt) rateBuckets.delete(key);
18
- }
19
- }, 3e5).unref();
1
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
2
+ import { localRateLimiter } from "./rate-limit.js";
20
3
  function verifyJwt(token, secret, issuer) {
21
4
  try {
22
5
  const parts = token.split(".");
@@ -31,7 +14,9 @@ function verifyJwt(token, secret, issuer) {
31
14
  return null;
32
15
  }
33
16
  const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
34
- if (payload.exp && payload.exp < Math.floor(Date.now() / 1e3)) return null;
17
+ const now = Math.floor(Date.now() / 1e3);
18
+ if (typeof payload.exp !== "number" || payload.exp <= now) return null;
19
+ if (payload.nbf !== void 0 && (typeof payload.nbf !== "number" || payload.nbf > now)) return null;
35
20
  if (issuer && payload.iss !== issuer) return null;
36
21
  return { sub: payload.sub ?? "unknown", scope: payload.scope };
37
22
  } catch {
@@ -44,64 +29,47 @@ function safeCompare(a, b) {
44
29
  if (bufA.length !== bufB.length) return false;
45
30
  return timingSafeEqual(bufA, bufB);
46
31
  }
47
- function createAuthMiddleware(config) {
48
- return function authenticate(req, res) {
32
+ function apiKeySubject(key) {
33
+ return `key:${createHash("sha256").update(key).digest("hex").slice(0, 16)}`;
34
+ }
35
+ function createAuthMiddleware(config, opts = {}) {
36
+ if (config.authMode === "jwt" && !config.authJwtSecret) {
37
+ throw new Error('Auth mode "jwt" needs a signing secret: set CODE_SEARCH_AUTH_JWT_SECRET (or auth.jwt_secret in config).');
38
+ }
39
+ const rateLimiter = opts.rateLimiter ?? localRateLimiter;
40
+ const reject = (res, status, error, headers = {}) => {
41
+ res.writeHead(status, { "Content-Type": "application/json", ...headers });
42
+ res.end(JSON.stringify({ error }));
43
+ return null;
44
+ };
45
+ return async function authenticate(req, res) {
49
46
  if (config.authMode === "none") {
50
47
  return { mode: "anonymous", subject: "anonymous", scopes: ["*"] };
51
48
  }
52
49
  const authHeader = req.headers.authorization;
53
- if (!authHeader?.startsWith("Bearer ")) {
54
- res.writeHead(401, { "Content-Type": "application/json" });
55
- res.end(JSON.stringify({ error: "Missing Authorization header" }));
56
- return null;
57
- }
50
+ if (!authHeader?.startsWith("Bearer ")) return reject(res, 401, "Missing Authorization header");
58
51
  const token = authHeader.slice(7);
52
+ let identity;
59
53
  if (config.authMode === "api-key") {
60
54
  const matched = config.authApiKeys.some((key) => safeCompare(token, key));
61
- if (!matched) {
62
- res.writeHead(401, { "Content-Type": "application/json" });
63
- res.end(JSON.stringify({ error: "Invalid API key" }));
64
- return null;
65
- }
66
- const identity = {
67
- mode: "api-key",
68
- subject: `key:${token.slice(0, 8)}...`,
69
- scopes: ["*"]
70
- };
71
- if (!checkRateLimit(identity.subject, config.rateLimitPerMinute)) {
72
- res.writeHead(429, { "Content-Type": "application/json" });
73
- res.end(JSON.stringify({ error: "Rate limit exceeded" }));
74
- return null;
75
- }
76
- return identity;
77
- }
78
- if (config.authMode === "jwt") {
79
- if (!config.authJwtSecret) {
80
- res.writeHead(500, { "Content-Type": "application/json" });
81
- res.end(JSON.stringify({ error: "JWT secret not configured" }));
82
- return null;
83
- }
55
+ if (!matched) return reject(res, 401, "Invalid API key");
56
+ identity = { mode: "api-key", subject: apiKeySubject(token), scopes: ["*"] };
57
+ } else if (config.authMode === "jwt") {
84
58
  const claims = verifyJwt(token, config.authJwtSecret, config.authJwtIssuer);
85
- if (!claims) {
86
- res.writeHead(401, { "Content-Type": "application/json" });
87
- res.end(JSON.stringify({ error: "Invalid or expired JWT" }));
88
- return null;
89
- }
90
- const identity = {
59
+ if (!claims) return reject(res, 401, "Invalid or expired JWT");
60
+ identity = {
91
61
  mode: "jwt",
92
62
  subject: claims.sub,
93
- scopes: claims.scope ? claims.scope.split(" ") : ["*"]
63
+ scopes: claims.scope ? claims.scope.split(" ").filter(Boolean) : []
94
64
  };
95
- if (!checkRateLimit(identity.subject, config.rateLimitPerMinute)) {
96
- res.writeHead(429, { "Content-Type": "application/json" });
97
- res.end(JSON.stringify({ error: "Rate limit exceeded" }));
98
- return null;
99
- }
100
- return identity;
65
+ } else {
66
+ return reject(res, 500, `Unknown auth mode: ${config.authMode}`);
101
67
  }
102
- res.writeHead(500, { "Content-Type": "application/json" });
103
- res.end(JSON.stringify({ error: `Unknown auth mode: ${config.authMode}` }));
104
- return null;
68
+ const decision = await rateLimiter(`auth:${identity.subject}`, config.rateLimitPerMinute);
69
+ if (!decision.ok) {
70
+ return reject(res, 429, "Rate limit exceeded", { "Retry-After": String(decision.retryAfterSec) });
71
+ }
72
+ return identity;
105
73
  };
106
74
  }
107
75
  export {
@@ -0,0 +1,37 @@
1
+ class SlidingWindow {
2
+ constructor(windowMs = 6e4, now = Date.now) {
3
+ this.windowMs = windowMs;
4
+ this.now = now;
5
+ }
6
+ hits = /* @__PURE__ */ new Map();
7
+ hit(key, limit) {
8
+ if (limit <= 0) return { ok: true, retryAfterSec: 0 };
9
+ const t = this.now();
10
+ const recent = (this.hits.get(key) ?? []).filter((at) => at > t - this.windowMs);
11
+ if (recent.length >= limit) {
12
+ this.hits.set(key, recent);
13
+ return { ok: false, retryAfterSec: Math.max(1, Math.ceil((recent[0] + this.windowMs - t) / 1e3)) };
14
+ }
15
+ recent.push(t);
16
+ this.hits.set(key, recent);
17
+ return { ok: true, retryAfterSec: 0 };
18
+ }
19
+ /** Forget keys with no hit inside the window. */
20
+ prune() {
21
+ const cutoff = this.now() - this.windowMs;
22
+ for (const [key, at] of this.hits) {
23
+ if (at[at.length - 1] <= cutoff) this.hits.delete(key);
24
+ }
25
+ }
26
+ }
27
+ const processWindow = new SlidingWindow();
28
+ setInterval(() => processWindow.prune(), 3e5).unref();
29
+ const localRateLimiter = async (key, limit) => processWindow.hit(key, limit);
30
+ function localRateHit(key, limit) {
31
+ return processWindow.hit(key, limit);
32
+ }
33
+ export {
34
+ SlidingWindow,
35
+ localRateHit,
36
+ localRateLimiter
37
+ };
@@ -46,8 +46,7 @@ class Logger {
46
46
  const entry = { level, msg, ts: (/* @__PURE__ */ new Date()).toISOString(), ...fields };
47
47
  this.opts.write(JSON.stringify(entry));
48
48
  } else {
49
- const comp = fields?.component ? `[${fields.component}]` : "";
50
- this.opts.write(`${level.toUpperCase()} ${comp} ${msg}`);
49
+ this.opts.write(fields?.component ? `[${fields.component}] ${msg}` : msg);
51
50
  }
52
51
  }
53
52
  }
@@ -59,8 +58,17 @@ function getLogger() {
59
58
  function configureLogger(opts) {
60
59
  _default = new Logger(opts);
61
60
  }
61
+ function getComponentLogger(component) {
62
+ return {
63
+ debug: (msg, fields) => getLogger().debug(msg, { component, ...fields }),
64
+ info: (msg, fields) => getLogger().info(msg, { component, ...fields }),
65
+ warn: (msg, fields) => getLogger().warn(msg, { component, ...fields }),
66
+ error: (msg, fields) => getLogger().error(msg, { component, ...fields })
67
+ };
68
+ }
62
69
  export {
63
70
  Logger,
64
71
  configureLogger,
72
+ getComponentLogger,
65
73
  getLogger
66
74
  };
@@ -46,6 +46,10 @@ class Gauge {
46
46
  set(value, labels) {
47
47
  this.buckets.set(labelKey(labels), { labels, value });
48
48
  }
49
+ /** Drop every series (a collector that owns the gauge starts each scrape empty). */
50
+ clear() {
51
+ this.buckets.clear();
52
+ }
49
53
  render() {
50
54
  const lines = [];
51
55
  lines.push(`# HELP ${this.name} ${this.help}`);
@@ -122,14 +126,11 @@ const registry = new Registry();
122
126
  const metrics = {
123
127
  queriesTotal: registry.register(new Counter("code_search_queries_total", "Total search queries by mode + repo.")),
124
128
  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.")),
129
+ indexChunks: registry.register(new Gauge("code_search_index_chunks_total", "Indexed chunks by project + repo.")),
130
+ indexAge: registry.register(new Gauge("code_search_index_age_seconds", "Seconds since the last successful index, by project.")),
127
131
  vectorIndexUnindexedRows: registry.register(new Gauge("code_search_vector_index_unindexed_rows", "Rows not yet folded into the vector index (brute-force scanned) by project.")),
128
132
  vectorIndexGrowthRatio: registry.register(new Gauge("code_search_vector_index_growth_ratio", "Rows written since the vector index was last trained / rows it was trained on, by project (retrains at CODE_SEARCH_VECTOR_RETRAIN_RATIO).")),
129
- embeddingCalls: registry.register(new Counter("code_search_embeddings_calls_total", "Embedding API calls by provider.")),
130
- llmCalls: registry.register(new Counter("code_search_llm_calls_total", "LLM API calls by phase.")),
131
- errors: registry.register(new Counter("code_search_errors_total", "Errors by kind.")),
132
- rerankerCacheHits: registry.register(new Counter("code_search_reranker_cache_hits_total", "Rerank cache lookups by outcome."))
133
+ errors: registry.register(new Counter("code_search_errors_total", "Errors by kind."))
133
134
  };
134
135
  export {
135
136
  Counter,
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import {
4
4
  resolveCodeSearchConfig
5
5
  } from "../core/config.js";
6
+ import { localRateHit } from "../middleware/rate-limit.js";
6
7
  const DEFAULT_QUOTAS = {
7
8
  maxQueriesPerMinute: 100,
8
9
  maxEmbeddingCostUsd: 0,
@@ -123,24 +124,21 @@ function projectAccessAllowed(project, identityScopes) {
123
124
  }
124
125
  return false;
125
126
  }
126
- const projectRateBuckets = /* @__PURE__ */ new Map();
127
+ function projectQuotaKey(project, identity) {
128
+ return `project:${project.name}|${identity}`;
129
+ }
127
130
  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 };
131
+ const d = localRateHit(projectQuotaKey(project, identity), project.quotas.maxQueriesPerMinute);
132
+ if (d.ok) return { ok: true };
133
+ return {
134
+ ok: false,
135
+ reason: `quota exceeded for project="${project.name}": ${project.quotas.maxQueriesPerMinute}/min`,
136
+ retryAfterSec: d.retryAfterSec
137
+ };
141
138
  }
142
139
  export {
143
140
  ProjectRegistry,
144
141
  checkProjectQuota,
145
- projectAccessAllowed
142
+ projectAccessAllowed,
143
+ projectQuotaKey
146
144
  };
@@ -18,6 +18,22 @@ function registerResources(ctx) {
18
18
  }
19
19
  ];
20
20
  }
21
+ function registerResourceTemplates() {
22
+ return [
23
+ {
24
+ uriTemplate: "code-search://repo/{name}/profile",
25
+ name: "Repo Profile",
26
+ description: "Profile of one repo (see code-search://repos for names)",
27
+ mimeType: "application/json"
28
+ },
29
+ {
30
+ uriTemplate: "code-search://repo/{name}/graph",
31
+ name: "Repo Graph",
32
+ description: "Code graph (nodes + links) of one repo",
33
+ mimeType: "application/json"
34
+ }
35
+ ];
36
+ }
21
37
  async function handleResource(uri, ctx) {
22
38
  try {
23
39
  if (uri === "code-search://repos") {
@@ -78,5 +94,6 @@ async function handleResource(uri, ctx) {
78
94
  }
79
95
  export {
80
96
  handleResource,
97
+ registerResourceTemplates,
81
98
  registerResources
82
99
  };