@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
package/dist/middleware/auth.js
CHANGED
|
@@ -1,22 +1,5 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
48
|
-
return
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
function projectQuotaKey(project, identity) {
|
|
128
|
+
return `project:${project.name}|${identity}`;
|
|
129
|
+
}
|
|
127
130
|
function checkProjectQuota(project, identity) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
};
|