@esankhan3/code-search-mcp 0.4.10 → 0.4.12
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/dist/__tests__/http-stateless.test.js +50 -0
- package/dist/server.js +140 -30
- package/dist/tools/search.js +5 -0
- package/dist/transports/http-transport.js +26 -1
- package/package.json +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
+
import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { startHttpTransport } from "../transports/http-transport.js";
|
|
8
|
+
function makeMcpServer() {
|
|
9
|
+
const server = new Server(
|
|
10
|
+
{ name: "stateless-test", version: "0.0.0" },
|
|
11
|
+
{ capabilities: { tools: {} } }
|
|
12
|
+
);
|
|
13
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
14
|
+
tools: [{ name: "ping", description: "test tool", inputSchema: { type: "object" } }]
|
|
15
|
+
}));
|
|
16
|
+
return server;
|
|
17
|
+
}
|
|
18
|
+
const config = {
|
|
19
|
+
port: 0,
|
|
20
|
+
host: "127.0.0.1",
|
|
21
|
+
transport: "streamable-http",
|
|
22
|
+
authEnabled: false,
|
|
23
|
+
authMode: "none",
|
|
24
|
+
authApiKeys: []
|
|
25
|
+
};
|
|
26
|
+
describe("http transport \u2014 stateless mode (cluster workers)", () => {
|
|
27
|
+
it("serves initialize + tools/list as independent sessionless requests", async () => {
|
|
28
|
+
const httpServer = await startHttpTransport({
|
|
29
|
+
config,
|
|
30
|
+
stateless: true,
|
|
31
|
+
createMcpServer: async () => ({ server: makeMcpServer() })
|
|
32
|
+
});
|
|
33
|
+
const base = `http://127.0.0.1:${httpServer.address().port}`;
|
|
34
|
+
try {
|
|
35
|
+
const client = new Client({ name: "test-client", version: "1.0.0" });
|
|
36
|
+
const transport = new StreamableHTTPClientTransport(new URL(`${base}/mcp`));
|
|
37
|
+
await client.connect(transport);
|
|
38
|
+
const tools = await client.listTools();
|
|
39
|
+
assert.equal(tools.tools[0]?.name, "ping");
|
|
40
|
+
assert.equal(transport.sessionId, void 0, "stateless server must not assign a session");
|
|
41
|
+
await client.close();
|
|
42
|
+
const get = await fetch(`${base}/mcp`, { method: "GET" });
|
|
43
|
+
assert.equal(get.status, 405);
|
|
44
|
+
const del = await fetch(`${base}/mcp`, { method: "DELETE" });
|
|
45
|
+
assert.equal(del.status, 405);
|
|
46
|
+
} finally {
|
|
47
|
+
httpServer.close();
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
ReadResourceRequestSchema
|
|
8
8
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import cluster from "node:cluster";
|
|
11
12
|
import { registerSearchTools, handleSearchTool } from "./tools/search.js";
|
|
12
13
|
import { registerGraphTools, handleGraphTool } from "./tools/graph.js";
|
|
13
14
|
import { registerProfileTools, handleProfileTool } from "./tools/profile.js";
|
|
@@ -75,19 +76,31 @@ async function startServer(projectName, directoryPath) {
|
|
|
75
76
|
};
|
|
76
77
|
const llmInfo = config.llmMode === "none" ? "disabled (profiling + service mesh skipped)" : config.llmMode === "api" ? `api \u2192 ${config.llmProvider}/${config.llmModel}${config.llmApiKey ? "" : " (WARNING: no API key!)"}` : `cli \u2192 ${config.claudeBin}`;
|
|
77
78
|
console.error(`[code-search-mcp] LLM: ${llmInfo}`);
|
|
78
|
-
await autoIndex(ctx);
|
|
79
|
+
await autoIndex(ctx, { readOnly: cluster.isWorker });
|
|
79
80
|
if (config.transport === "stdio") {
|
|
80
81
|
const server = createMcpServerInstance(ctx);
|
|
81
82
|
const transport = new StdioServerTransport();
|
|
82
83
|
await server.connect(transport);
|
|
83
84
|
console.error(`[code-search-mcp] Server running for "${projectName}" (stdio)`);
|
|
84
85
|
} else {
|
|
86
|
+
const workerCount = parseWorkerCount();
|
|
87
|
+
if (workerCount > 1 && cluster.isPrimary) {
|
|
88
|
+
armScheduledReindex(ctx, () => broadcastInvalidate(ctx));
|
|
89
|
+
runClusterPrimary(ctx, workerCount);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
85
92
|
if (config.authMode === "none" && config.host !== "127.0.0.1" && config.host !== "localhost") {
|
|
86
93
|
console.error(`[code-search-mcp] WARNING: Auth is disabled but server binds to ${config.host}. Any machine on the network can access your code search API.`);
|
|
87
94
|
console.error(`[code-search-mcp] Set CODE_SEARCH_AUTH_MODE=api-key or CODE_SEARCH_HOST=127.0.0.1 for security.`);
|
|
88
95
|
}
|
|
96
|
+
const isReadOnlyWorker = cluster.isWorker;
|
|
97
|
+
if (isReadOnlyWorker) registerWorkerIpc(ctx);
|
|
89
98
|
await startHttpTransport({
|
|
90
99
|
config,
|
|
100
|
+
// Workers must be stateless: the fronting proxy opens a new upstream
|
|
101
|
+
// connection per request, so in-memory MCP sessions cannot stick to one
|
|
102
|
+
// worker. Also opt-in for single-process deploys via env.
|
|
103
|
+
stateless: isReadOnlyWorker || process.env.CODE_SEARCH_STATELESS === "1",
|
|
91
104
|
createMcpServer: async () => ({
|
|
92
105
|
server: createMcpServerInstance(ctx)
|
|
93
106
|
}),
|
|
@@ -116,35 +129,12 @@ async function startServer(projectName, directoryPath) {
|
|
|
116
129
|
elapsedMs: ctx.indexing.startedAt ? Date.now() - ctx.indexing.startedAt : null
|
|
117
130
|
}
|
|
118
131
|
}),
|
|
119
|
-
onIndex:
|
|
120
|
-
const { resolve } = await import("node:path");
|
|
121
|
-
const { existsSync: existsSync2 } = await import("node:fs");
|
|
122
|
-
const dirPath = resolve(body.path);
|
|
123
|
-
if (!existsSync2(dirPath)) {
|
|
124
|
-
throw new Error(`Path does not exist: ${dirPath}`);
|
|
125
|
-
}
|
|
126
|
-
if (ctx.indexing.status === "indexing") {
|
|
127
|
-
throw new Error(`Indexing already in progress (phase: ${ctx.indexing.phase}). Wait for it to complete or check GET /status.`);
|
|
128
|
-
}
|
|
129
|
-
const project = body.project || dirPath.split("/").filter(Boolean).pop() || "project";
|
|
130
|
-
const stats = await trackedIndex(ctx, project, dirPath, {
|
|
131
|
-
force: body.force,
|
|
132
|
-
label: "admin-index"
|
|
133
|
-
});
|
|
134
|
-
ctx.projectName = project;
|
|
135
|
-
ctx.directoryPath = dirPath;
|
|
136
|
-
return {
|
|
137
|
-
status: "ok",
|
|
138
|
-
project,
|
|
139
|
-
path: dirPath,
|
|
140
|
-
chunks: stats.totalChunks,
|
|
141
|
-
repos: stats.repos.length,
|
|
142
|
-
crossRepoEdges: stats.crossRepoEdges,
|
|
143
|
-
durationMs: stats.indexDurationMs
|
|
144
|
-
};
|
|
145
|
-
}
|
|
132
|
+
onIndex: (body) => isReadOnlyWorker ? forwardIndexToPrimary(body) : runAdminIndex(ctx, body)
|
|
146
133
|
});
|
|
147
134
|
}
|
|
135
|
+
if (!cluster.isWorker) armScheduledReindex(ctx);
|
|
136
|
+
}
|
|
137
|
+
function armScheduledReindex(ctx, onSuccess) {
|
|
148
138
|
const reindexIntervalMs = parseReindexInterval();
|
|
149
139
|
if (reindexIntervalMs > 0 && ctx.directoryPath) {
|
|
150
140
|
console.error(`[code-search-mcp] Auto-reindex every ${Math.round(reindexIntervalMs / 6e4)}m`);
|
|
@@ -152,12 +142,128 @@ async function startServer(projectName, directoryPath) {
|
|
|
152
142
|
if (!ctx.directoryPath || ctx.indexing.status === "indexing") return;
|
|
153
143
|
try {
|
|
154
144
|
await trackedIndex(ctx, ctx.projectName, ctx.directoryPath, { label: "auto-reindex" });
|
|
145
|
+
onSuccess?.();
|
|
155
146
|
} catch (err) {
|
|
156
147
|
console.error(`[auto-reindex] Failed:`, err);
|
|
157
148
|
}
|
|
158
149
|
}, reindexIntervalMs).unref();
|
|
159
150
|
}
|
|
160
151
|
}
|
|
152
|
+
async function runAdminIndex(ctx, body) {
|
|
153
|
+
const dirPath = resolve(body.path);
|
|
154
|
+
if (!existsSync(dirPath)) {
|
|
155
|
+
throw new Error(`Path does not exist: ${dirPath}`);
|
|
156
|
+
}
|
|
157
|
+
if (ctx.indexing.status === "indexing") {
|
|
158
|
+
throw new Error(`Indexing already in progress (phase: ${ctx.indexing.phase}). Wait for it to complete or check GET /status.`);
|
|
159
|
+
}
|
|
160
|
+
const project = body.project || dirPath.split("/").filter(Boolean).pop() || "project";
|
|
161
|
+
const stats = await trackedIndex(ctx, project, dirPath, {
|
|
162
|
+
force: body.force,
|
|
163
|
+
label: "admin-index"
|
|
164
|
+
});
|
|
165
|
+
ctx.projectName = project;
|
|
166
|
+
ctx.directoryPath = dirPath;
|
|
167
|
+
return {
|
|
168
|
+
status: "ok",
|
|
169
|
+
project,
|
|
170
|
+
path: dirPath,
|
|
171
|
+
chunks: stats.totalChunks,
|
|
172
|
+
repos: stats.repos.length,
|
|
173
|
+
crossRepoEdges: stats.crossRepoEdges,
|
|
174
|
+
durationMs: stats.indexDurationMs
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function broadcastInvalidate(ctx) {
|
|
178
|
+
const msg = { type: "csm:invalidate", project: ctx.projectName, directoryPath: ctx.directoryPath };
|
|
179
|
+
for (const w of Object.values(cluster.workers ?? {})) {
|
|
180
|
+
try {
|
|
181
|
+
w?.send(msg);
|
|
182
|
+
} catch {
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function parseWorkerCount() {
|
|
187
|
+
const n = parseInt(process.env.CODE_SEARCH_WORKERS ?? "", 10);
|
|
188
|
+
return Number.isFinite(n) && n > 1 ? Math.min(n, 16) : 1;
|
|
189
|
+
}
|
|
190
|
+
function runClusterPrimary(ctx, workerCount) {
|
|
191
|
+
console.error(`[code-search-mcp] Cluster primary ${process.pid}: forking ${workerCount} read-only workers (stateless MCP)`);
|
|
192
|
+
let shuttingDown = false;
|
|
193
|
+
const send = (w, msg) => {
|
|
194
|
+
try {
|
|
195
|
+
w?.send(msg);
|
|
196
|
+
} catch {
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
cluster.on("message", (worker, msg) => {
|
|
200
|
+
if (msg?.type !== "csm:index") return;
|
|
201
|
+
void (async () => {
|
|
202
|
+
try {
|
|
203
|
+
const result = await runAdminIndex(ctx, msg.body);
|
|
204
|
+
broadcastInvalidate(ctx);
|
|
205
|
+
send(worker, { type: "csm:index-result", id: msg.id, ok: true, result });
|
|
206
|
+
} catch (err) {
|
|
207
|
+
send(worker, {
|
|
208
|
+
type: "csm:index-result",
|
|
209
|
+
id: msg.id,
|
|
210
|
+
ok: false,
|
|
211
|
+
error: err instanceof Error ? err.message : String(err)
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
})();
|
|
215
|
+
});
|
|
216
|
+
cluster.on("exit", (worker, code, signal) => {
|
|
217
|
+
if (shuttingDown) return;
|
|
218
|
+
console.error(`[code-search-mcp] Worker ${worker.process.pid} exited (${signal ?? code}) \u2014 respawning`);
|
|
219
|
+
cluster.fork();
|
|
220
|
+
});
|
|
221
|
+
const shutdown = (sig) => {
|
|
222
|
+
shuttingDown = true;
|
|
223
|
+
for (const w of Object.values(cluster.workers ?? {})) {
|
|
224
|
+
try {
|
|
225
|
+
w?.kill(sig);
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
process.exit(0);
|
|
230
|
+
};
|
|
231
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
232
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
233
|
+
for (let i = 0; i < workerCount; i++) cluster.fork();
|
|
234
|
+
}
|
|
235
|
+
let ipcSeq = 0;
|
|
236
|
+
const pendingIndexRequests = /* @__PURE__ */ new Map();
|
|
237
|
+
function forwardIndexToPrimary(body) {
|
|
238
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
239
|
+
if (!process.send) {
|
|
240
|
+
rejectPromise(new Error("IPC channel unavailable \u2014 not a cluster worker"));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const id = ++ipcSeq;
|
|
244
|
+
pendingIndexRequests.set(id, { resolve: resolvePromise, reject: rejectPromise });
|
|
245
|
+
const msg = { type: "csm:index", id, body };
|
|
246
|
+
process.send(msg);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function registerWorkerIpc(ctx) {
|
|
250
|
+
process.on("message", (msg) => {
|
|
251
|
+
if (msg?.type === "csm:index-result") {
|
|
252
|
+
const pending = pendingIndexRequests.get(msg.id);
|
|
253
|
+
if (!pending) return;
|
|
254
|
+
pendingIndexRequests.delete(msg.id);
|
|
255
|
+
if (msg.ok) pending.resolve(msg.result ?? {});
|
|
256
|
+
else pending.reject(new Error(msg.error ?? "Index failed"));
|
|
257
|
+
} else if (msg?.type === "csm:invalidate") {
|
|
258
|
+
void invalidateRetriever(msg.project).catch(() => {
|
|
259
|
+
});
|
|
260
|
+
ctx.projectName = msg.project;
|
|
261
|
+
if (msg.directoryPath) ctx.directoryPath = msg.directoryPath;
|
|
262
|
+
ctx.indexReady = true;
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
process.on("disconnect", () => process.exit(0));
|
|
266
|
+
}
|
|
161
267
|
function parseReindexInterval() {
|
|
162
268
|
const raw = process.env.CODE_SEARCH_REINDEX_INTERVAL?.trim();
|
|
163
269
|
if (!raw || raw === "0" || raw === "none") return 0;
|
|
@@ -224,7 +330,7 @@ async function trackedIndex(ctx, project, dirPath, opts) {
|
|
|
224
330
|
throw err;
|
|
225
331
|
}
|
|
226
332
|
}
|
|
227
|
-
async function autoIndex(ctx) {
|
|
333
|
+
async function autoIndex(ctx, opts) {
|
|
228
334
|
try {
|
|
229
335
|
const kbPath = getKnowledgeBasePath(ctx.projectName);
|
|
230
336
|
const hasLanceDB = existsSync(join(kbPath, "lancedb"));
|
|
@@ -234,6 +340,10 @@ async function autoIndex(ctx) {
|
|
|
234
340
|
console.error(`[code-search-mcp] Index loaded for "${ctx.projectName}"`);
|
|
235
341
|
return;
|
|
236
342
|
}
|
|
343
|
+
if (opts?.readOnly) {
|
|
344
|
+
console.error(`[code-search-mcp] No index yet \u2014 read-only worker waiting for the writer`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
237
347
|
if (!ctx.directoryPath) {
|
|
238
348
|
console.error(`[code-search-mcp] No index found and no directory path \u2014 tools will return empty results`);
|
|
239
349
|
return;
|
package/dist/tools/search.js
CHANGED
|
@@ -73,6 +73,11 @@ async function handleSearchTool(name, args, ctx) {
|
|
|
73
73
|
};
|
|
74
74
|
const result = await retriever.retrieve(query, {
|
|
75
75
|
maxChunks: maxResults,
|
|
76
|
+
// A search tool returns a fixed COUNT of results; the config token budget
|
|
77
|
+
// is for LLM-context packing and would truncate below maxResults (the
|
|
78
|
+
// benchmark saw ~6.6 of 10 returned). Give maxChunks room to bind while
|
|
79
|
+
// still bounding a pathological all-huge-chunks payload.
|
|
80
|
+
maxTokens: maxResults * 4e3,
|
|
76
81
|
repoFilter: repos,
|
|
77
82
|
mode: modeMap[name]
|
|
78
83
|
});
|
|
@@ -117,6 +117,31 @@ async function startHttpTransport(opts) {
|
|
|
117
117
|
const identity = authenticate(req, res);
|
|
118
118
|
if (!identity) return;
|
|
119
119
|
}
|
|
120
|
+
if (req.method === "POST" && opts.stateless) {
|
|
121
|
+
try {
|
|
122
|
+
const transport = new StreamableHTTPServerTransport({
|
|
123
|
+
sessionIdGenerator: void 0,
|
|
124
|
+
enableJsonResponse: true
|
|
125
|
+
});
|
|
126
|
+
const { server: mcpServer } = await createMcpServer();
|
|
127
|
+
res.on("close", () => {
|
|
128
|
+
void transport.close();
|
|
129
|
+
});
|
|
130
|
+
await mcpServer.connect(transport);
|
|
131
|
+
await transport.handleRequest(req, res);
|
|
132
|
+
} catch {
|
|
133
|
+
if (!res.headersSent) {
|
|
134
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
135
|
+
res.end(JSON.stringify({ error: "Request handling failed" }));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if ((req.method === "GET" || req.method === "DELETE") && opts.stateless) {
|
|
141
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
142
|
+
res.end(JSON.stringify({ error: "Stateless mode: sessions are not supported" }));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
120
145
|
if (req.method === "POST") {
|
|
121
146
|
const sessionId = req.headers["mcp-session-id"];
|
|
122
147
|
if (sessionId && sessions.has(sessionId)) {
|
|
@@ -194,7 +219,7 @@ async function startHttpTransport(opts) {
|
|
|
194
219
|
httpServer.listen(config.port, config.host, () => {
|
|
195
220
|
const url = `http://${config.host === "0.0.0.0" ? "localhost" : config.host}:${config.port}`;
|
|
196
221
|
onReady?.(url);
|
|
197
|
-
resolve();
|
|
222
|
+
resolve(httpServer);
|
|
198
223
|
});
|
|
199
224
|
});
|
|
200
225
|
}
|