@esankhan3/code-search-mcp 1.0.6 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +114 -54
  2. package/dist/__tests__/_harness.js +194 -0
  3. package/dist/__tests__/config-cli.test.js +110 -0
  4. package/dist/__tests__/config-resolver.test.js +1 -1
  5. package/dist/__tests__/daemon-rpc-protocol.test.js +95 -0
  6. package/dist/__tests__/daemon-runtime.test.js +145 -0
  7. package/dist/__tests__/graph-tools-store.test.js +314 -0
  8. package/dist/__tests__/graph-tools.test.js +11 -4
  9. package/dist/__tests__/graph-traversal.test.js +262 -0
  10. package/dist/__tests__/http-auth.test.js +180 -0
  11. package/dist/__tests__/http-index.test.js +125 -0
  12. package/dist/__tests__/metrics-collect.test.js +48 -0
  13. package/dist/__tests__/observability.test.js +3 -4
  14. package/dist/__tests__/remote-proxy.test.js +105 -0
  15. package/dist/__tests__/server-cli-args.test.js +146 -0
  16. package/dist/__tests__/server-lifecycle.test.js +108 -0
  17. package/dist/__tests__/tool-inputs.test.js +178 -0
  18. package/dist/backends/in-process.js +5 -2
  19. package/dist/backends/index.js +2 -2
  20. package/dist/cli/index.js +143 -76
  21. package/dist/core/config.js +106 -35
  22. package/dist/core/env-config.js +3 -5
  23. package/dist/core/project-name.js +12 -0
  24. package/dist/core/version.js +5 -0
  25. package/dist/daemon/index.js +106 -65
  26. package/dist/daemon/rpc-server.js +38 -18
  27. package/dist/index.js +113 -89
  28. package/dist/middleware/auth.js +34 -66
  29. package/dist/middleware/rate-limit.js +37 -0
  30. package/dist/observability/logger.js +10 -2
  31. package/dist/observability/metrics.js +7 -6
  32. package/dist/projects/registry.js +13 -15
  33. package/dist/resources/resources.js +17 -0
  34. package/dist/server.js +216 -129
  35. package/dist/tools/graph.js +448 -165
  36. package/dist/tools/index-tools.js +6 -2
  37. package/dist/tools/profile.js +14 -8
  38. package/dist/tools/search.js +61 -51
  39. package/dist/tools/validate.js +48 -0
  40. package/dist/transports/http-transport.js +94 -55
  41. package/dist/transports/remote-proxy.js +58 -97
  42. package/package.json +2 -2
@@ -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
  };