vexp-mcp 2.0.11 → 2.0.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.
@@ -307,7 +307,7 @@ function binaryEnvFor(binaryPath) {
307
307
  return env;
308
308
  }
309
309
  /** FNV-1a 64-bit hash — must match vexp-core/src/utils.rs md5_hash() */
310
- function fnvHash(input) {
310
+ export function fnvHash(input) {
311
311
  let hash = BigInt("0xcbf29ce484222325");
312
312
  const prime = BigInt("0x100000001b3");
313
313
  const mask = BigInt("0xffffffffffffffff");
@@ -470,6 +470,41 @@ function getDefaultSocketPath() {
470
470
  return path.join(workspaceRoot, ".vexp", "daemon.sock");
471
471
  }
472
472
  export { discoverWorkspaceRoot };
473
+ /**
474
+ * Reverse lookup: given the first 8 hex chars of fnvHash(workspaceRoot),
475
+ * return the socket path of the matching daemon from the registry.
476
+ *
477
+ * Used by the HTTP MCP transport to route /ws/:hash/mcp requests to the
478
+ * correct daemon. Returns null if no registry entry matches or the socket
479
+ * file is gone (stale entry).
480
+ */
481
+ export function resolveSocketByWorkspaceHash(hashPrefix) {
482
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
483
+ if (!home)
484
+ return null;
485
+ const registryPath = path.join(home, ".vexp", "daemons.json");
486
+ let registry;
487
+ try {
488
+ registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
489
+ }
490
+ catch {
491
+ return null;
492
+ }
493
+ const target = hashPrefix.toLowerCase();
494
+ for (const [ws, sock] of Object.entries(registry)) {
495
+ const hash = fnvHash(ws.toLowerCase()).slice(0, 8);
496
+ if (hash !== target)
497
+ continue;
498
+ if (process.platform === "win32")
499
+ return sock;
500
+ try {
501
+ if (fs.statSync(sock).isSocket())
502
+ return sock;
503
+ }
504
+ catch { /* stale */ }
505
+ }
506
+ return null;
507
+ }
473
508
  /** Human-friendly hint printed when a connection attempt fails — enumerates
474
509
  * what we tried to make the failure actionable instead of misleading. */
475
510
  function daemonRunHint() {
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { createServer } from "http";
7
7
  import { randomUUID } from "crypto";
8
8
  import fs from "fs";
9
9
  import path from "path";
10
- import { DaemonClient } from "./daemon-client.js";
10
+ import { DaemonClient, resolveSocketByWorkspaceHash } from "./daemon-client.js";
11
11
  import { GET_CONTEXT_CAPSULE_DEFINITION, handleGetContextCapsule, } from "./tools/get-context-capsule.js";
12
12
  import { GET_IMPACT_GRAPH_DEFINITION, handleGetImpactGraph, } from "./tools/get-impact-graph.js";
13
13
  import { SEARCH_LOGIC_FLOW_DEFINITION, handleSearchLogicFlow, } from "./tools/search-logic-flow.js";
@@ -168,9 +168,11 @@ function resolveToken() {
168
168
  }
169
169
  return token;
170
170
  }
171
- async function startHttp(daemon, port) {
171
+ async function startHttp(fallbackDaemon, port) {
172
172
  const app = express();
173
173
  app.use(express.json());
174
+ // Sessions are keyed per-workspace so different workspaces cannot leak
175
+ // transports into each other's streams.
174
176
  const transports = new Map();
175
177
  const bearerToken = resolveToken();
176
178
  // CORS preflight
@@ -180,38 +182,35 @@ async function startHttp(daemon, port) {
180
182
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Authorization");
181
183
  res.status(204).send();
182
184
  });
183
- // Bearer token auth middleware — protects /mcp, skips /health and OPTIONS
184
- app.use("/mcp", (req, res, next) => {
185
+ // Bearer token auth middleware — protects all /mcp and /ws/*/mcp paths.
186
+ const authGuard = (req, res, next) => {
185
187
  const authHeader = req.headers.authorization;
186
188
  if (!authHeader || authHeader !== `Bearer ${bearerToken}`) {
187
189
  res.status(401).json({ error: "Unauthorized", message: "Missing or invalid Bearer token" });
188
190
  return;
189
191
  }
190
192
  next();
191
- });
192
- // Single MCP endpoint — handles initialize (POST without session ID),
193
- // subsequent JSON-RPC requests, and GET for SSE notification streams.
194
- app.all("/mcp", async (req, res) => {
193
+ };
194
+ app.use("/mcp", authGuard);
195
+ app.use("/ws", authGuard);
196
+ const handleMcpRequest = async (req, res, hash, daemon) => {
195
197
  res.setHeader("Access-Control-Allow-Origin", "*");
196
198
  const sessionId = req.headers["mcp-session-id"];
197
199
  if (sessionId) {
198
- const transport = transports.get(sessionId);
199
- if (!transport) {
200
- // Session lost (server restart, transport closed) — create a new one
201
- // instead of returning 404, so the client transparently reconnects.
202
- console.error(`[vexp-mcp] Session ${sessionId} not found — creating new transport`);
203
- // Fall through to the "new session" code below
204
- }
205
- else {
206
- await transport.handleRequest(req, res, req.body);
200
+ const entry = transports.get(sessionId);
201
+ if (entry && entry.hash === hash) {
202
+ await entry.transport.handleRequest(req, res, req.body);
207
203
  return;
208
204
  }
205
+ // Session unknown or mismatched workspace — create a new one.
206
+ if (entry && entry.hash !== hash) {
207
+ console.error(`[vexp-mcp] Session ${sessionId} reused across workspaces (${entry.hash} → ${hash}); issuing new session`);
208
+ }
209
209
  }
210
- // No session ID → new client initializing
211
210
  const transport = new StreamableHTTPServerTransport({
212
211
  sessionIdGenerator: () => randomUUID(),
213
212
  onsessioninitialized: (sid) => {
214
- transports.set(sid, transport);
213
+ transports.set(sid, { transport, hash });
215
214
  },
216
215
  });
217
216
  transport.onclose = () => {
@@ -221,11 +220,35 @@ async function startHttp(daemon, port) {
221
220
  const server = createMcpServer(daemon);
222
221
  await server.connect(transport);
223
222
  await transport.handleRequest(req, res, req.body);
223
+ };
224
+ // New canonical route: /ws/:hash/mcp — hash is first 8 hex chars of
225
+ // fnvHash(workspaceRoot.toLowerCase()). Each request gets a DaemonClient
226
+ // pinned to the matching socket from ~/.vexp/daemons.json, so multiple
227
+ // workspaces do not contaminate each other's results.
228
+ app.all("/ws/:hash/mcp", async (req, res) => {
229
+ const { hash } = req.params;
230
+ const socketPath = resolveSocketByWorkspaceHash(hash);
231
+ if (!socketPath) {
232
+ res.status(503).json({
233
+ error: "NoDaemon",
234
+ message: `No running daemon matches workspace hash ${hash}. Run 'vexp setup' or 'vexp daemon-cmd start' in the project.`,
235
+ });
236
+ return;
237
+ }
238
+ const daemon = new DaemonClient(socketPath);
239
+ await handleMcpRequest(req, res, hash, daemon);
240
+ });
241
+ // Legacy route — kept for backward compat. Uses the fallback daemon,
242
+ // which resolves via registry at request time and returns the single
243
+ // entry if only one workspace is active. Multi-workspace setups should
244
+ // migrate to /ws/:hash/mcp via the next `vexp setup`.
245
+ app.all("/mcp", async (req, res) => {
246
+ await handleMcpRequest(req, res, "_legacy", fallbackDaemon);
224
247
  });
225
- // Health check
248
+ // Health check — uses fallback daemon (single-workspace registry lookup).
226
249
  app.get("/health", async (_req, res) => {
227
250
  try {
228
- const healthy = await daemon.health();
251
+ const healthy = await fallbackDaemon.health();
229
252
  res.json({ status: healthy ? "ok" : "daemon_down", sessions: transports.size });
230
253
  }
231
254
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-mcp",
3
- "version": "2.0.11",
3
+ "version": "2.0.12",
4
4
  "description": "vexp MCP server — AI context tools for coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",