@vitest-agent/mcp 3.0.3 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
@@ -1,27 +1,5 @@
1
1
  //#region src/utils/crash-guards.ts
2
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
3
  * @param transportConnected - whether `server.connect(transport)` has
26
4
  * already resolved for this process
27
5
  * @returns `true` when the process should exit rather than continue
@@ -0,0 +1,12 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/utils/replay-marker.ts
4
+ /**
5
+ * Fields to spread into a replayable success struct.
6
+ *
7
+ * @public
8
+ */
9
+ const IdempotentReplayMarker = { _idempotentReplay: Schema.optionalKey(Schema.Literal(true)).annotate({ description: "Present (true) when this response was replayed from the idempotency cache instead of re-running the mutation." }) };
10
+
11
+ //#endregion
12
+ export { IdempotentReplayMarker };
@@ -2,22 +2,6 @@ import { formatFatalError } from "@vitest-agent/sdk";
2
2
 
3
3
  //#region src/utils/safe-format-fatal-error.ts
4
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
5
  * Fixed fallback emitted when the formatter itself throws. Deliberately a
22
6
  * constant: anything derived from the offending value could throw again
23
7
  * on the recovery path.
@@ -35,9 +35,9 @@ function coerceThrownMessage(err) {
35
35
  }
36
36
  /**
37
37
  * Builds the structured envelope a tool's catch-all wrapper returns
38
- * when its resolver throws unexpectedly.
38
+ * when its handler throws unexpectedly.
39
39
  *
40
- * @param toolName - the MCP tool name under which the resolver was registered
40
+ * @param toolName - the MCP tool name under which the handler was registered
41
41
  * @param err - the value thrown or the rejection reason
42
42
  * @public
43
43
  */
@@ -51,7 +51,7 @@ function buildUnexpectedToolErrorEnvelope(toolName, err) {
51
51
  remediation: {
52
52
  suggestedTool: toolName,
53
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.`
54
+ humanHint: `The "${toolName}" tool's handler 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
55
  }
56
56
  }
57
57
  };
package/version.js ADDED
@@ -0,0 +1,13 @@
1
+ //#region src/version.ts
2
+ /**
3
+ * The version of this package, inlined at build time from
4
+ * `package.json#version` via the bundler's `__PACKAGE_VERSION__` substitution.
5
+ * Exported for version introspection by downstream tooling, and consumed by
6
+ * `main.ts` to back the advertised `serverInfo.version`.
7
+ *
8
+ * @public
9
+ */
10
+ const CURRENT_MCP_VERSION = "4.0.0";
11
+
12
+ //#endregion
13
+ export { CURRENT_MCP_VERSION };
package/layers/McpLive.js DELETED
@@ -1,29 +0,0 @@
1
- import { Layer } from "effect";
2
- import * as NodeServices from "@effect/platform-node/NodeServices";
3
- import { layer } from "@effect/sql-sqlite-node/SqliteClient";
4
- import * as SqliteMigrator from "@effect/sql-sqlite-node/SqliteMigrator";
5
- import { DataReaderLive, DataStoreLive, LoggerLive, OutputPipelineLive, PROJECT_MIGRATIONS, ProjectDiscoveryLive } from "@vitest-agent/sdk";
6
-
7
- //#region src/layers/McpLive.ts
8
- /**
9
- * Builds the Effect Layer that provides all services required by the MCP server.
10
- *
11
- * Composes DataReader, DataStore, ProjectDiscovery, OutputPipeline, SQLite
12
- * client, migrator, NodeServices, and the logger into a single
13
- * layer suitable for `ManagedRuntime.make`.
14
- *
15
- * @param dbPath - absolute path to the SQLite database file
16
- * @param logLevel - optional log level; defaults to the logger's own default
17
- * @param logFile - optional path to write structured log output
18
- * @returns an Effect Layer providing all MCP runtime services
19
- * @public
20
- */
21
- const McpLive = (dbPath, logLevel, logFile) => {
22
- const SqliteLayer = layer({ filename: dbPath });
23
- const PlatformLayer = NodeServices.layer;
24
- const MigratorLayer = SqliteMigrator.layer({ loader: SqliteMigrator.fromRecord(PROJECT_MIGRATIONS) }).pipe(Layer.provide(Layer.merge(SqliteLayer, PlatformLayer)));
25
- return Layer.mergeAll(DataReaderLive, DataStoreLive, ProjectDiscoveryLive, OutputPipelineLive).pipe(Layer.provideMerge(MigratorLayer), Layer.provideMerge(SqliteLayer), Layer.provideMerge(PlatformLayer), Layer.provideMerge(LoggerLive(logLevel, logFile)));
26
- };
27
-
28
- //#endregion
29
- export { McpLive };
package/prompts/index.js DELETED
@@ -1,89 +0,0 @@
1
- import { explainFailurePrompt } from "./explain-failure.js";
2
- import { regressionSincePassPrompt } from "./regression-since-pass.js";
3
- import { tddResumePrompt } from "./tdd-resume.js";
4
- import { triagePrompt } from "./triage.js";
5
- import { whyFlakyPrompt } from "./why-flaky.js";
6
- import { wrapupPrompt } from "./wrapup.js";
7
- import { z } from "zod";
8
-
9
- //#region src/prompts/index.ts
10
- function toMessages(messages) {
11
- return messages.map((m) => ({
12
- role: m.role,
13
- content: {
14
- type: "text",
15
- text: m.content.text
16
- }
17
- }));
18
- }
19
- function registerAllPrompts(server) {
20
- server.registerPrompt("triage", {
21
- title: "Triage Recent Failures",
22
- description: "Orient toward a triage workflow over the most recent test run; compose triage_brief, failure_signature_get, hypothesis_record.",
23
- argsSchema: { project: z.optional(z.string()).describe("Filter to a specific project") }
24
- }, (args) => {
25
- return { messages: toMessages(triagePrompt(args.project !== void 0 ? { project: args.project } : {}).messages) };
26
- });
27
- server.registerPrompt("why-flaky", {
28
- title: "Diagnose a Flaky Test",
29
- description: "Diagnose why a named test is flaky; compose test_history and failure_signature_get with timing/shared-state framing.",
30
- argsSchema: {
31
- test: z.string().describe("Full hierarchical test name (e.g. 'Suite > nested > test')"),
32
- project: z.optional(z.string()).describe("Filter to a specific project")
33
- }
34
- }, (args) => {
35
- return { messages: toMessages(whyFlakyPrompt(args.project !== void 0 ? {
36
- test: args.test,
37
- project: args.project
38
- } : { test: args.test }).messages) };
39
- });
40
- server.registerPrompt("regression-since-pass", {
41
- title: "Find What Broke a Test",
42
- description: "Walk back from the test's most recent passing run to identify the change that broke it; compose test_history, commit_changes, turn_search.",
43
- argsSchema: {
44
- test: z.string().describe("Full hierarchical test name"),
45
- project: z.optional(z.string()).describe("Filter to a specific project")
46
- }
47
- }, (args) => {
48
- return { messages: toMessages(regressionSincePassPrompt(args.project !== void 0 ? {
49
- test: args.test,
50
- project: args.project
51
- } : { test: args.test }).messages) };
52
- });
53
- server.registerPrompt("explain-failure", {
54
- title: "Explain a Failure Class",
55
- description: "Synthesize a root-cause explanation from the recurrence history of a failure signature.",
56
- argsSchema: { signature: z.string().describe("16-char failure signature hex") }
57
- }, (args) => {
58
- return { messages: toMessages(explainFailurePrompt({ signature: args.signature }).messages) };
59
- });
60
- server.registerPrompt("tdd-resume", {
61
- title: "Resume TDD Work",
62
- description: "Resume the active TDD task from its current phase; iron-law reminder for evidence-bound transitions.",
63
- argsSchema: { sessionId: z.optional(z.string()).describe("Host session id (defaults to MCP server's recovered SessionContext)") }
64
- }, (args) => {
65
- return { messages: toMessages(tddResumePrompt(args.sessionId !== void 0 ? { sessionId: args.sessionId } : {}).messages) };
66
- });
67
- server.registerPrompt("wrapup", {
68
- title: "Generate a Session Wrapup",
69
- description: "Surface the same wrapup content the post-hooks emit automatically.",
70
- argsSchema: {
71
- kind: z.optional(z.enum([
72
- "stop",
73
- "session_end",
74
- "pre_compact",
75
- "tdd_handoff",
76
- "user_prompt_nudge"
77
- ])).describe("Wrapup variant (default: user_prompt_nudge)"),
78
- since: z.optional(z.string()).describe("ISO 8601 timestamp lower bound for activity to summarize")
79
- }
80
- }, (args) => {
81
- const wrapupArgs = {};
82
- if (args.kind !== void 0) wrapupArgs.kind = args.kind;
83
- if (args.since !== void 0) wrapupArgs.since = args.since;
84
- return { messages: toMessages(wrapupPrompt(wrapupArgs).messages) };
85
- });
86
- }
87
-
88
- //#endregion
89
- export { registerAllPrompts };
package/router.js DELETED
@@ -1,74 +0,0 @@
1
- import { router } from "./context.js";
2
- import { acceptanceMetrics } from "./tools/acceptance-metrics.js";
3
- import { cacheHealth } from "./tools/cache-health.js";
4
- import { commitChanges } from "./tools/commit-changes.js";
5
- import { configure } from "./tools/configure.js";
6
- import { testCoverage } from "./tools/coverage.js";
7
- import { testErrors } from "./tools/errors.js";
8
- import { failureSignatureGet } from "./tools/failure-signature-get.js";
9
- import { fileCoverage } from "./tools/file-coverage.js";
10
- import { help } from "./tools/help.js";
11
- import { testHistory } from "./tools/history.js";
12
- import { hypothesis } from "./tools/hypothesis.js";
13
- import { inventory } from "./tools/inventory.js";
14
- import { note } from "./tools/note.js";
15
- import { testOverview } from "./tools/overview.js";
16
- import { ping } from "./tools/ping.js";
17
- import { registerAgent } from "./tools/register-agent.js";
18
- import { runTests } from "./tools/run-tests.js";
19
- import { settingsList } from "./tools/settings-list.js";
20
- import { testStatus } from "./tools/status.js";
21
- import { tddArtifactList } from "./tools/tdd-artifact.js";
22
- import { tddBehavior } from "./tools/tdd-behavior.js";
23
- import { tddGoal } from "./tools/tdd-goal.js";
24
- import { tddPhaseTransitionRequest } from "./tools/tdd-phase-transition-request.js";
25
- import { tddTask } from "./tools/tdd-task.js";
26
- import { test } from "./tools/test.js";
27
- import { testTrends } from "./tools/trends.js";
28
- import { triageBrief } from "./tools/triage-brief.js";
29
- import { turnSearch } from "./tools/turn-search.js";
30
- import { wrapupPrompt } from "./tools/wrapup-prompt.js";
31
-
32
- //#region src/router.ts
33
- /**
34
- * The tRPC router aggregating all MCP tool procedures.
35
- *
36
- * Pass to `createCallerFactory` in tests, or to `createCallerFactory(appRouter)`
37
- * followed by `startMcpServer` in the bin entry to start the MCP server.
38
- *
39
- * @public
40
- */
41
- const appRouter = router({
42
- help,
43
- test_status: testStatus,
44
- test_overview: testOverview,
45
- test_coverage: testCoverage,
46
- test_history: testHistory,
47
- test_trends: testTrends,
48
- test_errors: testErrors,
49
- test,
50
- file_coverage: fileCoverage,
51
- run_tests: runTests,
52
- register_agent: registerAgent,
53
- cache_health: cacheHealth,
54
- configure,
55
- inventory,
56
- settings_list: settingsList,
57
- note,
58
- turn_search: turnSearch,
59
- failure_signature_get: failureSignatureGet,
60
- tdd_task: tddTask,
61
- tdd_phase_transition_request: tddPhaseTransitionRequest,
62
- tdd_goal: tddGoal,
63
- tdd_behavior: tddBehavior,
64
- tdd_artifact_list: tddArtifactList,
65
- hypothesis,
66
- acceptance_metrics: acceptanceMetrics,
67
- triage_brief: triageBrief,
68
- wrapup_prompt: wrapupPrompt,
69
- commit_changes: commitChanges,
70
- ping
71
- });
72
-
73
- //#endregion
74
- export { appRouter };
package/session-env.js DELETED
@@ -1,112 +0,0 @@
1
- import { readFileSync, readdirSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
4
-
5
- //#region src/session-env.ts
6
- /**
7
- * Call-time SessionContext recovery from the per-session env files the
8
- * plugin's SessionStart hook writes to `~/.claude/session-env/`.
9
- *
10
- * The boot-time env recovery (`sessionContextFromEnv`) depends on Claude
11
- * Code auto-sourcing `CLAUDE_ENV_FILE` into the MCP child — which loses
12
- * two races:
13
- *
14
- * 1. **Boot race.** On a fresh Claude Code launch the MCP child can spawn
15
- * before the SessionStart hook has written `CLAUDE_ENV_FILE`, so the
16
- * child's `process.env` never carries the canonical UUIDs (observed
17
- * live: MCP spawn at 00:41:50, env file written 00:41:51).
18
- * 2. **`/reload-plugins`.** A plugin reload restarts the MCP server
19
- * mid-session with a fresh environment that has no session exports.
20
- *
21
- * In both cases the SessionStart hook has (or will have) written the same
22
- * exports to a second, known-name surface:
23
- * `~/.claude/session-env/<chat_id>/vitest-agent-hook.sh`. This module
24
- * reads that surface directly, so a null boot context can be recovered
25
- * lazily at the first tool call that needs it.
26
- *
27
- * Selection rule: among all session dirs whose exports name this server's
28
- * `projectDir`, the newest-mtime file wins — the most recently started
29
- * session for this project. With two live Claude Code windows on the same
30
- * project this can name the other window's session; that ambiguity is
31
- * inherent to a per-project (not per-process) surface and is accepted —
32
- * the pre-existing alternative was no attribution at all.
33
- *
34
- * @packageDocumentation
35
- */
36
- const EXPORT_LINE = /^export ([A-Z_][A-Z0-9_]*)=(.*)$/;
37
- /**
38
- * Undo the `printf '%q'` quoting the SessionStart hook applies to export
39
- * values. UUIDs and plain paths arrive bare; values with specials arrive
40
- * as `$'...'`, `'...'`, `"..."`, or backslash-escaped words.
41
- */
42
- const unquote = (raw) => {
43
- let v = raw.trim();
44
- if (v.startsWith("$'") && v.endsWith("'") && v.length >= 3) v = v.slice(2, -1);
45
- else if (v.startsWith("'") && v.endsWith("'") || v.startsWith("\"") && v.endsWith("\"")) {
46
- if (v.length >= 2) v = v.slice(1, -1);
47
- }
48
- return v.replace(/\\(.)/g, "$1");
49
- };
50
- /**
51
- * Parse `export KEY=value` lines from a session-env hook file into a
52
- * plain record. Non-export lines are ignored.
53
- *
54
- * @param content - the raw text of a session-env hook file
55
- * @returns a record of export names to unquoted values
56
- * @public
57
- */
58
- const parseSessionEnvExports = (content) => {
59
- const out = {};
60
- for (const line of content.split("\n")) {
61
- const m = EXPORT_LINE.exec(line.trim());
62
- if (m?.[1] !== void 0 && m[2] !== void 0) out[m[1]] = unquote(m[2]);
63
- }
64
- return out;
65
- };
66
- /**
67
- * Recover a {@link SessionContext} from the newest session-env hook file
68
- * whose `VITEST_AGENT_PROJECT_DIR` matches `projectDir`.
69
- *
70
- * Returns `null` when the session-env root is missing, unreadable, or no
71
- * session dir matches the project. Never throws — recovery is best-effort
72
- * and callers fall back to their existing null-context behavior.
73
- *
74
- * @param opts - `projectDir` to match against; `sessionEnvRoot` overrides
75
- * the default `~/.claude/session-env` (tests)
76
- * @public
77
- */
78
- const recoverSessionContextFromSessionEnv = (opts) => {
79
- const root = opts.sessionEnvRoot ?? join(homedir(), ".claude", "session-env");
80
- const wantDir = resolve(opts.projectDir);
81
- let entries;
82
- try {
83
- entries = readdirSync(root);
84
- } catch {
85
- return null;
86
- }
87
- let best = null;
88
- for (const entry of entries) {
89
- const file = join(root, entry, "vitest-agent-hook.sh");
90
- try {
91
- const st = statSync(file);
92
- const env = parseSessionEnvExports(readFileSync(file, "utf8"));
93
- const chatId = env.VITEST_AGENT_CHAT_ID;
94
- const conversationId = env.VITEST_AGENT_CONVERSATION_ID;
95
- const mainAgentId = env.VITEST_AGENT_MAIN_AGENT_ID ?? env.VITEST_AGENT_AGENT_ID;
96
- const fileProjectDir = env.VITEST_AGENT_PROJECT_DIR;
97
- if (chatId === void 0 || chatId.length === 0 || conversationId === void 0 || conversationId.length === 0 || mainAgentId === void 0 || mainAgentId.length === 0 || fileProjectDir === void 0 || resolve(fileProjectDir) !== wantDir) continue;
98
- if (best === null || st.mtimeMs > best.mtimeMs) best = {
99
- mtimeMs: st.mtimeMs,
100
- ctx: {
101
- chatId,
102
- conversationId,
103
- mainAgentId
104
- }
105
- };
106
- } catch {}
107
- }
108
- return best === null ? null : best.ctx;
109
- };
110
-
111
- //#endregion
112
- export { parseSessionEnvExports, recoverSessionContextFromSessionEnv };
@@ -1,158 +0,0 @@
1
- import { Schema } from "effect";
2
- import { z } from "zod";
3
-
4
- //#region src/utils/effect-to-zod.ts
5
- /**
6
- * Bridge an Effect Schema to a zod schema by routing through JSON
7
- * Schema. Used at the MCP `registerTool` boundary so a tool can keep
8
- * Effect Schema as the canonical source of truth for its output shape
9
- * while the SDK still receives the zod instance it expects in the
10
- * `outputSchema` field.
11
- *
12
- * @packageDocumentation
13
- */
14
- /**
15
- * Convert an Effect `Schema.Codec<A, I>` to a zod schema by
16
- * serializing it to JSON Schema (`Schema.toJsonSchemaDocument`) and ingesting the
17
- * result via zod 4's `z.fromJSONSchema`.
18
- *
19
- * Trade-offs:
20
- * - Effect-Schema-only refinements (custom predicates, Brand types)
21
- * erase to plain JSON Schema primitives during the round-trip,
22
- * so the resulting zod schema does not enforce them. Tools that
23
- * need refinement enforcement at the MCP boundary should declare
24
- * zod directly.
25
- * - `Schema.NullOr(...)` round-trips correctly via JSON Schema's
26
- * `oneOf` / nullable representation that zod understands.
27
- * - `z.fromJSONSchema` is marked experimental in zod 4. The bridge
28
- * contains a smoke-test in the corresponding test file so an
29
- * incompatible upgrade surfaces immediately instead of in
30
- * production tool registrations.
31
- *
32
- * Implementation note: zod 4's `z.fromJSONSchema` does not resolve
33
- * `$ref` lookups into `$defs` — every `{ $ref: "#/$defs/X" }` it
34
- * encounters throws "Reference not found". Effect's `Schema.toJsonSchemaDocument`
35
- * emits a `$ref`-and-`$defs` representation whenever a Schema carries
36
- * an `identifier` annotation. The bridge therefore inlines every
37
- * `$ref` in the document before handing it to zod (recursive
38
- * substitution, then drop `$defs`). The schemas don't use
39
- * `Schema.suspend`, so the substitution is acyclic.
40
- *
41
- * Annotation lifting: Effect lowers a checked numeric schema
42
- * (`Schema.Finite`, `Schema.Int`) to `{ type, allOf: [{ description }] }`
43
- * rather than putting the annotation on the node itself, and
44
- * `z.fromJSONSchema` preserves that nesting verbatim. A tool's
45
- * `outputSchema` would then advertise no description for the field. The
46
- * bridge therefore lifts annotation-only `allOf` members onto their
47
- * parent before handing the document to zod. Only the JSON Schema
48
- * annotation vocabulary is lifted -- a member carrying any constraint
49
- * keyword (`pattern`, `minimum`, ...) stays put so validation semantics
50
- * are never changed.
51
- *
52
- * MCP SDK constraint: `outputSchema` must normalise to a Zod object
53
- * schema (`normalizeObjectSchema` returns `undefined` for unions, then
54
- * `safeParseAsync(undefined, ...)` crashes with "Cannot read properties
55
- * of undefined (reading '_zod')"). When the resulting zod schema is not
56
- * object-typed (e.g. came from `Schema.Union` of discriminated
57
- * variants), the bridge wraps it in a permissive `z.object({}).catchall(z.unknown())`
58
- * so the SDK accepts it. The structured content the tool emits still
59
- * conforms to the original Effect Schema; consumers just don't get a
60
- * rich JSON Schema for the union in the tool listing. Restructure the
61
- * source schema as a single `Schema.Struct` with a discriminator field
62
- * if the rich listing matters.
63
- */
64
- const effectToZodSchema = (schema) => {
65
- const document = Schema.toJsonSchemaDocument(schema);
66
- const jsonSchema = {
67
- ...document.schema,
68
- $defs: document.definitions
69
- };
70
- const inlined = inlineAllRefs(jsonSchema);
71
- const zodSchema = z.fromJSONSchema(inlined);
72
- if (isObjectLike(zodSchema)) return zodSchema;
73
- return z.object({}).catchall(z.unknown());
74
- };
75
- const isObjectLike = (schema) => {
76
- const def = schema._zod?.def;
77
- return def?.type === "object" || def?.shape !== void 0;
78
- };
79
- const REF_PREFIX = "#/$defs/";
80
- /**
81
- * The JSON Schema annotation vocabulary -- keywords that describe a
82
- * schema without constraining what it accepts. Only these are safe to
83
- * hoist out of an `allOf` member onto the parent node.
84
- */
85
- const ANNOTATION_KEYWORDS = /* @__PURE__ */ new Set([
86
- "title",
87
- "description",
88
- "examples",
89
- "default",
90
- "deprecated",
91
- "readOnly",
92
- "writeOnly",
93
- "contentEncoding",
94
- "contentMediaType"
95
- ]);
96
- const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
97
- /**
98
- * Hoist the annotation keywords out of every `allOf` member onto
99
- * `node`, leaving each member's constraint keywords exactly where they
100
- * were. A member reduced to nothing is dropped, and `allOf` disappears
101
- * once every member has been consumed.
102
- *
103
- * Keys already present on the node win -- a nested annotation never
104
- * overwrites a more specific one -- and the first member to supply a
105
- * given key wins among siblings.
106
- */
107
- const liftAnnotations = (node) => {
108
- const members = node.allOf;
109
- if (!Array.isArray(members)) return node;
110
- const kept = [];
111
- const lifted = {};
112
- for (const member of members) {
113
- if (!isPlainObject(member)) {
114
- kept.push(member);
115
- continue;
116
- }
117
- const constraints = {};
118
- for (const [key, value] of Object.entries(member)) if (!ANNOTATION_KEYWORDS.has(key)) constraints[key] = value;
119
- else if (!(key in node) && !(key in lifted)) lifted[key] = value;
120
- if (Object.keys(constraints).length > 0) kept.push(constraints);
121
- }
122
- if (Object.keys(lifted).length === 0) return node;
123
- const out = {
124
- ...node,
125
- ...lifted
126
- };
127
- if (kept.length > 0) out.allOf = kept;
128
- else delete out.allOf;
129
- return out;
130
- };
131
- /**
132
- * Walk a JSON Schema tree and replace every `$ref: "#/$defs/X"` node
133
- * with the contents of `$defs.X`, recursively. The `$defs` table is
134
- * dropped from the returned root.
135
- */
136
- const inlineAllRefs = (root) => {
137
- const defs = root.$defs ?? {};
138
- const visit = (value) => {
139
- if (Array.isArray(value)) return value.map(visit);
140
- if (value === null || typeof value !== "object") return value;
141
- const obj = value;
142
- if (typeof obj.$ref === "string" && obj.$ref.startsWith(REF_PREFIX)) {
143
- const defName = obj.$ref.slice(8);
144
- const target = defs[defName];
145
- if (target !== void 0) return visit(target);
146
- }
147
- const out = {};
148
- for (const [k, v] of Object.entries(obj)) {
149
- if (k === "$defs") continue;
150
- out[k] = visit(v);
151
- }
152
- return liftAnnotations(out);
153
- };
154
- return visit(root);
155
- };
156
-
157
- //#endregion
158
- export { effectToZodSchema };