@mandujs/mcp 0.24.0 → 0.25.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.24.0",
3
+ "version": "0.25.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.21.0",
38
+ "@mandujs/ate": "^0.22.0",
39
39
  "@mandujs/skills": "^16.0.0",
40
40
  "@modelcontextprotocol/sdk": "^1.25.3"
41
41
  },
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `mandu_ate_boundary_probe` — Phase B.1 deterministic boundary-value
3
+ * generator for Zod contracts.
4
+ *
5
+ * See docs/ate/phase-b-spec.md §B.1 for the full I/O shape. Agents
6
+ * feed the returned probe set into `mandu_ate_prompt({ kind:
7
+ * "property_based" })` to produce adversarial specs.
8
+ *
9
+ * Snake_case tool name (§11 decision #4). Read-only.
10
+ */
11
+
12
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
13
+ import { generateBoundaryProbes } from "@mandujs/ate";
14
+
15
+ export const ateBoundaryProbeToolDefinitions: Tool[] = [
16
+ {
17
+ name: "mandu_ate_boundary_probe",
18
+ annotations: {
19
+ readOnlyHint: true,
20
+ },
21
+ description:
22
+ "Phase B.1 deterministic boundary probe for Zod contracts. Reads a " +
23
+ "*.contract.ts file, parses request-body schemas per HTTP method, and " +
24
+ "returns a deterministic set of probe values per field — one per " +
25
+ "category (valid / invalid_format / boundary_min / boundary_max / " +
26
+ "empty / null / type_mismatch / enum_reject / missing_required). " +
27
+ "Every probe also carries the expectedStatus code derived from the " +
28
+ "contract's response map (400/422 for invalid, 200/201 for valid). " +
29
+ "The output is stamped with graphVersion for agent cache " +
30
+ "invalidation. No LLM. No runtime Zod evaluation — source text is " +
31
+ "parsed directly. Default depth 1, max 3.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ repoRoot: {
36
+ type: "string",
37
+ description: "Absolute path to the Mandu project root.",
38
+ },
39
+ contractName: {
40
+ type: "string",
41
+ description:
42
+ "Contract identifier. Usually the basename of the contract file (e.g. 'SignupContract' or 'api-signup').",
43
+ },
44
+ contractFile: {
45
+ type: "string",
46
+ description: "Direct absolute path to the contract file (bypasses name resolution).",
47
+ },
48
+ method: {
49
+ type: "string",
50
+ enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
51
+ description: "Optional HTTP method filter. Omit to probe every declared method.",
52
+ },
53
+ depth: {
54
+ type: "number",
55
+ description: "Recursion depth for nested z.object() fields. Default 1, max 3.",
56
+ },
57
+ },
58
+ required: ["repoRoot"],
59
+ },
60
+ },
61
+ ];
62
+
63
+ export function ateBoundaryProbeTools(_projectRoot: string) {
64
+ return {
65
+ mandu_ate_boundary_probe: async (args: Record<string, unknown>) => {
66
+ const repoRoot = args.repoRoot as string | undefined;
67
+ const contractName = args.contractName as string | undefined;
68
+ const contractFile = args.contractFile as string | undefined;
69
+ const method = args.method as
70
+ | "GET"
71
+ | "POST"
72
+ | "PUT"
73
+ | "PATCH"
74
+ | "DELETE"
75
+ | undefined;
76
+ const depth = typeof args.depth === "number" ? args.depth : undefined;
77
+
78
+ if (!repoRoot || typeof repoRoot !== "string") {
79
+ return { ok: false, error: "repoRoot is required" };
80
+ }
81
+ if (!contractName && !contractFile) {
82
+ return { ok: false, error: "contractName or contractFile is required" };
83
+ }
84
+
85
+ try {
86
+ const result = await generateBoundaryProbes({
87
+ repoRoot,
88
+ contractName,
89
+ contractFile,
90
+ ...(method ? { method } : {}),
91
+ ...(depth !== undefined ? { depth } : {}),
92
+ });
93
+ return {
94
+ ok: true,
95
+ contractName: result.contractName,
96
+ contractFile: result.contractFile,
97
+ graphVersion: result.graphVersion,
98
+ probes: result.probes,
99
+ warnings: result.warnings,
100
+ };
101
+ } catch (err) {
102
+ return {
103
+ ok: false,
104
+ error: err instanceof Error ? err.message : String(err),
105
+ };
106
+ }
107
+ },
108
+ };
109
+ }
@@ -1,96 +1,159 @@
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
- }
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 {
25
+ ateContext,
26
+ appendMemoryEvent,
27
+ nowTimestamp,
28
+ readMemoryEvents,
29
+ } from "@mandujs/ate";
30
+
31
+ export const ateContextToolDefinitions: Tool[] = [
32
+ {
33
+ name: "mandu_ate_context",
34
+ annotations: {
35
+ readOnlyHint: true,
36
+ },
37
+ description:
38
+ "Phase A.1 agent-native context. Returns a single JSON blob containing the " +
39
+ "Mandu-specific semantic context an LLM needs to write a correct test: " +
40
+ "route metadata, contract (with examples), middleware chain, guard preset + " +
41
+ "suggested [data-route-id] selectors, recommended @mandujs/core/testing fixtures, " +
42
+ "existing specs (with last-run status when .mandu/ate-last-run.json is present), " +
43
+ "and related routes (sibling + ui-entry-point pairing). " +
44
+ "Scope values: " +
45
+ "'project' = repo summary with route + coverage counts; " +
46
+ "'route' = single-route deep view (requires id or route); " +
47
+ "'filling' = server-handler view with middleware + actions (requires id); " +
48
+ "'contract' = request/response + examples for a contract definition. " +
49
+ "Run mandu.ate.extract first — this tool reads .mandu/interaction-graph.json.",
50
+ inputSchema: {
51
+ type: "object",
52
+ properties: {
53
+ repoRoot: {
54
+ type: "string",
55
+ description: "Absolute path to the Mandu project root",
56
+ },
57
+ scope: {
58
+ type: "string",
59
+ enum: ["project", "route", "filling", "contract"],
60
+ description:
61
+ "project (summary) | route (single route deep view) | filling (handler view) | contract (contract definition view)",
62
+ },
63
+ id: {
64
+ type: "string",
65
+ description:
66
+ "Route id ('api-signup'), filling id ('filling:api-signup'), or contract name. Optional — supply id OR route.",
67
+ },
68
+ route: {
69
+ type: "string",
70
+ description:
71
+ "Route pattern match (e.g. '/api/signup'). Optional — supply id OR route.",
72
+ },
73
+ },
74
+ required: ["repoRoot", "scope"],
75
+ },
76
+ },
77
+ ];
78
+
79
+ export function ateContextTools(_projectRoot: string) {
80
+ return {
81
+ mandu_ate_context: async (args: Record<string, unknown>) => {
82
+ const { repoRoot, scope, id, route } = args as {
83
+ repoRoot: string;
84
+ scope: "project" | "route" | "filling" | "contract";
85
+ id?: string;
86
+ route?: string;
87
+ };
88
+ // Minimal validation — the MCP SDK already enforces the schema,
89
+ // but we guard repoRoot explicitly so mis-invocations surface a
90
+ // loud error rather than a cascading filesystem failure.
91
+ if (!repoRoot || typeof repoRoot !== "string") {
92
+ return { ok: false, error: "repoRoot is required" };
93
+ }
94
+ if (!scope) {
95
+ return { ok: false, error: "scope is required" };
96
+ }
97
+ const blob = await ateContext({ repoRoot, scope, id, route });
98
+
99
+ // Phase B.2 — first `mandu_ate_context` call of the day writes a
100
+ // `coverage_snapshot` event (best-effort). The snapshot is derived
101
+ // from the `project`-scope blob summary; for other scopes we still
102
+ // fire the snapshot (using scope==='project' would require an
103
+ // extra call — the project summary's field presence is enough).
104
+ try {
105
+ if (!snapshottedToday(repoRoot)) {
106
+ const withSpec = countWithSpec(blob);
107
+ const withProperty = 0; // Phase B — property-test detection is part of `mandu_ate_coverage`.
108
+ const totalRoutes = countTotalRoutes(blob);
109
+ appendMemoryEvent(repoRoot, {
110
+ kind: "coverage_snapshot",
111
+ timestamp: nowTimestamp(),
112
+ routes: totalRoutes,
113
+ withSpec,
114
+ withProperty,
115
+ });
116
+ }
117
+ } catch {
118
+ // swallow — snapshot is best-effort.
119
+ }
120
+
121
+ return { ok: true, context: blob };
122
+ },
123
+ };
124
+ }
125
+
126
+ function snapshottedToday(repoRoot: string): boolean {
127
+ try {
128
+ const events = readMemoryEvents(repoRoot);
129
+ const today = new Date().toISOString().slice(0, 10);
130
+ return events.some(
131
+ (e) => e.kind === "coverage_snapshot" && e.timestamp.slice(0, 10) === today,
132
+ );
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ function countTotalRoutes(blob: unknown): number {
139
+ if (!blob || typeof blob !== "object") return 0;
140
+ const b = blob as { scope?: string; summary?: { routes?: number }; route?: unknown };
141
+ if (b.scope === "project" && b.summary && typeof b.summary.routes === "number") {
142
+ return b.summary.routes;
143
+ }
144
+ // Non-project scope — we can't meaningfully count; leave 0 so the snapshot
145
+ // still records the timestamp without lying about totals.
146
+ return 0;
147
+ }
148
+
149
+ function countWithSpec(blob: unknown): number {
150
+ if (!blob || typeof blob !== "object") return 0;
151
+ const b = blob as {
152
+ scope?: string;
153
+ routes?: Array<{ existingSpecCount: number }>;
154
+ };
155
+ if (b.scope === "project" && Array.isArray(b.routes)) {
156
+ return b.routes.filter((r) => (r.existingSpecCount ?? 0) > 0).length;
157
+ }
158
+ return 0;
159
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `mandu_ate_coverage` — Phase B.4 quantified gap report.
3
+ *
4
+ * See docs/ate/phase-b-spec.md §B.5 for the output shape. Agents call
5
+ * this to discover `topGaps` and prioritize spec generation work.
6
+ *
7
+ * Snake_case (§11 decision #4). Read-only.
8
+ */
9
+
10
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
11
+ import { computeCoverage } from "@mandujs/ate";
12
+
13
+ export const ateCoverageToolDefinitions: Tool[] = [
14
+ {
15
+ name: "mandu_ate_coverage",
16
+ annotations: {
17
+ readOnlyHint: true,
18
+ },
19
+ description:
20
+ "Phase B.4 coverage metrics. Returns the 3-axis coverage report: " +
21
+ "(1) routes with unit / integration / e2e spec; (2) contracts with " +
22
+ "full / partial / no boundary-probe coverage; (3) middleware " +
23
+ "invariants (csrf / rate-limit / session / auth / i18n) tagged as " +
24
+ "covered / partial / missing. Also returns a `topGaps` list sorted " +
25
+ "high → medium → low severity. Stamped with graphVersion for " +
26
+ "agent cache invalidation.",
27
+ inputSchema: {
28
+ type: "object",
29
+ properties: {
30
+ repoRoot: {
31
+ type: "string",
32
+ description: "Absolute path to the Mandu project root.",
33
+ },
34
+ scope: {
35
+ type: "string",
36
+ enum: ["project", "route", "contract"],
37
+ description:
38
+ "Default 'project'. Use 'route' (with target=routeId) or 'contract' (with target=contractName) for narrow scans.",
39
+ },
40
+ target: {
41
+ type: "string",
42
+ description: "Route id or contract basename when scope is not 'project'.",
43
+ },
44
+ },
45
+ required: ["repoRoot"],
46
+ },
47
+ },
48
+ ];
49
+
50
+ export function ateCoverageTools(_projectRoot: string) {
51
+ return {
52
+ mandu_ate_coverage: async (args: Record<string, unknown>) => {
53
+ const repoRoot = args.repoRoot as string | undefined;
54
+ if (!repoRoot || typeof repoRoot !== "string") {
55
+ return { ok: false, error: "repoRoot is required" };
56
+ }
57
+ const scope = args.scope as "project" | "route" | "contract" | undefined;
58
+ const target = typeof args.target === "string" ? args.target : undefined;
59
+
60
+ try {
61
+ const metrics = await computeCoverage(repoRoot, {
62
+ scope: scope ?? "project",
63
+ ...(target ? { target } : {}),
64
+ });
65
+ return { ok: true, ...metrics };
66
+ } catch (err) {
67
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
68
+ }
69
+ },
70
+ };
71
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * `mandu_ate_recall` — Phase B.2 memory read tool.
3
+ *
4
+ * See docs/ate/phase-b-spec.md §B.2. Agents call this BEFORE generating
5
+ * a spec so they can reference prior intent / rejected healing history.
6
+ *
7
+ * Snake_case (§11 decision #4). Read-only.
8
+ */
9
+
10
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
11
+ import { recallMemory, type MemoryEventKind } from "@mandujs/ate";
12
+
13
+ export const ateRecallToolDefinitions: Tool[] = [
14
+ {
15
+ name: "mandu_ate_recall",
16
+ annotations: {
17
+ readOnlyHint: true,
18
+ },
19
+ description:
20
+ "Phase B.2 memory recall. Queries the project-local " +
21
+ ".mandu/ate-memory.jsonl append-only log with substring + token-" +
22
+ "overlap scoring (no embeddings). Useful BEFORE generation to see " +
23
+ "previously rejected specs, accepted heals, or intent history for " +
24
+ "the same route. Returns { events, totalMatching }. Default limit 10, " +
25
+ "default sinceDays 90. Filter by kind: intent_history | rejected_spec " +
26
+ "| accepted_healing | rejected_healing | prompt_version_drift | " +
27
+ "boundary_gap_filled | coverage_snapshot.",
28
+ inputSchema: {
29
+ type: "object",
30
+ properties: {
31
+ repoRoot: {
32
+ type: "string",
33
+ description: "Absolute path to the Mandu project root.",
34
+ },
35
+ intent: {
36
+ type: "string",
37
+ description: "Natural-language intent to search (substring + token overlap).",
38
+ },
39
+ route: {
40
+ type: "string",
41
+ description: "Route id or pattern ('api-signup' or '/api/signup').",
42
+ },
43
+ kind: {
44
+ type: "string",
45
+ description: "Filter by event kind.",
46
+ },
47
+ limit: {
48
+ type: "number",
49
+ description: "Max events to return. Default 10.",
50
+ },
51
+ sinceDays: {
52
+ type: "number",
53
+ description: "Drop events older than N days. Default 90.",
54
+ },
55
+ },
56
+ required: ["repoRoot"],
57
+ },
58
+ },
59
+ ];
60
+
61
+ export function ateRecallTools(_projectRoot: string) {
62
+ return {
63
+ mandu_ate_recall: async (args: Record<string, unknown>) => {
64
+ const repoRoot = args.repoRoot as string | undefined;
65
+ if (!repoRoot || typeof repoRoot !== "string") {
66
+ return { ok: false, error: "repoRoot is required" };
67
+ }
68
+ try {
69
+ const result = recallMemory(repoRoot, {
70
+ intent: typeof args.intent === "string" ? args.intent : undefined,
71
+ route: typeof args.route === "string" ? args.route : undefined,
72
+ kind:
73
+ typeof args.kind === "string"
74
+ ? (args.kind as MemoryEventKind)
75
+ : undefined,
76
+ limit: typeof args.limit === "number" ? args.limit : undefined,
77
+ sinceDays: typeof args.sinceDays === "number" ? args.sinceDays : undefined,
78
+ });
79
+ return { ok: true, ...result };
80
+ } catch (err) {
81
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
82
+ }
83
+ },
84
+ };
85
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `mandu_ate_remember` — Phase B.2 memory write tool.
3
+ *
4
+ * Snake_case (§11 decision #4). Idempotent append.
5
+ */
6
+
7
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
8
+ import {
9
+ appendMemoryEvent,
10
+ parseMemoryEvent,
11
+ type MemoryEvent,
12
+ } from "@mandujs/ate";
13
+
14
+ export const ateRememberToolDefinitions: Tool[] = [
15
+ {
16
+ name: "mandu_ate_remember",
17
+ description:
18
+ "Phase B.2 memory write. Appends one event to the project-local " +
19
+ ".mandu/ate-memory.jsonl. File auto-rotates to .bak when it crosses " +
20
+ "10 MB. Supported event kinds (discriminated union by `kind`): " +
21
+ "intent_history | rejected_spec | accepted_healing | rejected_healing " +
22
+ "| prompt_version_drift | boundary_gap_filled | coverage_snapshot. " +
23
+ "Timestamp defaults to now (ISO-8601 UTC) if omitted.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ repoRoot: {
28
+ type: "string",
29
+ description: "Absolute path to the Mandu project root.",
30
+ },
31
+ event: {
32
+ type: "object",
33
+ description:
34
+ "MemoryEvent object. Must carry a `kind` discriminator plus the " +
35
+ "event-kind-specific required fields (see @mandujs/ate memory/schema.ts).",
36
+ additionalProperties: true,
37
+ },
38
+ },
39
+ required: ["repoRoot", "event"],
40
+ },
41
+ },
42
+ ];
43
+
44
+ export function ateRememberTools(_projectRoot: string) {
45
+ return {
46
+ mandu_ate_remember: async (args: Record<string, unknown>) => {
47
+ const repoRoot = args.repoRoot as string | undefined;
48
+ const eventRaw = args.event;
49
+
50
+ if (!repoRoot || typeof repoRoot !== "string") {
51
+ return { ok: false, error: "repoRoot is required" };
52
+ }
53
+ if (!eventRaw || typeof eventRaw !== "object") {
54
+ return { ok: false, error: "event is required" };
55
+ }
56
+
57
+ // Default the timestamp if the caller omitted it (agents do).
58
+ const draft = { ...(eventRaw as Record<string, unknown>) };
59
+ if (typeof draft.timestamp !== "string") {
60
+ draft.timestamp = new Date().toISOString();
61
+ }
62
+ const parsed = parseMemoryEvent(draft);
63
+ if (!parsed) {
64
+ return {
65
+ ok: false,
66
+ error:
67
+ "Event failed validation. Check that `kind` and the kind-specific required fields are present.",
68
+ };
69
+ }
70
+
71
+ try {
72
+ const result = appendMemoryEvent(repoRoot, parsed as MemoryEvent);
73
+ return { ok: true, written: result.written, rotation: result.rotation ?? null };
74
+ } catch (err) {
75
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
76
+ }
77
+ },
78
+ };
79
+ }
@@ -1,139 +1,160 @@
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
- }
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 {
22
+ lintSpecContent,
23
+ appendMemoryEvent,
24
+ nowTimestamp,
25
+ type LintDiagnostic,
26
+ } from "@mandujs/ate";
27
+
28
+ // Re-export the diagnostic shape so callers can type-check against it without
29
+ // pulling @mandujs/ate directly.
30
+ export type { LintDiagnostic, LintSeverity } from "@mandujs/ate";
31
+
32
+ export const ateSaveToolDefinitions: Tool[] = [
33
+ {
34
+ name: "mandu_ate_save",
35
+ description:
36
+ "Phase A.3 persist-with-lint. Writes an agent-generated test file to " +
37
+ "disk, but first runs a small lint pass that blocks common LLM mistakes: " +
38
+ "ts-morph syntax errors, unresolved / banned import paths, hand-rolled " +
39
+ "CSRF cookies, DB mocks when createTestDb is available, and bare " +
40
+ "`localhost:<port>` URLs (prefer 127.0.0.1 per roadmap §9.2). Returns " +
41
+ "{ saved: true, path, lintDiagnostics: [warnings...] } on success or " +
42
+ "{ saved: false, blockingErrors: [...], lintDiagnostics: [...] } when " +
43
+ "a blocker fires (in which case no file is written).",
44
+ inputSchema: {
45
+ type: "object",
46
+ properties: {
47
+ path: {
48
+ type: "string",
49
+ description:
50
+ "Absolute path where the spec will be written. Parent directories are created if needed.",
51
+ },
52
+ content: {
53
+ type: "string",
54
+ description: "The full TypeScript test source to write.",
55
+ },
56
+ intent: {
57
+ type: "string",
58
+ description:
59
+ "Optional short description of what the test is verifying (logged to ATE memory).",
60
+ },
61
+ kind: {
62
+ type: "string",
63
+ description:
64
+ "Optional prompt kind this spec was generated for (filling_unit, filling_integration, e2e_playwright).",
65
+ },
66
+ sourcePrompt: {
67
+ type: "object",
68
+ description:
69
+ "Optional { kind, version } back-reference to the prompt that produced this spec (used by future memory queries).",
70
+ additionalProperties: true,
71
+ },
72
+ allowWarnings: {
73
+ type: "boolean",
74
+ description:
75
+ "When true, non-blocking warnings still allow the write (default true). When false, even warnings block.",
76
+ },
77
+ },
78
+ required: ["path", "content"],
79
+ },
80
+ },
81
+ ];
82
+
83
+ export function ateSaveTools(projectRoot: string) {
84
+ return {
85
+ mandu_ate_save: async (args: Record<string, unknown>) => {
86
+ const path = args.path as string | undefined;
87
+ const content = args.content as string | undefined;
88
+ const intent = typeof args.intent === "string" ? args.intent : undefined;
89
+ const kind = typeof args.kind === "string" ? args.kind : undefined;
90
+ const allowWarnings = args.allowWarnings !== false;
91
+
92
+ if (!path || typeof path !== "string") {
93
+ return { saved: false, error: "'path' is required" };
94
+ }
95
+ if (!isAbsolute(path)) {
96
+ return {
97
+ saved: false,
98
+ error: "'path' must be absolute — relative paths are rejected to prevent cwd drift.",
99
+ };
100
+ }
101
+ if (typeof content !== "string") {
102
+ return { saved: false, error: "'content' is required and must be a string" };
103
+ }
104
+
105
+ const diagnostics = await lintSpecContent(path, content);
106
+
107
+ const blocking = diagnostics.filter((d) => d.blocking);
108
+ const warnings = diagnostics.filter((d) => !d.blocking);
109
+
110
+ if (blocking.length > 0 || (!allowWarnings && warnings.length > 0)) {
111
+ return {
112
+ saved: false,
113
+ path,
114
+ blockingErrors: blocking,
115
+ lintDiagnostics: diagnostics,
116
+ };
117
+ }
118
+
119
+ const parent = dirname(path);
120
+ if (!existsSync(parent)) {
121
+ mkdirSync(parent, { recursive: true });
122
+ }
123
+ if (!statSync(parent).isDirectory()) {
124
+ return { saved: false, path, error: `Parent path is not a directory: ${parent}` };
125
+ }
126
+
127
+ writeFileSync(path, content, "utf8");
128
+
129
+ // Phase B.2 — auto-record intent_history event. Non-fatal on failure.
130
+ try {
131
+ appendMemoryEvent(projectRoot, {
132
+ kind: "intent_history",
133
+ timestamp: nowTimestamp(),
134
+ intent: intent ?? "(no intent supplied)",
135
+ agent: typeof args.agent === "string" ? (args.agent as string) : "unknown",
136
+ resulting: { saved: [path] },
137
+ ...(kind ? { routeId: kind } : {}),
138
+ });
139
+ } catch {
140
+ // swallow
141
+ }
142
+
143
+ return {
144
+ saved: true,
145
+ path,
146
+ bytes: Buffer.byteLength(content, "utf8"),
147
+ lintDiagnostics: diagnostics,
148
+ };
149
+ },
150
+ };
151
+ }
152
+
153
+ // Re-export for tests that need direct access (package.json test patterns
154
+ // already reach here).
155
+ export async function lintContent(
156
+ path: string,
157
+ content: string,
158
+ ): Promise<LintDiagnostic[]> {
159
+ return lintSpecContent(path, content);
160
+ }
package/src/tools/ate.ts CHANGED
@@ -171,16 +171,27 @@ export const ateToolDefinitions: Tool[] = [
171
171
  readOnlyHint: true,
172
172
  },
173
173
  description:
174
- "ATE Optimization — Impact Analysis: Calculate the minimal subset of routes affected by changed files using git diff. " +
175
- "Avoids running the full test suite when only part of the codebase changed. " +
176
- "Returns selectedRoutes to pass to mandu.ate.generate (onlyRoutes) or mandu.ate.run. " +
177
- "Typical use: run after `git commit` to test only affected routes in CI.",
174
+ "ATE Impact Analysis (Phase B.3 v2). Calculates the minimal set of " +
175
+ "routes / specs affected by changed files using git diff, classifies " +
176
+ "contract changes (additive / breaking / renaming), and returns " +
177
+ "`affected.specsToReRun`, `affected.specsLikelyBroken`, " +
178
+ "`affected.missingCoverage`, plus a `suggestions` list keyed to " +
179
+ "re_run / heal / regenerate / add_boundary_test. Stamped with " +
180
+ "graphVersion for agent caching. Keeps v1 fields (changedFiles, " +
181
+ "selectedRoutes, warnings) for backwards compatibility. " +
182
+ "Pass `since: 'working'` for uncommitted changes, `since: 'staged'` " +
183
+ "for staged changes, or a git rev (default: HEAD~1) for committed diffs.",
178
184
  inputSchema: {
179
185
  type: "object",
180
186
  properties: {
181
187
  repoRoot: { type: "string", description: "Absolute path to the Mandu project root" },
182
- base: { type: "string", description: "Git base ref for diff (default: HEAD~1 or main branch)" },
183
- head: { type: "string", description: "Git head ref for diff (default: current working tree)" },
188
+ base: { type: "string", description: "Git base ref (legacy v1 use `since` instead)" },
189
+ head: { type: "string", description: "Git head ref (legacy v1 defaults to HEAD)" },
190
+ since: {
191
+ type: "string",
192
+ description:
193
+ "v2 diff source: 'HEAD~1' | 'staged' | 'working' | any git rev. Default 'HEAD~1'.",
194
+ },
184
195
  },
185
196
  required: ["repoRoot"],
186
197
  },
@@ -333,11 +344,27 @@ export function ateTools(projectRoot: string) {
333
344
  return ateHeal({ repoRoot, runId });
334
345
  },
335
346
  "mandu.ate.impact": async (args: Record<string, unknown>) => {
336
- const { repoRoot, base, head } = args as {
347
+ const { repoRoot, base, head, since } = args as {
337
348
  repoRoot: string;
338
349
  base?: string;
339
350
  head?: string;
351
+ since?: "HEAD~1" | "staged" | "working" | string;
340
352
  };
353
+
354
+ // Phase B.3 — try the v2 impact pipeline first so callers get
355
+ // `affected`, `suggestions`, `contractDiffs`, `graphVersion` in
356
+ // addition to the v1 fields. Fall back to v1 on failure so the
357
+ // tool contract stays backwards compatible.
358
+ try {
359
+ const { computeImpactV2 } = await import("@mandujs/ate");
360
+ const v2 = await computeImpactV2({
361
+ repoRoot,
362
+ since: since ?? base,
363
+ });
364
+ return { ok: true, ...v2 };
365
+ } catch {
366
+ // Fall through to v1.
367
+ }
341
368
  return ateImpact({ repoRoot, base, head });
342
369
  },
343
370
  "mandu.ate.auto_pipeline": async (args: Record<string, unknown>) => {
@@ -34,6 +34,10 @@ export { ateFlakesTools, ateFlakesToolDefinitions } from "./ate-flakes.js";
34
34
  export { atePromptTools, atePromptToolDefinitions } from "./ate-prompt.js";
35
35
  export { ateExemplarTools, ateExemplarToolDefinitions } from "./ate-exemplar.js";
36
36
  export { ateSaveTools, ateSaveToolDefinitions } from "./ate-save.js";
37
+ export { ateBoundaryProbeTools, ateBoundaryProbeToolDefinitions } from "./ate-boundary-probe.js";
38
+ export { ateRecallTools, ateRecallToolDefinitions } from "./ate-recall.js";
39
+ export { ateRememberTools, ateRememberToolDefinitions } from "./ate-remember.js";
40
+ export { ateCoverageTools, ateCoverageToolDefinitions } from "./ate-coverage.js";
37
41
  export { resourceTools, resourceToolDefinitions } from "./resource.js";
38
42
  export { componentTools, componentToolDefinitions } from "./component.js";
39
43
  export { kitchenTools, kitchenToolDefinitions } from "./kitchen.js";
@@ -80,6 +84,13 @@ import { ateFlakesTools, ateFlakesToolDefinitions } from "./ate-flakes.js";
80
84
  import { atePromptTools, atePromptToolDefinitions } from "./ate-prompt.js";
81
85
  import { ateExemplarTools, ateExemplarToolDefinitions } from "./ate-exemplar.js";
82
86
  import { ateSaveTools, ateSaveToolDefinitions } from "./ate-save.js";
87
+ import {
88
+ ateBoundaryProbeTools,
89
+ ateBoundaryProbeToolDefinitions,
90
+ } from "./ate-boundary-probe.js";
91
+ import { ateRecallTools, ateRecallToolDefinitions } from "./ate-recall.js";
92
+ import { ateRememberTools, ateRememberToolDefinitions } from "./ate-remember.js";
93
+ import { ateCoverageTools, ateCoverageToolDefinitions } from "./ate-coverage.js";
83
94
  import { resourceTools, resourceToolDefinitions } from "./resource.js";
84
95
  import { componentTools, componentToolDefinitions } from "./component.js";
85
96
  import { kitchenTools, kitchenToolDefinitions } from "./kitchen.js";
@@ -143,6 +154,19 @@ const TOOL_MODULES: ToolModule[] = [
143
154
  { category: "ate-prompt", definitions: atePromptToolDefinitions, handlers: atePromptTools },
144
155
  { category: "ate-exemplar", definitions: ateExemplarToolDefinitions, handlers: ateExemplarTools },
145
156
  { category: "ate-save", definitions: ateSaveToolDefinitions, handlers: ateSaveTools },
157
+ // Phase B tool suite
158
+ {
159
+ category: "ate-boundary-probe",
160
+ definitions: ateBoundaryProbeToolDefinitions,
161
+ handlers: ateBoundaryProbeTools,
162
+ },
163
+ { category: "ate-recall", definitions: ateRecallToolDefinitions, handlers: ateRecallTools },
164
+ { category: "ate-remember", definitions: ateRememberToolDefinitions, handlers: ateRememberTools },
165
+ {
166
+ category: "ate-coverage",
167
+ definitions: ateCoverageToolDefinitions,
168
+ handlers: ateCoverageTools,
169
+ },
146
170
  { category: "resource", definitions: resourceToolDefinitions, handlers: resourceTools },
147
171
  { category: "component", definitions: componentToolDefinitions, handlers: componentTools },
148
172
  { category: "kitchen", definitions: kitchenToolDefinitions, handlers: kitchenTools },