vexp-mcp 2.0.3 → 2.0.5

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.
@@ -1,22 +1,67 @@
1
1
  import * as net from "net";
2
2
  import * as path from "path";
3
3
  import * as fs from "fs";
4
+ import { spawn } from "child_process";
5
+ import { fileURLToPath } from "url";
4
6
  import { randomUUID } from "crypto";
5
7
  /**
6
8
  * Client per comunicare con il vexp-core daemon via Unix socket / Named pipe.
7
9
  * Protocol: JSON lines (newline-delimited JSON).
8
10
  */
9
11
  export class DaemonClient {
10
- socketPath;
12
+ explicitSocketPath;
11
13
  requestCounter = 0;
12
14
  sessionId;
15
+ daemonSpawnAttempted = false;
13
16
  static MAX_RETRIES = 3;
14
17
  static RETRY_DELAY_MS = 1500;
15
18
  static RETRYABLE_CODES = ["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"];
16
19
  constructor(socketPath, sessionId) {
17
- this.socketPath = socketPath ?? getDefaultSocketPath();
20
+ // When a caller passes an explicit path we honor it for the client's
21
+ // lifetime (back-compat for tests and one-shot CLI usage). When omitted,
22
+ // we re-resolve via the registry on every connect so a long-lived HTTP
23
+ // MCP server can follow daemons that start/stop while it's up.
24
+ this.explicitSocketPath = socketPath;
18
25
  this.sessionId = sessionId ?? randomUUID();
19
26
  }
27
+ /**
28
+ * On the first connection failure, attempt to start the daemon once
29
+ * (idempotent lato Rust). Silent: success = next retry connects;
30
+ * failure = the ordinary retry/error path still surfaces a clear error
31
+ * with the registry candidates. Controlled by VEXP_NO_AUTOSTART=1.
32
+ */
33
+ async maybeSpawnDaemon() {
34
+ if (this.daemonSpawnAttempted)
35
+ return;
36
+ this.daemonSpawnAttempted = true;
37
+ if (process.env.VEXP_NO_AUTOSTART === "1")
38
+ return;
39
+ const workspaceRoot = process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
40
+ if (!fs.existsSync(path.join(workspaceRoot, ".vexp", "manifest.json")))
41
+ return;
42
+ const binary = resolveVexpCoreBinary();
43
+ if (!binary)
44
+ return;
45
+ await new Promise((resolve) => {
46
+ const child = spawn(binary, ["daemon-cmd", "start"], {
47
+ cwd: workspaceRoot,
48
+ stdio: "ignore",
49
+ detached: true,
50
+ env: binaryEnvFor(binary),
51
+ });
52
+ const done = () => { resolve(); };
53
+ child.on("exit", done);
54
+ child.on("error", done);
55
+ // Cap total wait at 4s: Rust daemon-cmd itself waits 2s for socket.
56
+ setTimeout(done, 4000);
57
+ });
58
+ }
59
+ /** Resolve socket path at request time to survive daemon restarts. */
60
+ get socketPath() {
61
+ if (this.explicitSocketPath)
62
+ return this.explicitSocketPath;
63
+ return resolveDaemonSocket() ?? getDefaultSocketPath();
64
+ }
20
65
  static isRetryableError(err) {
21
66
  const msg = err.message;
22
67
  return DaemonClient.RETRYABLE_CODES.some((code) => msg.includes(code));
@@ -33,6 +78,11 @@ export class DaemonClient {
33
78
  catch (err) {
34
79
  lastError = err instanceof Error ? err : new Error(String(err));
35
80
  if (attempt < DaemonClient.MAX_RETRIES && DaemonClient.isRetryableError(lastError)) {
81
+ // First failure: try to auto-start the daemon (one-shot).
82
+ // Subsequent failures skip the spawn and just retry.
83
+ if (attempt === 0) {
84
+ await this.maybeSpawnDaemon();
85
+ }
36
86
  console.error(`[vexp-mcp] Daemon connection failed (${lastError.message}), ` +
37
87
  `retrying in ${DaemonClient.RETRY_DELAY_MS}ms ` +
38
88
  `(attempt ${attempt + 1}/${DaemonClient.MAX_RETRIES})`);
@@ -119,7 +169,7 @@ export class DaemonClient {
119
169
  }
120
170
  });
121
171
  client.on("error", (err) => {
122
- settle(() => reject(new Error(`Daemon connection error: ${err.message}. Is vexp daemon running?`)));
172
+ settle(() => reject(new Error(`Daemon connection error: ${err.message}. ${daemonRunHint()}`)));
123
173
  });
124
174
  client.on("close", () => {
125
175
  settle(() => reject(new Error("Daemon connection closed unexpectedly")));
@@ -178,7 +228,7 @@ export class DaemonClient {
178
228
  }
179
229
  });
180
230
  client.on("error", (err) => {
181
- settle(() => reject(new Error(`Daemon connection error: ${err.message}. Is vexp daemon running?`)));
231
+ settle(() => reject(new Error(`Daemon connection error: ${err.message}. ${daemonRunHint()}`)));
182
232
  });
183
233
  client.on("close", () => {
184
234
  settle(() => reject(new Error("Daemon connection closed unexpectedly")));
@@ -198,6 +248,64 @@ export class DaemonClient {
198
248
  }
199
249
  }
200
250
  }
251
+ /**
252
+ * Locate the vexp-core binary from the MCP server's own filesystem
253
+ * position. Supports three layouts:
254
+ * - Env override: VEXP_CORE_PATH
255
+ * - CLI npm install: the MCP bundle lives in vexp-cli/mcp/ with a
256
+ * sibling @vexp/core-<plat> package at ../../@vexp/core-<plat>/bin/
257
+ * - VSCode extension: binaries/vexp-core-<plat>/ alongside dist/
258
+ * - Dev fallback: target/release/vexp-core in the monorepo
259
+ * Returns null if nothing matches.
260
+ */
261
+ function resolveVexpCoreBinary() {
262
+ if (process.env.VEXP_CORE_PATH && fs.existsSync(process.env.VEXP_CORE_PATH)) {
263
+ return process.env.VEXP_CORE_PATH;
264
+ }
265
+ // __dirname may not exist in ESM — derive it from import.meta.url fallback.
266
+ let scriptDir;
267
+ try {
268
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
269
+ scriptDir = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
270
+ }
271
+ catch {
272
+ scriptDir = process.cwd();
273
+ }
274
+ const plat = `${process.platform}-${process.arch === "arm64" ? "arm64" : "x64"}`;
275
+ const ext = process.platform === "win32" ? ".exe" : "";
276
+ const candidates = [
277
+ // CLI npm install: mcp-server.cjs → ../../@vexp/core-<plat>/bin/vexp-core
278
+ path.resolve(scriptDir, "..", "..", "@vexp", `core-${plat}`, "bin", `vexp-core${ext}`),
279
+ // Same but relative from dist/mcp-server.cjs inside vexp-cli
280
+ path.resolve(scriptDir, "..", "node_modules", "@vexp", `core-${plat}`, "bin", `vexp-core${ext}`),
281
+ // VSCode extension: dist/mcp-server.cjs → ../binaries/vexp-core-<plat>/vexp-core
282
+ path.resolve(scriptDir, "..", "binaries", `vexp-core-${plat}`, `vexp-core${ext}`),
283
+ // Dev fallback: monorepo target/release
284
+ path.resolve(scriptDir, "..", "..", "..", "target", "release", `vexp-core${ext}`),
285
+ ];
286
+ for (const c of candidates) {
287
+ if (fs.existsSync(c))
288
+ return c;
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Build env for spawning vexp-core: prepend binary dir to LD_LIBRARY_PATH
294
+ * (Linux) / DYLD_* (macOS) so the loader finds bundled libggml/libllama
295
+ * even when the binary's RPATH is missing (e.g. older VSIX artifacts).
296
+ */
297
+ function binaryEnvFor(binaryPath) {
298
+ const dir = path.dirname(binaryPath);
299
+ const env = { ...process.env };
300
+ if (process.platform === "linux") {
301
+ env.LD_LIBRARY_PATH = env.LD_LIBRARY_PATH ? `${dir}:${env.LD_LIBRARY_PATH}` : dir;
302
+ }
303
+ else if (process.platform === "darwin") {
304
+ env.DYLD_LIBRARY_PATH = env.DYLD_LIBRARY_PATH ? `${dir}:${env.DYLD_LIBRARY_PATH}` : dir;
305
+ env.DYLD_FALLBACK_LIBRARY_PATH = env.DYLD_FALLBACK_LIBRARY_PATH ? `${dir}:${env.DYLD_FALLBACK_LIBRARY_PATH}` : dir;
306
+ }
307
+ return env;
308
+ }
201
309
  /** FNV-1a 64-bit hash — must match vexp-core/src/utils.rs md5_hash() */
202
310
  function fnvHash(input) {
203
311
  let hash = BigInt("0xcbf29ce484222325");
@@ -239,38 +347,145 @@ function discoverWorkspaceRoot() {
239
347
  }
240
348
  return cwd;
241
349
  }
242
- function getDefaultSocketPath() {
350
+ /**
351
+ * Read `~/.vexp/daemons.json` and pick the best entry for the given workspace.
352
+ *
353
+ * The Rust daemon registers itself here on startup on ALL platforms (main.rs
354
+ * writes `{workspace_root → socket_path}`). This is the authoritative way to
355
+ * find a live daemon when the MCP server was spawned from an unrelated cwd
356
+ * (typical for Claude Code, Cursor, Windsurf, GitHub Copilot — they start
357
+ * MCP servers from ~, from the file's dir, etc., never guaranteed to be the
358
+ * workspace root).
359
+ *
360
+ * Selection policy:
361
+ * 1. Exact workspace match
362
+ * 2. Longest workspace that is a prefix of `workspaceHint` (handles
363
+ * submodule / nested-directory cwd pointing at a parent daemon)
364
+ * 3. If registry has exactly one entry, use it
365
+ * 4. Otherwise `null` (caller should error clearly)
366
+ */
367
+ function pickFromDaemonRegistry(workspaceHint) {
368
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
369
+ if (!home)
370
+ return null;
371
+ const registryPath = path.join(home, ".vexp", "daemons.json");
372
+ let registry;
373
+ try {
374
+ registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
375
+ }
376
+ catch {
377
+ return null;
378
+ }
379
+ // Filter out stale entries: SIGKILL'd daemons don't run their Drop
380
+ // handler so the registry accumulates ghost workspaces. Keep only
381
+ // entries whose socket file still exists (Windows named pipes are
382
+ // always exposed by the kernel, so skip the file check there).
383
+ const allEntries = Object.entries(registry);
384
+ const entries = process.platform === "win32"
385
+ ? allEntries
386
+ : allEntries.filter(([, sock]) => {
387
+ try {
388
+ return fs.statSync(sock).isSocket();
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ });
394
+ if (entries.length === 0)
395
+ return null;
396
+ const hintNorm = workspaceHint.toLowerCase();
397
+ for (const [ws, sock] of entries) {
398
+ if (ws.toLowerCase() === hintNorm)
399
+ return sock;
400
+ }
401
+ let bestWs = "";
402
+ let bestSock = null;
403
+ for (const [ws, sock] of entries) {
404
+ const wsNorm = ws.toLowerCase();
405
+ if (hintNorm.startsWith(wsNorm + path.sep) || hintNorm === wsNorm) {
406
+ if (wsNorm.length > bestWs.length) {
407
+ bestWs = wsNorm;
408
+ bestSock = sock;
409
+ }
410
+ }
411
+ }
412
+ if (bestSock)
413
+ return bestSock;
414
+ if (entries.length === 1)
415
+ return entries[0][1];
416
+ return null;
417
+ }
418
+ /**
419
+ * Resolve the daemon socket path / named pipe, cross-platform, using:
420
+ * 1. `VEXP_SOCKET` env — explicit override (highest precedence)
421
+ * 2. Workspace-local socket if it exists (`<workspaceRoot>/.vexp/daemon.sock`)
422
+ * 3. Global daemon registry `~/.vexp/daemons.json` (written by the Rust
423
+ * daemon at startup on all platforms)
424
+ * 4. Platform-specific fallback (Windows hash pipe, otherwise `null` →
425
+ * caller surfaces a clear "no daemon" error)
426
+ *
427
+ * Does NOT fall back to `~/.vexp/daemon.sock` — the daemon never writes
428
+ * there; that path is a dead-end that masks the real error.
429
+ */
430
+ export function resolveDaemonSocket() {
431
+ const explicit = process.env["VEXP_SOCKET"];
432
+ if (explicit)
433
+ return explicit;
243
434
  const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
244
435
  if (process.platform === "win32") {
245
- // 1. Try reading daemon.pipe from discovered workspace
436
+ // Workspace-specific pipe name file written by the daemon
246
437
  const pipeFile = path.join(workspaceRoot, ".vexp", "daemon.pipe");
247
438
  try {
248
439
  const pipeName = fs.readFileSync(pipeFile, "utf-8").trim();
249
440
  if (pipeName)
250
441
  return pipeName;
251
442
  }
252
- catch { /* file doesn't exist */ }
253
- // 2. Try global daemon registry (~/.vexp/daemons.json)
254
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
255
- if (home) {
256
- const registryPath = path.join(home, ".vexp", "daemons.json");
257
- try {
258
- const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
259
- const entries = Object.entries(registry);
260
- if (entries.length > 0) {
261
- const normalizedCwd = workspaceRoot.toLowerCase();
262
- for (const [ws, pipe] of entries) {
263
- if (ws.toLowerCase() === normalizedCwd)
264
- return pipe;
265
- }
266
- return entries[0][1]; // fallback to first available
267
- }
268
- }
269
- catch { }
270
- }
271
- // 3. Fallback: compute hash
443
+ catch { /* missing */ }
444
+ // Registry
445
+ const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
446
+ if (fromRegistry)
447
+ return fromRegistry;
448
+ // Hash-based last-ditch (daemon should always register, so this is rare)
272
449
  const hash = fnvHash(workspaceRoot.toLowerCase());
273
450
  return `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`;
274
451
  }
452
+ // Unix: prefer workspace-local if the socket file actually exists
453
+ const cwdSocket = path.join(workspaceRoot, ".vexp", "daemon.sock");
454
+ if (fs.existsSync(cwdSocket))
455
+ return cwdSocket;
456
+ // Registry (Linux + macOS) — the piece that was missing
457
+ const fromRegistry = pickFromDaemonRegistry(workspaceRoot);
458
+ if (fromRegistry)
459
+ return fromRegistry;
460
+ return null;
461
+ }
462
+ /** Back-compat wrapper: some callers expect a string (old behavior).
463
+ * Returns the resolved path or the nominal workspace path so the caller's
464
+ * connect attempt produces a meaningful ENOENT with the real path. */
465
+ function getDefaultSocketPath() {
466
+ const resolved = resolveDaemonSocket();
467
+ if (resolved)
468
+ return resolved;
469
+ const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
275
470
  return path.join(workspaceRoot, ".vexp", "daemon.sock");
276
471
  }
472
+ export { discoverWorkspaceRoot };
473
+ /** Human-friendly hint printed when a connection attempt fails — enumerates
474
+ * what we tried to make the failure actionable instead of misleading. */
475
+ function daemonRunHint() {
476
+ const workspaceRoot = process.env["VEXP_WORKSPACE"] ?? discoverWorkspaceRoot();
477
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
478
+ const candidates = [];
479
+ if (process.env["VEXP_SOCKET"])
480
+ candidates.push(`VEXP_SOCKET=${process.env["VEXP_SOCKET"]}`);
481
+ candidates.push(`${workspaceRoot}/.vexp/daemon.sock`);
482
+ if (home) {
483
+ try {
484
+ const reg = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "daemons.json"), "utf-8"));
485
+ for (const [ws, sock] of Object.entries(reg))
486
+ candidates.push(`registry: ${ws} → ${sock}`);
487
+ }
488
+ catch { /* registry missing is expected when no daemon has run yet */ }
489
+ }
490
+ return `Tried: [${candidates.join(", ")}]. Run \`vexp setup\` in your project to start the daemon, or set VEXP_WORKSPACE / VEXP_SOCKET.`;
491
+ }
package/dist/index.js CHANGED
@@ -61,105 +61,11 @@ function allToolsEnabled() {
61
61
  const lowered = v.trim().toLowerCase();
62
62
  return lowered === "1" || lowered === "true" || lowered === "yes" || lowered === "on";
63
63
  }
64
- /** FNV-1a 64-bit hash must match vexp-core/src/utils.rs md5_hash() */
65
- function fnvHash(input) {
66
- let hash = BigInt("0xcbf29ce484222325");
67
- const prime = BigInt("0x100000001b3");
68
- const mask = BigInt("0xffffffffffffffff");
69
- const bytes = Buffer.from(input, "utf-8");
70
- for (const byte of bytes) {
71
- hash ^= BigInt(byte);
72
- hash = (hash * prime) & mask;
73
- }
74
- return hash.toString(16);
75
- }
76
- /**
77
- * Walk up from cwd looking for .vexp/ or .git/ to find the workspace root.
78
- * Used when VEXP_WORKSPACE is not set (e.g., Codex, Cursor, or other agents
79
- * spawn the MCP server without passing the workspace path).
80
- */
81
- function discoverWorkspaceRoot() {
82
- const cwd = process.cwd();
83
- // First pass: look for .vexp/ (strongest signal — daemon was started here)
84
- let current = cwd;
85
- while (true) {
86
- if (fs.existsSync(path.join(current, ".vexp")))
87
- return current;
88
- const parent = path.dirname(current);
89
- if (parent === current)
90
- break;
91
- current = parent;
92
- }
93
- // Second pass: look for .git/ (standard repo root)
94
- current = cwd;
95
- while (true) {
96
- if (fs.existsSync(path.join(current, ".git")))
97
- return current;
98
- const parent = path.dirname(current);
99
- if (parent === current)
100
- break;
101
- current = parent;
102
- }
103
- return cwd;
104
- }
105
- function getSocketPath() {
106
- if (process.env.VEXP_SOCKET)
107
- return process.env.VEXP_SOCKET;
108
- const workspaceRoot = process.env.VEXP_WORKSPACE ?? discoverWorkspaceRoot();
109
- if (process.platform === "win32") {
110
- // 1. Try reading daemon.pipe from discovered workspace
111
- const pipeFile = path.join(workspaceRoot, ".vexp", "daemon.pipe");
112
- try {
113
- const pipeName = fs.readFileSync(pipeFile, "utf-8").trim();
114
- if (pipeName)
115
- return pipeName;
116
- }
117
- catch { /* file doesn't exist */ }
118
- // 2. Try global daemon registry (~/.vexp/daemons.json)
119
- const fromRegistry = readDaemonRegistry(workspaceRoot);
120
- if (fromRegistry)
121
- return fromRegistry;
122
- // 3. Fallback: compute hash (may mismatch if path differs from daemon's)
123
- const normalized = workspaceRoot.toLowerCase();
124
- const hash = fnvHash(normalized);
125
- return `\\\\.\\pipe\\vexp-${hash.slice(0, 8)}`;
126
- }
127
- const cwdSocket = `${workspaceRoot}/.vexp/daemon.sock`;
128
- if (fs.existsSync(cwdSocket))
129
- return cwdSocket;
130
- const home = process.env.HOME ?? "/tmp";
131
- return `${home}/.vexp/daemon.sock`;
132
- }
133
- /**
134
- * Read the global daemon registry (~/.vexp/daemons.json) to find a running daemon.
135
- * Falls back to: exact workspace match → single daemon → first available.
136
- */
137
- function readDaemonRegistry(workspaceRoot) {
138
- const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
139
- if (!home)
140
- return null;
141
- const registryPath = path.join(home, ".vexp", "daemons.json");
142
- try {
143
- const registry = JSON.parse(fs.readFileSync(registryPath, "utf-8"));
144
- const entries = Object.entries(registry);
145
- if (entries.length === 0)
146
- return null;
147
- // Try exact match (case-insensitive on Windows)
148
- const normalizedCwd = workspaceRoot.toLowerCase();
149
- for (const [ws, pipe] of entries) {
150
- if (ws.toLowerCase() === normalizedCwd)
151
- return pipe;
152
- }
153
- // Single daemon running — use it directly
154
- if (entries.length === 1)
155
- return entries[0][1];
156
- // Multiple daemons, no match — use first available
157
- return entries[0][1];
158
- }
159
- catch {
160
- return null;
161
- }
162
- }
64
+ // Socket path resolution + workspace discovery live in daemon-client.ts
65
+ // as the single source of truth (exported: resolveDaemonSocket,
66
+ // discoverWorkspaceRoot). The Rust daemon registers itself in
67
+ // ~/.vexp/daemons.json on all platforms, which is the authoritative
68
+ // lookup when an agent spawns the MCP server from an unrelated cwd.
163
69
  function createMcpServer(daemon) {
164
70
  const server = new Server({ name: "vexp-mcp", version: "1.0.0" }, { capabilities: { tools: {} } });
165
71
  server.setRequestHandler(ListToolsRequestSchema, () => ({
@@ -361,8 +267,12 @@ async function startHttp(daemon, port) {
361
267
  }
362
268
  async function main() {
363
269
  const args = process.argv.slice(2);
364
- const socketPath = getSocketPath();
365
- const daemon = new DaemonClient(socketPath);
270
+ // DaemonClient with no explicit socket: resolves via VEXP_SOCKET →
271
+ // workspace-local socket ~/.vexp/daemons.json registry on every
272
+ // connect. A long-lived HTTP MCP server thus follows daemons that
273
+ // start/stop while it's up, and fresh stdio spawns pick the right
274
+ // daemon without restarting the MCP server.
275
+ const daemon = new DaemonClient();
366
276
  const useHttp = args.includes("--http") || process.env.VEXP_HTTP === "1";
367
277
  const port = parseInt(args.find((a) => a.startsWith("--port="))?.split("=")[1] ??
368
278
  process.env.VEXP_PORT ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vexp-mcp",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "vexp MCP server — AI context tools for coding agents",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",