@esankhan3/code-search-mcp 0.4.11 → 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.
@@ -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: async (body) => {
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;
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esankhan3/code-search-mcp",
3
- "version": "0.4.11",
3
+ "version": "0.4.12",
4
4
  "description": "MCP server for multi-repo code search — GitHub org or local path, zero config",
5
5
  "type": "module",
6
6
  "license": "MIT",