/** * MCP tools — deploy intent inspection & compile. * * Issue #250 — Phase 1 follow-up. * * Two read-mostly tools agents use to drive Mandu's deploy pipeline: * * - `mandu.deploy.plan` — run the heuristic inferer over the routes * manifest and the existing intent cache. Returns the next cache * + a per-route diff. Default is read-only (`apply: false`); pass * `apply: true` to atomically write `.mandu/deploy.intent.json`. * * - `mandu.deploy.compile` — compile the manifest + cache into a * concrete `vercel.json` (other targets land in subsequent * phases). Returns the config object, the per-route summary, and * compile warnings. Read-only — never writes vercel.json. * * The two tools share the same `@mandujs/core/deploy` engine the CLI * uses, so agents and humans see identical results. Going through the * core API rather than spawning the CLI keeps the response structured * and avoids a child-process roundtrip. * * @see packages/cli/src/commands/deploy/plan.ts — interactive CLI * @see packages/cli/src/commands/deploy/adapters/vercel.ts — adapter */ import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { compileVercelJson, emptyDeployIntentCache, inferDeployIntentWithBrain, isStaticIntentValidFor, loadDeployIntentCache, planDeploy, saveDeployIntentCache, VercelCompileError, buildDeployInferenceContext, type DeployIntent, type DeployIntentCache, type PlanDiffEntry, type VercelCompileResult, } from "@mandujs/core/deploy"; import { generateManifest, loadManifest, type RoutesManifest, } from "@mandujs/core"; import { resolveBrainAdapter } from "@mandujs/core/brain"; import path from "node:path"; /** * Resolve the routes manifest for the project. * * Prefer the on-disk `.mandu/routes.manifest.json` (fast, written by * `mandu build` / `mandu dev`). When that's missing, scan `app/` * directly via `generateManifest()` so the tool keeps working in a * fresh checkout. Returns `{ manifest, error }` — the error surface * is `null` when at least one path succeeded, otherwise it carries * the most specific reason so the MCP response can guide the agent. */ async function resolveManifestForTooling( projectRoot: string, ): Promise<{ manifest: RoutesManifest | null; error: string | null }> { const manifestPath = path.join(projectRoot, ".mandu", "routes.manifest.json"); try { const result = await loadManifest(manifestPath); if (result.success && result.data) { return { manifest: result.data, error: null }; } // Fall through to fs scan if the on-disk manifest is missing/malformed. } catch { // Fall through. } try { const result = await generateManifest(projectRoot); return { manifest: result.manifest, error: null }; } catch (err) { return { manifest: null, error: err instanceof Error ? err.message : String(err), }; } } // ─── deploy.plan ────────────────────────────────────────────────────── interface DeployPlanInput { /** * When true, write the next cache to `.mandu/deploy.intent.json`. * Default false — agents should review the diff and call again with * `apply: true` only after the human sign-off. */ apply?: boolean; /** Force re-inference even on unchanged source hashes. */ reinfer?: boolean; /** * Wrap the heuristic with the OAuth-backed brain adapter. Falls * back to heuristic-only when no cloud token is available — the * MCP response surfaces this in `brain_status`. */ use_brain?: boolean; } interface DeployPlanResultPayload { /** ISO timestamp the inferer wrote into the next cache. */ generated_at: string; /** Identifier of the inferer (e.g. `heuristic`, `openai+heuristic`). */ brain_model: string; /** * Diagnostic for `use_brain: true` calls. Tells the agent whether * the brain actually ran or the call fell back to heuristic. * - `"used:openai"` / `"used:anthropic"` — brain ran. * - `"unavailable:needs_login"` — call `mandu brain login` first. * - `"unavailable:opted_out"` — telemetryOptOut is set in config. * - `"not_requested"` — `use_brain: false` (default). */ brain_status: string; /** Per-route diff suitable for rendering as a table. */ diff: Array<{ route_id: string; pattern: string; kind: PlanDiffEntry["kind"]; runtime?: DeployIntent["runtime"]; previous_runtime?: DeployIntent["runtime"]; rationale?: string; source?: "explicit" | "inferred"; }>; /** Validation warnings (intent vs route shape). */ warnings: string[]; /** Total intents in the next cache. */ intent_count: number; /** Whether the cache file was actually written. */ applied: boolean; } async function deployPlanHandler( projectRoot: string, input: DeployPlanInput, ): Promise { const apply = input.apply === true; const reinfer = input.reinfer === true; const { manifest, error: manifestError } = await resolveManifestForTooling(projectRoot); if (!manifest) { return { error: `Routes manifest could not be resolved: ${manifestError ?? "unknown reason"}`, hint: "Run `mandu build` (or ensure `app/` exists with at least one route) before calling deploy tools.", }; } let previous: DeployIntentCache; try { previous = await loadDeployIntentCache(projectRoot); } catch (err) { return { error: `Failed to load .mandu/deploy.intent.json: ${err instanceof Error ? err.message : String(err)}`, hint: "Delete or restore the file, then call `mandu.deploy.plan` again.", }; } // Resolve the inferer. `use_brain` opts into the cloud refinement // path; on missing tokens or telemetryOptOut we fall back silently // and tell the agent via `brain_status`. let infer: Parameters[0]["infer"] | undefined; let brainModel = "heuristic"; let brainStatus = "not_requested"; if (input.use_brain === true) { const resolution = await resolveBrainAdapter({ adapter: "auto", projectRoot, }); if (resolution.resolved === "template") { brainStatus = resolution.needsLogin ? "unavailable:needs_login" : "unavailable:opted_out"; } else { infer = inferDeployIntentWithBrain({ adapter: resolution.adapter }); brainModel = `${resolution.resolved}+heuristic`; brainStatus = `used:${resolution.resolved}`; } } const result = await planDeploy({ rootDir: projectRoot, manifest, previous, reinfer, brainModel, infer, }); // Validate every entry against route shape so agents see the same // warnings the CLI surfaces. const warnings: string[] = []; for (const route of manifest.routes) { const entry = result.cache.intents[route.id]; if (!entry) continue; const ctx = await buildDeployInferenceContext(projectRoot, route); const validation = isStaticIntentValidFor(entry.intent, { isDynamic: ctx.isDynamic, hasGenerateStaticParams: ctx.hasGenerateStaticParams, kind: ctx.kind, }); if (!validation.ok) { warnings.push(`${route.id} (${route.pattern}): ${validation.reason}`); } } if (apply) { await saveDeployIntentCache(projectRoot, result.cache); } return { generated_at: result.cache.generatedAt, brain_model: result.cache.brainModel, brain_status: brainStatus, diff: result.diff.map((d) => ({ route_id: d.routeId, pattern: d.pattern, kind: d.kind, runtime: d.next?.intent.runtime, previous_runtime: d.previous?.intent.runtime, rationale: d.next?.rationale, source: d.next?.source, })), warnings, intent_count: Object.keys(result.cache.intents).length, applied: apply, }; } // ─── deploy.compile ─────────────────────────────────────────────────── interface DeployCompileInput { /** Adapter target. Only `vercel` is supported in M3. */ target?: "vercel"; /** Project name override (Mandu bookkeeping; not emitted into output). */ project_name?: string; } interface DeployCompileResultPayload { target: "vercel"; /** Concrete vercel.json contents the adapter would write. */ config: VercelCompileResult["config"]; /** Compile warnings (e.g. #248 runtime gaps). */ warnings: string[]; /** Per-route summary the CLI prints in dry-run. */ per_route: VercelCompileResult["perRoute"]; } async function deployCompileHandler( projectRoot: string, input: DeployCompileInput, ): Promise }> { const target = input.target ?? "vercel"; if (target !== "vercel") { return { error: `Target "${target}" not supported yet. Phase 1 ships the Vercel compiler only.`, hint: "Pass target=\"vercel\" or wait for the Fly compiler in Phase 2.", }; } const { manifest, error: manifestError } = await resolveManifestForTooling(projectRoot); if (!manifest) { return { error: `Routes manifest could not be resolved: ${manifestError ?? "unknown reason"}`, hint: "Run `mandu build` (or ensure `app/` exists with at least one route) before calling deploy tools.", }; } let cache: DeployIntentCache; try { cache = await loadDeployIntentCache(projectRoot); } catch (err) { return { error: `Failed to load .mandu/deploy.intent.json: ${err instanceof Error ? err.message : String(err)}`, hint: "Run `mandu.deploy.plan` (with apply=true) first, then re-run `mandu.deploy.compile`.", }; } if (Object.keys(cache.intents).length === 0) { cache = emptyDeployIntentCache(); return { error: "Intent cache is empty", hint: "Run `mandu.deploy.plan` with apply=true to populate .mandu/deploy.intent.json first.", }; } const projectName = input.project_name ?? deriveProjectName(projectRoot); try { const result = compileVercelJson(manifest, cache, { projectName }); return { target: "vercel", config: result.config, warnings: result.warnings, per_route: result.perRoute, }; } catch (err) { if (err instanceof VercelCompileError) { return { error: "Vercel compile failed", hint: "Resolve the per-route reasons below — usually by re-running `mandu.deploy.plan` with apply=true after editing the offending route.", routes: err.routes.map((r) => ({ route_id: r.routeId, reason: r.reason })), }; } throw err; } } function deriveProjectName(projectRoot: string): string { // Mirror the CLI's default — last path segment, sanitised. const last = projectRoot.split(/[\\/]/).filter(Boolean).pop() ?? "mandu-project"; return last.toLowerCase().replace(/[^a-z0-9-_]/g, "-").slice(0, 100); } // ─── MCP definitions + handlers ─────────────────────────────────────── export const deployPlanToolDefinitions: Tool[] = [ { name: "mandu.deploy.plan", description: "Infer DeployIntent for every route via the offline heuristic and (optionally) write `.mandu/deploy.intent.json`. Returns the per-route diff plus validation warnings. Default `apply: false` is read-only — agents should review before persisting. The `reinfer` flag forces re-inference even on unchanged source hashes.", annotations: { readOnlyHint: false, }, inputSchema: { type: "object", properties: { apply: { type: "boolean", description: "When true, atomically write the next cache to `.mandu/deploy.intent.json`. Default false (preview-only).", }, reinfer: { type: "boolean", description: "Force re-inference even on unchanged source hashes.", }, use_brain: { type: "boolean", description: "Wrap the heuristic with the OAuth-backed brain adapter (issue #250 M4). Falls back to heuristic when no cloud token is reachable; the response's `brain_status` tells you which path actually ran.", }, }, required: [], }, }, { name: "mandu.deploy.compile", description: "Compile the routes manifest + `.mandu/deploy.intent.json` into a concrete `vercel.json`. Returns the config object, per-route summary, and compile warnings (e.g. runtime gaps from issue #248). Read-only — does not write vercel.json. Phase 1 supports `target: \"vercel\"` only.", annotations: { readOnlyHint: true, }, inputSchema: { type: "object", properties: { target: { type: "string", enum: ["vercel"], description: "Adapter target. Phase 1 supports vercel only.", }, project_name: { type: "string", description: "Project name override (Mandu bookkeeping; never emitted into vercel.json). Defaults to the directory name.", }, }, required: [], }, }, ]; export function deployPlanTools(projectRoot: string) { const handlers: Record) => Promise> = { "mandu.deploy.plan": async (args) => deployPlanHandler(projectRoot, args as DeployPlanInput), "mandu.deploy.compile": async (args) => deployCompileHandler(projectRoot, args as DeployCompileInput), }; return handlers; }