@esankhan3/code-search-mcp 1.0.5 → 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.
- 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__/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 +353 -133
- package/dist/tools/index-tools.js +3 -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 +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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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
|
-
--
|
|
90
|
-
--
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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) =>
|
|
181
|
+
onProgress: (m) => log.info(m)
|
|
160
182
|
});
|
|
161
|
-
|
|
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
|
-
|
|
188
|
+
log.error(`Directory not found: ${directoryPath}`);
|
|
169
189
|
process.exit(1);
|
|
170
190
|
}
|
|
171
|
-
|
|
172
|
-
projectName = directoryPath.split("/").filter(Boolean).pop() || "project";
|
|
173
|
-
}
|
|
174
|
-
} else if (!projectName) {
|
|
191
|
+
} else if (local && !projectName) {
|
|
175
192
|
directoryPath = process.cwd();
|
|
176
|
-
|
|
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
|
-
|
|
205
|
+
log.error(`Fatal: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
182
206
|
process.exit(1);
|
|
183
207
|
}
|
|
184
208
|
})();
|
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
|
};
|