@mandujs/mcp 0.22.4 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/mcp",
3
- "version": "0.22.4",
3
+ "version": "0.24.0",
4
4
  "description": "Mandu MCP Server - Agent-native interface for Mandu framework operations",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@mandujs/core": "^0.37.0",
38
- "@mandujs/ate": "^0.19.2",
38
+ "@mandujs/ate": "^0.21.0",
39
39
  "@mandujs/skills": "^16.0.0",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
41
41
  },
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `mandu_ate_context` — Phase A.1 agent-native context tool.
3
+ *
4
+ * See `docs/ate/roadmap-v2-agent-native.md` §4.1 for the full design
5
+ * and §11 decision 4 for the naming convention (snake_case).
6
+ *
7
+ * Semantics: return a single JSON blob that an LLM-driven agent
8
+ * (Cursor / Claude Code / Codex) can read *before* generating a test.
9
+ * The blob fuses:
10
+ *
11
+ * 1. Route metadata (pattern, file, isRedirect, static params)
12
+ * 2. Contract surface (request/response schemas + examples)
13
+ * 3. Middleware chain (canonical name + options + file)
14
+ * 4. Guard preset + suggested data-route-id selectors
15
+ * 5. Fixture recommendations (createTestSession, createTestDb, ...)
16
+ * 6. Existing specs (user-written vs ate-generated, last-run status)
17
+ * 7. Related routes (siblings + ui-entry-point pairing)
18
+ *
19
+ * The handler itself is deliberately thin — almost all work is done
20
+ * inside `@mandujs/ate`'s `buildContext` so the same logic is
21
+ * importable from non-MCP callers (CLI, tests).
22
+ */
23
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
24
+ import { ateContext } from "@mandujs/ate";
25
+
26
+ export const ateContextToolDefinitions: Tool[] = [
27
+ {
28
+ name: "mandu_ate_context",
29
+ annotations: {
30
+ readOnlyHint: true,
31
+ },
32
+ description:
33
+ "Phase A.1 agent-native context. Returns a single JSON blob containing the " +
34
+ "Mandu-specific semantic context an LLM needs to write a correct test: " +
35
+ "route metadata, contract (with examples), middleware chain, guard preset + " +
36
+ "suggested [data-route-id] selectors, recommended @mandujs/core/testing fixtures, " +
37
+ "existing specs (with last-run status when .mandu/ate-last-run.json is present), " +
38
+ "and related routes (sibling + ui-entry-point pairing). " +
39
+ "Scope values: " +
40
+ "'project' = repo summary with route + coverage counts; " +
41
+ "'route' = single-route deep view (requires id or route); " +
42
+ "'filling' = server-handler view with middleware + actions (requires id); " +
43
+ "'contract' = request/response + examples for a contract definition. " +
44
+ "Run mandu.ate.extract first — this tool reads .mandu/interaction-graph.json.",
45
+ inputSchema: {
46
+ type: "object",
47
+ properties: {
48
+ repoRoot: {
49
+ type: "string",
50
+ description: "Absolute path to the Mandu project root",
51
+ },
52
+ scope: {
53
+ type: "string",
54
+ enum: ["project", "route", "filling", "contract"],
55
+ description:
56
+ "project (summary) | route (single route deep view) | filling (handler view) | contract (contract definition view)",
57
+ },
58
+ id: {
59
+ type: "string",
60
+ description:
61
+ "Route id ('api-signup'), filling id ('filling:api-signup'), or contract name. Optional — supply id OR route.",
62
+ },
63
+ route: {
64
+ type: "string",
65
+ description:
66
+ "Route pattern match (e.g. '/api/signup'). Optional — supply id OR route.",
67
+ },
68
+ },
69
+ required: ["repoRoot", "scope"],
70
+ },
71
+ },
72
+ ];
73
+
74
+ export function ateContextTools(_projectRoot: string) {
75
+ return {
76
+ mandu_ate_context: async (args: Record<string, unknown>) => {
77
+ const { repoRoot, scope, id, route } = args as {
78
+ repoRoot: string;
79
+ scope: "project" | "route" | "filling" | "contract";
80
+ id?: string;
81
+ route?: string;
82
+ };
83
+ // Minimal validation — the MCP SDK already enforces the schema,
84
+ // but we guard repoRoot explicitly so mis-invocations surface a
85
+ // loud error rather than a cascading filesystem failure.
86
+ if (!repoRoot || typeof repoRoot !== "string") {
87
+ return { ok: false, error: "repoRoot is required" };
88
+ }
89
+ if (!scope) {
90
+ return { ok: false, error: "scope is required" };
91
+ }
92
+ const blob = await ateContext({ repoRoot, scope, id, route });
93
+ return { ok: true, context: blob };
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `mandu_ate_exemplar` — Phase A.3 exemplar browser.
3
+ *
4
+ * See `docs/ate/roadmap-v2-agent-native.md` §4.3. Returns the
5
+ * `@ate-exemplar:` tagged tests for a given kind so an agent can
6
+ * few-shot against them without paying the "compose the whole prompt"
7
+ * token cost.
8
+ *
9
+ * Snake_case tool name (§11 decision #4). Read-only.
10
+ */
11
+
12
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
+ import { scanExemplars, type Exemplar } from "@mandujs/ate";
14
+
15
+ export const ateExemplarToolDefinitions: Tool[] = [
16
+ {
17
+ name: "mandu_ate_exemplar",
18
+ annotations: {
19
+ readOnlyHint: true,
20
+ },
21
+ description:
22
+ "Phase A.3 agent-native exemplar browser. Returns up to `limit` tests " +
23
+ "tagged with `@ate-exemplar: kind=<kind>` from the repo. Each entry " +
24
+ "carries the file path, start/end line, tags, and the full source of the " +
25
+ "test() / it() / describe() call that follows the tag. Set " +
26
+ "includeAnti:true to also surface `@ate-exemplar-anti:` (DO-NOT-do-this) " +
27
+ "cases. Exemplars are manually curated (roadmap §11 decision 2) — no " +
28
+ "auto-heuristic. Use this when you want few-shot examples without paying " +
29
+ "for the full composed prompt.",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ repoRoot: {
34
+ type: "string",
35
+ description: "Absolute path to the Mandu project root.",
36
+ },
37
+ kind: {
38
+ type: "string",
39
+ description:
40
+ "Match against the tag's `kind=` attribute. Examples: filling_unit, filling_integration, e2e_playwright.",
41
+ },
42
+ limit: {
43
+ type: "number",
44
+ description: "Max entries to return. Default 5.",
45
+ },
46
+ includeAnti: {
47
+ type: "boolean",
48
+ description:
49
+ "Also include @ate-exemplar-anti markers (default false — only positive exemplars).",
50
+ },
51
+ },
52
+ required: ["repoRoot", "kind"],
53
+ },
54
+ },
55
+ ];
56
+
57
+ export function ateExemplarTools(_projectRoot: string) {
58
+ return {
59
+ mandu_ate_exemplar: async (args: Record<string, unknown>) => {
60
+ const repoRoot = args.repoRoot as string | undefined;
61
+ const kind = args.kind as string | undefined;
62
+ const limit = typeof args.limit === "number" ? args.limit : 5;
63
+ const includeAnti = args.includeAnti === true;
64
+
65
+ if (!repoRoot || typeof repoRoot !== "string") {
66
+ return { ok: false, error: "'repoRoot' is required" };
67
+ }
68
+ if (!kind || typeof kind !== "string") {
69
+ return { ok: false, error: "'kind' is required" };
70
+ }
71
+
72
+ try {
73
+ const all = await scanExemplars(repoRoot);
74
+ const filtered = all.filter((e) => e.kind === kind);
75
+ const selected: Exemplar[] = [];
76
+ const positives = filtered.filter((e) => !e.anti).slice(0, limit);
77
+ selected.push(...positives);
78
+ if (includeAnti) {
79
+ // Reserve up to half the limit for antis so positives aren't crowded out.
80
+ const antiBudget = Math.max(1, Math.floor(limit / 2));
81
+ const antis = filtered.filter((e) => e.anti).slice(0, antiBudget);
82
+ selected.push(...antis);
83
+ }
84
+
85
+ return { ok: true, exemplars: selected, total: filtered.length };
86
+ } catch (err) {
87
+ const msg = err instanceof Error ? err.message : String(err);
88
+ return { ok: false, error: msg };
89
+ }
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `mandu_ate_flakes` — Phase A.2 flake detector surface.
3
+ *
4
+ * Returns every spec whose rolling pass/fail transition ratio exceeds
5
+ * `minScore` (default 0.1) within the last `windowSize` runs
6
+ * (default 20). Agents use this to prioritize stabilization work.
7
+ *
8
+ * Data source: `.mandu/ate-run-history.jsonl`, appended to by
9
+ * `runSpec`. When no history is present we return an empty array —
10
+ * not an error.
11
+ *
12
+ * Snake_case naming per §11 decision 4.
13
+ */
14
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
15
+ import { summarizeFlakes } from "@mandujs/ate";
16
+
17
+ export const ateFlakesToolDefinitions: Tool[] = [
18
+ {
19
+ name: "mandu_ate_flakes",
20
+ annotations: {
21
+ readOnlyHint: true,
22
+ },
23
+ description:
24
+ "Phase A.2 flake detector. Reads `.mandu/ate-run-history.jsonl` and returns specs " +
25
+ "whose pass/fail status flips often within the rolling window. `flakeScore` = " +
26
+ "status_transitions / (N - 1) over last `windowSize` non-skipped runs. " +
27
+ "Pure-pass PPPPP = 0.0 (stable), pure-fail FFFFF = 0.0 (broken, NOT flaky), " +
28
+ "alternating PFPF = 1.0. Returns an empty list when history is empty or no spec " +
29
+ "clears `minScore`. Use this to prioritize which flaky tests to fix first — feed " +
30
+ "a specPath from the result into mandu_ate_run for a re-run + full failure.v1 " +
31
+ "diagnostic.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ repoRoot: {
36
+ type: "string",
37
+ description: "Absolute path to the Mandu project root",
38
+ },
39
+ windowSize: {
40
+ type: "number",
41
+ minimum: 2,
42
+ description: "Rolling window size. Default: 20.",
43
+ },
44
+ minScore: {
45
+ type: "number",
46
+ minimum: 0,
47
+ maximum: 1,
48
+ description: "Filter threshold for flakeScore. Default: 0.1.",
49
+ },
50
+ },
51
+ required: ["repoRoot"],
52
+ },
53
+ },
54
+ ];
55
+
56
+ export function ateFlakesTools(_projectRoot: string) {
57
+ return {
58
+ mandu_ate_flakes: async (args: Record<string, unknown>) => {
59
+ const { repoRoot, windowSize, minScore } = args as {
60
+ repoRoot: string;
61
+ windowSize?: number;
62
+ minScore?: number;
63
+ };
64
+ if (!repoRoot || typeof repoRoot !== "string") {
65
+ return { ok: false, error: "repoRoot is required" };
66
+ }
67
+ if (typeof windowSize === "number" && windowSize < 2) {
68
+ return { ok: false, error: "windowSize must be >= 2" };
69
+ }
70
+ if (
71
+ typeof minScore === "number" &&
72
+ (minScore < 0 || minScore > 1 || !Number.isFinite(minScore))
73
+ ) {
74
+ return { ok: false, error: "minScore must be in [0, 1]" };
75
+ }
76
+ try {
77
+ const flakyTests = summarizeFlakes(repoRoot, {
78
+ windowSize,
79
+ minScore,
80
+ });
81
+ return { ok: true, flakyTests };
82
+ } catch (err) {
83
+ return {
84
+ ok: false,
85
+ error: `summarizeFlakes failed: ${err instanceof Error ? err.message : String(err)}`,
86
+ };
87
+ }
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * `mandu_ate_prompt` — Phase A.3 prompt catalog tool.
3
+ *
4
+ * See `docs/ate/roadmap-v2-agent-native.md` §4.2 and §7 (A.3 extension
5
+ * "Pre-composed prompts") for the design.
6
+ *
7
+ * Semantics:
8
+ * - If `context` is provided, the handler composes the full prompt
9
+ * (template + matched exemplars + serialized context) and returns
10
+ * a single ready-to-send-to-LLM string.
11
+ * - If `context` is omitted, the handler returns the raw template body +
12
+ * sha256 + a peek at available exemplars — the agent composes.
13
+ *
14
+ * Snake_case tool name (§11 decision #4). Read-only.
15
+ */
16
+
17
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
18
+ import {
19
+ loadPrompt,
20
+ scanExemplars,
21
+ composePrompt,
22
+ type Exemplar,
23
+ } from "@mandujs/ate";
24
+
25
+ export const atePromptToolDefinitions: Tool[] = [
26
+ {
27
+ name: "mandu_ate_prompt",
28
+ annotations: {
29
+ readOnlyHint: true,
30
+ },
31
+ description:
32
+ "Phase A.3 agent-native prompt catalog. Returns the system prompt for a " +
33
+ "given test kind, with Mandu-specific primitives, anti-patterns, and the " +
34
+ "selector convention baked in. When `context` is passed, the handler " +
35
+ "also injects up-to-3 matching exemplars (tagged with @ate-exemplar:) " +
36
+ "plus the JSON context block and returns a fully composed, ready-to-" +
37
+ "send-to-LLM string. When `context` is omitted, the handler returns the " +
38
+ "raw template + the separate exemplar list so the agent can compose. " +
39
+ "Kinds available in v1: filling_unit, filling_integration, e2e_playwright. " +
40
+ "The returned sha256 is stable per-version and safe as a cache key.",
41
+ inputSchema: {
42
+ type: "object",
43
+ properties: {
44
+ repoRoot: {
45
+ type: "string",
46
+ description:
47
+ "Absolute path to the Mandu project root. Required when context is " +
48
+ "omitted so we can scan exemplars from the repo.",
49
+ },
50
+ kind: {
51
+ type: "string",
52
+ description:
53
+ "Prompt kind. v1 catalog: filling_unit | filling_integration | e2e_playwright.",
54
+ },
55
+ version: {
56
+ type: "number",
57
+ description:
58
+ "Pin to a specific template version. Defaults to the highest available.",
59
+ },
60
+ context: {
61
+ type: "object",
62
+ description:
63
+ "Optional semantic context (usually the output of mandu_ate_context). " +
64
+ "When present, the returned `prompt` is pre-composed.",
65
+ additionalProperties: true,
66
+ },
67
+ maxPositive: {
68
+ type: "number",
69
+ description: "Max positive exemplars to inject. Default 3.",
70
+ },
71
+ maxAnti: {
72
+ type: "number",
73
+ description: "Max anti-exemplars to inject. Default 1.",
74
+ },
75
+ },
76
+ required: ["kind"],
77
+ },
78
+ },
79
+ ];
80
+
81
+ export function atePromptTools(_projectRoot: string) {
82
+ return {
83
+ mandu_ate_prompt: async (args: Record<string, unknown>) => {
84
+ const kind = args.kind as string | undefined;
85
+ if (!kind || typeof kind !== "string") {
86
+ return { ok: false, error: "'kind' is required and must be a string" };
87
+ }
88
+
89
+ const version = typeof args.version === "number" ? args.version : undefined;
90
+ const repoRoot = typeof args.repoRoot === "string" ? args.repoRoot : undefined;
91
+ const context = args.context;
92
+ const maxPositive = typeof args.maxPositive === "number" ? args.maxPositive : undefined;
93
+ const maxAnti = typeof args.maxAnti === "number" ? args.maxAnti : undefined;
94
+
95
+ try {
96
+ // When context is given, return a pre-composed prompt.
97
+ if (context !== undefined) {
98
+ const composed = await composePrompt({
99
+ kind,
100
+ version,
101
+ context,
102
+ repoRoot,
103
+ ...(maxPositive !== undefined ? { maxPositive } : {}),
104
+ ...(maxAnti !== undefined ? { maxAnti } : {}),
105
+ });
106
+ return {
107
+ ok: true,
108
+ prompt: composed.prompt,
109
+ sha256: composed.sha256,
110
+ version: composed.version,
111
+ kind: composed.kind,
112
+ exemplarCount: composed.exemplarCount,
113
+ antiCount: composed.antiCount,
114
+ tokenEstimate: composed.tokenEstimate,
115
+ };
116
+ }
117
+
118
+ // Otherwise: raw template + exemplar peek so the agent composes.
119
+ const loaded = loadPrompt(kind, version);
120
+ let exemplars: Exemplar[] = [];
121
+ if (repoRoot) {
122
+ try {
123
+ const all = await scanExemplars(repoRoot);
124
+ exemplars = all.filter((e) => e.kind === kind).slice(0, 5);
125
+ } catch {
126
+ // non-fatal — caller may invoke without a repoRoot
127
+ }
128
+ }
129
+ return {
130
+ ok: true,
131
+ prompt: loaded.raw,
132
+ sha256: loaded.sha256,
133
+ version: loaded.frontmatter.version,
134
+ kind: loaded.frontmatter.kind,
135
+ exemplars,
136
+ exemplarCount: exemplars.filter((e) => !e.anti).length,
137
+ antiCount: exemplars.filter((e) => e.anti).length,
138
+ tokenEstimate: Math.ceil(loaded.raw.length / 4),
139
+ };
140
+ } catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ return { ok: false, error: msg };
143
+ }
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * `mandu_ate_run` — Phase A.2 agent-facing spec runner.
3
+ *
4
+ * Wraps `@mandujs/ate`'s `runSpec` behind the MCP tool surface.
5
+ *
6
+ * Semantics: execute a single spec file (Playwright or bun:test,
7
+ * auto-detected from the path), then return the failure.v1-shaped
8
+ * JSON — `{ status: "pass", ... }` on green, full failure envelope
9
+ * on red. Shard argument is forwarded transparently.
10
+ *
11
+ * The handler validates the returned shape against the failure.v1
12
+ * Zod schema on failure (cheap, catches translator regressions).
13
+ * On pass we return the pass envelope as-is.
14
+ *
15
+ * Snake_case naming per §11 decision 4.
16
+ */
17
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
18
+ import { runSpec, failureV1Schema, type RunResult } from "@mandujs/ate";
19
+
20
+ export const ateRunToolDefinitions: Tool[] = [
21
+ {
22
+ name: "mandu_ate_run",
23
+ annotations: {
24
+ readOnlyHint: false,
25
+ },
26
+ description:
27
+ "Phase A.2 agent-native spec runner. Executes ONE spec file " +
28
+ "(Playwright if the path matches tests/e2e/** or *.e2e.ts, otherwise bun:test) " +
29
+ "and returns structured JSON. On pass: { status: 'pass', durationMs, assertions, graphVersion, runId }. " +
30
+ "On fail: a failure.v1 envelope with discriminated `kind` (one of: selector_drift, " +
31
+ "contract_mismatch, redirect_unexpected, hydration_timeout, rate_limit_exceeded, " +
32
+ "csrf_invalid, fixture_missing, semantic_divergence), kind-specific `detail`, " +
33
+ "`healing.auto[]` (deterministic replacements when confidence >= threshold), " +
34
+ "`healing.requires_llm` (true for shape-level failures), `flakeScore`, `lastPassedAt`, " +
35
+ "`graphVersion` (agent cache invalidation key), and trace/screenshot/dom artifacts " +
36
+ "staged under .mandu/ate-artifacts/<runId>/. Use `shard: { current, total }` to " +
37
+ "distribute across CI workers.",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: {
41
+ repoRoot: {
42
+ type: "string",
43
+ description: "Absolute path to the Mandu project root",
44
+ },
45
+ spec: {
46
+ oneOf: [
47
+ { type: "string" },
48
+ {
49
+ type: "object",
50
+ properties: {
51
+ path: { type: "string" },
52
+ },
53
+ required: ["path"],
54
+ },
55
+ ],
56
+ description:
57
+ "Spec file — either a path string (relative to repoRoot) or { path }. " +
58
+ "Runner is auto-detected from the path (Playwright vs bun:test).",
59
+ },
60
+ headed: {
61
+ type: "boolean",
62
+ description: "Playwright only — run headed. Default: false (headless).",
63
+ },
64
+ trace: {
65
+ type: "boolean",
66
+ description: "Playwright only — capture trace. Default: true.",
67
+ },
68
+ shard: {
69
+ type: "object",
70
+ properties: {
71
+ current: { type: "number", minimum: 1 },
72
+ total: { type: "number", minimum: 1 },
73
+ },
74
+ required: ["current", "total"],
75
+ description:
76
+ "CI sharding — `current` is 1-based. Playwright receives --shard=current/total; " +
77
+ "bun:test falls back to hash-based partitioning.",
78
+ },
79
+ },
80
+ required: ["repoRoot", "spec"],
81
+ },
82
+ },
83
+ ];
84
+
85
+ export function ateRunTools(_projectRoot: string) {
86
+ return {
87
+ mandu_ate_run: async (args: Record<string, unknown>) => {
88
+ const { repoRoot, spec, headed, trace, shard } = args as {
89
+ repoRoot: string;
90
+ spec: string | { path: string };
91
+ headed?: boolean;
92
+ trace?: boolean;
93
+ shard?: { current: number; total: number };
94
+ };
95
+ if (!repoRoot || typeof repoRoot !== "string") {
96
+ return { ok: false, error: "repoRoot is required" };
97
+ }
98
+ if (!spec) {
99
+ return { ok: false, error: "spec is required" };
100
+ }
101
+ const specPath = typeof spec === "string" ? spec : spec?.path;
102
+ if (!specPath || typeof specPath !== "string") {
103
+ return { ok: false, error: "spec.path or spec string is required" };
104
+ }
105
+ if (shard) {
106
+ if (
107
+ typeof shard.current !== "number" ||
108
+ typeof shard.total !== "number" ||
109
+ shard.current < 1 ||
110
+ shard.total < 1 ||
111
+ shard.current > shard.total
112
+ ) {
113
+ return {
114
+ ok: false,
115
+ error: `invalid shard: ${JSON.stringify(shard)} (current must be 1..total)`,
116
+ };
117
+ }
118
+ }
119
+
120
+ let result: RunResult;
121
+ try {
122
+ result = await runSpec({
123
+ repoRoot,
124
+ spec: specPath,
125
+ headed,
126
+ trace,
127
+ shard,
128
+ });
129
+ } catch (err) {
130
+ return {
131
+ ok: false,
132
+ error: `runSpec failed: ${err instanceof Error ? err.message : String(err)}`,
133
+ };
134
+ }
135
+
136
+ // On failure, re-validate the shape against failure.v1. The
137
+ // runSpec path already does this, but re-checking at the MCP
138
+ // boundary means a buggy translator is caught before the
139
+ // payload crosses the wire.
140
+ if (result.status === "fail") {
141
+ const parsed = failureV1Schema.safeParse(result);
142
+ if (!parsed.success) {
143
+ return {
144
+ ok: false,
145
+ error: `runSpec emitted invalid failure.v1: ${parsed.error.issues[0]?.message ?? "schema mismatch"}`,
146
+ result,
147
+ };
148
+ }
149
+ return { ok: true, result: parsed.data };
150
+ }
151
+ return { ok: true, result };
152
+ },
153
+ };
154
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * `mandu_ate_save` — Phase A.3 spec persistence with lint-before-write.
3
+ *
4
+ * See `docs/ate/roadmap-v2-agent-native.md` §4.7 and the §7 extension
5
+ * ("mandu_ate_save lint-before-write").
6
+ *
7
+ * Semantics:
8
+ * 1. Run `lintSpecContent` (from @mandujs/ate) which:
9
+ * - parses with ts-morph (syntax errors block),
10
+ * - walks import declarations (banned typos, unknown @mandujs/* barrels),
11
+ * - detects anti-patterns (bare localhost, hand-rolled CSRF, DB mocks).
12
+ * 2. If any *blocking* diagnostic fires, return { saved: false, ... } WITHOUT
13
+ * writing. Otherwise write and return { saved: true, path }.
14
+ *
15
+ * Snake_case tool name (§11 decision #4).
16
+ */
17
+
18
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
19
+ import { writeFileSync, mkdirSync, existsSync, statSync } from "node:fs";
20
+ import { dirname, isAbsolute } from "node:path";
21
+ import { lintSpecContent, type LintDiagnostic } from "@mandujs/ate";
22
+
23
+ // Re-export the diagnostic shape so callers can type-check against it without
24
+ // pulling @mandujs/ate directly.
25
+ export type { LintDiagnostic, LintSeverity } from "@mandujs/ate";
26
+
27
+ export const ateSaveToolDefinitions: Tool[] = [
28
+ {
29
+ name: "mandu_ate_save",
30
+ description:
31
+ "Phase A.3 persist-with-lint. Writes an agent-generated test file to " +
32
+ "disk, but first runs a small lint pass that blocks common LLM mistakes: " +
33
+ "ts-morph syntax errors, unresolved / banned import paths, hand-rolled " +
34
+ "CSRF cookies, DB mocks when createTestDb is available, and bare " +
35
+ "`localhost:<port>` URLs (prefer 127.0.0.1 per roadmap §9.2). Returns " +
36
+ "{ saved: true, path, lintDiagnostics: [warnings...] } on success or " +
37
+ "{ saved: false, blockingErrors: [...], lintDiagnostics: [...] } when " +
38
+ "a blocker fires (in which case no file is written).",
39
+ inputSchema: {
40
+ type: "object",
41
+ properties: {
42
+ path: {
43
+ type: "string",
44
+ description:
45
+ "Absolute path where the spec will be written. Parent directories are created if needed.",
46
+ },
47
+ content: {
48
+ type: "string",
49
+ description: "The full TypeScript test source to write.",
50
+ },
51
+ intent: {
52
+ type: "string",
53
+ description:
54
+ "Optional short description of what the test is verifying (logged to ATE memory).",
55
+ },
56
+ kind: {
57
+ type: "string",
58
+ description:
59
+ "Optional prompt kind this spec was generated for (filling_unit, filling_integration, e2e_playwright).",
60
+ },
61
+ sourcePrompt: {
62
+ type: "object",
63
+ description:
64
+ "Optional { kind, version } back-reference to the prompt that produced this spec (used by future memory queries).",
65
+ additionalProperties: true,
66
+ },
67
+ allowWarnings: {
68
+ type: "boolean",
69
+ description:
70
+ "When true, non-blocking warnings still allow the write (default true). When false, even warnings block.",
71
+ },
72
+ },
73
+ required: ["path", "content"],
74
+ },
75
+ },
76
+ ];
77
+
78
+ export function ateSaveTools(_projectRoot: string) {
79
+ return {
80
+ mandu_ate_save: async (args: Record<string, unknown>) => {
81
+ const path = args.path as string | undefined;
82
+ const content = args.content as string | undefined;
83
+ const allowWarnings = args.allowWarnings !== false;
84
+
85
+ if (!path || typeof path !== "string") {
86
+ return { saved: false, error: "'path' is required" };
87
+ }
88
+ if (!isAbsolute(path)) {
89
+ return {
90
+ saved: false,
91
+ error: "'path' must be absolute — relative paths are rejected to prevent cwd drift.",
92
+ };
93
+ }
94
+ if (typeof content !== "string") {
95
+ return { saved: false, error: "'content' is required and must be a string" };
96
+ }
97
+
98
+ const diagnostics = await lintSpecContent(path, content);
99
+
100
+ const blocking = diagnostics.filter((d) => d.blocking);
101
+ const warnings = diagnostics.filter((d) => !d.blocking);
102
+
103
+ if (blocking.length > 0 || (!allowWarnings && warnings.length > 0)) {
104
+ return {
105
+ saved: false,
106
+ path,
107
+ blockingErrors: blocking,
108
+ lintDiagnostics: diagnostics,
109
+ };
110
+ }
111
+
112
+ const parent = dirname(path);
113
+ if (!existsSync(parent)) {
114
+ mkdirSync(parent, { recursive: true });
115
+ }
116
+ if (!statSync(parent).isDirectory()) {
117
+ return { saved: false, path, error: `Parent path is not a directory: ${parent}` };
118
+ }
119
+
120
+ writeFileSync(path, content, "utf8");
121
+
122
+ return {
123
+ saved: true,
124
+ path,
125
+ bytes: Buffer.byteLength(content, "utf8"),
126
+ lintDiagnostics: diagnostics,
127
+ };
128
+ },
129
+ };
130
+ }
131
+
132
+ // Re-export for tests that need direct access (package.json test patterns
133
+ // already reach here).
134
+ export async function lintContent(
135
+ path: string,
136
+ content: string,
137
+ ): Promise<LintDiagnostic[]> {
138
+ return lintSpecContent(path, content);
139
+ }
@@ -28,6 +28,12 @@ export { runtimeTools, runtimeToolDefinitions } from "./runtime.js";
28
28
  export { seoTools, seoToolDefinitions } from "./seo.js";
29
29
  export { projectTools, projectToolDefinitions, getDevServerState } from "./project.js";
30
30
  export { ateTools, ateToolDefinitions, atePhase5ToolDefinitions, createAtePhase5Handlers } from "./ate.js";
31
+ export { ateContextTools, ateContextToolDefinitions } from "./ate-context.js";
32
+ export { ateRunTools, ateRunToolDefinitions } from "./ate-run.js";
33
+ export { ateFlakesTools, ateFlakesToolDefinitions } from "./ate-flakes.js";
34
+ export { atePromptTools, atePromptToolDefinitions } from "./ate-prompt.js";
35
+ export { ateExemplarTools, ateExemplarToolDefinitions } from "./ate-exemplar.js";
36
+ export { ateSaveTools, ateSaveToolDefinitions } from "./ate-save.js";
31
37
  export { resourceTools, resourceToolDefinitions } from "./resource.js";
32
38
  export { componentTools, componentToolDefinitions } from "./component.js";
33
39
  export { kitchenTools, kitchenToolDefinitions } from "./kitchen.js";
@@ -68,6 +74,12 @@ import { runtimeTools, runtimeToolDefinitions } from "./runtime.js";
68
74
  import { seoTools, seoToolDefinitions } from "./seo.js";
69
75
  import { projectTools, projectToolDefinitions } from "./project.js";
70
76
  import { ateTools, ateToolDefinitions, atePhase5ToolDefinitions, createAtePhase5Handlers } from "./ate.js";
77
+ import { ateContextTools, ateContextToolDefinitions } from "./ate-context.js";
78
+ import { ateRunTools, ateRunToolDefinitions } from "./ate-run.js";
79
+ import { ateFlakesTools, ateFlakesToolDefinitions } from "./ate-flakes.js";
80
+ import { atePromptTools, atePromptToolDefinitions } from "./ate-prompt.js";
81
+ import { ateExemplarTools, ateExemplarToolDefinitions } from "./ate-exemplar.js";
82
+ import { ateSaveTools, ateSaveToolDefinitions } from "./ate-save.js";
71
83
  import { resourceTools, resourceToolDefinitions } from "./resource.js";
72
84
  import { componentTools, componentToolDefinitions } from "./component.js";
73
85
  import { kitchenTools, kitchenToolDefinitions } from "./kitchen.js";
@@ -125,6 +137,12 @@ const TOOL_MODULES: ToolModule[] = [
125
137
  { category: "project", definitions: projectToolDefinitions, handlers: projectTools as ToolModule["handlers"], requiresServer: true },
126
138
  { category: "ate", definitions: ateToolDefinitions, handlers: ateTools as ToolModule["handlers"] },
127
139
  { category: "ate-phase5", definitions: atePhase5ToolDefinitions, handlers: createAtePhase5Handlers as unknown as ToolModule["handlers"] },
140
+ { category: "ate-context", definitions: ateContextToolDefinitions, handlers: ateContextTools },
141
+ { category: "ate-run", definitions: ateRunToolDefinitions, handlers: ateRunTools },
142
+ { category: "ate-flakes", definitions: ateFlakesToolDefinitions, handlers: ateFlakesTools },
143
+ { category: "ate-prompt", definitions: atePromptToolDefinitions, handlers: atePromptTools },
144
+ { category: "ate-exemplar", definitions: ateExemplarToolDefinitions, handlers: ateExemplarTools },
145
+ { category: "ate-save", definitions: ateSaveToolDefinitions, handlers: ateSaveTools },
128
146
  { category: "resource", definitions: resourceToolDefinitions, handlers: resourceTools },
129
147
  { category: "component", definitions: componentToolDefinitions, handlers: componentTools },
130
148
  { category: "kitchen", definitions: kitchenToolDefinitions, handlers: kitchenTools },