@vitest-agent/mcp 2.1.3 → 2.2.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/README.md CHANGED
@@ -13,6 +13,8 @@ The `vitest-agent-mcp` MCP server bin. Exposes action-keyed tools over stdio tha
13
13
  - **29 action-keyed tools** — per-CRUD families collapse into single tools dispatching on an `action` discriminator; covers `test_status`, `test_overview`, `test_coverage`, `test_errors`, `run_tests`, `note`, `hypothesis`, `tdd_task`, `tdd_goal`, `tdd_behavior` and more
14
14
  - **Six framing prompts** — `triage`, `why-flaky`, `regression-since-pass`, `explain-failure`, `tdd-resume`, `wrapup`
15
15
  - **Idempotency middleware** — `tdd_task`, `tdd_goal`, `tdd_behavior` and `hypothesis` create-actions are idempotent on derived keys
16
+ - **Strict tool inputs** — an unknown key is rejected with an error naming it and listing the accepted params, instead of being stripped and running a wider query than the caller asked for
17
+ - **Session-surviving error handling** — a resolver that throws returns a structured `UnexpectedToolError` envelope, and a stray unhandled rejection after the transport connects is logged to stderr rather than killing the server mid-session
16
18
  - **Programmatic API** — `buildMcpServer` constructs the server without connecting a transport; `parseSessionEnvExports` and `recoverSessionContextFromSessionEnv` recover the host session context from the Claude Code plugin's session-env files, so agent attribution survives a plugin reload
17
19
 
18
20
  ## Install
@@ -3,12 +3,81 @@ import { createCurrentSessionIdRef, createSessionContextRef, sessionContextFromE
3
3
  import { McpLive } from "../layers/McpLive.js";
4
4
  import { startMcpServer } from "../server.js";
5
5
  import { recoverSessionContextFromSessionEnv } from "../session-env.js";
6
+ import { shouldExitOnUncaughtException } from "../utils/crash-guards.js";
7
+ import { safeFormatFatalError } from "../utils/safe-format-fatal-error.js";
6
8
  import * as NodeServices from "@effect/platform-node/NodeServices";
7
- import { PathResolutionLive, formatFatalError, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
9
+ import { PathResolutionLive, resolveDataPath, resolveLogFile, resolveLogLevel } from "@vitest-agent/sdk";
8
10
  import { Effect, ManagedRuntime } from "effect";
9
11
 
10
12
  //#region src/bin.ts
11
13
  /**
14
+ * Whether `server.connect(transport)` has resolved for this process.
15
+ * Read by the `uncaughtException` handler below via
16
+ * {@link shouldExitOnUncaughtException} — see that function's doc
17
+ * comment for the judgment call this flag backs (issue #191).
18
+ */
19
+ let transportConnected = false;
20
+ /**
21
+ * Issue #191, sub-item A: the MCP server used to be a bare
22
+ * `main().catch(...)` with no process-level guards. Under Node >=15 an
23
+ * unhandled promise rejection anywhere in the async chain — a
24
+ * fire-and-forgotten Effect fiber, a background timer, anything that
25
+ * never flows through a tool call's own await chain — crashes the
26
+ * process by default, closing the stdio transport and silently
27
+ * deregistering every tool from the client's perspective mid session,
28
+ * with no recovery path. Register both guards at module scope, before
29
+ * any async work in `main()` starts, so they also cover the dbPath
30
+ * resolution / ManagedRuntime construction phase.
31
+ *
32
+ * Every throw or rejection *inside* a single tool call is already
33
+ * caught by the MCP SDK's own `CallToolRequestSchema` handler (see
34
+ * `server.ts`'s `registerTool` shadow for the structured-envelope
35
+ * layer on top of that). Anything that reaches these handlers
36
+ * therefore originated outside any tool-call boundary and, by
37
+ * definition, has no in-flight caller waiting on it — logging and
38
+ * continuing is safe because there is no request context to roll back
39
+ * and no benefit to dropping every *other* in-flight and future tool
40
+ * call over it.
41
+ */
42
+ process.on("unhandledRejection", (reason) => {
43
+ process.stderr.write(`vitest-agent-mcp: unhandledRejection: ${safeFormatFatalError(reason)}\n`);
44
+ });
45
+ /**
46
+ * Node's own guidance for `uncaughtException` is "do not resume normal
47
+ * operation" — arbitrary in-process state may be corrupt. This process
48
+ * accepts that residual risk once the transport is connected: see
49
+ * `shouldExitOnUncaughtException`'s doc comment for the reasoning
50
+ * (no long-lived mutable state outside SQLite's own transactions, and
51
+ * silent process death mid-session is strictly worse). Before the
52
+ * transport connects there is no client session to preserve by staying
53
+ * alive, so this exits loudly instead.
54
+ */
55
+ process.on("uncaughtException", (err, origin) => {
56
+ process.stderr.write(`vitest-agent-mcp: uncaughtException (${origin}): ${safeFormatFatalError(err)}\n`);
57
+ if (shouldExitOnUncaughtException(transportConnected)) {
58
+ process.exitCode = 1;
59
+ process.exit(1);
60
+ }
61
+ });
62
+ /**
63
+ * Test-only crash injection, gated by an env var so it can never fire
64
+ * in a normal install. Exists so `__test__/bin-crash-resilience.e2e.test.ts`
65
+ * can exercise the guards above against a *real* child process (a
66
+ * crash in the test's own process is not something a unit test can
67
+ * safely simulate). Fires exactly once, on the next event-loop turn
68
+ * after the transport is known to be connected, so ordering relative
69
+ * to `transportConnected` is deterministic regardless of client-side
70
+ * handshake timing.
71
+ */
72
+ function scheduleTestCrashInjection() {
73
+ const kind = process.env.VITEST_AGENT_MCP_TEST_INJECT_CRASH;
74
+ if (kind !== "unhandledRejection" && kind !== "uncaughtException") return;
75
+ setImmediate(() => {
76
+ if (kind === "unhandledRejection") Promise.reject(/* @__PURE__ */ new Error("[test-injected] unhandledRejection"));
77
+ else throw new Error("[test-injected] uncaughtException");
78
+ });
79
+ }
80
+ /**
12
81
  * Resolve the user's project directory.
13
82
  *
14
83
  * Precedence (most explicit wins):
@@ -68,9 +137,11 @@ async function main() {
68
137
  console.error(`[vitest-agent-mcp] Initial chat id: ${chatIdResolved !== null ? "(set)" : "(none — SessionStart hook had not written CLAUDE_ENV_FILE yet)"}`);
69
138
  if (recoveredContext !== null) console.error("[vitest-agent-mcp] Recovered session context: agent=(set) conversation=(set)");
70
139
  await startMcpServer(ctx);
140
+ transportConnected = true;
141
+ scheduleTestCrashInjection();
71
142
  }
72
143
  main().catch((err) => {
73
- process.stderr.write(`vitest-agent-mcp: ${formatFatalError(err)}\n`);
144
+ process.stderr.write(`vitest-agent-mcp: ${safeFormatFatalError(err)}\n`);
74
145
  process.exit(1);
75
146
  });
76
147
 
package/index.d.ts CHANGED
@@ -277,6 +277,9 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
277
277
  test_history: import("@trpc/server").TRPCQueryProcedure<{
278
278
  input: {
279
279
  readonly project: string;
280
+ readonly testName?: string | undefined;
281
+ readonly modulePath?: string | undefined;
282
+ readonly limit?: number | undefined;
280
283
  };
281
284
  output: {
282
285
  readonly project: string;
@@ -388,6 +391,7 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
388
391
  readonly action: "get";
389
392
  readonly fullName: string;
390
393
  readonly project?: string | undefined;
394
+ readonly modulePath?: string | undefined;
391
395
  } | {
392
396
  readonly action: "for_file";
393
397
  readonly filePath: string;
@@ -437,6 +441,8 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
437
441
  readonly found: false;
438
442
  readonly project: string;
439
443
  readonly fullName: string;
444
+ readonly ambiguous?: boolean | undefined;
445
+ readonly candidateModules?: readonly string[] | undefined;
440
446
  } | {
441
447
  readonly action: "for_file";
442
448
  readonly filePath: string;
@@ -523,6 +529,15 @@ declare const appRouter: import("@trpc/server").TRPCBuiltRouter<{
523
529
  output: {
524
530
  readonly kind: "ok";
525
531
  readonly project?: string | undefined;
532
+ readonly scope: {
533
+ readonly project: string | null;
534
+ readonly files: readonly string[];
535
+ readonly tags: {
536
+ readonly all?: readonly string[] | undefined;
537
+ readonly any?: readonly string[] | undefined;
538
+ readonly none?: readonly string[] | undefined;
539
+ } | null;
540
+ };
526
541
  readonly report: {
527
542
  readonly timestamp: string;
528
543
  readonly project?: string | 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.1.3";
15
+ const CURRENT_MCP_VERSION = "2.2.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.1.3",
3
+ "version": "2.2.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-beta.107",
43
43
  "@modelcontextprotocol/sdk": "^1.30.0",
44
44
  "@trpc/server": "^11.18.0",
45
- "@vitest-agent/sdk": "2.2.0",
45
+ "@vitest-agent/sdk": "2.3.0",
46
46
  "effect": "4.0.0-beta.107",
47
47
  "zod": "^4.4.3"
48
48
  },
package/server.js CHANGED
@@ -31,6 +31,7 @@ import { WrapupPromptResult } from "./tools/wrapup-prompt.js";
31
31
  import { appRouter } from "./router.js";
32
32
  import { registerAllPrompts } from "./prompts/index.js";
33
33
  import { effectToZodSchema } from "./utils/effect-to-zod.js";
34
+ import { buildUnexpectedToolErrorEnvelope } from "./utils/tool-error-envelope.js";
34
35
  import { ChannelEvent, DataReader } from "@vitest-agent/sdk";
35
36
  import { Effect, Exit, Option, Schema } from "effect";
36
37
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -39,6 +40,25 @@ import { z } from "zod";
39
40
 
40
41
  //#region src/server.ts
41
42
  /**
43
+ * Build a strict (unknown-keys-rejected) zod object from a raw shape, with
44
+ * an `unrecognized_keys` error message that names the accepted params —
45
+ * plain `z.strictObject(shape)` only reports the offending key(s), leaving
46
+ * an agent to re-read the tool description to self-correct. Every
47
+ * `registerTool` input in this file goes through this helper so the whole
48
+ * served surface rejects unknown keys consistently (issue #200).
49
+ *
50
+ * The rule applies at EVERY object level, not just the top one: a nested
51
+ * plain `z.object` strips unknown keys, so a misspelled nested param
52
+ * decoded to an empty sub-object and the tool ran unfiltered (issue
53
+ * #243). Nested shapes go through this helper too.
54
+ *
55
+ * @internal
56
+ */
57
+ function strict(shape) {
58
+ const acceptedKeys = Object.keys(shape);
59
+ return z.strictObject(shape, { error: (issue) => issue.code === "unrecognized_keys" ? `Unrecognized parameter(s): ${issue.keys.join(", ")}. Accepted params: ${acceptedKeys.join(", ")}` : void 0 });
60
+ }
61
+ /**
42
62
  * For behavior-scoped events, resolve goalId/sessionId server-side from
43
63
  * behaviorId so a stale orchestrator context cannot push the wrong tree
44
64
  * coordinates. Goal-scoped events get sessionId resolved from goalId.
@@ -140,6 +160,27 @@ function buildMcpServer(ctx) {
140
160
  version: "0.1.0"
141
161
  }, { capabilities: { experimental: { "claude/channel": {} } } });
142
162
  const caller = createCallerFactory(appRouter)(ctx);
163
+ const originalRegisterTool = server.registerTool.bind(server);
164
+ server.registerTool = (...registerArgs) => {
165
+ const [name, config, cb] = registerArgs;
166
+ const wrapped = async (...handlerArgs) => {
167
+ try {
168
+ return await cb(...handlerArgs);
169
+ } catch (err) {
170
+ const envelope = buildUnexpectedToolErrorEnvelope(name, err);
171
+ console.error(`[vitest-agent-mcp] tool "${name}" resolver threw: ${envelope.error.message}`);
172
+ return {
173
+ content: [{
174
+ type: "text",
175
+ text: JSON.stringify(envelope, null, 2)
176
+ }],
177
+ isError: true,
178
+ structuredContent: envelope
179
+ };
180
+ }
181
+ };
182
+ return originalRegisterTool(name, config, wrapped);
183
+ };
143
184
  server.registerTool("help", {
144
185
  description: "Use when you need the catalog of available MCP tools and their parameters. Markdown in content[]; same string available as structuredContent.helpText.",
145
186
  outputSchema: effectToZodSchema(HelpResult)
@@ -149,7 +190,7 @@ function buildMcpServer(ctx) {
149
190
  });
150
191
  server.registerTool("test_status", {
151
192
  description: "Use when you need each project's current pass/fail state from the most recent run. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, manifestUpdatedAt, projectFilter?, entries[] } or absent variant).",
152
- inputSchema: { project: z.optional(z.string()).describe("Filter to a specific project") },
193
+ inputSchema: strict({ project: z.optional(z.string()).describe("Filter to a specific project") }),
153
194
  outputSchema: effectToZodSchema(TestStatusResult)
154
195
  }, async (args) => {
155
196
  const data = await caller.test_status({ project: args.project });
@@ -157,7 +198,7 @@ function buildMcpServer(ctx) {
157
198
  });
158
199
  server.registerTool("test_overview", {
159
200
  description: "Use when you want a summary of the test landscape with per-project run metrics. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, projectFilter?, runs[] } or absent variant).",
160
- inputSchema: { project: z.optional(z.string()).describe("Filter to a specific project") },
201
+ inputSchema: strict({ project: z.optional(z.string()).describe("Filter to a specific project") }),
161
202
  outputSchema: effectToZodSchema(TestOverviewResult)
162
203
  }, async (args) => {
163
204
  const data = await caller.test_overview({ project: args.project });
@@ -165,26 +206,36 @@ function buildMcpServer(ctx) {
165
206
  });
166
207
  server.registerTool("test_coverage", {
167
208
  description: "Use when coverage drops and you need per-metric gap analysis against thresholds and targets. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, project, coverage } or absent variant).",
168
- inputSchema: { project: z.optional(z.string()).describe("Project name") },
209
+ inputSchema: strict({ project: z.optional(z.string()).describe("Project name") }),
169
210
  outputSchema: effectToZodSchema(TestCoverageResult)
170
211
  }, async (args) => {
171
212
  const data = await caller.test_coverage({ project: args.project });
172
213
  return structuredResult(Schema.decodeSync(TestCoverageAsMarkdown)(data), data);
173
214
  });
174
215
  server.registerTool("test_history", {
175
- description: "Use when failures recur and you need flaky, persistent, and recovered test classifications. Returns markdown in content[] and a typed JSON object in structuredContent (project, hasData, history, flaky[], persistent[], recovered[]).",
176
- inputSchema: { project: z.string().describe("Project name (required)") },
216
+ description: "Use when failures recur and you need flaky, persistent, and recovered test classifications. Returns markdown in content[] and a typed JSON object in structuredContent (project, hasData, history, flaky[], persistent[], recovered[]). Optional testName/modulePath narrow to a single test; limit caps runs kept per test (default 20) — omit all three only when you actually need the whole project's history.",
217
+ inputSchema: strict({
218
+ project: z.string().describe("Project name (required)"),
219
+ testName: z.optional(z.string()).describe("Exact full_name match — narrows to a single test's history"),
220
+ modulePath: z.optional(z.string()).describe("Exact module_path match — narrows to tests in one file"),
221
+ limit: z.optional(z.coerce.number().int().positive()).describe("Max runs kept per test, most-recent-first; positive integer (default 20)")
222
+ }),
177
223
  outputSchema: effectToZodSchema(TestHistoryResult)
178
224
  }, async (args) => {
179
- const data = await caller.test_history({ project: args.project });
225
+ const data = await caller.test_history({
226
+ project: args.project,
227
+ testName: args.testName,
228
+ modulePath: args.modulePath,
229
+ limit: args.limit
230
+ });
180
231
  return structuredResult(Schema.decodeSync(TestHistoryAsMarkdown)(data), data);
181
232
  });
182
233
  server.registerTool("test_trends", {
183
234
  description: "Use when you want to see whether a project's coverage is trending up or down over time. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, project, trends? }).",
184
- inputSchema: {
235
+ inputSchema: strict({
185
236
  project: z.string().describe("Project name (required)"),
186
237
  limit: z.optional(z.coerce.number()).describe("Max number of trend entries to return")
187
- },
238
+ }),
188
239
  outputSchema: effectToZodSchema(TestTrendsResult)
189
240
  }, async (args) => {
190
241
  const data = await caller.test_trends({
@@ -195,10 +246,10 @@ function buildMcpServer(ctx) {
195
246
  });
196
247
  server.registerTool("test_errors", {
197
248
  description: "Use when a test fails and you need error detail, diffs, and the cite-able test_errors.id / stack_frames.id values needed by hypothesis (action: record). Returns both a markdown rendering (in content[].text) and a typed JSON object (in structuredContent) — agents should prefer structuredContent.errors[].",
198
- inputSchema: {
249
+ inputSchema: strict({
199
250
  project: z.string().describe("Project name (required)"),
200
251
  errorName: z.optional(z.string()).describe("Filter to a specific error name")
201
- },
252
+ }),
202
253
  outputSchema: effectToZodSchema(TestErrorsResult)
203
254
  }, async (args) => {
204
255
  const data = await caller.test_errors({
@@ -208,8 +259,8 @@ function buildMcpServer(ctx) {
208
259
  return structuredResult(Schema.decodeSync(TestErrorsAsMarkdown)(data), data);
209
260
  });
210
261
  server.registerTool("test", {
211
- description: "Use to inspect tests, with an action discriminator: action='list' (project?, state?, module?, limit?) returns matching tests; action='get' (fullName, project?) returns details + errors + run history; action='for_file' (filePath) returns test modules covering a source file. structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
212
- inputSchema: {
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. structuredContent carries the typed payload (discriminate on `action`, then on `found` for get).",
263
+ inputSchema: strict({
213
264
  action: z.enum([
214
265
  "list",
215
266
  "get",
@@ -220,8 +271,9 @@ function buildMcpServer(ctx) {
220
271
  module: z.optional(z.string()).describe("list: filter by module path"),
221
272
  limit: z.optional(z.coerce.number()).describe("list: max rows to return"),
222
273
  fullName: z.optional(z.string()).describe("get: full test name"),
274
+ modulePath: z.optional(z.string()).describe("get: exact module path, disambiguating a fullName present in several files"),
223
275
  filePath: z.optional(z.string()).describe("for_file: source file path")
224
- },
276
+ }),
225
277
  outputSchema: effectToZodSchema(TestResult)
226
278
  }, async (args) => {
227
279
  let data;
@@ -235,7 +287,8 @@ function buildMcpServer(ctx) {
235
287
  else if (args.action === "get") data = await caller.test({
236
288
  action: "get",
237
289
  fullName: args.fullName,
238
- ...args.project !== void 0 && { project: args.project }
290
+ ...args.project !== void 0 && { project: args.project },
291
+ ...args.modulePath !== void 0 && { modulePath: args.modulePath }
239
292
  });
240
293
  else data = await caller.test({
241
294
  action: "for_file",
@@ -245,10 +298,10 @@ function buildMcpServer(ctx) {
245
298
  });
246
299
  server.registerTool("file_coverage", {
247
300
  description: "Use when you need coverage for one source file: per-metric values, uncovered lines, and related tests. Returns markdown in content[] and a typed JSON object in structuredContent ({ dataAvailable, matched?, filePath, report?, totals?, relatedTestFiles[] }).",
248
- inputSchema: {
301
+ inputSchema: strict({
249
302
  filePath: z.string().describe("Source file path to check coverage for"),
250
303
  project: z.optional(z.string()).describe("Project name")
251
- },
304
+ }),
252
305
  outputSchema: effectToZodSchema(FileCoverageResult)
253
306
  }, async (args) => {
254
307
  const data = await caller.file_coverage({
@@ -259,7 +312,7 @@ function buildMcpServer(ctx) {
259
312
  });
260
313
  server.registerTool("configure", {
261
314
  description: "Use when you need the captured Vitest settings for a test run. Returns markdown in content[] and a typed JSON object in structuredContent ({ found, source, settings?, requestedHash? }).",
262
- inputSchema: { settingsHash: z.optional(z.string()).describe("Settings hash from a manifest entry or test run") },
315
+ inputSchema: strict({ settingsHash: z.optional(z.string()).describe("Settings hash from a manifest entry or test run") }),
263
316
  outputSchema: effectToZodSchema(ConfigureResult)
264
317
  }, async (args) => {
265
318
  const data = await caller.configure({ settingsHash: args.settingsHash });
@@ -274,7 +327,7 @@ function buildMcpServer(ctx) {
274
327
  });
275
328
  server.registerTool("inventory", {
276
329
  description: "Use to discover what exists in the workspace, with a kind discriminator: project / module / suite / session. structuredContent discriminates on `inventoryKind` (project, module, suite, session_detail, session_list) so callers can branch on the response shape without parsing markdown.",
277
- inputSchema: {
330
+ inputSchema: strict({
278
331
  kind: z.enum([
279
332
  "project",
280
333
  "module",
@@ -286,7 +339,7 @@ function buildMcpServer(ctx) {
286
339
  module: z.optional(z.string()).describe("suite: filter by module path"),
287
340
  agentKind: z.optional(z.enum(["main", "subagent"])).describe("session: filter by agent kind"),
288
341
  limit: z.optional(z.coerce.number()).describe("session: max rows")
289
- },
342
+ }),
290
343
  outputSchema: effectToZodSchema(InventoryResult)
291
344
  }, async (args) => {
292
345
  let data;
@@ -318,7 +371,7 @@ function buildMcpServer(ctx) {
318
371
  });
319
372
  server.registerTool("register_agent", {
320
373
  description: "Use when an LLM-agent invocation starts and must be recorded in the per-project store. Idempotent on (chatId, agentType, parentAgentId, clientNonce). Returns ok:true with agentId on insert, or ok:false with error.code='AGENT_ALREADY_REGISTERED'/'PARENT_AGENT_NOT_FOUND'/'SESSION_NOT_FOUND'/'INVALID_AGENT_TYPE_PREFIX' on the four documented failure modes. agentType must begin with the host-kind prefix (e.g., 'claude-code-main').",
321
- inputSchema: {
374
+ inputSchema: strict({
322
375
  chatId: z.string().describe("Host's chat UUID (session_id from CC hook payload, etc.)"),
323
376
  conversationId: z.optional(z.string()).describe("Canonical conversation UUID (from session-map mapConversation)"),
324
377
  hostKind: z.optional(z.string()).describe("Host vendor identifier; defaults to 'claude-code'"),
@@ -328,7 +381,7 @@ function buildMcpServer(ctx) {
328
381
  startGitBranch: z.optional(z.string()),
329
382
  startGitCommitSha: z.optional(z.string()),
330
383
  startWorktreeDir: z.optional(z.string())
331
- },
384
+ }),
332
385
  outputSchema: effectToZodSchema(RegisterAgentResult)
333
386
  }, async (args) => {
334
387
  const result = await caller.register_agent({
@@ -352,22 +405,30 @@ function buildMcpServer(ctx) {
352
405
  };
353
406
  });
354
407
  server.registerTool("run_tests", {
355
- description: "Use to run Vitest tests, with optional file and project filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error). The legacy format=json arg is dropped — structuredContent supersedes it.",
356
- inputSchema: {
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.",
409
+ inputSchema: strict({
357
410
  files: z.optional(z.array(z.string())).describe("Test file paths to run"),
358
411
  project: z.optional(z.string()).describe("Project name to filter"),
412
+ tags: z.optional(strict({
413
+ all: z.optional(z.array(z.string())).describe("Require every listed tag"),
414
+ any: z.optional(z.array(z.string())).describe("Require at least one listed tag"),
415
+ none: z.optional(z.array(z.string())).describe("Exclude any listed tag")
416
+ })).describe("Structured tag filter; all/any/none AND together with each other and with project/files"),
417
+ passWithNoTests: z.optional(z.boolean()).describe("Per-call override of Vitest's native test.passWithNoTests"),
359
418
  timeout: z.optional(z.coerce.number()).describe("Timeout in seconds (default: 120)"),
360
- _sessionContext: z.optional(z.object({
419
+ _sessionContext: z.optional(strict({
361
420
  chatId: z.string(),
362
421
  conversationId: z.string(),
363
422
  mainAgentId: z.string()
364
423
  })).describe("Hook-injected session attribution UUIDs; do not pass manually.")
365
- },
424
+ }),
366
425
  outputSchema: effectToZodSchema(RunTestsResult)
367
426
  }, async (args) => {
368
427
  const data = await caller.run_tests({
369
428
  files: args.files,
370
429
  project: args.project,
430
+ tags: args.tags,
431
+ passWithNoTests: args.passWithNoTests,
371
432
  timeout: args.timeout,
372
433
  ...args._sessionContext !== void 0 && { _sessionContext: args._sessionContext }
373
434
  });
@@ -375,7 +436,7 @@ function buildMcpServer(ctx) {
375
436
  });
376
437
  server.registerTool("note", {
377
438
  description: "Use to manage notes, with a CRUD action discriminator: action='create' writes a scoped note; action='list' (scope?, project?, testFullName?) returns matching notes; action='get' (id) returns a structured note; action='update' (id, ...patch) edits; action='delete' (id) removes; action='search' (query) does FTS5 across title and content. structuredContent always carries the typed result (discriminate on `action`); list/search additionally render markdown in the text channel.",
378
- inputSchema: {
439
+ inputSchema: strict({
379
440
  action: z.enum([
380
441
  "create",
381
442
  "list",
@@ -403,7 +464,7 @@ function buildMcpServer(ctx) {
403
464
  expiresAt: z.optional(z.string()),
404
465
  pinned: z.optional(z.boolean()),
405
466
  query: z.optional(z.string()).describe("search: FTS5 query")
406
- },
467
+ }),
407
468
  outputSchema: effectToZodSchema(NoteResult)
408
469
  }, async (args) => {
409
470
  if (args.action === "create") return structuredJsonResult(await caller.note({
@@ -452,7 +513,7 @@ function buildMcpServer(ctx) {
452
513
  });
453
514
  server.registerTool("turn_search", {
454
515
  description: "Use when you need to find past turns across sessions by type, time, or session. Returns markdown in content[] and a typed JSON object in structuredContent ({ count, turns[] }).",
455
- inputSchema: {
516
+ inputSchema: strict({
456
517
  sessionId: z.optional(z.coerce.number()).describe("Filter to a specific session id"),
457
518
  since: z.optional(z.string()).describe("ISO 8601 cutoff — return turns after this timestamp"),
458
519
  type: z.optional(z.enum([
@@ -465,7 +526,7 @@ function buildMcpServer(ctx) {
465
526
  "hypothesis"
466
527
  ])).describe("Filter by turn type"),
467
528
  limit: z.optional(z.coerce.number()).describe("Max turns to return (default 100)")
468
- },
529
+ }),
469
530
  outputSchema: effectToZodSchema(TurnSearchResult)
470
531
  }, async (args) => {
471
532
  const data = await caller.turn_search({
@@ -478,7 +539,7 @@ function buildMcpServer(ctx) {
478
539
  });
479
540
  server.registerTool("failure_signature_get", {
480
541
  description: "Use when you have a failure-signature hash and need its first-seen date and occurrence history. Returns markdown in content[] and a typed JSON object in structuredContent ({ found, signatureHash?, firstSeenAt?, occurrenceCount?, recentErrors?[] } or absent variant).",
481
- inputSchema: { hash: z.string().describe("16-char failure signature hash") },
542
+ inputSchema: strict({ hash: z.string().describe("16-char failure signature hash") }),
482
543
  outputSchema: effectToZodSchema(FailureSignatureGetResult)
483
544
  }, async (args) => {
484
545
  const data = await caller.failure_signature_get({ hash: args.hash });
@@ -486,7 +547,7 @@ function buildMcpServer(ctx) {
486
547
  });
487
548
  server.registerTool("tdd_task", {
488
549
  description: "Use to manage a TDD task lifecycle, with an action discriminator: action='start' (goal, sessionId|chatId, parentTddTaskId?, startedAt?, runId?) opens a new task; action='end' (tddTaskId, outcome, summaryNoteId?) closes one; action='get' (tddTaskId) returns markdown details; action='resume' (tddTaskId) returns a compact digest.",
489
- inputSchema: {
550
+ inputSchema: strict({
490
551
  action: z.enum([
491
552
  "start",
492
553
  "end",
@@ -506,7 +567,7 @@ function buildMcpServer(ctx) {
506
567
  "abandoned"
507
568
  ])).describe("end: final outcome"),
508
569
  summaryNoteId: z.optional(z.coerce.number())
509
- },
570
+ }),
510
571
  outputSchema: effectToZodSchema(TddTaskResult)
511
572
  }, async (args) => {
512
573
  let data;
@@ -537,7 +598,7 @@ function buildMcpServer(ctx) {
537
598
  });
538
599
  server.registerTool("tdd_phase_transition_request", {
539
600
  description: "Use when advancing a TDD cycle and you need a phase transition validated and recorded. Validates goal status, behavior↔goal membership, and D2 artifact-evidence binding rules; returns accept/deny. On accept, auto-promotes a behavior 'pending' → 'in_progress' when behaviorId is supplied. citedArtifactId is OPTIONAL — when omitted, the most recent matching artifact is auto-resolved (kind comes from citedArtifactKind if supplied, otherwise from the transition's required-evidence rule). Transitions like spike→red that require no artifact need neither field. The accepted response echoes citedArtifactId + citedArtifactSource so the caller can see which row was picked.",
540
- inputSchema: {
601
+ inputSchema: strict({
541
602
  tddTaskId: z.coerce.number().describe("tdd_tasks.id"),
542
603
  goalId: z.coerce.number().describe("tdd_session_goals.id (required; goal must be in_progress)"),
543
604
  requestedPhase: z.enum([
@@ -561,7 +622,7 @@ function buildMcpServer(ctx) {
561
622
  ])).describe("Kind to look up when citedArtifactId is omitted (defaults to the kind required by the transition)."),
562
623
  behaviorId: z.optional(z.coerce.number()).describe("tdd_session_behaviors.id when transitioning a specific behavior (must belong to goalId)"),
563
624
  reason: z.optional(z.string()).describe("Free-text reason for the transition")
564
- },
625
+ }),
565
626
  outputSchema: effectToZodSchema(PhaseTransitionResult)
566
627
  }, async (args) => structuredJsonResult(await caller.tdd_phase_transition_request({
567
628
  tddTaskId: args.tddTaskId,
@@ -574,7 +635,7 @@ function buildMcpServer(ctx) {
574
635
  })));
575
636
  server.registerTool("tdd_goal", {
576
637
  description: "Use to manage TDD goals, with a CRUD action discriminator: action='create' (tddTaskId, goal) is idempotent on (tddTaskId, goal); action='update' (id, goal?, status?) edits text and/or lifecycle status; action='delete' (id) hard-deletes (prefer status:'abandoned'); action='get' (id) reads with nested behaviors; action='list' (tddTaskId) returns all goals for a TDD task.",
577
- inputSchema: {
638
+ inputSchema: strict({
578
639
  action: z.enum([
579
640
  "create",
580
641
  "update",
@@ -591,7 +652,7 @@ function buildMcpServer(ctx) {
591
652
  "done",
592
653
  "abandoned"
593
654
  ]))
594
- },
655
+ }),
595
656
  outputSchema: effectToZodSchema(TddGoalResult)
596
657
  }, async (args) => {
597
658
  if (args.action === "create") return structuredJsonResult(await caller.tdd_goal({
@@ -620,7 +681,7 @@ function buildMcpServer(ctx) {
620
681
  });
621
682
  server.registerTool("tdd_behavior", {
622
683
  description: "Use to manage TDD behaviors, with a CRUD action discriminator: action='create' (goalId, behavior, suggestedTestName?, dependsOnBehaviorIds?) is idempotent on (goalId, behavior); action='update' (id, ...patch) edits; action='delete' (id) hard-deletes; action='get' (id) reads; action='list_by_goal' (goalId) lists one goal's behaviors; action='list_by_tdd_task' (tddTaskId) lists across all goals.",
623
- inputSchema: {
684
+ inputSchema: strict({
624
685
  action: z.enum([
625
686
  "create",
626
687
  "update",
@@ -641,7 +702,7 @@ function buildMcpServer(ctx) {
641
702
  "abandoned"
642
703
  ])),
643
704
  dependsOnBehaviorIds: z.optional(z.array(z.coerce.number()))
644
- },
705
+ }),
645
706
  outputSchema: effectToZodSchema(TddBehaviorResult)
646
707
  }, async (args) => {
647
708
  if (args.action === "create") return structuredJsonResult(await caller.tdd_behavior({
@@ -678,7 +739,7 @@ function buildMcpServer(ctx) {
678
739
  });
679
740
  server.registerTool("tdd_artifact_list", {
680
741
  description: "Use when you need the artifact id to cite in tdd_phase_transition_request without querying SQLite directly. Lists TDD artifacts (test_written, test_failed_run, code_written, test_passed_run, refactor, test_weakened) for a tdd_task, newest first. Filters: artifactKind, phaseId, behaviorId, limit (default 50).",
681
- inputSchema: {
742
+ inputSchema: strict({
682
743
  tddTaskId: z.coerce.number().describe("tdd_tasks.id"),
683
744
  artifactKind: z.optional(z.enum([
684
745
  "test_written",
@@ -691,7 +752,7 @@ function buildMcpServer(ctx) {
691
752
  phaseId: z.optional(z.coerce.number()).describe("Restrict to artifacts recorded in one phase"),
692
753
  behaviorId: z.optional(z.coerce.number()).describe("Restrict to artifacts recorded in phases bound to one behavior"),
693
754
  limit: z.optional(z.coerce.number()).describe("Max rows (default 50)")
694
- },
755
+ }),
695
756
  outputSchema: effectToZodSchema(TddArtifactListResult)
696
757
  }, async (args) => {
697
758
  const data = await caller.tdd_artifact_list({
@@ -705,7 +766,7 @@ function buildMcpServer(ctx) {
705
766
  });
706
767
  server.registerTool("hypothesis", {
707
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.",
708
- inputSchema: {
769
+ inputSchema: strict({
709
770
  action: z.enum([
710
771
  "record",
711
772
  "validate",
@@ -727,7 +788,7 @@ function buildMcpServer(ctx) {
727
788
  validatedTurnId: z.optional(z.coerce.number()),
728
789
  validatedAt: z.optional(z.string()).describe("ISO 8601 timestamp (action=validate)"),
729
790
  limit: z.optional(z.coerce.number())
730
- },
791
+ }),
731
792
  outputSchema: effectToZodSchema(HypothesisResult)
732
793
  }, async (args) => {
733
794
  if (args.action === "record") return structuredJsonResult(await caller.hypothesis({
@@ -756,7 +817,7 @@ function buildMcpServer(ctx) {
756
817
  });
757
818
  server.registerTool("tdd_progress_push", {
758
819
  description: "Use when a TDD orchestrator needs to report progress to the main agent over a Claude Code channel. The MCP server validates the payload against the ChannelEvent union and resolves goalId/sessionId server-side from behaviorId for behavior-scoped events (so a stale orchestrator context cannot push the wrong tree coordinates). Best-effort — returns { ok: true } regardless of whether channels are active.",
759
- inputSchema: { payload: z.string().describe("Pre-stringified ChannelEvent JSON (see schemas/ChannelEvent in @vitest-agent/sdk)") }
820
+ inputSchema: strict({ payload: z.string().describe("Pre-stringified ChannelEvent JSON (see schemas/ChannelEvent in @vitest-agent/sdk)") })
760
821
  }, async (args) => {
761
822
  let resolvedPayload = args.payload;
762
823
  try {
@@ -773,7 +834,7 @@ function buildMcpServer(ctx) {
773
834
  });
774
835
  server.registerTool("acceptance_metrics", {
775
836
  description: "Use when you need the four spec Annex A acceptance metrics computed from the current database. Returns markdown in content[] and a typed JSON object in structuredContent (per-metric { total, ratio, ... }).",
776
- inputSchema: {},
837
+ inputSchema: strict({}),
777
838
  outputSchema: effectToZodSchema(AcceptanceMetricsResult)
778
839
  }, async () => {
779
840
  const data = await caller.acceptance_metrics({});
@@ -781,10 +842,10 @@ function buildMcpServer(ctx) {
781
842
  });
782
843
  server.registerTool("triage_brief", {
783
844
  description: "Use when you need to orient on the current test landscape: failing tests, flaky tests, open TDD sessions, and suggested next actions. Returns markdown in content[] and a typed envelope in structuredContent ({ hasContent, markdown }).",
784
- inputSchema: {
845
+ inputSchema: strict({
785
846
  project: z.optional(z.string()).describe("Filter to a specific project"),
786
847
  maxLines: z.optional(z.coerce.number()).describe("Soft cap on rendered output lines")
787
- },
848
+ }),
788
849
  outputSchema: effectToZodSchema(TriageBriefResult)
789
850
  }, async (args) => {
790
851
  const data = await caller.triage_brief({
@@ -795,7 +856,7 @@ function buildMcpServer(ctx) {
795
856
  });
796
857
  server.registerTool("wrapup_prompt", {
797
858
  description: "Use when a session is ending and you need a tailored wrap-up prompt (Stop / SessionEnd / PreCompact / TDD handoff / UserPromptSubmit nudge variants). Returns markdown in content[] and a typed envelope in structuredContent ({ hasContent, kind, markdown }).",
798
- inputSchema: {
859
+ inputSchema: strict({
799
860
  sessionId: z.optional(z.coerce.number()).describe("sessions.id (integer); omit to use chatId"),
800
861
  chatId: z.optional(z.string()).describe("Host chat UUID (alternative to sessionId)"),
801
862
  kind: z.optional(z.enum([
@@ -806,7 +867,7 @@ function buildMcpServer(ctx) {
806
867
  "user_prompt_nudge"
807
868
  ])).describe("Wrap-up flavor (default: session_end)"),
808
869
  userPromptHint: z.optional(z.string()).describe("For user_prompt_nudge: the prompt text to inspect")
809
- },
870
+ }),
810
871
  outputSchema: effectToZodSchema(WrapupPromptResult)
811
872
  }, async (args) => {
812
873
  const data = await caller.wrapup_prompt({
@@ -819,7 +880,7 @@ function buildMcpServer(ctx) {
819
880
  });
820
881
  server.registerTool("commit_changes", {
821
882
  description: "Use when you need commit metadata and changed files captured by the post-commit hook. Returns up to 20 most-recent when sha is omitted. Returns markdown in content[] and a typed JSON object in structuredContent ({ filterSha?, count, commits[] }).",
822
- inputSchema: { sha: z.optional(z.string()).describe("Specific commit sha to fetch; omit for recent commits") },
883
+ inputSchema: strict({ sha: z.optional(z.string()).describe("Specific commit sha to fetch; omit for recent commits") }),
823
884
  outputSchema: effectToZodSchema(CommitChangesResult)
824
885
  }, async (args) => {
825
886
  const data = await caller.commit_changes({ sha: args.sha });
package/tools/help.js CHANGED
@@ -36,7 +36,7 @@ const HELP_TEXT = `# vitest-agent MCP Tools
36
36
 
37
37
  \`test\` actions:
38
38
  - \`{ action: "list", project?, state?, module?, limit? }\`
39
- - \`{ action: "get", fullName, project? }\`
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
42
 
package/tools/history.js CHANGED
@@ -91,12 +91,25 @@ const TestHistoryAsMarkdown = TestHistoryResult.pipe(Schema.decodeTo(Schema.Stri
91
91
  decode: SchemaGetter.transform((data) => formatTestHistoryMarkdown(data)),
92
92
  encode: SchemaGetter.forbidden(() => "TestHistoryAsMarkdown is one-way: markdown cannot be parsed back to TestHistoryResult.")
93
93
  }));
94
- const testHistory = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({ project: Schema.String }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
94
+ const testHistory = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
95
+ project: Schema.String,
96
+ testName: Schema.optional(Schema.String).annotate({ description: "Exact full_name match — narrows to a single test's history." }),
97
+ modulePath: Schema.optional(Schema.String).annotate({ description: "Exact module_path match — narrows to tests in one file." }),
98
+ limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0)).annotate({ description: "Max runs kept per test, most-recent-first. Must be a positive integer. Default 20." }))
99
+ }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
95
100
  const reader = yield* DataReader;
101
+ const scopeOptions = {
102
+ ...input.testName !== void 0 && { testName: input.testName },
103
+ ...input.modulePath !== void 0 && { modulePath: input.modulePath }
104
+ };
105
+ const historyOptions = {
106
+ ...scopeOptions,
107
+ ...input.limit !== void 0 && { limit: input.limit }
108
+ };
96
109
  const [history, flaky, persistent] = yield* Effect.all([
97
- reader.getHistory(input.project),
98
- reader.getFlaky(input.project),
99
- reader.getPersistentFailures(input.project)
110
+ reader.getHistory(input.project, historyOptions),
111
+ reader.getFlaky(input.project, scopeOptions),
112
+ reader.getPersistentFailures(input.project, scopeOptions)
100
113
  ]);
101
114
  const recovered = history.tests.filter((t) => {
102
115
  const runs = t.runs;
@@ -8,9 +8,26 @@ import { join } from "node:path";
8
8
  import { Writable } from "node:stream";
9
9
 
10
10
  //#region src/tools/run-tests.ts
11
+ const TagFilter = Schema.Struct({
12
+ all: Schema.optional(Schema.Array(Schema.String)),
13
+ any: Schema.optional(Schema.Array(Schema.String)),
14
+ none: Schema.optional(Schema.Array(Schema.String))
15
+ }).annotate({
16
+ identifier: "TagFilter",
17
+ description: "All three sub-filters AND together with `project` and `files`. `all` requires every listed tag on the test. `any` requires at least one. `none` excludes any test carrying a listed tag."
18
+ });
19
+ const RunTestsScope = Schema.Struct({
20
+ project: Schema.NullOr(Schema.String),
21
+ files: Schema.Array(Schema.String),
22
+ tags: Schema.NullOr(TagFilter)
23
+ }).annotate({
24
+ identifier: "RunTestsScope",
25
+ description: "The filter set actually used for this run, verbatim. Lets an agent tell 'ran exactly what I asked' apart from 'a dropped/misspelled param silently ran everything' without cross-checking summary counts."
26
+ });
11
27
  const RunTestsOk = Schema.Struct({
12
28
  kind: Schema.Literal("ok").annotate({ description: "Discriminant — `true` test run completed (with or without failures)." }),
13
29
  project: Schema.optional(Schema.String),
30
+ scope: RunTestsScope,
14
31
  report: AgentReport.annotate({ description: "Full AgentReport including pass/fail counts and per-module errors." }),
15
32
  classifications: Schema.Record(Schema.String, Schema.String).annotate({ description: "Per-test classification labels: stable, new-failure, persistent, flaky, recovered." }),
16
33
  discoveryLastScannedAt: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "ISO timestamp of the most recent real disk scan performed by discoverProjects() in this process (issue #100). `null`/absent means discovery has not scanned disk in this process yet (e.g. a config that doesn't call AgentPlugin.discover()). A stale-looking test count is self-explaining when compared against this value." })
@@ -23,14 +40,6 @@ const RunTestsError = Schema.Struct({
23
40
  kind: Schema.Literal("error"),
24
41
  message: Schema.String
25
42
  }).annotate({ identifier: "RunTestsError" });
26
- const TagFilter = Schema.Struct({
27
- all: Schema.optional(Schema.Array(Schema.String)),
28
- any: Schema.optional(Schema.Array(Schema.String)),
29
- none: Schema.optional(Schema.Array(Schema.String))
30
- }).annotate({
31
- identifier: "TagFilter",
32
- description: "All three sub-filters AND together with `project` and `files`. `all` requires every listed tag on the test. `any` requires at least one. `none` excludes any test carrying a listed tag."
33
- });
34
43
  const RunTestsNoMatch = Schema.Struct({
35
44
  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." }),
36
45
  filter: Schema.Struct({
@@ -391,6 +400,11 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
391
400
  return {
392
401
  kind: "ok",
393
402
  ...project !== void 0 && { project },
403
+ scope: {
404
+ project: project ?? null,
405
+ files,
406
+ tags: tagsInput ?? null
407
+ },
394
408
  report,
395
409
  classifications: classifications ? Object.fromEntries(classifications) : {},
396
410
  discoveryLastScannedAt: readDiscoveryLastScannedAt() ?? null
package/tools/test.js CHANGED
@@ -52,7 +52,16 @@ const TestGetMissing = Schema.Struct({
52
52
  action: Schema.Literal("get"),
53
53
  found: Schema.Literal(false),
54
54
  project: Schema.String,
55
- fullName: Schema.String
55
+ fullName: Schema.String,
56
+ /**
57
+ * `true` when the lookup failed *because* the name matched more than
58
+ * one module in the project's latest run and no `modulePath` was
59
+ * supplied to disambiguate (issue #243) — as opposed to the name
60
+ * simply not existing.
61
+ */
62
+ ambiguous: Schema.optional(Schema.Boolean),
63
+ /** The module paths a `fullName` matched when `ambiguous` is `true`. */
64
+ candidateModules: Schema.optional(Schema.Array(Schema.String))
56
65
  }).annotate({ identifier: "TestGetMissing" });
57
66
  const TestForFileResult = Schema.Struct({
58
67
  action: Schema.Literal("for_file"),
@@ -93,7 +102,19 @@ const formatTestMarkdown = (data) => {
93
102
  return lines.join("\n").trimEnd();
94
103
  }
95
104
  if (data.action === "get") {
96
- if (!data.found) return `Test not found: \`${data.fullName}\`\n\nUse test({ action: "list" }) to discover available tests (format: "Suite > test name").`;
105
+ if (!data.found) {
106
+ if (data.ambiguous === true) {
107
+ const modules = data.candidateModules ?? [];
108
+ return [
109
+ `Ambiguous test name: \`${data.fullName}\` matches ${modules.length} modules in project \`${data.project}\`.`,
110
+ "",
111
+ ...modules.map((m) => `- \`${m}\``),
112
+ "",
113
+ `Re-run with a modulePath, e.g. test({ action: "get", fullName: "${data.fullName}", modulePath: "${modules[0] ?? ""}" }).`
114
+ ].join("\n");
115
+ }
116
+ return `Test not found: \`${data.fullName}\`\n\nUse test({ action: "list" }) to discover available tests (format: "Suite > test name").`;
117
+ }
97
118
  const t = data.test;
98
119
  const lines = [
99
120
  `# Test: ${t.fullName}`,
@@ -180,7 +201,8 @@ const ListVariant = Schema.Struct({
180
201
  const GetVariant = Schema.Struct({
181
202
  action: Schema.Literal("get"),
182
203
  fullName: Schema.String,
183
- project: Schema.optional(Schema.String)
204
+ project: Schema.optional(Schema.String),
205
+ modulePath: Schema.optional(Schema.String).annotate({ description: "Exact module_path match — disambiguates a fullName that exists in more than one test file." })
184
206
  });
185
207
  const ForFileVariant = Schema.Struct({
186
208
  action: Schema.Literal("for_file"),
@@ -228,15 +250,28 @@ const test = publicProcedure.input(Schema.toStandardSchemaV1(TestInput)).query(a
228
250
  const reader = yield* DataReader;
229
251
  const candidates = variant.project ? [variant.project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
230
252
  for (const project of candidates) {
231
- const testOpt = yield* reader.getTestByFullName(project, variant.fullName);
253
+ const modules = yield* reader.getTestModulesByFullName(project, variant.fullName);
254
+ if (modules.length === 0) continue;
255
+ if (variant.modulePath === void 0 && modules.length > 1) return {
256
+ action: "get",
257
+ found: false,
258
+ project,
259
+ fullName: variant.fullName,
260
+ ambiguous: true,
261
+ candidateModules: modules
262
+ };
263
+ const testOpt = yield* reader.getTestByFullName(project, variant.fullName, { ...variant.modulePath !== void 0 && { modulePath: variant.modulePath } });
232
264
  if (Option.isNone(testOpt)) continue;
233
- const matchingErrors = (yield* reader.getErrors(project)).filter((e) => e.testFullName === variant.fullName).map((e) => ({
265
+ const matchingErrors = (yield* reader.getErrors(project)).filter((e) => e.testFullName === variant.fullName && e.moduleFile === testOpt.value.module).map((e) => ({
234
266
  name: e.name,
235
267
  message: e.message,
236
268
  diff: e.diff,
237
269
  stack: e.stack
238
270
  }));
239
- const testHistory = (yield* reader.getHistory(project)).tests.find((entry) => entry.fullName === variant.fullName);
271
+ const testHistory = (yield* reader.getHistory(project, {
272
+ testName: variant.fullName,
273
+ modulePath: testOpt.value.module
274
+ })).tests.find((entry) => entry.fullName === variant.fullName);
240
275
  return {
241
276
  action: "get",
242
277
  found: true,
@@ -0,0 +1,35 @@
1
+ //#region src/utils/crash-guards.ts
2
+ /**
3
+ * Pure decision logic backing `bin.ts`'s `process.on("uncaughtException")`
4
+ * guard (issue #191, sub-item A).
5
+ *
6
+ * Node's own guidance for `uncaughtException` is "do not resume normal
7
+ * operation" because arbitrary in-process state may be corrupt. This
8
+ * package accepts that residual risk *after* the stdio transport is
9
+ * connected: it holds no long-lived mutable state outside SQLite
10
+ * itself (every `DataStore`/`DataReader` call is a self-contained
11
+ * transaction via the shared `ManagedRuntime`), so a synchronous throw
12
+ * that escapes even the MCP SDK's own per-tool-call try/catch cannot
13
+ * leave this process's own bookkeeping half-mutated in a way that
14
+ * would corrupt the *next* call — and the alternative, silent process
15
+ * death mid-TDD-session (deregistering every tool from the client), is
16
+ * strictly worse for a long-running dev tool.
17
+ *
18
+ * Before the transport connects there is no client session to
19
+ * preserve by staying alive, so failing fast and loud is the better
20
+ * default rather than spinning forever in a half-initialized state.
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+ /**
25
+ * @param transportConnected - whether `server.connect(transport)` has
26
+ * already resolved for this process
27
+ * @returns `true` when the process should exit rather than continue
28
+ * @public
29
+ */
30
+ function shouldExitOnUncaughtException(transportConnected) {
31
+ return !transportConnected;
32
+ }
33
+
34
+ //#endregion
35
+ export { shouldExitOnUncaughtException };
@@ -0,0 +1,44 @@
1
+ import { formatFatalError } from "@vitest-agent/sdk";
2
+
3
+ //#region src/utils/safe-format-fatal-error.ts
4
+ /**
5
+ * Crash-handler-safe wrapper around `@vitest-agent/sdk`'s
6
+ * {@link formatFatalError}.
7
+ *
8
+ * `bin.ts`'s `unhandledRejection` / `uncaughtException` handlers exist so
9
+ * a stray throw cannot kill a live MCP session (issue #191). Calling the
10
+ * formatter directly from inside them reopened exactly that hole:
11
+ * `formatFatalError` introspects the value it is given — `Symbol.for(...)
12
+ * in reason`, `err instanceof Error`, `JSON.stringify(err)` — and every
13
+ * one of those is hijackable by a `Proxy` whose `has` / `getPrototypeOf`
14
+ * / `get` trap throws. A throw *inside* an `uncaughtException` handler is
15
+ * fatal to the process with no second chance to report it, so the crash
16
+ * guard would have crashed the process it exists to protect (issue #243).
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+ /**
21
+ * Fixed fallback emitted when the formatter itself throws. Deliberately a
22
+ * constant: anything derived from the offending value could throw again
23
+ * on the recovery path.
24
+ *
25
+ * @internal
26
+ */
27
+ const UNFORMATTABLE_ERROR_TEXT = "<unformattable error value — the error formatter itself threw>";
28
+ /**
29
+ * Format an unknown error for a crash handler. Never throws.
30
+ *
31
+ * @param err - the value thrown or the rejection reason
32
+ * @internal
33
+ */
34
+ function safeFormatFatalError(err) {
35
+ try {
36
+ const formatted = formatFatalError(err);
37
+ return typeof formatted === "string" ? formatted : UNFORMATTABLE_ERROR_TEXT;
38
+ } catch {
39
+ return UNFORMATTABLE_ERROR_TEXT;
40
+ }
41
+ }
42
+
43
+ //#endregion
44
+ export { UNFORMATTABLE_ERROR_TEXT, safeFormatFatalError };
@@ -0,0 +1,61 @@
1
+ //#region src/utils/tool-error-envelope.ts
2
+ /**
3
+ * Fixed fallback used when even *introspecting* the thrown value throws.
4
+ * Deliberately a constant — anything derived from `err` could throw again
5
+ * on the recovery path.
6
+ */
7
+ const UNREADABLE_THROWN_VALUE = "<unreadable thrown value>";
8
+ /**
9
+ * Coerces an unknown thrown value into a display-safe message without
10
+ * risking a second throw (a getter-backed `.message` can itself throw —
11
+ * see `@vitest-agent/sdk`'s `coerceErrorField` for the same concern on
12
+ * raw Vitest error objects).
13
+ *
14
+ * The outer guard matters as much as the inner one: `err instanceof
15
+ * Error` walks the prototype chain, which a `Proxy` `getPrototypeOf`
16
+ * trap can hijack and throw from, and `String(err)` invokes
17
+ * `Symbol.toPrimitive` / `toString`, which a trap can hijack too. A
18
+ * throw here escapes the envelope builder entirely and the agent gets
19
+ * nothing structured back — the exact degradation this module exists to
20
+ * prevent (issue #243).
21
+ */
22
+ function coerceThrownMessage(err) {
23
+ try {
24
+ if (typeof err === "string") return err;
25
+ if (err instanceof Error) try {
26
+ const message = err.message;
27
+ return typeof message === "string" ? message : String(message);
28
+ } catch {
29
+ return "<unreadable Error.message>";
30
+ }
31
+ return String(err);
32
+ } catch {
33
+ return UNREADABLE_THROWN_VALUE;
34
+ }
35
+ }
36
+ /**
37
+ * Builds the structured envelope a tool's catch-all wrapper returns
38
+ * when its resolver throws unexpectedly.
39
+ *
40
+ * @param toolName - the MCP tool name under which the resolver was registered
41
+ * @param err - the value thrown or the rejection reason
42
+ * @public
43
+ */
44
+ function buildUnexpectedToolErrorEnvelope(toolName, err) {
45
+ return {
46
+ ok: false,
47
+ error: {
48
+ _tag: "UnexpectedToolError",
49
+ tool: toolName,
50
+ message: coerceThrownMessage(err),
51
+ remediation: {
52
+ suggestedTool: toolName,
53
+ suggestedArgs: {},
54
+ humanHint: `The "${toolName}" tool's resolver threw before producing a result (unrelated to your input in most cases). Retry the call; if it persists, check the MCP server's stderr for the logged error.`
55
+ }
56
+ }
57
+ };
58
+ }
59
+
60
+ //#endregion
61
+ export { buildUnexpectedToolErrorEnvelope };