vexp-mcp 2.0.11 → 2.0.13
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/daemon-client.js +36 -1
- package/dist/index.js +44 -21
- package/package.json +1 -1
package/dist/daemon-client.js
CHANGED
|
@@ -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(
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
199
|
-
if (
|
|
200
|
-
|
|
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
|
|
251
|
+
const healthy = await fallbackDaemon.health();
|
|
229
252
|
res.json({ status: healthy ? "ok" : "daemon_down", sessions: transports.size });
|
|
230
253
|
}
|
|
231
254
|
catch {
|