@vitest-agent/mcp 3.0.4 → 4.0.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.
Files changed (54) hide show
  1. package/README.md +7 -6
  2. package/annotations.js +18 -0
  3. package/bin/vitest-agent-mcp.js +5 -140
  4. package/{middleware/idempotency.js → idempotency.js} +48 -49
  5. package/index.d.ts +5610 -1549
  6. package/index.js +35 -17
  7. package/main.d.ts +31 -0
  8. package/main.js +144 -0
  9. package/package.json +9 -6
  10. package/prompts/layer.js +108 -0
  11. package/register-toolkit.js +311 -0
  12. package/server.js +24 -894
  13. package/{context.js → session.js} +38 -22
  14. package/toolkit.js +86 -0
  15. package/tools/acceptance-metrics.js +26 -14
  16. package/tools/cache-health.js +28 -17
  17. package/tools/commit-changes.js +33 -10
  18. package/tools/configure.js +33 -10
  19. package/tools/coverage.js +34 -5
  20. package/tools/errors.js +36 -26
  21. package/tools/failure-signature-get.js +33 -10
  22. package/tools/file-coverage.js +37 -13
  23. package/tools/help.js +45 -4
  24. package/tools/history.js +39 -19
  25. package/tools/hypothesis.js +118 -102
  26. package/tools/inventory.js +149 -146
  27. package/tools/note.js +131 -113
  28. package/tools/overview.js +33 -10
  29. package/tools/ping.js +22 -10
  30. package/tools/register-agent.js +88 -62
  31. package/tools/run-tests.js +76 -23
  32. package/tools/settings-list.js +27 -10
  33. package/tools/status.js +35 -10
  34. package/tools/tdd-artifact.js +37 -22
  35. package/tools/tdd-behavior.js +120 -108
  36. package/tools/tdd-goal.js +98 -85
  37. package/tools/tdd-phase-transition-request.js +201 -166
  38. package/tools/tdd-progress-push.js +102 -0
  39. package/tools/tdd-task.js +140 -138
  40. package/tools/test.js +152 -138
  41. package/tools/trends.js +37 -18
  42. package/tools/triage-brief.js +34 -15
  43. package/tools/turn-search.js +39 -16
  44. package/tools/wrapup-prompt.js +37 -17
  45. package/utils/crash-guards.js +0 -22
  46. package/utils/replay-marker.js +12 -0
  47. package/utils/safe-format-fatal-error.js +0 -16
  48. package/utils/tool-error-envelope.js +3 -3
  49. package/version.js +13 -0
  50. package/layers/McpLive.js +0 -29
  51. package/prompts/index.js +0 -89
  52. package/router.js +0 -74
  53. package/session-env.js +0 -112
  54. package/utils/effect-to-zod.js +0 -158
package/tools/overview.js CHANGED
@@ -1,13 +1,9 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Option, Schema, SchemaGetter } from "effect";
3
- import { DataReader } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
4
5
 
5
6
  //#region src/tools/overview.ts
6
- /**
7
- * `test_overview` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
7
  const ProjectRunSummary = Schema.Struct({
12
8
  project: Schema.String,
13
9
  lastRun: Schema.NullOr(Schema.String),
@@ -34,6 +30,11 @@ const OverviewAbsent = Schema.Struct({
34
30
  projectFilter: Schema.optional(Schema.String),
35
31
  reason: Schema.Literals(["no_runs", "project_filter_empty"])
36
32
  }).annotate({ identifier: "TestOverviewAbsent" });
33
+ /**
34
+ * The `test_overview` tool's success payload.
35
+ *
36
+ * @public
37
+ */
37
38
  const TestOverviewResult = Schema.Union([OverviewAvailable, OverviewAbsent]).annotate({
38
39
  identifier: "TestOverviewResult",
39
40
  title: "test_overview result",
@@ -70,7 +71,18 @@ const TestOverviewAsMarkdown = TestOverviewResult.pipe(Schema.decodeTo(Schema.St
70
71
  decode: SchemaGetter.transform((data) => formatTestOverviewMarkdown(data)),
71
72
  encode: SchemaGetter.forbidden(() => "TestOverviewAsMarkdown is one-way.")
72
73
  }));
73
- const testOverview = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({ project: Schema.optional(Schema.String) }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
74
+ /**
75
+ * The `test_overview` tool's parameters.
76
+ *
77
+ * @public
78
+ */
79
+ const TestOverviewInput = Schema.Struct({ project: Schema.optionalKey(Schema.String).annotate({ description: "Filter to a specific project" }) });
80
+ /**
81
+ * Handler for {@link testOverviewTool}.
82
+ *
83
+ * @public
84
+ */
85
+ const handleTestOverview = (input) => Effect.gen(function* () {
74
86
  const reader = yield* DataReader;
75
87
  const [manifestOpt, runs] = yield* Effect.all([reader.getManifest(), reader.getRunsByProject()], { concurrency: "unbounded" });
76
88
  if (Option.isNone(manifestOpt) || runs.length === 0) return {
@@ -89,7 +101,18 @@ const testOverview = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Stru
89
101
  ...input.project !== void 0 && { projectFilter: input.project },
90
102
  runs: filteredRuns
91
103
  };
92
- })));
104
+ }).pipe(Effect.orDie);
105
+ /**
106
+ * The Effect-native `test_overview` tool.
107
+ *
108
+ * @public
109
+ */
110
+ const testOverviewTool = Tool.make("test_overview", {
111
+ 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).",
112
+ parameters: TestOverviewInput,
113
+ success: TestOverviewResult,
114
+ dependencies: [DataReader]
115
+ }).annotate(Tool.Title, "Test overview").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatTestOverviewMarkdown(encoded));
93
116
 
94
117
  //#endregion
95
- export { TestOverviewAsMarkdown, TestOverviewResult, formatTestOverviewMarkdown, testOverview };
118
+ export { TestOverviewInput, TestOverviewResult, formatTestOverviewMarkdown, handleTestOverview, testOverviewTool };
package/tools/ping.js CHANGED
@@ -1,22 +1,34 @@
1
- import { publicProcedure } from "../context.js";
2
- import { Schema } from "effect";
1
+ import { Effect, Schema } from "effect";
2
+ import { Tool } from "effect/unstable/ai";
3
3
 
4
4
  //#region src/tools/ping.ts
5
5
  /**
6
- * `ping` MCP tool Schema-driven implementation.
6
+ * The `ping` tool's success payload.
7
7
  *
8
- * Trivial liveness probe used to verify hot-patch reload of the MCP
9
- * server. Returns the canonical `pong` payload so callers can assert
10
- * a healthy round-trip.
11
- *
12
- * @packageDocumentation
8
+ * @public
13
9
  */
14
10
  const PingResult = Schema.Struct({ message: Schema.Literal("pong").annotate({ description: "Constant `pong`. Presence confirms the MCP server responded." }) }).annotate({
15
11
  identifier: "PingResult",
16
12
  title: "ping result",
17
13
  description: "Liveness probe. Carries no data beyond the constant `pong` discriminant."
18
14
  });
19
- const ping = publicProcedure.query(async () => ({ message: "pong" }));
15
+ /**
16
+ * The Effect-native `ping` tool. No parameters (the default
17
+ * `Tool.EmptyParams` serves as a strict empty object; `Schema.Struct({})`
18
+ * would serialize to a non-object JSON Schema that MCP rejects).
19
+ *
20
+ * @public
21
+ */
22
+ const pingTool = Tool.make("ping", {
23
+ description: "Ping the MCP server — returns 'pong'. Used to verify hot-patch reload.",
24
+ success: PingResult
25
+ }).annotate(Tool.Title, "Ping").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
26
+ /**
27
+ * Handler for {@link pingTool}.
28
+ *
29
+ * @public
30
+ */
31
+ const handlePing = () => Effect.succeed({ message: "pong" });
20
32
 
21
33
  //#endregion
22
- export { PingResult, ping };
34
+ export { PingResult, handlePing, pingTool };
@@ -1,6 +1,6 @@
1
- import { publicProcedure } from "../context.js";
2
1
  import { Effect, Option, Schema } from "effect";
3
- import { DataReader, DataStore, deriveIdempotencyKey } from "@vitest-agent/sdk";
2
+ import { DataReader, DataStore, deriveIdempotencyKey } from "@vitest-agent/engine";
3
+ import { Tool } from "effect/unstable/ai";
4
4
 
5
5
  //#region src/tools/register-agent.ts
6
6
  /**
@@ -31,16 +31,21 @@ import { DataReader, DataStore, deriveIdempotencyKey } from "@vitest-agent/sdk";
31
31
  * `AGENT_ALREADY_REGISTERED` here so MCP clients see one shape per
32
32
  * outcome.
33
33
  */
34
+ /**
35
+ * The `register_agent` tool's parameters.
36
+ *
37
+ * @public
38
+ */
34
39
  const RegisterAgentInput = Schema.Struct({
35
- chatId: Schema.String,
36
- conversationId: Schema.optional(Schema.String),
37
- hostKind: Schema.optional(Schema.String),
38
- agentType: Schema.String,
39
- parentAgentId: Schema.optional(Schema.String),
40
- clientNonce: Schema.optional(Schema.String),
41
- startGitBranch: Schema.optional(Schema.String),
42
- startGitCommitSha: Schema.optional(Schema.String),
43
- startWorktreeDir: Schema.optional(Schema.String)
40
+ chatId: Schema.String.annotate({ description: "Host's chat UUID (session_id from CC hook payload, etc.)" }),
41
+ conversationId: Schema.optionalKey(Schema.String).annotate({ description: "Canonical conversation UUID (from session-map mapConversation)" }),
42
+ hostKind: Schema.optionalKey(Schema.String).annotate({ description: "Host vendor identifier; defaults to 'claude-code'" }),
43
+ agentType: Schema.String.annotate({ description: "Agent type; must begin with the host-kind prefix" }),
44
+ parentAgentId: Schema.optionalKey(Schema.String).annotate({ description: "Parent agent UUID for subagent registrations" }),
45
+ clientNonce: Schema.optionalKey(Schema.String).annotate({ description: "Disambiguator for sibling-subagent registrations under the same parent; the server derives a deterministic default when omitted, which collapses parallel siblings into one row" }),
46
+ startGitBranch: Schema.optionalKey(Schema.String),
47
+ startGitCommitSha: Schema.optionalKey(Schema.String),
48
+ startWorktreeDir: Schema.optionalKey(Schema.String)
44
49
  });
45
50
  const RegisterAgentSuccess = Schema.Struct({
46
51
  ok: Schema.Literal(true).annotate({ description: "Discriminant — `true` when the agent row was inserted (or an existing one recovered)." }),
@@ -65,12 +70,24 @@ const RegisterAgentFailure = Schema.Struct({
65
70
  expectedPrefix: Schema.optional(Schema.String).annotate({ description: "Present only when `code = INVALID_AGENT_TYPE_PREFIX`. The required `<hostKind>-` prefix." })
66
71
  })
67
72
  }).annotate({ identifier: "RegisterAgentFailure" });
73
+ /**
74
+ * The `register_agent` tool's success payload.
75
+ *
76
+ * @public
77
+ */
68
78
  const RegisterAgentResult = Schema.Union([RegisterAgentSuccess, RegisterAgentFailure]).annotate({
69
79
  identifier: "RegisterAgentResult",
70
80
  title: "register_agent result",
71
81
  description: "Discriminate on `ok`. The four failure codes are documented per their `code` literal."
72
82
  });
73
- const registerAgent = publicProcedure.input(Schema.toStandardSchemaV1(RegisterAgentInput)).mutation(async ({ ctx, input }) => {
83
+ /**
84
+ * Handler for {@link registerAgentTool}. Idempotency is the tool's own
85
+ * business rule (the `agents` idempotency key), not the generic
86
+ * `withIdempotency` combinator.
87
+ *
88
+ * @public
89
+ */
90
+ const handleRegisterAgent = (input) => Effect.gen(function* () {
74
91
  const expectedPrefix = `${input.hostKind ?? "claude-code"}-`;
75
92
  if (!input.agentType.startsWith(expectedPrefix)) return {
76
93
  ok: false,
@@ -86,55 +103,64 @@ const registerAgent = publicProcedure.input(Schema.toStandardSchemaV1(RegisterAg
86
103
  parentAgentId: input.parentAgentId ?? null,
87
104
  clientNonce
88
105
  });
89
- return ctx.runtime.runPromise(Effect.gen(function* () {
90
- const reader = yield* DataReader;
91
- const store = yield* DataStore;
92
- const sessionOpt = yield* reader.getSessionByChatId(input.chatId);
93
- if (Option.isNone(sessionOpt)) return {
94
- ok: false,
95
- error: {
96
- code: "SESSION_NOT_FOUND",
97
- message: `chat ${input.chatId} has not been registered; the host must call its SessionStart equivalent first`
98
- }
99
- };
100
- const sessionRowId = sessionOpt.value.id;
101
- const result = yield* store.registerAgent({
102
- sessionId: sessionRowId,
103
- agentType: input.agentType,
104
- parentAgentId: input.parentAgentId ?? null,
105
- conversationId: input.conversationId ?? null,
106
- startedAt: Math.floor(Date.now() / 1e3),
107
- ...input.startGitBranch !== void 0 && { startGitBranch: input.startGitBranch },
108
- ...input.startGitCommitSha !== void 0 && { startGitCommitSha: input.startGitCommitSha },
109
- ...input.startWorktreeDir !== void 0 && { startWorktreeDir: input.startWorktreeDir },
110
- idempotencyKey
111
- }).pipe(Effect.catchTag("RegistrationConflictError", (e) => Effect.succeed({
112
- _tag: "Conflict",
113
- reason: e.reason
114
- })));
115
- if ("_tag" in result && result._tag === "Conflict") return {
116
- ok: false,
117
- error: {
118
- code: "PARENT_AGENT_NOT_FOUND",
119
- message: result.reason
120
- }
121
- };
122
- if ("_tag" in result && result._tag === "IdempotencyHit") return {
123
- ok: false,
124
- error: {
125
- code: "AGENT_ALREADY_REGISTERED",
126
- message: "agent already registered for (chatId, agentType, parentAgentId, clientNonce); use existingAgentId",
127
- existingAgentId: result.existingAgentId
128
- }
129
- };
130
- return {
131
- ok: true,
132
- agentId: result.agentId,
133
- conversationId: result.conversationId,
134
- idempotencyKey: result.idempotencyKey
135
- };
136
- }));
137
- });
106
+ const reader = yield* DataReader;
107
+ const store = yield* DataStore;
108
+ const sessionOpt = yield* reader.getSessionByChatId(input.chatId);
109
+ if (Option.isNone(sessionOpt)) return {
110
+ ok: false,
111
+ error: {
112
+ code: "SESSION_NOT_FOUND",
113
+ message: `chat ${input.chatId} has not been registered; the host must call its SessionStart equivalent first`
114
+ }
115
+ };
116
+ const sessionRowId = sessionOpt.value.id;
117
+ const result = yield* store.registerAgent({
118
+ sessionId: sessionRowId,
119
+ agentType: input.agentType,
120
+ parentAgentId: input.parentAgentId ?? null,
121
+ conversationId: input.conversationId ?? null,
122
+ startedAt: Math.floor(Date.now() / 1e3),
123
+ ...input.startGitBranch !== void 0 && { startGitBranch: input.startGitBranch },
124
+ ...input.startGitCommitSha !== void 0 && { startGitCommitSha: input.startGitCommitSha },
125
+ ...input.startWorktreeDir !== void 0 && { startWorktreeDir: input.startWorktreeDir },
126
+ idempotencyKey
127
+ }).pipe(Effect.catchTag("RegistrationConflictError", (e) => Effect.succeed({
128
+ _tag: "Conflict",
129
+ reason: e.reason
130
+ })));
131
+ if ("_tag" in result && result._tag === "Conflict") return {
132
+ ok: false,
133
+ error: {
134
+ code: "PARENT_AGENT_NOT_FOUND",
135
+ message: result.reason
136
+ }
137
+ };
138
+ if ("_tag" in result && result._tag === "IdempotencyHit") return {
139
+ ok: false,
140
+ error: {
141
+ code: "AGENT_ALREADY_REGISTERED",
142
+ message: "agent already registered for (chatId, agentType, parentAgentId, clientNonce); use existingAgentId",
143
+ existingAgentId: result.existingAgentId
144
+ }
145
+ };
146
+ return {
147
+ ok: true,
148
+ agentId: result.agentId,
149
+ conversationId: result.conversationId,
150
+ idempotencyKey: result.idempotencyKey
151
+ };
152
+ }).pipe(Effect.orDie);
153
+ /**
154
+ * The Effect-native `register_agent` tool.
155
+ *
156
+ * @public
157
+ */
158
+ const registerAgentTool = Tool.make("register_agent", {
159
+ 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').",
160
+ parameters: RegisterAgentInput,
161
+ success: RegisterAgentResult,
162
+ dependencies: [DataReader, DataStore]
163
+ }).annotate(Tool.Title, "Register agent").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true);
138
164
 
139
165
  //#endregion
140
- export { RegisterAgentResult, registerAgent };
166
+ export { RegisterAgentInput, RegisterAgentResult, handleRegisterAgent, registerAgentTool };
@@ -1,7 +1,10 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
+ import { McpSession } from "../session.js";
2
3
  import { createRequire } from "node:module";
3
4
  import { Data, Effect, Schema, SchemaGetter, Semaphore } from "effect";
4
- import { AgentReport, DataReader, DataStore, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote } from "@vitest-agent/sdk";
5
+ import { DataReader, DataStore } from "@vitest-agent/engine";
6
+ import { Tool } from "effect/unstable/ai";
7
+ import { AgentReport, buildAgentReport, buildConsoleLeaks, coerceErrorField, collectConsoleLeakEntries, formatScopedCoverageNote } from "@vitest-agent/sdk";
5
8
  import { AsyncLocalStorage } from "node:async_hooks";
6
9
  import { execFile } from "node:child_process";
7
10
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
@@ -14,9 +17,9 @@ import { promisify } from "node:util";
14
17
 
15
18
  //#region src/tools/run-tests.ts
16
19
  const TagFilter = Schema.Struct({
17
- all: Schema.optional(Schema.Array(Schema.String)),
18
- any: Schema.optional(Schema.Array(Schema.String)),
19
- none: Schema.optional(Schema.Array(Schema.String))
20
+ all: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Require every listed tag" }),
21
+ any: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Require at least one listed tag" }),
22
+ none: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Exclude any listed tag" })
20
23
  }).annotate({
21
24
  identifier: "TagFilter",
22
25
  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."
@@ -57,6 +60,11 @@ const RunTestsNoMatch = Schema.Struct({
57
60
  resolvedExpression: Schema.NullOr(Schema.String)
58
61
  })
59
62
  }).annotate({ identifier: "RunTestsNoMatch" });
63
+ /**
64
+ * The `run_tests` tool's success payload.
65
+ *
66
+ * @public
67
+ */
60
68
  const RunTestsResult = Schema.Union([
61
69
  RunTestsOk,
62
70
  RunTestsTimeout,
@@ -155,7 +163,7 @@ function ensureStdioPatched() {
155
163
  /**
156
164
  * Run `fn` with `process.stdout.write` and `process.stderr.write`
157
165
  * diverted to `stream.write` for code executing inside the call's
158
- * async context. Code in other async contexts (concurrent tRPC
166
+ * async context. Code in other async contexts (concurrent tool
159
167
  * procedure handlers, the MCP stdio transport) sees the original
160
168
  * writes unchanged.
161
169
  *
@@ -366,9 +374,6 @@ function resolveVitestNodeEntry(root) {
366
374
  */
367
375
  const vitestLoader = { load: (entry) => import(entry) };
368
376
  const runTestsSemaphore = Effect.runSync(Semaphore.make(1));
369
- function serializeRunTests(fn) {
370
- return Effect.runPromise(Semaphore.withPermit(runTestsSemaphore, Effect.promise(() => fn())));
371
- }
372
377
  /**
373
378
  * Coerce unknown Vitest unhandled errors into VitestModuleError shape.
374
379
  *
@@ -511,19 +516,31 @@ function formatReportMarkdown(report, classifications, scopedNote) {
511
516
  }
512
517
  return lines.join("\n");
513
518
  }
514
- const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
515
- files: Schema.optional(Schema.Array(Schema.String)),
516
- project: Schema.optional(Schema.String),
517
- tags: Schema.optional(TagFilter),
518
- passWithNoTests: Schema.optional(Schema.Boolean),
519
- timeout: Schema.optional(Schema.Number),
520
- projectRoot: Schema.optional(Schema.String),
521
- _sessionContext: Schema.optional(Schema.Struct({
519
+ /**
520
+ * The `run_tests` tool's parameters. `tags` and `_sessionContext` are
521
+ * nested structs and are served strict at their own level (issue #243).
522
+ *
523
+ * @public
524
+ */
525
+ const RunTestsInput = Schema.Struct({
526
+ files: Schema.optionalKey(Schema.Array(Schema.String)).annotate({ description: "Test file paths to run" }),
527
+ project: Schema.optionalKey(Schema.String).annotate({ description: "Project name to filter" }),
528
+ projectRoot: Schema.optionalKey(Schema.String).annotate({ description: "Explicit Vitest root for this call, used verbatim. Omit it to get the config-anchored default (walk up from the server's boot dir for a vitest/vite config, bounded at the git root). Prefer an absolute path; a relative path is resolved against ctx.cwd, not the server process's cwd. Validated: must be an existing directory in the same git repository as ctx.cwd (same git-common-dir, e.g. a sibling worktree). Rejected with { kind: 'error' } naming both paths otherwise." }),
529
+ tags: Schema.optionalKey(TagFilter).annotate({ description: "Structured tag filter; all/any/none AND together with each other and with project/files" }),
530
+ passWithNoTests: Schema.optionalKey(Schema.Boolean).annotate({ description: "Per-call override of Vitest's native test.passWithNoTests" }),
531
+ timeout: Schema.optionalKey(Schema.Finite).annotate({ description: "Timeout in seconds (default: 120)" }),
532
+ _sessionContext: Schema.optionalKey(Schema.Struct({
522
533
  chatId: Schema.String,
523
534
  conversationId: Schema.String,
524
535
  mainAgentId: Schema.String
525
- }))
526
- }))).mutation(({ ctx, input }) => serializeRunTests(async () => {
536
+ })).annotate({ description: "Hook-injected session attribution UUIDs; do not pass manually." })
537
+ });
538
+ /**
539
+ * The run body, promise-shaped because it drives Vitest's promise API and
540
+ * the AsyncLocalStorage stdio capture; every failure is folded into the
541
+ * `{ kind: "error" }` envelope, so it never rejects.
542
+ */
543
+ const runTestsBody = async (input, ctx) => {
527
544
  const files = input.files ? sanitizeTestArgs(input.files) : [];
528
545
  const project = input.project ? sanitizeTestArgs([input.project])[0] : void 0;
529
546
  const tagsInput = input.tags;
@@ -614,7 +631,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
614
631
  } : baseReport;
615
632
  let classifications;
616
633
  try {
617
- classifications = await ctx.runtime.runPromise(Effect.gen(function* () {
634
+ classifications = await ctx.runPromise(Effect.gen(function* () {
618
635
  const reader = yield* DataReader;
619
636
  const projects = project ? [project] : yield* reader.getRunsByProject().pipe(Effect.map((rs) => rs.map((r) => r.project)));
620
637
  const entries = [];
@@ -626,7 +643,7 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
626
643
  }));
627
644
  } catch {}
628
645
  const chatId = ctx.currentSessionId.get();
629
- if (chatId !== null) ctx.runtime.runPromise(Effect.gen(function* () {
646
+ if (chatId !== null) ctx.runPromise(Effect.gen(function* () {
630
647
  yield* (yield* DataStore).associateLatestRunWithSession({
631
648
  chatId,
632
649
  invocationMethod: "mcp"
@@ -679,7 +696,43 @@ const runTests = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({
679
696
  } catch {}
680
697
  }
681
698
  }
682
- }));
699
+ };
700
+ /**
701
+ * Handler for {@link runTestsTool}. Serialized through a one-permit
702
+ * semaphore: the body assigns the active attribution UUIDs into
703
+ * `process.env.VITEST_AGENT_*` and then awaits `createVitest` /
704
+ * `vitest.start`, which spawns the worker pool that snapshots env at
705
+ * spawn time — two interleaved calls would race, attributing one call's
706
+ * results to the other's agent.
707
+ *
708
+ * @public
709
+ */
710
+ const handleRunTests = (input) => Effect.gen(function* () {
711
+ const session = yield* McpSession;
712
+ const services = yield* Effect.context();
713
+ const ctx = {
714
+ cwd: session.cwd,
715
+ currentSessionId: session.currentSessionId,
716
+ sessionContext: session.sessionContext,
717
+ runPromise: (effect) => Effect.runPromise(Effect.provideContext(effect, services))
718
+ };
719
+ return yield* Semaphore.withPermit(runTestsSemaphore, Effect.promise(() => runTestsBody(input, ctx)));
720
+ });
721
+ /**
722
+ * The Effect-native `run_tests` tool.
723
+ *
724
+ * @public
725
+ */
726
+ const runTestsTool = Tool.make("run_tests", {
727
+ description: "Use to run Vitest tests, with optional file, project, and tag filters. structuredContent carries the typed AgentReport plus per-test classifications (discriminate on `kind`: ok, timeout, error, no-match). Unknown parameters are rejected — accepted keys are files, project, tags, passWithNoTests, timeout, projectRoot. When projectRoot is omitted, the server anchors the Vitest root at the directory of the vitest (or vite) config Vitest would load anyway, walking up from its boot dir and stopping at the git root — so a server booted inside a package subtree still resolves the root config's relative globalSetup/setupFiles correctly. projectRoot overrides that for this call and is used verbatim, but only after validation: it must be an existing directory belonging to the same git repository as ctx.cwd (checked via `git rev-parse --git-common-dir`, which is identical across a repo and all its worktrees, including a sibling worktree checked out from the same repo). A path in a different repository, or a non-existent path, is rejected with `{ kind: \"error\" }` naming both paths — never a silent fallback to ctx.cwd. The resolved root actually used is always echoed back on success. The legacy format=json arg is dropped — structuredContent supersedes it.",
728
+ parameters: RunTestsInput,
729
+ success: RunTestsResult,
730
+ dependencies: [
731
+ McpSession,
732
+ DataReader,
733
+ DataStore
734
+ ]
735
+ }).annotate(Tool.Title, "Run tests").annotate(Tool.Readonly, false).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, false).annotate(RenderText, (encoded) => formatRunTestsMarkdown(encoded));
683
736
 
684
737
  //#endregion
685
- export { RunTestsAsMarkdown, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTests, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
738
+ export { RunTestsInput, RunTestsResult, coerceErrors, composeTagExpression, formatNoMatchMarkdown, formatReportMarkdown, formatRunTestsMarkdown, handleRunTests, makeCoverageDirOverride, readDiscoveryLastScannedAt, resolveAnchoredConfigFile, resolveConfigAnchoredRoot, resolveGitCommonDir, resolveVitestNodeEntry, runTestsTool, sanitizeTestArgs, validateProjectRoot, vitestLoader, withStdioCaptured };
@@ -1,17 +1,18 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Schema, SchemaGetter } from "effect";
3
- import { DataReader } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
4
5
 
5
6
  //#region src/tools/settings-list.ts
6
- /**
7
- * `settings_list` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
7
  const SettingsRow = Schema.Struct({
12
8
  hash: Schema.String.annotate({ description: "Stable SHA-1 of the captured Vitest settings; FK target on test_runs." }),
13
9
  capturedAt: Schema.String.annotate({ description: "ISO-8601 timestamp the settings row was first written." })
14
10
  }).annotate({ identifier: "SettingsListRow" });
11
+ /**
12
+ * The `settings_list` tool's success payload.
13
+ *
14
+ * @public
15
+ */
15
16
  const SettingsListResult = Schema.Struct({
16
17
  count: Schema.Number,
17
18
  settings: Schema.Array(SettingsRow).annotate({ description: "Distinct captured settings hashes the reporter has written, newest first." })
@@ -35,13 +36,29 @@ const SettingsListAsMarkdown = SettingsListResult.pipe(Schema.decodeTo(Schema.St
35
36
  decode: SchemaGetter.transform((data) => formatSettingsListMarkdown(data)),
36
37
  encode: SchemaGetter.forbidden(() => "SettingsListAsMarkdown is one-way.")
37
38
  }));
38
- const settingsList = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({}))).query(async ({ ctx }) => ctx.runtime.runPromise(Effect.gen(function* () {
39
+ /**
40
+ * Handler for {@link settingsListTool}.
41
+ *
42
+ * @public
43
+ */
44
+ const handleSettingsList = () => Effect.gen(function* () {
39
45
  const settings = yield* (yield* DataReader).listSettings();
40
46
  return {
41
47
  count: settings.length,
42
48
  settings
43
49
  };
44
- })));
50
+ }).pipe(Effect.orDie);
51
+ /**
52
+ * The Effect-native `settings_list` tool. No parameters (the default
53
+ * `Tool.EmptyParams` serves as a strict empty object).
54
+ *
55
+ * @public
56
+ */
57
+ const settingsListTool = Tool.make("settings_list", {
58
+ description: "Use when you need every captured settings snapshot and its hash. Returns markdown in content[] and a typed JSON object in structuredContent ({ count, settings[] }).",
59
+ success: SettingsListResult,
60
+ dependencies: [DataReader]
61
+ }).annotate(Tool.Title, "Settings list").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatSettingsListMarkdown(encoded));
45
62
 
46
63
  //#endregion
47
- export { SettingsListAsMarkdown, SettingsListResult, formatSettingsListMarkdown, settingsList };
64
+ export { SettingsListResult, formatSettingsListMarkdown, handleSettingsList, settingsListTool };
package/tools/status.js CHANGED
@@ -1,13 +1,10 @@
1
- import { publicProcedure } from "../context.js";
1
+ import { RenderText } from "../annotations.js";
2
2
  import { Effect, Option, Schema, SchemaGetter } from "effect";
3
- import { CacheManifestEntry, DataReader } from "@vitest-agent/sdk";
3
+ import { DataReader } from "@vitest-agent/engine";
4
+ import { Tool } from "effect/unstable/ai";
5
+ import { CacheManifestEntry } from "@vitest-agent/sdk";
4
6
 
5
7
  //#region src/tools/status.ts
6
- /**
7
- * `test_status` MCP tool — Schema-driven implementation.
8
- *
9
- * @packageDocumentation
10
- */
11
8
  const StatusAvailable = Schema.Struct({
12
9
  dataAvailable: Schema.Literal(true).annotate({ description: "Discriminant — `true` when at least one project entry exists in the manifest." }),
13
10
  manifestUpdatedAt: Schema.String,
@@ -19,6 +16,11 @@ const StatusAbsent = Schema.Struct({
19
16
  projectFilter: Schema.optional(Schema.String),
20
17
  reason: Schema.Literals(["no_manifest", "project_filter_empty"])
21
18
  }).annotate({ identifier: "TestStatusAbsent" });
19
+ /**
20
+ * The `test_status` tool's success payload.
21
+ *
22
+ * @public
23
+ */
22
24
  const TestStatusResult = Schema.Union([StatusAvailable, StatusAbsent]).annotate({
23
25
  identifier: "TestStatusResult",
24
26
  title: "test_status result",
@@ -47,7 +49,19 @@ const TestStatusAsMarkdown = TestStatusResult.pipe(Schema.decodeTo(Schema.String
47
49
  decode: SchemaGetter.transform((data) => formatTestStatusMarkdown(data)),
48
50
  encode: SchemaGetter.forbidden(() => "TestStatusAsMarkdown is one-way.")
49
51
  }));
50
- const testStatus = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct({ project: Schema.optional(Schema.String) }))).query(async ({ ctx, input }) => ctx.runtime.runPromise(Effect.gen(function* () {
52
+ /**
53
+ * The `test_status` tool's parameters.
54
+ *
55
+ * @public
56
+ */
57
+ const TestStatusInput = Schema.Struct({ project: Schema.optionalKey(Schema.String).annotate({ description: "Filter to a specific project" }) });
58
+ /**
59
+ * Handler for {@link testStatusTool}: the single implementation of the
60
+ * tool.
61
+ *
62
+ * @public
63
+ */
64
+ const handleTestStatus = (input) => Effect.gen(function* () {
51
65
  const manifestOpt = yield* (yield* DataReader).getManifest();
52
66
  if (Option.isNone(manifestOpt)) return {
53
67
  dataAvailable: false,
@@ -67,7 +81,18 @@ const testStatus = publicProcedure.input(Schema.toStandardSchemaV1(Schema.Struct
67
81
  ...input.project !== void 0 && { projectFilter: input.project },
68
82
  entries
69
83
  };
70
- })));
84
+ }).pipe(Effect.orDie);
85
+ /**
86
+ * The Effect-native `test_status` tool.
87
+ *
88
+ * @public
89
+ */
90
+ const testStatusTool = Tool.make("test_status", {
91
+ 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).",
92
+ parameters: TestStatusInput,
93
+ success: TestStatusResult,
94
+ dependencies: [DataReader]
95
+ }).annotate(Tool.Title, "Test status").annotate(Tool.Readonly, true).annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, false).annotate(Tool.Idempotent, true).annotate(RenderText, (encoded) => formatTestStatusMarkdown(encoded));
71
96
 
72
97
  //#endregion
73
- export { TestStatusAsMarkdown, TestStatusResult, formatTestStatusMarkdown, testStatus };
98
+ export { TestStatusInput, TestStatusResult, formatTestStatusMarkdown, handleTestStatus, testStatusTool };