vexp-cli 3.1.1 → 3.1.2
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/agent-config.js +5 -1
- package/dist/cli.js +34 -3
- package/dist/doctor.js +51 -6
- package/dist/mcp-supervisor.js +67 -7
- package/dist/serve.js +40 -1
- package/mcp/mcp-server.cjs +36 -34
- package/package.json +6 -6
package/dist/agent-config.js
CHANGED
|
@@ -2918,7 +2918,11 @@ export function installCodexHintHook(workspaceRoot, binaryPath) {
|
|
|
2918
2918
|
let root = {};
|
|
2919
2919
|
if (fs.existsSync(hooksJsonPath)) {
|
|
2920
2920
|
try {
|
|
2921
|
-
|
|
2921
|
+
// parseJsonc, not JSON.parse: every other config reader here is
|
|
2922
|
+
// BOM-tolerant and this one was missed. Windows PowerShell 5.1 writes
|
|
2923
|
+
// JSON with a UTF-8 BOM, and a user who edits .codex/hooks.json by hand
|
|
2924
|
+
// there would have the hook silently left unregistered.
|
|
2925
|
+
root = parseJsonc(fs.readFileSync(hooksJsonPath, "utf-8"));
|
|
2922
2926
|
}
|
|
2923
2927
|
catch {
|
|
2924
2928
|
warnUnparseable(hooksJsonPath);
|
package/dist/cli.js
CHANGED
|
@@ -66,13 +66,24 @@ program.hook("preAction", async (_thisCmd, actionCmd) => {
|
|
|
66
66
|
// the parent, so we boot (or reuse) the parent daemon instead of spawning
|
|
67
67
|
// a conflicting one for the child.
|
|
68
68
|
const eff = findEffectiveWorkspace();
|
|
69
|
-
|
|
69
|
+
// Say so. Bootstrapping is what replaces a stale daemon and takes over a
|
|
70
|
+
// stale MCP server on :7821, and it is silently skipped outside a
|
|
71
|
+
// configured workspace: a tester ran the command doctor had suggested from
|
|
72
|
+
// a package subdirectory of a monorepo, got no error and no takeover, and
|
|
73
|
+
// reasonably concluded the server had been replaced (tier-4 field report,
|
|
74
|
+
// 2026-09-05). Doing nothing quietly is the one outcome that cannot be
|
|
75
|
+
// told apart from success.
|
|
76
|
+
if (!eff) {
|
|
77
|
+
console.error(chalk.yellow(` ⚠ no vexp workspace found from ${process.cwd()} — the daemon and the MCP server were NOT started or updated.`));
|
|
78
|
+
console.error(chalk.dim(` vexp walks up for .vexp/manifest.json; run 'vexp setup' in the workspace root.`));
|
|
70
79
|
return;
|
|
80
|
+
}
|
|
71
81
|
let binaryPath;
|
|
72
82
|
try {
|
|
73
83
|
binaryPath = getBinaryPath();
|
|
74
84
|
}
|
|
75
|
-
catch {
|
|
85
|
+
catch (err) {
|
|
86
|
+
console.error(chalk.yellow(` ⚠ vexp-core binary not resolved (${err instanceof Error ? err.message : err}) — nothing was started or updated.`));
|
|
76
87
|
return;
|
|
77
88
|
}
|
|
78
89
|
// Full bootstrap: daemon + MCP + autostart-if-needed. All three steps are
|
|
@@ -274,7 +285,12 @@ async function ensureBootstrap(workspaceRoot, binaryPath) {
|
|
|
274
285
|
try {
|
|
275
286
|
await ensureMcpHttpServer({ owner: "cli" });
|
|
276
287
|
}
|
|
277
|
-
catch {
|
|
288
|
+
catch (err) {
|
|
289
|
+
// Non-fatal, but never silent: this is the step that replaces an MCP
|
|
290
|
+
// server left behind by an older install, and skipping it leaves every
|
|
291
|
+
// MCP client on the old build.
|
|
292
|
+
console.error(chalk.dim(` ⚠ MCP HTTP server not started or taken over: ${err instanceof Error ? err.message : err}`));
|
|
293
|
+
}
|
|
278
294
|
}
|
|
279
295
|
/**
|
|
280
296
|
* Start the vexp daemon and MCP HTTP server in the background.
|
|
@@ -725,6 +741,21 @@ program
|
|
|
725
741
|
selectedNames = requested.map((n) => resolveAgentName(n));
|
|
726
742
|
console.log(chalk.dim(` Agents (from flag): ${selectedNames.join(", ")}`));
|
|
727
743
|
}
|
|
744
|
+
else if (!process.stdin.isTTY) {
|
|
745
|
+
// No one is at the keyboard. This command is named as a remediation
|
|
746
|
+
// in doctor's own output, and an agent — or a CI step, or a script
|
|
747
|
+
// reading that line — cannot answer a prompt: it hangs until it is
|
|
748
|
+
// killed, which is worse than failing (field report, 2026-09-07: a
|
|
749
|
+
// tester followed doctor's advice and had to abandon the command).
|
|
750
|
+
// Configure what is detected, and say that is what happened.
|
|
751
|
+
selectedNames = allAgents.filter((a) => detectedNames.has(a.agent)).map((a) => a.agent);
|
|
752
|
+
if (selectedNames.length === 0) {
|
|
753
|
+
console.error(chalk.yellow(" No AI agent detected here and stdin is not a terminal — nothing to configure."));
|
|
754
|
+
console.error(chalk.dim(" Name them explicitly: vexp setup --agents \"Claude Code,Codex\""));
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
console.log(chalk.dim(` Agents (detected, non-interactive): ${selectedNames.join(", ")}`));
|
|
758
|
+
}
|
|
728
759
|
else {
|
|
729
760
|
// Interactive selection
|
|
730
761
|
console.log(chalk.bold("\n Select AI agents to configure:\n"));
|
package/dist/doctor.js
CHANGED
|
@@ -207,7 +207,7 @@ export function gitHooksVerdict(hooksPath, repoRoot, installedCount) {
|
|
|
207
207
|
* wherever it stands. Null when nothing was skipped for size, or when the
|
|
208
208
|
* file predates 3.1 and has no skip keys.
|
|
209
209
|
*/
|
|
210
|
-
export function coverageVerdict(cov) {
|
|
210
|
+
export function coverageVerdict(cov, coveragePath = ".vexp/coverage.json") {
|
|
211
211
|
if (!cov || typeof cov !== "object")
|
|
212
212
|
return null;
|
|
213
213
|
const c = cov;
|
|
@@ -225,7 +225,12 @@ export function coverageVerdict(cov) {
|
|
|
225
225
|
level: WARN,
|
|
226
226
|
message: `${oversized} file(s) over max_file_size_kb = ${cap} are NOT indexed — their symbols and callers are invisible to impact, search and run_pipeline:\n` +
|
|
227
227
|
examples.map((e) => ` - ${e}`).join("\n") +
|
|
228
|
-
(more > 0 ? `\n … +${more} more
|
|
228
|
+
(more > 0 ? `\n … +${more} more` : "") +
|
|
229
|
+
// The path, not the name. coverage.json lives at the WORKSPACE root;
|
|
230
|
+
// a tester on a monorepo looked for it beside the package he was
|
|
231
|
+
// working in, found nothing, and concluded the feature had not
|
|
232
|
+
// shipped (tier-4 field report, 2026-09-05).
|
|
233
|
+
`\n full list: ${coveragePath}` +
|
|
229
234
|
`\n raise max_file_size_kb in .vexp/vexp.toml (0 = no cap) to include them, or exclude them on purpose with exclude_patterns — excluded files stop being counted here at the next reindex ('vexp index').`,
|
|
230
235
|
};
|
|
231
236
|
}
|
|
@@ -334,7 +339,7 @@ export function workspaceCoverageFindings(root) {
|
|
|
334
339
|
catch {
|
|
335
340
|
continue; /* never indexed, or an index older than 2.7 */
|
|
336
341
|
}
|
|
337
|
-
const v = coverageVerdict(cov);
|
|
342
|
+
const v = coverageVerdict(cov, path.join(t.dir, ".vexp", "coverage.json"));
|
|
338
343
|
if (v)
|
|
339
344
|
out.push({ level: v.level, message: targets.length > 1 ? `[${t.alias}] ${v.message}` : v.message });
|
|
340
345
|
}
|
|
@@ -394,9 +399,18 @@ export async function runDoctor() {
|
|
|
394
399
|
if (live) {
|
|
395
400
|
const st = await queryDaemon(sock, "index_status");
|
|
396
401
|
if (st) {
|
|
402
|
+
// A daemon that is still loading its model answers "rule", which reads
|
|
403
|
+
// as a downgrade for the ~20s it takes to settle. The daemon already
|
|
404
|
+
// says which of the two it is; doctor was printing the label and
|
|
405
|
+
// dropping the reason (tier-4 field report, 2026-09-05).
|
|
406
|
+
const loading = st.compressor !== "llm"
|
|
407
|
+
&& typeof st.llm_inactive_reason === "string"
|
|
408
|
+
&& /still loading|finishing its startup/i.test(st.llm_inactive_reason);
|
|
397
409
|
const comp = st.compressor === "llm"
|
|
398
410
|
? `llm (${st.llm_model ?? "?"}, ${st.llm_inference ?? "?"})`
|
|
399
|
-
:
|
|
411
|
+
: loading
|
|
412
|
+
? "loading (the model is still starting — this is not the rule compressor settling in)"
|
|
413
|
+
: st.compressor ?? "unknown (older daemon)";
|
|
400
414
|
line(OK, `index: ${st.total_files ?? "?"} files · ${st.total_nodes ?? "?"} nodes · state ${st.status ?? "?"} · compressor ${comp}`);
|
|
401
415
|
if (st.llm_configured_but_inactive === true) {
|
|
402
416
|
// The daemon knows WHY, and doctor was throwing it away: it printed
|
|
@@ -1004,22 +1018,53 @@ export async function runDoctor() {
|
|
|
1004
1018
|
}
|
|
1005
1019
|
}
|
|
1006
1020
|
}
|
|
1021
|
+
// A supervisor from an older install respawning the current bundle. It
|
|
1022
|
+
// keeps reclaiming :7821 every 60s, and NO takeover rule on this side can
|
|
1023
|
+
// stop it: the old process runs the old comparison. The only fix is to end
|
|
1024
|
+
// it, so doctor has to name it — with its pid, which the record now
|
|
1025
|
+
// carries. A tester spent a night on a :7821 server that came back with a
|
|
1026
|
+
// new pid every two minutes, reported as v3.0.1 on a machine where 3.0.1
|
|
1027
|
+
// was not installed anywhere: the LABEL came from a `vexp serve` resident
|
|
1028
|
+
// since before the upgrade, holding its own version in memory
|
|
1029
|
+
// (field report, 2026-09-06).
|
|
1030
|
+
const staleSupervisorLine = (rec) => {
|
|
1031
|
+
const by = typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined;
|
|
1032
|
+
if (!by || by === CLI_VERSION)
|
|
1033
|
+
return;
|
|
1034
|
+
const pid = typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined;
|
|
1035
|
+
const stillUp = pid !== undefined && isAlive(pid);
|
|
1036
|
+
const who = rec.owner === "serve" ? "'vexp serve' supervisor" : `${rec.owner ?? "process"}`;
|
|
1037
|
+
line(WARN, `that server was spawned by a ${who} running v${by}, not v${CLI_VERSION}` +
|
|
1038
|
+
(pid ? ` (pid ${pid}${stillUp ? ", still running" : ", already gone"})` : "") +
|
|
1039
|
+
`\n an older supervisor reclaims :${rec.port} on its own schedule and cannot be stopped by re-running setup` +
|
|
1040
|
+
// Ending it is the whole remediation: login autostart (or the next
|
|
1041
|
+
// `vexp` command) starts a fresh supervisor, which reads the current
|
|
1042
|
+
// package.json and serves the current build. The tester who did this
|
|
1043
|
+
// had a correct server one second later without running anything
|
|
1044
|
+
// else — and the `vexp setup` this line used to recommend blocks on
|
|
1045
|
+
// an interactive prompt, which for the reader of a doctor line is a
|
|
1046
|
+
// hang, not a fix (field report, 2026-09-07).
|
|
1047
|
+
(stillUp ? `\n end it — 'kill ${pid}' — a fresh supervisor starts on its own and serves the current build` : ""));
|
|
1048
|
+
};
|
|
1007
1049
|
// 6) HTTP MCP supervisor.
|
|
1008
1050
|
console.log(chalk.bold("\nHTTP MCP supervisor (~/.vexp/mcp.pid)"));
|
|
1009
1051
|
try {
|
|
1010
1052
|
const rec = JSON.parse(fs.readFileSync(path.join(home, ".vexp", "mcp.pid"), "utf-8"));
|
|
1011
1053
|
const alive = isAlive(rec.pid);
|
|
1012
1054
|
line(alive ? OK : WARN, `pid ${rec.pid} on :${rec.port} — ${alive ? "alive" : "DEAD (stale pid file)"}${rec.version ? ` · v${rec.version}` : ""}`);
|
|
1055
|
+
if (alive && rec.version === CLI_VERSION)
|
|
1056
|
+
staleSupervisorLine(rec);
|
|
1013
1057
|
// The supervisor is replaced on version mismatch only when a vexp command
|
|
1014
1058
|
// next runs; until then every HTTP client (Codex http transport, a
|
|
1015
1059
|
// `serverUrl` entry) talks to the OLD build — which advertises the old
|
|
1016
1060
|
// tool surface (2 tools before 2.7.0) and none of the newer fixes. A user
|
|
1017
1061
|
// saw two vexp servers with different tool counts and could not tell why.
|
|
1018
1062
|
if (alive && rec.version && rec.version !== CLI_VERSION) {
|
|
1019
|
-
line(WARN, `the HTTP MCP server on :${rec.port} is v${rec.version} but this CLI is v${CLI_VERSION} — HTTP clients get the old build until it is replaced — run 'vexp setup' (
|
|
1063
|
+
line(WARN, `the HTTP MCP server on :${rec.port} is v${rec.version} but this CLI is v${CLI_VERSION} — HTTP clients get the old build until it is replaced — run 'vexp setup' in the WORKSPACE ROOT (the directory holding .vexp/) and the current CLI takes it over`);
|
|
1064
|
+
staleSupervisorLine(rec);
|
|
1020
1065
|
}
|
|
1021
1066
|
else if (alive && !rec.version) {
|
|
1022
|
-
line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup'
|
|
1067
|
+
line(WARN, `the HTTP MCP server on :${rec.port} predates version tracking (older than 2.4) — run 'vexp setup' in the WORKSPACE ROOT so the current CLI replaces it and HTTP clients get v${CLI_VERSION}`);
|
|
1023
1068
|
}
|
|
1024
1069
|
}
|
|
1025
1070
|
catch {
|
package/dist/mcp-supervisor.js
CHANGED
|
@@ -33,6 +33,8 @@ export function readPidRecord() {
|
|
|
33
33
|
// pass (measured 60.7s replacement period). The reuse fast path
|
|
34
34
|
// is only as good as the record it reads.
|
|
35
35
|
version: typeof rec.version === "string" ? rec.version : undefined,
|
|
36
|
+
spawnedBy: typeof rec.spawnedBy === "string" ? rec.spawnedBy : undefined,
|
|
37
|
+
spawnedByPid: typeof rec.spawnedByPid === "number" ? rec.spawnedByPid : undefined,
|
|
36
38
|
};
|
|
37
39
|
}
|
|
38
40
|
}
|
|
@@ -97,6 +99,48 @@ export function resolveOrGenerateMcpToken() {
|
|
|
97
99
|
* The MCP server is deliberately workspace-agnostic: clients disambiguate
|
|
98
100
|
* via URL path /ws/<workspaceHash>/mcp. No VEXP_WORKSPACE env is passed.
|
|
99
101
|
*/
|
|
102
|
+
/**
|
|
103
|
+
* Is the running server's version strictly older than ours?
|
|
104
|
+
*
|
|
105
|
+
* A record with no version predates version tracking (< 2.4) and counts as
|
|
106
|
+
* older. An unparseable version counts as older too: replacing it once is
|
|
107
|
+
* recoverable, whereas deferring to it forever is what the field report
|
|
108
|
+
* showed. A NEWER server is left alone — this process is the stale one.
|
|
109
|
+
*/
|
|
110
|
+
/**
|
|
111
|
+
* The version of the MCP bundle at `mcpPath`, read from the package.json that
|
|
112
|
+
* ships it — the build a client will actually be served, as opposed to the
|
|
113
|
+
* version of whatever process happens to be spawning it.
|
|
114
|
+
*/
|
|
115
|
+
export function mcpBundleVersion(mcpPath) {
|
|
116
|
+
const pkg = path.resolve(path.dirname(mcpPath), "..", "package.json");
|
|
117
|
+
try {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(pkg, "utf-8"));
|
|
119
|
+
if (typeof parsed.version === "string")
|
|
120
|
+
return parsed.version;
|
|
121
|
+
}
|
|
122
|
+
catch { /* not readable: fall back to the caller's own version */ }
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
export function isOlderVersion(recorded, mine) {
|
|
126
|
+
if (!recorded)
|
|
127
|
+
return true;
|
|
128
|
+
if (recorded === mine)
|
|
129
|
+
return false;
|
|
130
|
+
const parts = (v) => {
|
|
131
|
+
const nums = v.split("-")[0].split(".").map((n) => Number.parseInt(n, 10));
|
|
132
|
+
return nums.length === 3 && nums.every((n) => Number.isFinite(n)) ? nums : null;
|
|
133
|
+
};
|
|
134
|
+
const a = parts(recorded);
|
|
135
|
+
const b = parts(mine);
|
|
136
|
+
if (!a || !b)
|
|
137
|
+
return true;
|
|
138
|
+
for (let i = 0; i < 3; i++) {
|
|
139
|
+
if (a[i] !== b[i])
|
|
140
|
+
return a[i] < b[i];
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
100
144
|
export async function ensureMcpHttpServer(opts = {}) {
|
|
101
145
|
const port = opts.port ?? DEFAULT_PORT;
|
|
102
146
|
const owner = opts.owner ?? "unknown";
|
|
@@ -104,15 +148,23 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
104
148
|
const mcpPath = getMcpServerPath();
|
|
105
149
|
if (!mcpPath)
|
|
106
150
|
return null;
|
|
107
|
-
// Fast path: already alive and tracked - but only if it is
|
|
108
|
-
// Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
109
|
-
// 9 days across extension upgrades because reuse never compared
|
|
110
|
-
//
|
|
111
|
-
//
|
|
151
|
+
// Fast path: already alive and tracked - but only if it is not OLDER than
|
|
152
|
+
// us. Field report (Nathan): a 2.3.0 http server stayed authoritative for
|
|
153
|
+
// 9 days across extension upgrades because reuse never compared versions.
|
|
154
|
+
// On an older server we take over: SIGTERM the old listener and spawn the
|
|
155
|
+
// current build.
|
|
156
|
+
//
|
|
157
|
+
// The comparison is ORDERED, not an equality check. Equality made takeover
|
|
158
|
+
// symmetric, so two installs of different versions each saw the other as
|
|
159
|
+
// "not mine" and reclaimed the port from it — and `vexp serve` re-runs this
|
|
160
|
+
// every 60s, so a supervisor left behind by an older install resurrected
|
|
161
|
+
// its own build indefinitely. A tester watched a v3.0.1 server come back
|
|
162
|
+
// after being replaced by v3.1.1, with a fresh pid (tier-4 field report,
|
|
163
|
+
// 2026-09-05). An older build now stands down and reuses what is there.
|
|
112
164
|
{
|
|
113
165
|
const existing = readPidRecord();
|
|
114
166
|
if (existing && existing.port === port && isPidAlive(existing.pid) && (await isPortInUse(port))) {
|
|
115
|
-
if (existing.version
|
|
167
|
+
if (!isOlderVersion(existing.version, CLI_VERSION)) {
|
|
116
168
|
return { pid: existing.pid, port, started: false };
|
|
117
169
|
}
|
|
118
170
|
try {
|
|
@@ -184,7 +236,15 @@ export async function ensureMcpHttpServer(opts = {}) {
|
|
|
184
236
|
const pid = child.pid;
|
|
185
237
|
if (!pid)
|
|
186
238
|
return null;
|
|
187
|
-
writePidRecord({
|
|
239
|
+
writePidRecord({
|
|
240
|
+
pid,
|
|
241
|
+
port,
|
|
242
|
+
startedAt: Date.now(),
|
|
243
|
+
owner,
|
|
244
|
+
version: mcpBundleVersion(mcpPath) ?? CLI_VERSION,
|
|
245
|
+
spawnedBy: CLI_VERSION,
|
|
246
|
+
spawnedByPid: process.pid,
|
|
247
|
+
});
|
|
188
248
|
// Wait for the child to actually bind the port BEFORE releasing the
|
|
189
249
|
// lock. Without this, a second caller arriving right after release
|
|
190
250
|
// sees pidfile+live-pid but port-not-yet-bound → falsely concludes
|
package/dist/serve.js
CHANGED
|
@@ -207,7 +207,46 @@ async function resurrectAll() {
|
|
|
207
207
|
if (resurrected.get(sock))
|
|
208
208
|
live[ws] = sock;
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
// A row this supervisor did not resurrect is not thereby dead.
|
|
211
|
+
//
|
|
212
|
+
// `live` used to become the WHOLE new registry, so every row the plan
|
|
213
|
+
// skipped — another supervisor's daemon, a linked worktree, a workspace
|
|
214
|
+
// whose manifest had moved — was deleted while its daemon was still
|
|
215
|
+
// running and answering on its socket. The health loop re-runs this every
|
|
216
|
+
// 60s, so the deletion was permanent and self-renewing: on a tester's
|
|
217
|
+
// machine two daemons that had been serving a pre-upgrade build for five
|
|
218
|
+
// days disappeared from `vexp daemons`, from `index_status`'s "other live
|
|
219
|
+
// daemons" and from the concurrency fence the moment a supervisor
|
|
220
|
+
// restarted, leaving a three-daemon box reporting a clean single-workspace
|
|
221
|
+
// install (field report, 2026-09-07).
|
|
222
|
+
//
|
|
223
|
+
// Liveness decides what stays in the registry, never ownership. The fence
|
|
224
|
+
// counts these rows, so dropping a live daemon also made the cap evadable
|
|
225
|
+
// by restarting a supervisor.
|
|
226
|
+
writeRegistry(await survivingRegistryRows(reg, live, isSocketAlive, appendLog));
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* The rows that stay in `~/.vexp/daemons.json` after a resurrection pass:
|
|
230
|
+
* everything this supervisor brought up, PLUS every other row whose daemon is
|
|
231
|
+
* still answering. Only a row that is neither is dropped.
|
|
232
|
+
*
|
|
233
|
+
* Separated out because the rule is the whole bug: a supervisor may only
|
|
234
|
+
* report what it could not find, never delete what it does not own.
|
|
235
|
+
*/
|
|
236
|
+
export async function survivingRegistryRows(before, resurrected, alive, log = () => { }) {
|
|
237
|
+
const canonical = (ws) => (process.platform === "win32" ? ws.toLowerCase() : ws);
|
|
238
|
+
const out = { ...resurrected };
|
|
239
|
+
const kept = new Set(Object.keys(out).map(canonical));
|
|
240
|
+
for (const [ws, sock] of Object.entries(before)) {
|
|
241
|
+
if (kept.has(canonical(ws)))
|
|
242
|
+
continue;
|
|
243
|
+
if (await alive(sock)) {
|
|
244
|
+
out[ws] = sock;
|
|
245
|
+
kept.add(canonical(ws));
|
|
246
|
+
log(`registry keep: ${ws} (not ours to resurrect, but its daemon is alive)`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return out;
|
|
211
250
|
}
|
|
212
251
|
async function startMcp() {
|
|
213
252
|
const port = parseInt(process.env.VEXP_PORT ?? String(DEFAULT_MCP_PORT), 10);
|