@vitest-agent/mcp 2.1.4 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/bin/vitest-agent-mcp.js +73 -2
- package/index.d.ts +15 -0
- package/index.js +1 -1
- package/package.json +2 -2
- package/server.js +110 -49
- package/tools/help.js +1 -1
- package/tools/history.js +17 -4
- package/tools/run-tests.js +22 -8
- package/tools/test.js +41 -6
- package/utils/crash-guards.js +35 -0
- package/utils/safe-format-fatal-error.js +44 -0
- package/utils/tool-error-envelope.js +61 -0
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
|
package/bin/vitest-agent-mcp.js
CHANGED
|
@@ -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,
|
|
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: ${
|
|
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
|
|
15
|
+
const CURRENT_MCP_VERSION = "2.2.1";
|
|
16
16
|
|
|
17
17
|
//#endregion
|
|
18
18
|
export { CURRENT_MCP_VERSION, McpLive, appRouter, buildMcpServer, createCallerFactory, createCurrentSessionIdRef, createSessionContextRef, parseSessionEnvExports, recoverSessionContextFromSessionEnv, startMcpServer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitest-agent/mcp",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Model Context Protocol server for vitest-agent. Exposes 53 tools for agent access to test data, TDD lifecycle, and session management.",
|
|
6
6
|
"keywords": [
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@effect/sql-sqlite-node": "4.0.0-beta.107",
|
|
43
43
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
44
44
|
"@trpc/server": "^11.18.0",
|
|
45
|
-
"@vitest-agent/sdk": "2.
|
|
45
|
+
"@vitest-agent/sdk": "2.3.1",
|
|
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: {
|
|
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({
|
|
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
|
|
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(
|
|
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({
|
|
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;
|
package/tools/run-tests.js
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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 };
|