@vitest-agent/mcp 5.1.1 → 5.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/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import { ToolRefusal } from "@effected/mcp";
2
2
  import { Context, Effect, Layer, Schema, Stdio } from "effect";
3
3
  import { DataReader, DataStore, ProjectDiscovery, SessionContext } from "@vitest-agent/engine";
4
4
  import { Toolkit } from "effect/ai";
5
+ import "@effected/git";
5
6
  import "@vitest-agent/sdk";
6
7
  import { Remediation } from "@effected/engine";
7
8
  //#region src/annotations.d.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/mcp",
3
- "version": "5.1.1",
3
+ "version": "5.1.2",
4
4
  "private": false,
5
5
  "description": "Model Context Protocol server for vitest-agent. Exposes 30 tools for agent access to test data, TDD lifecycle, and session management.",
6
6
  "keywords": [
@@ -46,8 +46,10 @@
46
46
  "@effect/platform-node": "4.0.0-rc.118",
47
47
  "@effect/sql-sqlite-node": "4.0.0-rc.118",
48
48
  "@effected/engine": "^0.2.0",
49
+ "@effected/git": "^0.19.0",
49
50
  "@effected/mcp": "^0.3.0",
50
- "@vitest-agent/engine": "0.2.10",
51
+ "@effected/walker": "^0.14.0",
52
+ "@vitest-agent/engine": "0.2.11",
51
53
  "@vitest-agent/sdk": "5.1.3",
52
54
  "effect": "4.0.0-rc.118"
53
55
  },
@@ -1,19 +1,20 @@
1
1
  import { McpSession } from "../session.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { ToolOutputSchema } from "@effected/mcp";
4
- import { Data, Effect, Schema, Semaphore } from "effect";
4
+ import { Data, Effect, Layer, Option, Path, Result, Schema, Semaphore } from "effect";
5
5
  import { DataReader, DataStore } from "@vitest-agent/engine";
6
6
  import { Tool } from "effect/ai";
7
7
  import { AgentReport, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote } from "@vitest-agent/sdk";
8
8
  import { AsyncLocalStorage } from "node:async_hooks";
9
- import { execFile } from "node:child_process";
10
9
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
11
- import { realpath, stat } from "node:fs/promises";
10
+ import { stat } from "node:fs/promises";
12
11
  import { tmpdir } from "node:os";
13
- import { dirname, join, resolve } from "node:path";
12
+ import { join, resolve } from "node:path";
14
13
  import { Writable } from "node:stream";
15
- import { pathToFileURL } from "node:url";
16
- import { promisify } from "node:util";
14
+ import { fileURLToPath, pathToFileURL } from "node:url";
15
+ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node";
16
+ import { Git } from "@effected/git";
17
+ import { Walker } from "@effected/walker";
17
18
 
18
19
  //#region src/tools/run-tests.ts
19
20
  const TagFilter = Schema.Struct({
@@ -186,29 +187,18 @@ function sanitizeTestArgs(args) {
186
187
  }
187
188
  return result;
188
189
  }
189
- const execFileAsync = promisify(execFile);
190
190
  /**
191
- * Resolve the git common directory for `dir` (`git rev-parse
192
- * --git-common-dir`) — identical across a repository and every worktree
193
- * attached to it, which is what makes it the right "same repository"
194
- * comparison (a plain `--show-toplevel` differs per worktree). Returns
195
- * `null` when `dir` is not inside a git repository or the command fails
196
- * for any other reason; callers treat `null` as "cannot confirm same
197
- * repository", never as a silent pass.
198
- *
199
- * @internal exported for tests
191
+ * The git common directory for `dir` (`Git.commonDir`: `git rev-parse
192
+ * --path-format=absolute --git-common-dir`) — identical across a
193
+ * repository, its subdirectories, every linked worktree and any symlinked
194
+ * path to them, which is what makes it the right "same repository"
195
+ * comparison (`--show-toplevel` differs per worktree). Yields `null` when
196
+ * `dir` is not inside a git repository or the command fails for any other
197
+ * reason; callers treat `null` as "cannot confirm same repository", never
198
+ * as a silent pass.
200
199
  */
201
- async function resolveGitCommonDir(dir) {
202
- try {
203
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: dir });
204
- const trimmed = stdout.trim();
205
- if (trimmed.length === 0) return null;
206
- const candidate = resolve(dir, trimmed);
207
- return await realpath(candidate);
208
- } catch {
209
- return null;
210
- }
211
- }
200
+ const gitCommonDir = (dir) => Effect.flatMap(Git, (git) => git.commonDir(dir)).pipe(Effect.option, Effect.map(Option.getOrNull), Effect.catchDefect(() => Effect.succeed(null)));
201
+ const VITEST_CONFIG_PREFIXES = ["vitest.config.", "vite.config."];
212
202
  const VITEST_CONFIG_EXTENSIONS = [
213
203
  "ts",
214
204
  "mts",
@@ -217,57 +207,54 @@ const VITEST_CONFIG_EXTENSIONS = [
217
207
  "mjs",
218
208
  "cjs"
219
209
  ];
220
- function findConfigInDir(dir) {
221
- for (const prefix of ["vitest.config.", "vite.config."]) for (const ext of VITEST_CONFIG_EXTENSIONS) {
222
- const candidate = join(dir, `${prefix}${ext}`);
223
- if (existsSync(candidate)) return candidate;
224
- }
225
- return null;
226
- }
227
210
  /**
228
211
  * The one walk both anchoring helpers share: step UP from `startDir`
229
212
  * until a vitest/vite config file is found, returning its absolute path.
230
- * Bounded at the git root (inclusive — the directory containing `.git`
231
- * is still examined before the walk stops) and at the filesystem root.
232
- * Returns `null` when no config is found in range or when anything about
233
- * the walk throws.
213
+ * Bounded at the git work-tree root (inclusive, so the root is still
214
+ * examined) and at the filesystem root. `Git.repoRoot` answers a PHYSICAL
215
+ * path (`/private/var/...` for a `/var/...` tmpdir on macOS) while the chain
216
+ * is lexical, so the bound is `Walker.ascendWithin`, which stops on a
217
+ * realpath match; a lexical `stopAt` would match nothing there and let the
218
+ * walk run past the repository. Any `repoRoot` failure (not a repository,
219
+ * git missing) means no git bound: the walk runs to the filesystem root, as
220
+ * it always has outside a repository. Yields `null` when no config is found in
221
+ * range. An unreadable entry reads as absent (the walker absorbs
222
+ * per-candidate failures), and a defect anywhere in the walk also yields
223
+ * `null`, so the helper never fails.
234
224
  */
235
- function walkUpToConfigFile(startDir) {
236
- try {
237
- let dir = resolve(startDir);
238
- for (;;) {
239
- const found = findConfigInDir(dir);
240
- if (found !== null) return found;
241
- if (existsSync(join(dir, ".git"))) return null;
242
- const parent = dirname(dir);
243
- if (parent === dir) return null;
244
- dir = parent;
245
- }
246
- } catch {
247
- return null;
248
- }
249
- }
225
+ const walkUpToConfigFile = (startDir) => Effect.gen(function* () {
226
+ const path = yield* Path.Path;
227
+ const start = path.resolve(startDir);
228
+ const git = yield* Git;
229
+ const dirs = yield* Walker.ascendWithin(start, yield* Effect.option(git.repoRoot(start)));
230
+ const found = yield* Walker.findUpward(dirs, (dir) => VITEST_CONFIG_PREFIXES.flatMap((prefix) => VITEST_CONFIG_EXTENSIONS.map((ext) => path.join(dir, `${prefix}${ext}`))));
231
+ return Option.getOrNull(found);
232
+ }).pipe(Effect.catchDefect(() => Effect.succeed(null)));
250
233
  /**
251
- * Walk UP from `startDir` looking for the vitest/vite config file, returning
234
+ * Walk UP from `startDir` looking for the vitest/vite config file, yielding
252
235
  * its absolute path, or `null` when none is found in range.
253
236
  *
254
237
  * @internal exported for tests
255
238
  */
256
- function resolveAnchoredConfigFile(startDir) {
257
- return walkUpToConfigFile(startDir);
258
- }
239
+ const resolveAnchoredConfigFile = (startDir) => walkUpToConfigFile(startDir);
259
240
  /**
260
241
  * Walk UP from `startDir` looking for the vitest/vite config Vitest would
261
- * load anyway, returning the directory that holds it. Returns `startDir`
242
+ * load anyway, yielding the directory that holds it. Yields `startDir`
262
243
  * unchanged when no config is found in range. See the issue #259 comment
263
244
  * above `validateProjectRoot` for the full rationale.
264
245
  *
265
246
  * @internal exported for tests
266
247
  */
267
- function resolveConfigAnchoredRoot(startDir) {
268
- const found = walkUpToConfigFile(startDir);
269
- return found === null ? startDir : dirname(found);
270
- }
248
+ const resolveConfigAnchoredRoot = (startDir) => Effect.gen(function* () {
249
+ const found = yield* walkUpToConfigFile(startDir);
250
+ if (found === null) return startDir;
251
+ return (yield* Path.Path).dirname(found);
252
+ });
253
+ const NodeFsPath = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer);
254
+ /** The Node platform the promise-shaped run body walks the real disk with; bound once so it memoizes. */
255
+ const NodeWalkPlatform = Git.layer.pipe(Layer.provide(NodeChildProcessSpawner.layer), Layer.provideMerge(NodeFsPath));
256
+ /** Run a never-failing platform walk against the real disk. */
257
+ const runOnNodePlatform = (effect) => Effect.runPromise(Effect.provide(effect, NodeWalkPlatform));
271
258
  /**
272
259
  * Validate an optional caller-supplied `projectRoot` against `ctxCwd`
273
260
  * (the MCP server's boot-time root). `undefined` anchors at the directory
@@ -290,7 +277,7 @@ function resolveConfigAnchoredRoot(startDir) {
290
277
  async function validateProjectRoot(projectRoot, ctxCwd) {
291
278
  if (projectRoot === void 0) return {
292
279
  ok: true,
293
- root: resolveConfigAnchoredRoot(ctxCwd)
280
+ root: await runOnNodePlatform(resolveConfigAnchoredRoot(ctxCwd))
294
281
  };
295
282
  const resolvedRoot = resolve(ctxCwd, projectRoot);
296
283
  let isDirectory;
@@ -306,7 +293,7 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
306
293
  ok: false,
307
294
  message: `projectRoot "${resolvedRoot}" is not a directory (ctx.cwd is "${ctxCwd}").`
308
295
  };
309
- const [rootCommonDir, cwdCommonDir] = await Promise.all([resolveGitCommonDir(resolvedRoot), resolveGitCommonDir(ctxCwd)]);
296
+ const [rootCommonDir, cwdCommonDir] = await runOnNodePlatform(Effect.all([gitCommonDir(resolvedRoot), gitCommonDir(ctxCwd)], { concurrency: 2 }));
310
297
  if (rootCommonDir === null || cwdCommonDir === null || rootCommonDir !== cwdCommonDir) return {
311
298
  ok: false,
312
299
  message: `projectRoot "${resolvedRoot}" does not belong to the same git repository as ctx.cwd "${ctxCwd}".`
@@ -355,6 +342,25 @@ function resolveVitestNodeEntry(root) {
355
342
  }
356
343
  }
357
344
  /**
345
+ * Issue #461: the MCP server is long-lived. When the lockfile is regenerated
346
+ * mid-session (dep bump, branch switch), pnpm can rename vitest's `.pnpm`
347
+ * store directory (peer-hash change), so the entry resolved for this process
348
+ * no longer exists and every run would otherwise fail opaquely inside the fork
349
+ * pool ("Worker forks emitted error", total: 0). Returns an actionable message
350
+ * when `entry` is a `file://` URL whose path is missing on disk, else `null`.
351
+ * The bare `"vitest/node"` fallback is not a path and is never checked. The
352
+ * check is on the exact path that will be imported, because Node's require
353
+ * resolution caches can hand back a stale path in a long-lived process.
354
+ *
355
+ * @internal exported for tests
356
+ */
357
+ function staleInstallMessage(entry) {
358
+ if (!entry.startsWith("file:")) return null;
359
+ const path = fileURLToPath(entry);
360
+ if (existsSync(path)) return null;
361
+ return `The resolved vitest entry "${path}" no longer exists on disk. The install changed since this vitest-agent MCP server loaded it (for example the lockfile was regenerated by a dependency install or branch switch). Restart the vitest-agent MCP server (in Claude Code: /mcp) or restart the session, then retry.`;
362
+ }
363
+ /**
358
364
  * Indirection seam around `import(<vitest/node entry>)`. vitest's own
359
365
  * vite-node externalizes "vitest"/"vitest/node" for every importer, and
360
366
  * `vi.mock("vitest/node", ...)` only special-cases AST-literal
@@ -366,7 +372,10 @@ function resolveVitestNodeEntry(root) {
366
372
  *
367
373
  * @internal exported for tests
368
374
  */
369
- const vitestLoader = { load: (entry) => import(entry) };
375
+ const vitestLoader = {
376
+ resolveEntry: (root) => resolveVitestNodeEntry(root),
377
+ load: (entry) => import(entry)
378
+ };
370
379
  const runTestsSemaphore = Effect.runSync(Semaphore.make(1));
371
380
  /**
372
381
  * Coerce unknown Vitest unhandled errors into VitestModuleError shape.
@@ -394,7 +403,7 @@ function coerceErrors(errors) {
394
403
  const RunTestsInput = Schema.Struct({
395
404
  files: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Test file paths to run" }),
396
405
  project: Schema.optionalKey(Schema.String).annotate({ description: "Project name to filter" }),
397
- projectRoot: Schema.optionalKey(Schema.String).annotate({ description: "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." }),
406
+ projectRoot: Schema.optionalKey(Schema.String).annotate({ description: "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, compared via `git rev-parse --path-format=absolute --git-common-dir`, which requires git 2.31 or newer; e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise." }),
398
407
  tags: Schema.optionalKey(TagFilter).annotate({ description: "Structured tag filter; all/any/none AND together with each other and with project/files" }),
399
408
  passWithNoTests: Schema.optionalKey(Schema.Boolean).annotate({ description: "Per-call override of Vitest's native test.passWithNoTests" }),
400
409
  timeout: Schema.optionalKey(Schema.Finite).annotate({ description: "Timeout in seconds (default: 120)" }),
@@ -405,6 +414,92 @@ const RunTestsInput = Schema.Struct({
405
414
  })).annotate({ description: "Hook-injected session attribution UUIDs; do not pass manually." })
406
415
  });
407
416
  /**
417
+ * Derive the run's {@link RunScope} from its input. Every file, project and
418
+ * tag value passes through `sanitizeTestArgs` (tag values ride into Vitest's
419
+ * tag-expression compiler unmodified, so shell metacharacters are rejected
420
+ * the same way); a refused argument fails with the message the tool returns
421
+ * as its `{ kind: "error" }` result. An all-empty tag filter composes to no
422
+ * expression and so is not a filter.
423
+ *
424
+ * @internal exported for tests
425
+ */
426
+ function deriveRunScope(input) {
427
+ const tags = input.tags;
428
+ try {
429
+ const files = input.files ? sanitizeTestArgs(input.files) : [];
430
+ const project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
431
+ if (tags) {
432
+ if (tags.all) sanitizeTestArgs(tags.all);
433
+ if (tags.any) sanitizeTestArgs(tags.any);
434
+ if (tags.none) sanitizeTestArgs(tags.none);
435
+ }
436
+ const resolvedExpression = composeTagExpression(tags ?? null);
437
+ const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
438
+ return Result.succeed({
439
+ files,
440
+ project,
441
+ tags,
442
+ resolvedExpression,
443
+ hasFilter
444
+ });
445
+ } catch (err) {
446
+ return Result.fail(err instanceof Error ? err.message : String(err));
447
+ }
448
+ }
449
+ /**
450
+ * Whether a finished run is `no-match`: a filter was supplied, no test
451
+ * module was collected, and no unhandled error was raised. Filter-driven,
452
+ * not result-driven — an unfiltered empty run is `ok`, and the
453
+ * `passWithNoTests` policy never reshapes the discriminator.
454
+ *
455
+ * @internal exported for tests
456
+ */
457
+ function isNoMatch(scope, testModuleCount, unhandledErrorCount) {
458
+ return scope.hasFilter && testModuleCount === 0 && unhandledErrorCount === 0;
459
+ }
460
+ /**
461
+ * Shape the `no-match` result, echoing the resolved filter verbatim.
462
+ *
463
+ * @internal exported for tests
464
+ */
465
+ function toNoMatch(scope, projectRoot) {
466
+ return {
467
+ kind: "no-match",
468
+ projectRoot,
469
+ filter: {
470
+ project: scope.project ?? null,
471
+ files: scope.files,
472
+ tags: scope.tags ?? null,
473
+ resolvedExpression: scope.resolvedExpression
474
+ }
475
+ };
476
+ }
477
+ /**
478
+ * Shape the `ok` result from a finished run's plain values. `project` is
479
+ * echoed at the top level only when supplied; `scope` always echoes the
480
+ * resolved filter set; absent classifications and scan time become `{}` and
481
+ * `null`.
482
+ *
483
+ * @internal exported for tests
484
+ */
485
+ function toOkPayload(args) {
486
+ const { scope } = args;
487
+ return {
488
+ kind: "ok",
489
+ ...scope.project !== void 0 && { project: scope.project },
490
+ projectRoot: args.projectRoot,
491
+ scope: {
492
+ project: scope.project ?? null,
493
+ files: scope.files,
494
+ tags: scope.tags ?? null
495
+ },
496
+ report: args.report,
497
+ classifications: args.classifications ? Object.fromEntries(args.classifications) : {},
498
+ discoveryLastScannedAt: args.discoveryLastScannedAt ?? null,
499
+ scopedNote: args.scopedNote
500
+ };
501
+ }
502
+ /**
408
503
  * Build the best-effort runner for a `RunTestsContext`: forks the effect
409
504
  * as a fiber on the provided services with its expected failure channel
410
505
  * ignored (`Effect.ignore`), so a typed failure never touches the tool
@@ -421,25 +516,13 @@ const makeBestEffortFork = (services) => (effect) => Effect.runFork(Effect.provi
421
516
  * `{ kind: "error" }` envelope, so it never rejects.
422
517
  */
423
518
  const runTestsBody = async (input, ctx) => {
424
- let files;
425
- let project;
426
- const tagsInput = input.tags;
427
- try {
428
- files = input.files ? sanitizeTestArgs(input.files) : [];
429
- project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
430
- if (tagsInput) {
431
- if (tagsInput.all) sanitizeTestArgs(tagsInput.all);
432
- if (tagsInput.any) sanitizeTestArgs(tagsInput.any);
433
- if (tagsInput.none) sanitizeTestArgs(tagsInput.none);
434
- }
435
- } catch (err) {
436
- return {
437
- kind: "error",
438
- message: err instanceof Error ? err.message : String(err)
439
- };
440
- }
441
- const resolvedExpression = composeTagExpression(tagsInput ?? null);
442
- const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
519
+ const derived = deriveRunScope(input);
520
+ if (Result.isFailure(derived)) return {
521
+ kind: "error",
522
+ message: derived.failure
523
+ };
524
+ const scope = derived.success;
525
+ const { files, project, resolvedExpression, hasFilter } = scope;
443
526
  const projectRootValidation = await validateProjectRoot(input.projectRoot, ctx.cwd);
444
527
  if (!projectRootValidation.ok) return {
445
528
  kind: "error",
@@ -448,13 +531,19 @@ const runTestsBody = async (input, ctx) => {
448
531
  const resolvedRoot = projectRootValidation.root;
449
532
  let anchoredConfig;
450
533
  if (input.projectRoot !== void 0) {
451
- const found = resolveAnchoredConfigFile(resolvedRoot);
534
+ const found = await runOnNodePlatform(resolveAnchoredConfigFile(resolvedRoot));
452
535
  if (found === null) return {
453
536
  kind: "error",
454
537
  message: `No vitest.config.* or vite.config.* was found at or above projectRoot "${resolvedRoot}" within the repository. Vitest 5 does not search ancestor directories, so this run would collect no tests and load no plugins. Pass a projectRoot that contains a Vitest config, or omit projectRoot to anchor automatically.`
455
538
  };
456
539
  anchoredConfig = found;
457
540
  }
541
+ const vitestEntry = vitestLoader.resolveEntry(resolvedRoot);
542
+ const staleMessage = staleInstallMessage(vitestEntry);
543
+ if (staleMessage !== null) return {
544
+ kind: "error",
545
+ message: staleMessage
546
+ };
458
547
  const timeoutMs = (input.timeout ?? 120) * 1e3;
459
548
  const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
460
549
  if (recovered !== null) {
@@ -465,10 +554,17 @@ const runTestsBody = async (input, ctx) => {
465
554
  const nullStream = new Writable({ write(_chunk, _encoding, cb) {
466
555
  cb();
467
556
  } });
468
- const { createVitest } = await vitestLoader.load(resolveVitestNodeEntry(resolvedRoot));
557
+ const { createVitest } = await vitestLoader.load(vitestEntry);
469
558
  let vitest;
470
559
  let covOverride;
560
+ let previousCwd;
561
+ try {
562
+ previousCwd = process.cwd();
563
+ } catch {
564
+ previousCwd = void 0;
565
+ }
471
566
  try {
567
+ process.chdir(resolvedRoot);
472
568
  covOverride = makeCoverageDirOverride();
473
569
  vitest = await createVitest({
474
570
  root: resolvedRoot,
@@ -501,16 +597,7 @@ const runTestsBody = async (input, ctx) => {
501
597
  const result = startOutcome.value;
502
598
  const testModules = result.testModules;
503
599
  const unhandledErrors = coerceErrors(result.unhandledErrors);
504
- if (hasFilter && result.testModules.length === 0 && unhandledErrors.length === 0) return {
505
- kind: "no-match",
506
- projectRoot: resolvedRoot,
507
- filter: {
508
- project: project ?? null,
509
- files,
510
- tags: tagsInput ?? null,
511
- resolvedExpression
512
- }
513
- };
600
+ if (isNoMatch(scope, result.testModules.length, unhandledErrors.length)) return toNoMatch(scope, resolvedRoot);
514
601
  const preliminaryReason = unhandledErrors.length > 0 || result.testModules.some((m) => m.state() === "failed") ? "failed" : "passed";
515
602
  const baseReport = buildAgentReport(testModules, unhandledErrors, preliminaryReason, { omitPassingTests: true });
516
603
  const leaks = buildConsoleLeaks(collectConsoleLeakEntries(localVitest.state.getFiles()));
@@ -547,20 +634,14 @@ const runTestsBody = async (input, ctx) => {
547
634
  } catch {}
548
635
  scopedNote = formatScopedCoverageNote(testedFileCount, totalFileCount);
549
636
  }
550
- return {
551
- kind: "ok",
552
- ...project !== void 0 && { project },
637
+ return toOkPayload({
638
+ scope,
553
639
  projectRoot: resolvedRoot,
554
- scope: {
555
- project: project ?? null,
556
- files,
557
- tags: tagsInput ?? null
558
- },
559
640
  report,
560
- classifications: classifications ? Object.fromEntries(classifications) : {},
561
- discoveryLastScannedAt: readDiscoveryLastScannedAt() ?? null,
641
+ classifications,
642
+ discoveryLastScannedAt: readDiscoveryLastScannedAt(),
562
643
  scopedNote
563
- };
644
+ });
564
645
  } catch (err) {
565
646
  let message;
566
647
  try {
@@ -576,6 +657,9 @@ const runTestsBody = async (input, ctx) => {
576
657
  try {
577
658
  await vitest?.close();
578
659
  } finally {
660
+ try {
661
+ if (previousCwd !== void 0) process.chdir(previousCwd);
662
+ } catch {}
579
663
  nullStream.destroy();
580
664
  if (covOverride !== void 0) try {
581
665
  rmSync(covOverride.dir, {
@@ -606,7 +690,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
606
690
  runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services)),
607
691
  runFork: makeBestEffortFork(services)
608
692
  };
609
- return yield* Semaphore.withPermit(runTestsSemaphore, Effect.promise(() => runTestsBody(input, ctx)));
693
+ return yield* Semaphore.withPermit(runTestsSemaphore, Effect.uninterruptible(Effect.promise(() => runTestsBody(input, ctx))));
610
694
  });
611
695
  /**
612
696
  * The Effect-native `run_tests` tool.
@@ -614,7 +698,7 @@ const handleRunTests = (input) => Effect.gen(function* () {
614
698
  * @public
615
699
  */
616
700
  const runTestsTool = Tool.make("run_tests", {
617
- 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.",
701
+ 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 --path-format=absolute --git-common-dir`, which needs git 2.31 or newer and 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.",
618
702
  parameters: RunTestsInput,
619
703
  success: RunTestsResult,
620
704
  dependencies: [
@@ -625,4 +709,4 @@ const runTestsTool = Tool.make("run_tests", {
625
709
  }).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false);
626
710
 
627
711
  //#endregion
628
- export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, handleRunTests, makeBestEffortFork, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
712
+ export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, deriveRunScope, handleRunTests, isNoMatch, makeBestEffortFork, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveVitestNodeEntry, runTestsSemaphore, runTestsTool, sanitizeTestArgs, staleInstallMessage, toNoMatch, toOkPayload, validateProjectRoot, vitestLoader, withStdioCaptured };
package/version.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * @public
9
9
  */
10
- const CURRENT_MCP_VERSION = "5.1.1";
10
+ const CURRENT_MCP_VERSION = "5.1.2";
11
11
 
12
12
  //#endregion
13
13
  export { CURRENT_MCP_VERSION };