@vitest-agent/mcp 2.4.14 → 3.0.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 CHANGED
@@ -213,6 +213,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
213
213
  readonly functions?: number | undefined;
214
214
  readonly branches?: number | undefined;
215
215
  readonly statements?: number | undefined;
216
+ readonly perFile?: boolean | {
217
+ readonly lines?: number | undefined;
218
+ readonly functions?: number | undefined;
219
+ readonly branches?: number | undefined;
220
+ readonly statements?: number | undefined;
221
+ };
216
222
  }])[];
217
223
  };
218
224
  readonly targets?: {
@@ -227,6 +233,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
227
233
  readonly functions?: number | undefined;
228
234
  readonly branches?: number | undefined;
229
235
  readonly statements?: number | undefined;
236
+ readonly perFile?: boolean | {
237
+ readonly lines?: number | undefined;
238
+ readonly functions?: number | undefined;
239
+ readonly branches?: number | undefined;
240
+ readonly statements?: number | undefined;
241
+ };
230
242
  }])[];
231
243
  } | undefined;
232
244
  readonly baselines?: {
@@ -241,6 +253,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
241
253
  readonly functions?: number | undefined;
242
254
  readonly branches?: number | undefined;
243
255
  readonly statements?: number | undefined;
256
+ readonly perFile?: boolean | {
257
+ readonly lines?: number | undefined;
258
+ readonly functions?: number | undefined;
259
+ readonly branches?: number | undefined;
260
+ readonly statements?: number | undefined;
261
+ };
244
262
  }])[];
245
263
  } | undefined;
246
264
  readonly scoped: boolean;
@@ -377,6 +395,15 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
377
395
  readonly scope: "module" | "suite" | "test" | "unhandled";
378
396
  readonly testFullName: string | null;
379
397
  readonly moduleFile: string | null;
398
+ readonly annotations: readonly {
399
+ readonly type: string;
400
+ readonly message: string;
401
+ readonly location?: {
402
+ readonly file: string;
403
+ readonly line: number;
404
+ readonly column: number;
405
+ } | undefined;
406
+ }[];
380
407
  }[];
381
408
  };
382
409
  meta: object;
@@ -400,6 +427,18 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
400
427
  readonly action: "for_tag";
401
428
  readonly tag: string;
402
429
  readonly project?: string | undefined;
430
+ } | {
431
+ readonly action: "annotations";
432
+ readonly fullName: string;
433
+ readonly project?: string | undefined;
434
+ readonly modulePath?: string | undefined;
435
+ readonly maxBytes?: number | undefined;
436
+ } | {
437
+ readonly action: "artifacts";
438
+ readonly fullName: string;
439
+ readonly project?: string | undefined;
440
+ readonly modulePath?: string | undefined;
441
+ readonly maxBytes?: number | undefined;
403
442
  };
404
443
  output: {
405
444
  readonly action: "list";
@@ -464,6 +503,51 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
464
503
  readonly classification: string | null;
465
504
  }[];
466
505
  }[];
506
+ } | {
507
+ readonly action: "annotations";
508
+ readonly project: string;
509
+ readonly fullName: string;
510
+ readonly count: number;
511
+ readonly annotations: readonly {
512
+ readonly id: number;
513
+ readonly type: string;
514
+ readonly message: string;
515
+ readonly location?: {
516
+ readonly file: string;
517
+ readonly line: number;
518
+ readonly column: number;
519
+ } | undefined;
520
+ readonly attachments: readonly {
521
+ readonly contentType?: string | undefined;
522
+ readonly path?: string | undefined;
523
+ readonly byteSize: number | null;
524
+ readonly body?: string | undefined;
525
+ readonly bodyEncoding?: "base64" | "utf-8" | undefined;
526
+ }[];
527
+ }[];
528
+ } | {
529
+ readonly action: "artifacts";
530
+ readonly project: string;
531
+ readonly fullName: string;
532
+ readonly count: number;
533
+ readonly artifacts: readonly {
534
+ readonly id: number;
535
+ readonly type: string;
536
+ readonly message: string | null;
537
+ readonly data: string | null;
538
+ readonly location?: {
539
+ readonly file: string;
540
+ readonly line: number;
541
+ readonly column: number;
542
+ } | undefined;
543
+ readonly attachments: readonly {
544
+ readonly contentType?: string | undefined;
545
+ readonly path?: string | undefined;
546
+ readonly byteSize: number | null;
547
+ readonly body?: string | undefined;
548
+ readonly bodyEncoding?: "base64" | "utf-8" | undefined;
549
+ }[];
550
+ }[];
467
551
  };
468
552
  meta: object;
469
553
  }>;
@@ -608,6 +692,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
608
692
  readonly functions?: number | undefined;
609
693
  readonly branches?: number | undefined;
610
694
  readonly statements?: number | undefined;
695
+ readonly perFile?: boolean | {
696
+ readonly lines?: number | undefined;
697
+ readonly functions?: number | undefined;
698
+ readonly branches?: number | undefined;
699
+ readonly statements?: number | undefined;
700
+ };
611
701
  }])[];
612
702
  };
613
703
  readonly targets?: {
@@ -622,6 +712,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
622
712
  readonly functions?: number | undefined;
623
713
  readonly branches?: number | undefined;
624
714
  readonly statements?: number | undefined;
715
+ readonly perFile?: boolean | {
716
+ readonly lines?: number | undefined;
717
+ readonly functions?: number | undefined;
718
+ readonly branches?: number | undefined;
719
+ readonly statements?: number | undefined;
720
+ };
625
721
  }])[];
626
722
  } | undefined;
627
723
  readonly baselines?: {
@@ -636,6 +732,12 @@ export declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
636
732
  readonly functions?: number | undefined;
637
733
  readonly branches?: number | undefined;
638
734
  readonly statements?: number | undefined;
735
+ readonly perFile?: boolean | {
736
+ readonly lines?: number | undefined;
737
+ readonly functions?: number | undefined;
738
+ readonly branches?: number | undefined;
739
+ readonly statements?: number | undefined;
740
+ };
639
741
  }])[];
640
742
  } | undefined;
641
743
  readonly scoped: boolean;
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.14";
15
+ const CURRENT_MCP_VERSION = "3.0.0";
16
16
 
17
17
  //#endregion
18
18
  export { CURRENT_MCP_VERSION, McpLive, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
package/layers/McpLive.js CHANGED
@@ -2,7 +2,7 @@ import { Layer } from "effect";
2
2
  import * as NodeServices from "@effect/platform-node/NodeServices";
3
3
  import { layer } from "@effect/sql-sqlite-node/SqliteClient";
4
4
  import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
5
- import { DataReaderLive, DataStoreLive, LoggerLive, OutputPipelineLive, ProjectDiscoveryLive, migration0001 } from "@vitest-agent/sdk";
5
+ import { DataReaderLive, DataStoreLive, LoggerLive, OutputPipelineLive, PROJECT_MIGRATIONS, ProjectDiscoveryLive } from "@vitest-agent/sdk";
6
6
 
7
7
  //#region src/layers/McpLive.ts
8
8
  /**
@@ -21,7 +21,7 @@ import { DataReaderLive, DataStoreLive, LoggerLive, OutputPipelineLive, ProjectD
21
21
  const McpLive = (dbPath, logLevel, logFile) => {
22
22
  const SqliteLayer = layer({ filename: dbPath });
23
23
  const PlatformLayer = NodeServices.layer;
24
- const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord({ "0001_initial": migration0001 }) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
24
+ const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(PROJECT_MIGRATIONS) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
25
25
  return Layer.mergeAll(DataReaderLive, DataStoreLive, ProjectDiscoveryLive, OutputPipelineLive).pipe(Layer.provideMerge(MigratorLayer), Layer.provideMerge(SqliteLayer), Layer.provideMerge(PlatformLayer), Layer.provideMerge(LoggerLive(logLevel, logFile)));
26
26
  };
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vitest-agent/mcp",
3
- "version": "2.4.14",
3
+ "version": "3.0.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": [
@@ -38,16 +38,16 @@
38
38
  "vitest-agent-mcp": "bin/vitest-agent-mcp.js"
39
39
  },
40
40
  "dependencies": {
41
- "@effect/platform-node": "4.0.0-rc.109",
42
- "@effect/sql-sqlite-node": "4.0.0-rc.109",
41
+ "@effect/platform-node": "4.0.0-rc.112",
42
+ "@effect/sql-sqlite-node": "4.0.0-rc.112",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@trpc/server": "^11.18.0",
45
- "@vitest-agent/sdk": "2.5.0",
46
- "effect": "4.0.0-rc.109",
45
+ "@vitest-agent/sdk": "3.0.0",
46
+ "effect": "4.0.0-rc.112",
47
47
  "zod": "^4.5.4"
48
48
  },
49
49
  "peerDependencies": {
50
- "vitest": "^4.1.0"
50
+ "vitest": "^5.0.0"
51
51
  },
52
52
  "engines": {
53
53
  "node": ">=24.11.0"
package/server.js CHANGED
@@ -259,17 +259,18 @@ function buildMcpServer(ctx) {
259
259
  return structuredResult(Schema.decodeSync(TestErrorsAsMarkdown)(data), data);
260
260
  });
261
261
  server.registerTool("test", {
262
- description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?, modulePath?) returns details + errors + run history — a fullName that exists in more than one module returns found=false with ambiguous=true and candidateModules[], so pass modulePath to disambiguate; action='for_file' (filePath) returns test modules covering a source file; action='for_tag' (tag, project?) returns tests carrying a tag, grouped by project. structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
262
+ description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?, modulePath?) returns details + errors + run history — a fullName that exists in more than one module returns found=false with ambiguous=true and candidateModules[], so pass modulePath to disambiguate; action='for_file' (filePath) returns test modules covering a source file; action='for_tag' (tag, project?) returns tests carrying a tag, grouped by project; action='annotations' (fullName, project?, modulePath?) returns the test annotations the author recorded via context.annotate; action='artifacts' (fullName, project?, modulePath?) returns the test artifacts recorded for the test — both return attachment descriptors (contentType, path, byteSize) and omit inline bodies unless maxBytes (a non-negative integer byte budget for the whole response, default 0) is passed, and neither has anything to do with TDD artifacts (see tdd_artifact_list). structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
263
263
  inputSchema: strict({
264
264
  action: z.enum(TEST_ACTIONS).describe("Inspection discriminator"),
265
265
  project: z.optional(z.string()),
266
266
  state: z.optional(z.string()).describe("list: filter by state"),
267
267
  module: z.optional(z.string()).describe("list: filter by module path"),
268
268
  limit: z.optional(z.coerce.number()).describe("list: max rows to return"),
269
- fullName: z.optional(z.string()).describe("get: full test name"),
270
- modulePath: z.optional(z.string()).describe("get: exact module path, disambiguating a fullName present in several files"),
269
+ fullName: z.optional(z.string()).describe("get / annotations / artifacts: full test name"),
270
+ modulePath: z.optional(z.string()).describe("get / annotations / artifacts: exact module path, disambiguating a fullName present in several files"),
271
271
  filePath: z.optional(z.string()).describe("for_file: source file path"),
272
- tag: z.optional(z.string()).describe("for_tag: tag name")
272
+ tag: z.optional(z.string()).describe("for_tag: tag name"),
273
+ maxBytes: z.optional(z.coerce.number().int().nonnegative()).describe("annotations / artifacts: total byte budget for inline attachment bodies across the response; default 0 returns descriptors only")
273
274
  }),
274
275
  outputSchema: effectToZodSchema(TestResult)
275
276
  }, async (args) => {
@@ -291,6 +292,20 @@ function buildMcpServer(ctx) {
291
292
  action: "for_file",
292
293
  filePath: args.filePath
293
294
  });
295
+ else if (args.action === "annotations") data = await caller.test({
296
+ action: "annotations",
297
+ fullName: args.fullName,
298
+ ...args.project !== void 0 && { project: args.project },
299
+ ...args.modulePath !== void 0 && { modulePath: args.modulePath },
300
+ ...args.maxBytes !== void 0 && { maxBytes: args.maxBytes }
301
+ });
302
+ else if (args.action === "artifacts") data = await caller.test({
303
+ action: "artifacts",
304
+ fullName: args.fullName,
305
+ ...args.project !== void 0 && { project: args.project },
306
+ ...args.modulePath !== void 0 && { modulePath: args.modulePath },
307
+ ...args.maxBytes !== void 0 && { maxBytes: args.maxBytes }
308
+ });
294
309
  else data = await caller.test({
295
310
  action: "for_tag",
296
311
  tag: args.tag,
package/tools/errors.js CHANGED
@@ -20,6 +20,16 @@ import { DataReader } from "@vitest-agent/sdk";
20
20
  *
21
21
  * @packageDocumentation
22
22
  */
23
+ /** One annotation attached to a failing test, surfaced with its error. */
24
+ const TestErrorAnnotation = Schema.Struct({
25
+ type: Schema.String.annotate({ description: "Annotation type as the test author wrote it — an arbitrary string, not an enum." }),
26
+ message: Schema.String,
27
+ location: Schema.optional(Schema.Struct({
28
+ file: Schema.String,
29
+ line: Schema.Number,
30
+ column: Schema.Number
31
+ }))
32
+ }).annotate({ identifier: "TestErrorAnnotation" });
23
33
  /** One row in the structured `errors[]` array. */
24
34
  const TestErrorRow = Schema.Struct({
25
35
  id: Schema.Finite.annotate({
@@ -43,7 +53,8 @@ const TestErrorRow = Schema.Struct({
43
53
  "unhandled"
44
54
  ]).annotate({ description: "Where the error fired: `test` (a single test case), `suite` (a `describe` setup), `module` (collection / import time), or `unhandled` (uncaught from a background context)." }),
45
55
  testFullName: Schema.NullOr(Schema.String).annotate({ description: "Full hierarchical test name (`describe > it`); `null` for non-test scopes (`module`, `unhandled`)." }),
46
- moduleFile: Schema.NullOr(Schema.String).annotate({ description: "Repo-relative path of the test module the error originated in." })
56
+ moduleFile: Schema.NullOr(Schema.String).annotate({ description: "Repo-relative path of the test module the error originated in." }),
57
+ annotations: Schema.Array(TestErrorAnnotation).annotate({ description: "Test annotations the author recorded on this test (`context.annotate`). Empty for non-test scopes and for tests with no annotations." })
47
58
  }).annotate({
48
59
  identifier: "TestErrorRow",
49
60
  title: "Test error row",
@@ -87,6 +98,10 @@ const formatTestErrorsMarkdown = (data) => {
87
98
  lines.push(`**Scope:** ${error.scope}`);
88
99
  if (error.testFullName !== null) lines.push(`**Test:** ${error.testFullName}`);
89
100
  if (error.moduleFile !== null) lines.push(`**File:** \`${error.moduleFile}\``);
101
+ if (error.annotations.length > 0) {
102
+ lines.push("**Annotations:**");
103
+ for (const annotation of error.annotations) lines.push(`- [${annotation.type}] ${annotation.message}`);
104
+ }
90
105
  lines.push("");
91
106
  lines.push("**Cite-able IDs (for `hypothesis (action: record)`):**");
92
107
  lines.push(`- citedTestErrorId: ${error.id}`);
@@ -142,14 +157,40 @@ const testErrors = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct
142
157
  project: Schema.String,
143
158
  errorName: Schema.optional(Schema.String)
144
159
  }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
145
- const errors = yield* (yield* DataReader).getErrors(input.project, input.errorName);
160
+ const reader = yield* DataReader;
161
+ const errors = yield* reader.getErrors(input.project, input.errorName);
162
+ const cache = /* @__PURE__ */ new Map();
163
+ const rows = [];
164
+ for (const error of errors) {
165
+ if (error.testFullName === null) {
166
+ rows.push({
167
+ ...error,
168
+ annotations: []
169
+ });
170
+ continue;
171
+ }
172
+ const key = `${error.testFullName}\u0000${error.moduleFile ?? ""}`;
173
+ let annotations = cache.get(key);
174
+ if (annotations === void 0) {
175
+ annotations = (yield* reader.getAnnotationsForTest(input.project, error.testFullName, { ...error.moduleFile !== null && { modulePath: error.moduleFile } })).map((a) => ({
176
+ type: a.type,
177
+ message: a.message,
178
+ ...a.location && { location: a.location }
179
+ }));
180
+ cache.set(key, annotations);
181
+ }
182
+ rows.push({
183
+ ...error,
184
+ annotations
185
+ });
186
+ }
146
187
  return {
147
188
  project: input.project,
148
189
  ...input.errorName !== void 0 && { errorName: input.errorName },
149
- count: errors.length,
150
- errors
190
+ count: rows.length,
191
+ errors: rows
151
192
  };
152
193
  })));
153
194
 
154
195
  //#endregion
155
- export { TestErrorRow, TestErrorsAsMarkdown, TestErrorsResult, formatTestErrorsMarkdown, testErrors };
196
+ export { TestErrorAnnotation, TestErrorRow, TestErrorsAsMarkdown, TestErrorsResult, formatTestErrorsMarkdown, testErrors };
package/tools/help.js CHANGED
@@ -31,14 +31,17 @@ const HELP_TEXT = `# vitest-agent MCP Tools
31
31
  | \`file_coverage\` | \`filePath\`, \`project?\` | Per-file coverage with uncovered lines and related tests |
32
32
  | \`test_history\` | \`project\` | Flaky/persistent/recovered tests |
33
33
  | \`test_trends\` | \`project\`, \`limit?\` | Coverage trajectory over time |
34
- | \`test_errors\` | \`project\`, \`errorName?\`, \`format?\` (\`markdown\` \\| \`xml\`) | Errors with diffs, stacks, and the cite-able \`testErrorId\` / \`topStackFrameId\` values needed by \`hypothesis (action: record)\` |
35
- | \`test\` | \`action\` (\`list\`/\`get\`/\`for_file\`/\`for_tag\`), plus per-action params | Consolidated test inspection: list/get/for_file/for_tag |
34
+ | \`test_errors\` | \`project\`, \`errorName?\`, \`format?\` (\`markdown\` \\| \`xml\`) | Errors with diffs, stacks, each row's \`annotations[]\` (the test annotations the author recorded), and the cite-able \`testErrorId\` / \`topStackFrameId\` values needed by \`hypothesis (action: record)\` |
35
+ | \`test\` | \`action\` (\`list\`/\`get\`/\`for_file\`/\`for_tag\`/\`annotations\`/\`artifacts\`), plus per-action params | Consolidated test inspection: list/get/for_file/for_tag/annotations/artifacts |
36
36
 
37
37
  \`test\` actions:
38
38
  - \`{ action: "list", project?, state?, module?, limit? }\`
39
39
  - \`{ action: "get", fullName, project?, modulePath? }\` — a \`fullName\` present in more than one module returns \`found: false\` with \`ambiguous: true\` and \`candidateModules[]\`; pass \`modulePath\` to pick one
40
40
  - \`{ action: "for_file", filePath }\`
41
41
  - \`{ action: "for_tag", tag, project? }\` — list every test carrying a tag, grouped by project (or one group when project is supplied)
42
+ - \`{ action: "annotations", fullName, project?, modulePath?, maxBytes? }\` — the test annotations the author recorded via \`context.annotate\`, with attachment descriptors (\`contentType\`, \`path\`, \`byteSize\`)
43
+ - \`{ action: "artifacts", fullName, project?, modulePath?, maxBytes? }\` — the test artifacts recorded for the test, same descriptor shape. These are Vitest test artifacts, not TDD artifacts
44
+ - \`maxBytes\` (both actions) is the total byte budget for inline attachment bodies across the response; it defaults to 0, so a \`body\` is returned only when you ask for one and the running total stays inside the budget
42
45
 
43
46
  ## Discovery
44
47
 
@@ -107,7 +110,7 @@ const HELP_TEXT = `# vitest-agent MCP Tools
107
110
  | \`tdd_task\` | \`action\` (\`start\`/\`end\`/\`get\`/\`resume\`), plus per-action params | TDD task lifecycle |
108
111
  | \`tdd_goal\` | \`action\` (\`create\`/\`update\`/\`delete\`/\`get\`/\`list\`), plus per-action params | Goals under a TDD task |
109
112
  | \`tdd_behavior\` | \`action\` (\`create\`/\`update\`/\`delete\`/\`get\`/\`list_by_goal\`/\`list_by_tdd_task\`), plus per-action params | Behaviors under a goal |
110
- | \`tdd_artifact_list\` | \`tddTaskId\`, \`artifactKind?\`, \`phaseId?\`, \`behaviorId?\`, \`limit?\`, \`format?\` | List recorded TDD artifacts (newest first); use to find the artifact id to cite in \`tdd_phase_transition_request\` |
113
+ | \`tdd_artifact_list\` | \`tddTaskId\`, \`artifactKind?\`, \`phaseId?\`, \`behaviorId?\`, \`limit?\`, \`format?\` | List recorded TDD artifacts (newest first); use to find the artifact id to cite in \`tdd_phase_transition_request\`. TDD artifacts are red/green evidence rows, not Vitest test artifacts (see \`test\` action \`artifacts\`) |
111
114
  | \`tdd_phase_transition_request\` | \`tddTaskId\`, \`goalId\`, \`requestedPhase\`, \`citedArtifactId?\`, \`citedArtifactKind?\`, \`behaviorId?\`, \`reason?\` | Request a phase transition; validates D2 binding rules. \`citedArtifactId\` is optional — when omitted, the most recent matching artifact (kind from \`citedArtifactKind\` or the transition's required-evidence rule) is auto-resolved |
112
115
  | \`tdd_progress_push\` | \`payload\` | Push a TDD progress event to the main agent (best-effort) |
113
116
 
@@ -209,9 +209,38 @@ const VITEST_CONFIG_EXTENSIONS = [
209
209
  "mjs",
210
210
  "cjs"
211
211
  ];
212
+ function findConfigInDir(dir) {
213
+ for (const prefix of ["vitest.config.", "vite.config."]) for (const ext of VITEST_CONFIG_EXTENSIONS) {
214
+ const candidate = join(dir, `${prefix}${ext}`);
215
+ if (existsSync(candidate)) return candidate;
216
+ }
217
+ return null;
218
+ }
212
219
  function dirHasVitestOrViteConfig(dir) {
213
- for (const prefix of ["vitest.config.", "vite.config."]) for (const ext of VITEST_CONFIG_EXTENSIONS) if (existsSync(join(dir, `${prefix}${ext}`))) return true;
214
- return false;
220
+ return findConfigInDir(dir) !== null;
221
+ }
222
+ /**
223
+ * Walk UP from `startDir` looking for the vitest/vite config file, returning
224
+ * its absolute path. Same walk and same git-root bound as
225
+ * `resolveConfigAnchoredRoot`; returns `null` when no config is found in
226
+ * range or when anything about the walk throws.
227
+ *
228
+ * @internal exported for tests
229
+ */
230
+ function resolveAnchoredConfigFile(startDir) {
231
+ try {
232
+ let dir = resolve(startDir);
233
+ for (;;) {
234
+ const found = findConfigInDir(dir);
235
+ if (found !== null) return found;
236
+ if (existsSync(join(dir, ".git"))) return null;
237
+ const parent = dirname(dir);
238
+ if (parent === dir) return null;
239
+ dir = parent;
240
+ }
241
+ } catch {
242
+ return null;
243
+ }
215
244
  }
216
245
  /**
217
246
  * Walk UP from `startDir` looking for the vitest/vite config Vitest would
@@ -245,11 +274,14 @@ function resolveConfigAnchoredRoot(startDir) {
245
274
  * defaulted to anything else when no config is found in range.
246
275
  *
247
276
  * A supplied `projectRoot` is VALIDATED, not trusted, and used VERBATIM
248
- * once validated — explicit is explicit, no anchoring applied. It must
249
- * resolve to an existing directory that shares a git common directory with
250
- * `ctxCwd` (same repository, including across worktrees). Any failure
251
- * returns `{ ok: false, message }` naming both paths — never a silent
252
- * fallback to `ctxCwd`, never a raw throw.
277
+ * once validated — explicit is explicit, no anchoring applied to `root`.
278
+ * It must resolve to an existing directory that shares a git common
279
+ * directory with `ctxCwd` (same repository, including across worktrees).
280
+ * Any failure returns `{ ok: false, message }` naming both paths — never a
281
+ * silent fallback to `ctxCwd`, never a raw throw. The CONFIG for such a
282
+ * root is resolved separately by the caller via
283
+ * `resolveAnchoredConfigFile`, because Vitest 5 no longer walks up to find
284
+ * one.
253
285
  *
254
286
  * @internal exported for tests
255
287
  */
@@ -288,8 +320,8 @@ async function validateProjectRoot(projectRoot, ctxCwd) {
288
320
  * `@vitest-agent/mcp`'s OWN install location. `vitest` is a peerDependency
289
321
  * of this package, and pnpm routinely materializes MORE THAN ONE physical
290
322
  * instance of the same vitest version when peer-resolution hashes differ
291
- * (e.g. `vitest@4.1.11_@types+node@26.2.0_...` alongside
292
- * `vitest@4.1.11_@types+node@26.3.0_...` under `node_modules/.pnpm`). When
323
+ * (e.g. `vitest@<version>_@types+node@26.2.0_...` alongside
324
+ * `vitest@<version>_@types+node@26.3.0_...` under `node_modules/.pnpm`). When
293
325
  * the bare specifier resolves to a DIFFERENT physical copy than the one the
294
326
  * project's test files import, `SnapshotClient.setup()` runs against one
295
327
  * copy's module-level `_client` singleton while `expect(...).toMatchSnapshot()`
@@ -508,6 +540,15 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
508
540
  message: projectRootValidation.message
509
541
  };
510
542
  const resolvedRoot = projectRootValidation.root;
543
+ let anchoredConfig;
544
+ if (input.projectRoot !== void 0) {
545
+ const found = resolveAnchoredConfigFile(resolvedRoot);
546
+ if (found === null) return {
547
+ kind: "error",
548
+ 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.`
549
+ };
550
+ anchoredConfig = found;
551
+ }
511
552
  const timeoutMs = (input.timeout ?? 120) * 1e3;
512
553
  const recovered = input._sessionContext ?? null ?? ctx.sessionContext.get();
513
554
  if (recovered !== null) {
@@ -523,8 +564,9 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
523
564
  let covOverride;
524
565
  try {
525
566
  covOverride = makeCoverageDirOverride();
526
- vitest = await createVitest("test", {
567
+ vitest = await createVitest({
527
568
  root: resolvedRoot,
569
+ ...anchoredConfig !== void 0 ? { config: anchoredConfig } : {},
528
570
  run: true,
529
571
  coverage: covOverride.coverage,
530
572
  ...project ? { project } : {},
@@ -640,4 +682,4 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
640
682
  }));
641
683
 
642
684
  //#endregion
643
- export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTests, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
685
+ export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTests, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
package/tools/test.js CHANGED
@@ -4,16 +4,6 @@ import { Effect, Match, Option, Schema, SchemaGetter } from "effect";
4
4
  import { DataReader } from "@vitest-agent/sdk";
5
5
 
6
6
  //#region src/tools/test.ts
7
- /**
8
- * Consolidated `test` MCP tool — Schema-driven implementation.
9
- *
10
- * Replaces `test_list`, `test_get`, and `test_for_file` with one
11
- * tool keyed on `action`. Result variants discriminate on
12
- * `inventoryKind` so a single Effect Schema describes every shape
13
- * the tool can emit.
14
- *
15
- * @packageDocumentation
16
- */
17
7
  const TestRowSchema = Schema.Struct({
18
8
  id: Schema.Number,
19
9
  fullName: Schema.String,
@@ -76,16 +66,93 @@ const TestForTagResult = Schema.Struct({
76
66
  count: Schema.Number,
77
67
  groups: Schema.Array(TestListGroup)
78
68
  }).annotate({ identifier: "TestForTag" });
69
+ const AttachmentDescriptor = Schema.Struct({
70
+ contentType: Schema.optional(Schema.String),
71
+ path: Schema.optional(Schema.String),
72
+ byteSize: Schema.NullOr(Schema.Number),
73
+ /** Present only for inline attachments under the 64 KiB persistence cap. */
74
+ body: Schema.optional(Schema.String),
75
+ /** How to read `body`. Absent when nothing was stored inline. */
76
+ bodyEncoding: Schema.optional(Schema.Literals(["base64", "utf-8"]))
77
+ }).annotate({ identifier: "TestAttachmentDescriptor" });
78
+ /**
79
+ * Inline bodies are opt-in and budgeted. The 64 KiB persistence cap is
80
+ * per attachment, so a test with many attachments could still flood an
81
+ * agent's context; `maxBytes` (default 0) is the total byte budget for
82
+ * every body in one response. Attachments are walked in order and each
83
+ * body is charged its recorded `byteSize` (falling back to the stored
84
+ * string's length when the row carries none). A body that would take
85
+ * the running total past the budget is dropped along with its
86
+ * `bodyEncoding`; the descriptor half — `contentType`, `path`,
87
+ * `byteSize` — always survives.
88
+ */
89
+ const applyBodyBudget = (rows, maxBytes) => {
90
+ let spent = 0;
91
+ return rows.map((row) => ({
92
+ ...row,
93
+ attachments: row.attachments.map((attachment) => {
94
+ const { body, bodyEncoding, ...descriptor } = attachment;
95
+ if (body === void 0) return descriptor;
96
+ const cost = attachment.byteSize ?? body.length;
97
+ if (spent + cost > maxBytes) return descriptor;
98
+ spent += cost;
99
+ return {
100
+ ...descriptor,
101
+ body,
102
+ ...bodyEncoding !== void 0 && { bodyEncoding }
103
+ };
104
+ })
105
+ }));
106
+ };
107
+ const AnnotationRowSchema = Schema.Struct({
108
+ id: Schema.Number,
109
+ type: Schema.String,
110
+ message: Schema.String,
111
+ location: Schema.optional(Schema.Struct({
112
+ file: Schema.String,
113
+ line: Schema.Number,
114
+ column: Schema.Number
115
+ })),
116
+ attachments: Schema.Array(AttachmentDescriptor)
117
+ }).annotate({ identifier: "TestAnnotationRow" });
118
+ const ArtifactRowSchema = Schema.Struct({
119
+ id: Schema.Number,
120
+ type: Schema.String,
121
+ message: Schema.NullOr(Schema.String),
122
+ data: Schema.NullOr(Schema.String),
123
+ location: Schema.optional(Schema.Struct({
124
+ file: Schema.String,
125
+ line: Schema.Number,
126
+ column: Schema.Number
127
+ })),
128
+ attachments: Schema.Array(AttachmentDescriptor)
129
+ }).annotate({ identifier: "TestArtifactRow" });
130
+ const TestAnnotationsResult = Schema.Struct({
131
+ action: Schema.Literal("annotations"),
132
+ project: Schema.String,
133
+ fullName: Schema.String,
134
+ count: Schema.Number,
135
+ annotations: Schema.Array(AnnotationRowSchema)
136
+ }).annotate({ identifier: "TestAnnotations" });
137
+ const TestArtifactsResult = Schema.Struct({
138
+ action: Schema.Literal("artifacts"),
139
+ project: Schema.String,
140
+ fullName: Schema.String,
141
+ count: Schema.Number,
142
+ artifacts: Schema.Array(ArtifactRowSchema)
143
+ }).annotate({ identifier: "TestArtifacts" });
79
144
  const TestResult = Schema.Union([
80
145
  TestListResult,
81
146
  TestGetFound,
82
147
  TestGetMissing,
83
148
  TestForFileResult,
84
- TestForTagResult
149
+ TestForTagResult,
150
+ TestAnnotationsResult,
151
+ TestArtifactsResult
85
152
  ]).annotate({
86
153
  identifier: "TestResult",
87
154
  title: "test result",
88
- description: "Discriminate on `action`. `get` further discriminates on `found`. `list`, `for_file`, and `for_tag` all carry counted arrays — `list` and `for_tag` group by project."
155
+ description: "Discriminate on `action`. `get` further discriminates on `found`. `list`, `for_file`, `for_tag`, `annotations`, and `artifacts` all carry counted arrays — `list` and `for_tag` group by project; `annotations` and `artifacts` are scoped to one test and return attachment descriptors — an inline `body` comes back only when `maxBytes` is passed and the running total stays inside it."
89
156
  });
90
157
  const formatTestMarkdown = (data) => {
91
158
  if (data.action === "list") {
@@ -178,6 +245,42 @@ const formatTestMarkdown = (data) => {
178
245
  }
179
246
  return lines.join("\n").trimEnd();
180
247
  }
248
+ if (data.action === "annotations" || data.action === "artifacts") {
249
+ const noun = data.action === "annotations" ? "test annotations" : "test artifacts";
250
+ if (data.count === 0) return `No ${noun} recorded for \`${data.fullName}\`.`;
251
+ const rows = data.action === "annotations" ? data.annotations.map((a) => ({
252
+ type: a.type,
253
+ message: a.message,
254
+ location: a.location,
255
+ attachments: a.attachments
256
+ })) : data.artifacts.map((a) => ({
257
+ type: a.type,
258
+ message: a.message ?? a.data,
259
+ location: a.location,
260
+ attachments: a.attachments
261
+ }));
262
+ const cell = (value) => {
263
+ const flat = value.split("\n").join(" ").split("|").join("\\|");
264
+ return flat.length > 200 ? `${flat.slice(0, 200)}… (truncated)` : flat;
265
+ };
266
+ const lines = [
267
+ `# ${data.action === "annotations" ? "Annotations" : "Artifacts"} for \`${data.fullName}\``,
268
+ "",
269
+ `Project: \`${data.project}\` — ${data.count} row${data.count === 1 ? "" : "s"}.`,
270
+ "",
271
+ "| Type | Message | Location | Attachments |",
272
+ "| --- | --- | --- | --- |"
273
+ ];
274
+ for (const row of rows) {
275
+ const location = row.location ? `\`${row.location.file}:${row.location.line}:${row.location.column}\`` : "—";
276
+ const attachments = row.attachments.length === 0 ? "—" : row.attachments.map((att) => {
277
+ return `\`${cell(att.path ?? att.contentType ?? "(inline)")}\`${att.byteSize !== null ? ` (${att.byteSize} bytes)` : ""}`;
278
+ }).join(", ");
279
+ const message = row.message === null ? "—" : cell(row.message);
280
+ lines.push(`| ${row.type} | ${message} | ${location} | ${attachments} |`);
281
+ }
282
+ return lines.join("\n");
283
+ }
181
284
  if (data.count === 0) return `No test modules found covering \`${data.filePath}\`. Run run_tests({}) to populate the database, or check the file path.`;
182
285
  const lines = [
183
286
  `# Tests for \`${data.filePath}\``,
@@ -214,11 +317,27 @@ const ForTagVariant = Schema.Struct({
214
317
  tag: Schema.String,
215
318
  project: Schema.optional(Schema.String)
216
319
  });
320
+ const AnnotationsVariant = Schema.Struct({
321
+ action: Schema.Literal("annotations"),
322
+ fullName: Schema.String,
323
+ project: Schema.optional(Schema.String),
324
+ modulePath: Schema.optional(Schema.String).annotate({ description: "Exact module_path match — disambiguates a fullName that exists in more than one test file." }),
325
+ maxBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ description: "Total byte budget for inline attachment bodies across the whole response. Must be a non-negative integer. Defaults to 0 — descriptors only, no bodies." }))
326
+ });
327
+ const ArtifactsVariant = Schema.Struct({
328
+ action: Schema.Literal("artifacts"),
329
+ fullName: Schema.String,
330
+ project: Schema.optional(Schema.String),
331
+ modulePath: Schema.optional(Schema.String).annotate({ description: "Exact module_path match — disambiguates a fullName that exists in more than one test file." }),
332
+ maxBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).annotate({ description: "Total byte budget for inline attachment bodies across the whole response. Must be a non-negative integer. Defaults to 0 — descriptors only, no bodies." }))
333
+ });
217
334
  const TestInput = Schema.Union([
218
335
  ListVariant,
219
336
  GetVariant,
220
337
  ForFileVariant,
221
- ForTagVariant
338
+ ForTagVariant,
339
+ AnnotationsVariant,
340
+ ArtifactsVariant
222
341
  ]);
223
342
  /**
224
343
  * Single source of truth for the `test` tool's `action` discriminant,
@@ -229,7 +348,9 @@ const TEST_ACTIONS = [
229
348
  "list",
230
349
  "get",
231
350
  "for_file",
232
- "for_tag"
351
+ "for_tag",
352
+ "annotations",
353
+ "artifacts"
233
354
  ];
234
355
  const test = publicProcedure.input(Schema.toStandardSchemaV1(TestInput)).query(async ({ ctx, input }) => {
235
356
  return ctx.runtime.runPromise(Match.value(input).pipe(Match.discriminatorsExhaustive("action")({
@@ -316,6 +437,32 @@ const test = publicProcedure.input(Schema.toStandardSchemaV1(TestInput)).query(a
316
437
  count: grouped.total,
317
438
  groups
318
439
  };
440
+ }),
441
+ annotations: (variant) => Effect.gen(function* () {
442
+ const reader = yield* DataReader;
443
+ const project = variant.project ?? (yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs[0]?.project ?? "")));
444
+ const rows = yield* reader.getAnnotationsForTest(project, variant.fullName, { ...variant.modulePath !== void 0 && { modulePath: variant.modulePath } });
445
+ const annotations = applyBodyBudget(rows, variant.maxBytes ?? 0);
446
+ return {
447
+ action: "annotations",
448
+ project,
449
+ fullName: variant.fullName,
450
+ count: annotations.length,
451
+ annotations
452
+ };
453
+ }),
454
+ artifacts: (variant) => Effect.gen(function* () {
455
+ const reader = yield* DataReader;
456
+ const project = variant.project ?? (yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs[0]?.project ?? "")));
457
+ const rows = yield* reader.getArtifactsForTest(project, variant.fullName, { ...variant.modulePath !== void 0 && { modulePath: variant.modulePath } });
458
+ const artifacts = applyBodyBudget(rows, variant.maxBytes ?? 0);
459
+ return {
460
+ action: "artifacts",
461
+ project,
462
+ fullName: variant.fullName,
463
+ count: artifacts.length,
464
+ artifacts
465
+ };
319
466
  })
320
467
  })));
321
468
  });