@vitest-agent/mcp 2.3.4 → 2.4.0
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 +4 -1
- package/index.js +1 -1
- package/package.json +2 -2
- package/server.js +5 -3
- package/tools/help.js +1 -1
- package/tools/hypothesis.js +2 -2
- package/tools/run-tests.js +87 -5
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
|
|
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.
|
|
15
|
+
const CURRENT_MCP_VERSION = "2.4.0";
|
|
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
|
+
"version": "2.4.0",
|
|
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.
|
|
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
|
|
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
|
package/tools/hypothesis.js
CHANGED
|
@@ -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" };
|
package/tools/run-tests.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 };
|