/** * `mandu_ate_context` — Phase A.1 agent-native context tool. * * See `docs/ate/roadmap-v2-agent-native.md` §4.1 for the full design * and §11 decision 4 for the naming convention (snake_case). * * Semantics: return a single JSON blob that an LLM-driven agent * (Cursor / Claude Code / Codex) can read *before* generating a test. * The blob fuses: * * 1. Route metadata (pattern, file, isRedirect, static params) * 2. Contract surface (request/response schemas + examples) * 3. Middleware chain (canonical name + options + file) * 4. Guard preset + suggested data-route-id selectors * 5. Fixture recommendations (createTestSession, createTestDb, ...) * 6. Existing specs (user-written vs ate-generated, last-run status) * 7. Related routes (siblings + ui-entry-point pairing) * * The handler itself is deliberately thin — almost all work is done * inside `@mandujs/ate`'s `buildContext` so the same logic is * importable from non-MCP callers (CLI, tests). */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { ateContext, appendMemoryEvent, nowTimestamp, readMemoryEvents, buildRpcContext, } from "@mandujs/ate"; export const ateContextToolDefinitions: Tool[] = [ { name: "mandu_ate_context", annotations: { readOnlyHint: true, }, description: "Phase A.1 agent-native context. Returns a single JSON blob containing the " + "Mandu-specific semantic context an LLM needs to write a correct test: " + "route metadata, contract (with examples), middleware chain, guard preset + " + "suggested [data-route-id] selectors, recommended @mandujs/core/testing fixtures, " + "existing specs (with last-run status when .mandu/ate-last-run.json is present), " + "and related routes (sibling + ui-entry-point pairing). " + "Scope values: " + "'project' = repo summary with route + coverage counts; " + "'route' = single-route deep view (requires id or route); " + "'filling' = server-handler view with middleware + actions (requires id); " + "'contract' = request/response + examples for a contract definition. " + "Run mandu.ate.extract first — this tool reads .mandu/interaction-graph.json.", inputSchema: { type: "object", properties: { repoRoot: { type: "string", description: "Absolute path to the Mandu project root", }, scope: { type: "string", enum: ["project", "route", "filling", "contract", "rpc"], description: "project (summary) | route (single route deep view) | filling (handler view) | contract (contract definition view) | rpc (typed RPC procedure view — Phase C.3)", }, id: { type: "string", description: "Route id ('api-signup'), filling id ('filling:api-signup'), or contract name. Optional — supply id OR route.", }, route: { type: "string", description: "Route pattern match (e.g. '/api/signup'). Optional — supply id OR route.", }, }, required: ["repoRoot", "scope"], }, }, ]; export function ateContextTools(_projectRoot: string) { return { mandu_ate_context: async (args: Record) => { const { repoRoot, scope, id, route } = args as { repoRoot: string; scope: "project" | "route" | "filling" | "contract" | "rpc"; id?: string; route?: string; }; // Minimal validation — the MCP SDK already enforces the schema, // but we guard repoRoot explicitly so mis-invocations surface a // loud error rather than a cascading filesystem failure. if (!repoRoot || typeof repoRoot !== "string") { return { ok: false, error: "repoRoot is required" }; } if (!scope) { return { ok: false, error: "scope is required" }; } // Phase C.3 — `scope: "rpc"` delegates to the RPC extractor + context builder. if (scope === "rpc") { if (!id) { return { ok: false, error: "scope='rpc' requires `id` (e.g. 'users.signup' or 'signup')", }; } const blob = await buildRpcContext({ repoRoot, id }); return { ok: true, context: blob }; } const blob = await ateContext({ repoRoot, scope, id, route }); // Phase B.2 — first `mandu_ate_context` call of the day writes a // `coverage_snapshot` event (best-effort). The snapshot is derived // from the `project`-scope blob summary; for other scopes we still // fire the snapshot (using scope==='project' would require an // extra call — the project summary's field presence is enough). try { if (!snapshottedToday(repoRoot)) { const withSpec = countWithSpec(blob); const withProperty = 0; // Phase B — property-test detection is part of `mandu_ate_coverage`. const totalRoutes = countTotalRoutes(blob); appendMemoryEvent(repoRoot, { kind: "coverage_snapshot", timestamp: nowTimestamp(), routes: totalRoutes, withSpec, withProperty, }); } } catch { // swallow — snapshot is best-effort. } return { ok: true, context: blob }; }, }; } function snapshottedToday(repoRoot: string): boolean { try { const events = readMemoryEvents(repoRoot); const today = new Date().toISOString().slice(0, 10); return events.some( (e) => e.kind === "coverage_snapshot" && e.timestamp.slice(0, 10) === today, ); } catch { return false; } } function countTotalRoutes(blob: unknown): number { if (!blob || typeof blob !== "object") return 0; const b = blob as { scope?: string; summary?: { routes?: number }; route?: unknown }; if (b.scope === "project" && b.summary && typeof b.summary.routes === "number") { return b.summary.routes; } // Non-project scope — we can't meaningfully count; leave 0 so the snapshot // still records the timestamp without lying about totals. return 0; } function countWithSpec(blob: unknown): number { if (!blob || typeof blob !== "object") return 0; const b = blob as { scope?: string; routes?: Array<{ existingSpecCount: number }>; }; if (b.scope === "project" && Array.isArray(b.routes)) { return b.routes.filter((r) => (r.existingSpecCount ?? 0) > 0).length; } return 0; }