@vitest-agent/mcp 2.4.6 → 2.4.7

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/index.js CHANGED
@@ -12,7 +12,7 @@ import { parseSessionEnvExports, recoverSessionContextFromSessionEnv } from "./s
12
12
  *
13
13
  * @public
14
14
  */
15
- const CURRENT_MCP_VERSION = "2.4.6";
15
+ const CURRENT_MCP_VERSION = "2.4.7";
16
16
 
17
17
  //#endregion
18
18
  export { CURRENT_MCP_VERSION, McpLive, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/mcp",
3
- "version": "2.4.6",
3
+ "version": "2.4.7",
4
4
  "private": false,
5
5
  "description": "Model Context Protocol server for vitest-agent. Exposes 53 tools for agent access to test data, TDD lifecycle, and session management.",
6
6
  "keywords": [
package/server.js CHANGED
@@ -405,11 +405,11 @@ function buildMcpServer(ctx) {
405
405
  };
406
406
  });
407
407
  server.registerTool("run_tests", {
408
- description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. The server's Vitest root is normally frozen at boot (ctx.cwd) — projectRoot overrides it for this call, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --git-common-dir`, which is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. The legacy format=json arg is dropped — structuredContent supersedes it.",
408
+ description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --git-common-dir`, which is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. The legacy format=json arg is dropped — structuredContent supersedes it.",
409
409
  inputSchema: strict({
410
410
  files: z.optional(z.array(z.string())).describe("Test file paths to run"),
411
411
  project: z.optional(z.string()).describe("Project name to filter"),
412
- projectRoot: z.optional(z.string()).describe("Explicit Vitest root to use instead of the server's boot-time ctx.cwd. Prefer an absolute path; a relative path is resolved against ctx.cwd, not the server process's cwd. Validated: must be an existing directory in the same git repository as ctx.cwd (same git-common-dir, e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise."),
412
+ projectRoot: z.optional(z.string()).describe("Explicit Vitest root for this call, used verbatim. Omit it to get the config-anchored default (walk up from the server's boot dir for a vitest/vite config, bounded at the git root). Prefer an absolute path; a relative path is resolved against ctx.cwd, not the server process's cwd. Validated: must be an existing directory in the same git repository as ctx.cwd (same git-common-dir, e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise."),
413
413
  tags: z.optional(strict({
414
414
  all: z.optional(z.array(z.string())).describe("Require every listed tag"),
415
415
  any: z.optional(z.array(z.string())).describe("Require at least one listed tag"),
@@ -1,13 +1,15 @@
1
1
  import { publicProcedure } from "../context.js";
2
+ import { createRequire } from "node:module";
2
3
  import { AgentReport, DataReader, DataStore, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries } from "@vitest-agent/sdk";
3
4
  import { Effect, Schema, SchemaGetter } from "effect";
4
5
  import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { execFile } from "node:child_process";
6
- import { mkdtempSync, rmSync } from "node:fs";
7
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
7
8
  import { realpath, stat } from "node:fs/promises";
8
9
  import { tmpdir } from "node:os";
9
- import { join, resolve } from "node:path";
10
+ import { dirname, join, resolve } from "node:path";
10
11
  import { Writable } from "node:stream";
12
+ import { pathToFileURL } from "node:url";
11
13
  import { promisify } from "node:util";
12
14
 
13
15
  //#region src/tools/run-tests.ts
@@ -192,15 +194,52 @@ async function resolveGitCommonDir(dir) {
192
194
  return null;
193
195
  }
194
196
  }
197
+ const VITEST_CONFIG_EXTENSIONS = [
198
+ "ts",
199
+ "mts",
200
+ "cts",
201
+ "js",
202
+ "mjs",
203
+ "cjs"
204
+ ];
205
+ function dirHasVitestOrViteConfig(dir) {
206
+ for (const prefix of ["vitest.config.", "vite.config."]) for (const ext of VITEST_CONFIG_EXTENSIONS) if (existsSync(join(dir, `${prefix}${ext}`))) return true;
207
+ return false;
208
+ }
209
+ /**
210
+ * Walk UP from `startDir` looking for the vitest/vite config Vitest would
211
+ * load anyway, returning the directory that holds it. Bounded at the git
212
+ * root (inclusive — the directory containing `.git` is still examined
213
+ * before the walk stops). Returns `startDir` unchanged when no config is
214
+ * found in range, or when anything about the walk throws. See the
215
+ * issue #259 comment above `validateProjectRoot` for the full rationale.
216
+ *
217
+ * @internal exported for tests
218
+ */
219
+ function resolveConfigAnchoredRoot(startDir) {
220
+ try {
221
+ let dir = resolve(startDir);
222
+ for (;;) {
223
+ if (dirHasVitestOrViteConfig(dir)) return dir;
224
+ if (existsSync(join(dir, ".git"))) return startDir;
225
+ const parent = dirname(dir);
226
+ if (parent === dir) return startDir;
227
+ dir = parent;
228
+ }
229
+ } catch {
230
+ return startDir;
231
+ }
232
+ }
195
233
  /**
196
234
  * Validate an optional caller-supplied `projectRoot` against `ctxCwd`
197
- * (the MCP server's boot-time root). `undefined` always resolves to
198
- * `ctxCwd` unchanged (issue #252's option 1: explicit opt-in only
199
- * never inferred, never defaulted to anything but the historical
200
- * behavior when the caller says nothing).
235
+ * (the MCP server's boot-time root). `undefined` anchors at the directory
236
+ * of the vitest/vite config Vitest would load anyway (issue #259, via
237
+ * `resolveConfigAnchoredRoot`) never inferred beyond that, never
238
+ * defaulted to anything else when no config is found in range.
201
239
  *
202
- * A supplied `projectRoot` is VALIDATED, not trusted: it must resolve to
203
- * an existing directory that shares a git common directory with
240
+ * A supplied `projectRoot` is VALIDATED, not trusted, and used VERBATIM
241
+ * once validated explicit is explicit, no anchoring applied. It must
242
+ * resolve to an existing directory that shares a git common directory with
204
243
  * `ctxCwd` (same repository, including across worktrees). Any failure
205
244
  * returns `{ ok: false, message }` naming both paths — never a silent
206
245
  * fallback to `ctxCwd`, never a raw throw.
@@ -210,7 +249,7 @@ async function resolveGitCommonDir(dir) {
210
249
  async function validateProjectRoot(projectRoot, ctxCwd) {
211
250
  if (projectRoot === void 0) return {
212
251
  ok: true,
213
- root: ctxCwd
252
+ root: resolveConfigAnchoredRoot(ctxCwd)
214
253
  };
215
254
  const resolvedRoot = resolve(ctxCwd, projectRoot);
216
255
  let isDirectory;
@@ -236,6 +275,57 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
236
275
  root: resolvedRoot
237
276
  };
238
277
  }
278
+ /**
279
+ * Issue #303: resolve `vitest/node` anchored at the run's project root
280
+ * instead of the bare `"vitest/node"` specifier, which resolves relative to
281
+ * `@vitest-agent/mcp`'s OWN install location. `vitest` is a peerDependency
282
+ * of this package, and pnpm routinely materializes MORE THAN ONE physical
283
+ * instance of the same vitest version when peer-resolution hashes differ
284
+ * (e.g. `vitest@4.1.11_@types+node@26.2.0_...` alongside
285
+ * `vitest@4.1.11_@types+node@26.3.0_...` under `node_modules/.pnpm`). When
286
+ * the bare specifier resolves to a DIFFERENT physical copy than the one the
287
+ * project's test files import, `SnapshotClient.setup()` runs against one
288
+ * copy's module-level `_client` singleton while `expect(...).toMatchSnapshot()`
289
+ * inside the test file goes through the other copy's singleton, which has
290
+ * no state — every snapshot assertion then fails with "The snapshot state
291
+ * for '<file>' is not found. Did you call 'SnapshotClient.setup()'?" while
292
+ * every non-snapshot assertion still passes.
293
+ *
294
+ * `createRequire` needs a file path (not a bare directory) to anchor
295
+ * resolution, hence the synthetic, never-created `__vitest-agent-resolver__.js`
296
+ * filename joined onto `root`. vitest's package.json `exports` map for
297
+ * `./node` carries a `default` condition (`./dist/node.js`) and vitest ships
298
+ * `"type": "module"`, so `require.resolve("vitest/node")` resolves correctly
299
+ * even though vitest itself is ESM — the result is then converted to a
300
+ * `file://` URL, which is what dynamic `import()` needs.
301
+ *
302
+ * Falls back to the bare `"vitest/node"` specifier when root-anchored
303
+ * resolution throws (e.g. a project root with no local vitest install) so
304
+ * that case keeps working exactly as it did before this fix.
305
+ *
306
+ * @internal exported for tests
307
+ */
308
+ function resolveVitestNodeEntry(root) {
309
+ try {
310
+ const req = createRequire(join(root, "__vitest-agent-resolver__.js"));
311
+ return pathToFileURL(req.resolve("vitest/node")).href;
312
+ } catch {
313
+ return "vitest/node";
314
+ }
315
+ }
316
+ /**
317
+ * Indirection seam around `import(<vitest/node entry>)`. vitest's own
318
+ * vite-node externalizes "vitest"/"vitest/node" for every importer, and
319
+ * `vi.mock("vitest/node", ...)` only special-cases AST-literal
320
+ * `import("vitest/node")` call sites for interception — a computed
321
+ * specifier (unavoidable here; see `resolveVitestNodeEntry`) silently
322
+ * bypasses that interception and loads the real module. Tests substitute
323
+ * `.load` directly (mutating this shared object's property — no `vi.mock`
324
+ * required) instead of trying to mock the module.
325
+ *
326
+ * @internal exported for tests
327
+ */
328
+ const vitestLoader = { load: (entry) => import(entry) };
239
329
  let _runTestsChain = Promise.resolve();
240
330
  function serializeRunTests(fn) {
241
331
  const next = _runTestsChain.then(fn, fn);
@@ -416,7 +506,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
416
506
  const nullStream = new Writable({ write(_chunk, _encoding, cb) {
417
507
  cb();
418
508
  } });
419
- const { createVitest } = await import("vitest/node");
509
+ const { createVitest } = await vitestLoader.load(resolveVitestNodeEntry(resolvedRoot));
420
510
  let vitest;
421
511
  let covOverride;
422
512
  try {
@@ -522,4 +612,4 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
522
612
  }));
523
613
 
524
614
  //#endregion
525
- export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveGitCommonDir, runTests, sanitizeTestArgs, validateProjectRoot, withStdioCaptured };
615
+ export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTests, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };