@vitest-agent/mcp 2.3.4 → 2.4.1

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
@@ -520,6 +520,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
520
520
  } | undefined;
521
521
  readonly passWithNoTests?: boolean | undefined;
522
522
  readonly timeout?: number | undefined;
523
+ readonly projectRoot?: string | undefined;
523
524
  readonly _sessionContext?: {
524
525
  readonly chatId: string;
525
526
  readonly conversationId: string;
@@ -529,6 +530,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
529
530
  output: {
530
531
  readonly kind: "ok";
531
532
  readonly project?: string | undefined;
533
+ readonly projectRoot: string;
532
534
  readonly scope: {
533
535
  readonly project: string | null;
534
536
  readonly files: readonly string[];
@@ -691,6 +693,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
691
693
  readonly message: string;
692
694
  } | {
693
695
  readonly kind: "no-match";
696
+ readonly projectRoot: string;
694
697
  readonly filter: {
695
698
  readonly project: string | null;
696
699
  readonly files: readonly string[];
@@ -1450,7 +1453,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
1450
1453
  readonly id: number;
1451
1454
  readonly outcome: "abandoned" | "confirmed" | "refuted";
1452
1455
  readonly validatedTurnId?: number | undefined;
1453
- readonly validatedAt: string;
1456
+ readonly validatedAt?: string | undefined;
1454
1457
  } | {
1455
1458
  readonly action: "list";
1456
1459
  readonly sessionId?: number | undefined;
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.3.4";
15
+ const CURRENT_MCP_VERSION = "2.4.1";
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.3.4",
3
+ "version": "2.4.1",
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": [
@@ -42,7 +42,7 @@
42
42
  "@effect/sql-sqlite-node": "4.0.0-rc.109",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@trpc/server": "^11.18.0",
45
- "@vitest-agent/sdk": "2.4.4",
45
+ "@vitest-agent/sdk": "2.4.5",
46
46
  "effect": "4.0.0-rc.109",
47
47
  "zod": "^4.4.3"
48
48
  },
package/server.js CHANGED
@@ -405,10 +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. 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. 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.",
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
413
  tags: z.optional(strict({
413
414
  all: z.optional(z.array(z.string())).describe("Require every listed tag"),
414
415
  any: z.optional(z.array(z.string())).describe("Require at least one listed tag"),
@@ -430,6 +431,7 @@ function buildMcpServer(ctx) {
430
431
  tags: args.tags,
431
432
  passWithNoTests: args.passWithNoTests,
432
433
  timeout: args.timeout,
434
+ ...args.projectRoot !== void 0 && { projectRoot: args.projectRoot },
433
435
  ...args._sessionContext !== void 0 && { _sessionContext: args._sessionContext }
434
436
  });
435
437
  return structuredResult(Schema.decodeSync(RunTestsAsMarkdown)(data), data);
@@ -765,7 +767,7 @@ function buildMcpServer(ctx) {
765
767
  return structuredResult(Schema.decodeSync(TddArtifactListAsMarkdown)(data), data);
766
768
  });
767
769
  server.registerTool("hypothesis", {
768
- description: "Use to manage debugging hypotheses, with a CRUD action discriminator: action='record' (content, tddTaskId?, optional citation ids) writes a hypothesis — the binding session is resolved server-side from the recovered host context (active TDD subagent, else main session); pass tddTaskId (returned by tdd_task action='start') to bind deterministically to that task's session, and do not pass sessionId when recording; action='validate' (id, outcome, validatedAt) records a validation outcome; action='list' (sessionId?, outcome?, limit?) returns matching hypotheses as markdown.",
770
+ description: "Use to manage debugging hypotheses, with a CRUD action discriminator: action='record' (content, tddTaskId?, optional citation ids) writes a hypothesis — the binding session is resolved server-side from the recovered host context (active TDD subagent, else main session); pass tddTaskId (returned by tdd_task action='start') to bind deterministically to that task's session, and do not pass sessionId when recording; action='validate' (id, outcome, validatedAt?) records a validation outcome — validatedAt is optional and defaults server-side to now when omitted, or is honored verbatim when supplied; action='list' (sessionId?, outcome?, limit?) returns matching hypotheses as markdown.",
769
771
  inputSchema: strict({
770
772
  action: z.enum([
771
773
  "record",
@@ -804,7 +806,7 @@ function buildMcpServer(ctx) {
804
806
  action: "validate",
805
807
  id: args.id,
806
808
  outcome: args.outcome,
807
- validatedAt: args.validatedAt,
809
+ ...args.validatedAt !== void 0 && { validatedAt: args.validatedAt },
808
810
  ...args.validatedTurnId !== void 0 && { validatedTurnId: args.validatedTurnId }
809
811
  }));
810
812
  const result = await caller.hypothesis({
package/tools/help.js CHANGED
@@ -97,7 +97,7 @@ const HELP_TEXT = `# vitest-agent MCP Tools
97
97
 
98
98
  \`hypothesis\` actions:
99
99
  - \`{ action: "record", sessionId, content, createdTurnId?, citedTestErrorId?, citedStackFrameId? }\`
100
- - \`{ action: "validate", id, outcome, validatedAt, validatedTurnId? }\`
100
+ - \`{ action: "validate", id, outcome, validatedAt?, validatedTurnId? }\` — \`validatedAt\` defaults server-side to now when omitted; an explicit value is honored verbatim
101
101
  - \`{ action: "list", sessionId?, outcome?, limit? }\`
102
102
 
103
103
  ## TDD lifecycle
@@ -73,7 +73,7 @@ const ValidateVariant = Schema.Struct({
73
73
  "abandoned"
74
74
  ]),
75
75
  validatedTurnId: Schema.optional(Schema.Number),
76
- validatedAt: Schema.String
76
+ validatedAt: Schema.optional(Schema.String)
77
77
  });
78
78
  const ListVariant = Schema.Struct({
79
79
  action: Schema.Literal("list"),
@@ -136,7 +136,7 @@ const hypothesis = idempotentProcedure.input(Schema.toStandardSchemaV1(Hypothesi
136
136
  yield* (yield* DataStore).validateHypothesis({
137
137
  id: variant.id,
138
138
  outcome: variant.outcome,
139
- validatedAt: variant.validatedAt,
139
+ validatedAt: variant.validatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
140
140
  ...variant.validatedTurnId !== void 0 && { validatedTurnId: variant.validatedTurnId }
141
141
  });
142
142
  return { action: "validate" };
@@ -2,10 +2,13 @@ import { publicProcedure } from "../context.js";
2
2
  import { AgentReport, DataReader, DataStore, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries } from "@vitest-agent/sdk";
3
3
  import { Effect, Schema, SchemaGetter } from "effect";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
5
+ import { execFile } from "node:child_process";
5
6
  import { mkdtempSync, rmSync } from "node:fs";
7
+ import { realpath, stat } from "node:fs/promises";
6
8
  import { tmpdir } from "node:os";
7
- import { join } from "node:path";
9
+ import { join, resolve } from "node:path";
8
10
  import { Writable } from "node:stream";
11
+ import { promisify } from "node:util";
9
12
 
10
13
  //#region src/tools/run-tests.ts
11
14
  const TagFilter = Schema.Struct({
@@ -27,6 +30,7 @@ const RunTestsScope = Schema.Struct({
27
30
  const RunTestsOk = Schema.Struct({
28
31
  kind: Schema.Literal("ok").annotate({ description: "Discriminant — `true` test run completed (with or without failures)." }),
29
32
  project: Schema.optional(Schema.String),
33
+ projectRoot: Schema.String.annotate({ description: "The absolute Vitest root actually used for this run (issue #252). Echoed whether or not the caller supplied a `projectRoot` param -- when absent, this is the server's boot-time ctx.cwd; when supplied and validated, this is the resolved, validated path. Lets an agent confirm discovery resolved where it expected, or that an explicit projectRoot was actually honored." }),
30
34
  scope: RunTestsScope,
31
35
  report: AgentReport.annotate({ description: "Full AgentReport including pass/fail counts and per-module errors." }),
32
36
  classifications: Schema.Record(Schema.String, Schema.String).annotate({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." }),
@@ -42,6 +46,7 @@ const RunTestsError = Schema.Struct({
42
46
  }).annotate({ identifier: "RunTestsError" });
43
47
  const RunTestsNoMatch = Schema.Struct({
44
48
  kind: Schema.Literal("no-match").annotate({ description: "Discriminant — the resolved filter set matched zero test cases. Tests did not run; this is independent of passWithNoTests policy." }),
49
+ projectRoot: Schema.String.annotate({ description: "The absolute Vitest root actually resolved for this call (issue #252) — echoed the same as on RunTestsOk, even though no test ran." }),
45
50
  filter: Schema.Struct({
46
51
  project: Schema.NullOr(Schema.String),
47
52
  files: Schema.Array(Schema.String),
@@ -164,6 +169,73 @@ function sanitizeTestArgs(args) {
164
169
  }
165
170
  return result;
166
171
  }
172
+ const execFileAsync = promisify(execFile);
173
+ /**
174
+ * Resolve the git common directory for `dir` (`git rev-parse
175
+ * --git-common-dir`) — identical across a repository and every worktree
176
+ * attached to it, which is what makes it the right "same repository"
177
+ * comparison (a plain `--show-toplevel` differs per worktree). Returns
178
+ * `null` when `dir` is not inside a git repository or the command fails
179
+ * for any other reason; callers treat `null` as "cannot confirm same
180
+ * repository", never as a silent pass.
181
+ *
182
+ * @internal exported for tests
183
+ */
184
+ async function resolveGitCommonDir(dir) {
185
+ try {
186
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd: dir });
187
+ const trimmed = stdout.trim();
188
+ if (trimmed.length === 0) return null;
189
+ const candidate = resolve(dir, trimmed);
190
+ return await realpath(candidate);
191
+ } catch {
192
+ return null;
193
+ }
194
+ }
195
+ /**
196
+ * 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).
201
+ *
202
+ * A supplied `projectRoot` is VALIDATED, not trusted: it must resolve to
203
+ * an existing directory that shares a git common directory with
204
+ * `ctxCwd` (same repository, including across worktrees). Any failure
205
+ * returns `{ ok: false, message }` naming both paths — never a silent
206
+ * fallback to `ctxCwd`, never a raw throw.
207
+ *
208
+ * @internal exported for tests
209
+ */
210
+ async function validateProjectRoot(projectRoot, ctxCwd) {
211
+ if (projectRoot === void 0) return {
212
+ ok: true,
213
+ root: ctxCwd
214
+ };
215
+ const resolvedRoot = resolve(ctxCwd, projectRoot);
216
+ let isDirectory;
217
+ try {
218
+ isDirectory = (await stat(resolvedRoot)).isDirectory();
219
+ } catch {
220
+ return {
221
+ ok: false,
222
+ message: `projectRoot "${resolvedRoot}" does not exist (ctx.cwd is "${ctxCwd}").`
223
+ };
224
+ }
225
+ if (!isDirectory) return {
226
+ ok: false,
227
+ message: `projectRoot "${resolvedRoot}" is not a directory (ctx.cwd is "${ctxCwd}").`
228
+ };
229
+ const [rootCommonDir, cwdCommonDir] = await Promise.all([resolveGitCommonDir(resolvedRoot), resolveGitCommonDir(ctxCwd)]);
230
+ if (rootCommonDir === null || cwdCommonDir === null || rootCommonDir !== cwdCommonDir) return {
231
+ ok: false,
232
+ message: `projectRoot "${resolvedRoot}" does not belong to the same git repository as ctx.cwd "${ctxCwd}".`
233
+ };
234
+ return {
235
+ ok: true,
236
+ root: resolvedRoot
237
+ };
238
+ }
167
239
  let _runTestsChain = Promise.resolve();
168
240
  function serializeRunTests(fn) {
169
241
  const next = _runTestsChain.then(fn, fn);
@@ -195,9 +267,10 @@ function coerceErrors(errors) {
195
267
  function formatRunTestsMarkdown(data) {
196
268
  if (data.kind === "timeout") return `Test run timed out after ${data.timeoutSeconds} seconds.`;
197
269
  if (data.kind === "error") return `Test run failed: ${data.message}`;
198
- if (data.kind === "no-match") return formatNoMatchMarkdown(data.filter);
270
+ const rootLine = `\nProject root: \`${data.projectRoot}\``;
271
+ if (data.kind === "no-match") return `${formatNoMatchMarkdown(data.filter)}${rootLine}`;
199
272
  const classMap = new Map(Object.entries(data.classifications));
200
- return formatReportMarkdown(data.report, classMap);
273
+ return `${formatReportMarkdown(data.report, classMap)}${rootLine}`;
201
274
  }
202
275
  /**
203
276
  * Render the `no-match` filter context plus a remediation pointer aimed at
@@ -310,6 +383,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
310
383
  tags: Schema.optional(TagFilter),
311
384
  passWithNoTests: Schema.optional(Schema.Boolean),
312
385
  timeout: Schema.optional(Schema.Number),
386
+ projectRoot: Schema.optional(Schema.String),
313
387
  _sessionContext: Schema.optional(Schema.Struct({
314
388
  chatId: Schema.String,
315
389
  conversationId: Schema.String,
@@ -326,6 +400,12 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
326
400
  }
327
401
  const resolvedExpression = composeTagExpression(tagsInput ?? null);
328
402
  const hasFilter = files.length > 0 || project !== void 0 || resolvedExpression !== null;
403
+ const projectRootValidation = await validateProjectRoot(input.projectRoot, ctx.cwd);
404
+ if (!projectRootValidation.ok) return {
405
+ kind: "error",
406
+ message: projectRootValidation.message
407
+ };
408
+ const resolvedRoot = projectRootValidation.root;
329
409
  const timeoutMs = (input.timeout ?? 120) * 1e3;
330
410
  const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
331
411
  if (recovered !== null) {
@@ -342,7 +422,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
342
422
  try {
343
423
  covOverride = makeCoverageDirOverride();
344
424
  vitest = await createVitest("test", {
345
- root: ctx.cwd,
425
+ root: resolvedRoot,
346
426
  run: true,
347
427
  coverage: covOverride.coverage,
348
428
  ...project ? { project } : {},
@@ -363,6 +443,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
363
443
  const unhandledErrors = coerceErrors(result.unhandledErrors);
364
444
  if (hasFilter && result.testModules.length === 0 && unhandledErrors.length === 0) return {
365
445
  kind: "no-match",
446
+ projectRoot: resolvedRoot,
366
447
  filter: {
367
448
  project: project ?? null,
368
449
  files,
@@ -400,6 +481,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
400
481
  return {
401
482
  kind: "ok",
402
483
  ...project !== void 0 && { project },
484
+ projectRoot: resolvedRoot,
403
485
  scope: {
404
486
  project: project ?? null,
405
487
  files,
@@ -440,4 +522,4 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
440
522
  }));
441
523
 
442
524
  //#endregion
443
- export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, runTests, sanitizeTestArgs, withStdioCaptured };
525
+ export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveGitCommonDir, runTests, sanitizeTestArgs, validateProjectRoot, withStdioCaptured };