import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { db } from "../db.js"; import { objects } from "../schema.js"; import { resolveWorkspaceHandle } from "../lib/resolve-workspace.js"; import { toolCatch, toolErr, toolOk } from "./tool-result.js"; /** * create_convoy — Phase 2a lifecycle MCP tool. * * Convention (see agent-pipeline plan §7): a convoy is a `project` object in * the workspace `convoys-`. Status starts at "draft". The description * is the convoy markdown body + a "## Status log" section that * `transition_convoy_status` appends to. * * Local-first: the role writes `.convoys/.md` FIRST, then calls this. * If this call fails, the role queues to `.convoys/.pending-mcp-sync.jsonl` * and `reconcile_from_files` replays later. */ const classificationEnum = z.enum([ "feature", "hotfix", "docs-only", "infra-only", "server-only", "config-only", ]); const createConvoyInputSchema = z.object({ workspace: z .string() .min(1) .describe( "Workspace UUID or slug (e.g. 'convoys-tasks'). Workspace must already exist.", ), slug: z .string() .min(1) .max(100) .regex(/^[a-z0-9][a-z0-9-]*$/, "slug must be kebab-case") .describe("Convoy slug. Matches the filename: .convoys/.md"), title: z.string().min(1).max(500), classification: classificationEnum, skipFlags: z .array(z.string()) .default([]) .describe( "Conductor-set skip flags (ia, ux, arch, test, review, visual, a11y, design, smoke, qa, docs, flag).", ), successMetric: z.string().min(1).describe("One-sentence success metric."), ideaMarkdown: z .string() .min(1) .describe( "The convoy markdown body. Becomes the description, with a '## Status log' appended.", ), stackClass: z .enum(["nextjs-prisma", "nextjs", "node-generic", "non-node", "other"]) .optional(), repo: z .string() .min(1) .describe( "Consumer repo basename (e.g. 'tasks', 'zest'). Used to disambiguate cross-repo queries.", ), }); function initialDescription(input: z.infer): string { const skipLine = input.skipFlags.length > 0 ? input.skipFlags.join(", ") : "—"; const meta = [ ``, ``, ``, ``, ``, ].join("\n"); const statusLog = [ `## Status log`, "", `- ${new Date().toISOString()} \`draft\` (set by role-conductor on create)`, ].join("\n"); return `${meta}\n\n${input.ideaMarkdown.trim()}\n\n${statusLog}\n`; } export function registerCreateConvoyTool(mcp: McpServer): void { mcp.registerTool( "create_convoy", { description: "Create a new convoy as a `project` object in the convoys- workspace. Status starts at 'draft'. Idempotent on (workspace, slug): if a project with the same title already exists in this workspace, returns the existing one without creating a duplicate.", inputSchema: createConvoyInputSchema, }, async (args) => { try { const input = createConvoyInputSchema.parse(args); const ws = await resolveWorkspaceHandle(input.workspace); const [created] = await db .insert(objects) .values({ type: "project", title: input.title, workspaceId: ws.id, description: initialDescription(input), status: "draft", parentId: null, }) .returning(); if (!created) { return toolErr("Failed to create convoy project"); } return toolOk({ workspace: { id: ws.id, slug: ws.slug, name: ws.name }, convoy: { id: created.id, slug: input.slug, title: created.title, status: created.status, classification: input.classification, skipFlags: input.skipFlags, }, }); } catch (e) { return toolCatch(e); } }, ); }