From f7008107fb6106eba4d913665b8c0bb4f7cfde83 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 11:43:26 -0500 Subject: [PATCH] feat: expose GSD planning tools over MCP --- .../mcp-server/src/workflow-tools.test.ts | 70 ++++++++- packages/mcp-server/src/workflow-tools.ts | 133 ++++++++++++++++++ .../extensions/gsd/bootstrap/db-tools.ts | 70 +-------- .../extensions/gsd/tests/workflow-mcp.test.ts | 23 ++- .../gsd/tests/workflow-tool-executors.test.ts | 91 ++++++++++++ .../gsd/tools/workflow-tool-executors.ts | 83 +++++++++++ src/resources/extensions/gsd/workflow-mcp.ts | 2 + 7 files changed, 400 insertions(+), 72 deletions(-) diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index 62242f887..2efaccced 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -42,14 +42,14 @@ function makeMockServer() { } describe("workflow MCP tools", () => { - it("registers the three workflow tools", () => { + it("registers the five workflow tools", () => { const server = makeMockServer(); registerWorkflowTools(server as any); - assert.equal(server.tools.length, 3); + assert.equal(server.tools.length, 5); assert.deepEqual( server.tools.map((t) => t.name), - ["gsd_summary_save", "gsd_task_complete", "gsd_milestone_status"], + ["gsd_plan_milestone", "gsd_plan_slice", "gsd_summary_save", "gsd_task_complete", "gsd_milestone_status"], ); }); @@ -124,4 +124,68 @@ describe("workflow MCP tools", () => { cleanup(base); } }); + + it("gsd_plan_milestone and gsd_plan_slice work end-to-end", async () => { + const base = makeTmpBase(); + try { + const server = makeMockServer(); + registerWorkflowTools(server as any); + const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone"); + const sliceTool = server.tools.find((t) => t.name === "gsd_plan_slice"); + assert.ok(milestoneTool, "milestone planning tool should be registered"); + assert.ok(sliceTool, "slice planning tool should be registered"); + + const milestoneResult = await milestoneTool!.handler({ + projectDir: base, + milestoneId: "M001", + title: "Workflow MCP planning", + vision: "Plan milestone over MCP.", + slices: [ + { + sliceId: "S01", + title: "Bridge planning", + risk: "medium", + depends: [], + demo: "Milestone plan persists through MCP.", + goal: "Persist roadmap state.", + successCriteria: "ROADMAP.md renders from DB.", + proofLevel: "integration", + integrationClosure: "Prompts and MCP call the same handler.", + observabilityImpact: "Executor tests cover output paths.", + }, + ], + }); + assert.match((milestoneResult as any).content[0].text as string, /Planned milestone M001/); + + const sliceResult = await sliceTool!.handler({ + projectDir: base, + milestoneId: "M001", + sliceId: "S01", + goal: "Persist slice plan over MCP.", + tasks: [ + { + taskId: "T01", + title: "Add planning bridge", + description: "Implement the shared executor path.", + estimate: "15m", + files: ["src/resources/extensions/gsd/tools/workflow-tool-executors.ts"], + verify: "node --test", + inputs: ["ROADMAP.md"], + expectedOutput: ["S01-PLAN.md", "T01-PLAN.md"], + }, + ], + }); + assert.match((sliceResult as any).content[0].text as string, /Planned slice S01/); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-PLAN.md")), + "slice plan should exist on disk", + ); + assert.ok( + existsSync(join(base, ".gsd", "milestones", "M001", "slices", "S01", "tasks", "T01-PLAN.md")), + "task plan should exist on disk", + ); + } finally { + cleanup(base); + } + }); }); diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index dae45a2df..26e0e2dea 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -9,6 +9,61 @@ const SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", type WorkflowToolExecutors = { SUPPORTED_SUMMARY_ARTIFACT_TYPES: readonly string[]; executeMilestoneStatus: (params: { milestoneId: string }) => Promise; + executePlanMilestone: ( + params: { + milestoneId: string; + title: string; + vision: string; + slices: Array<{ + sliceId: string; + title: string; + risk: string; + depends: string[]; + demo: string; + goal: string; + successCriteria: string; + proofLevel: string; + integrationClosure: string; + observabilityImpact: string; + }>; + status?: string; + dependsOn?: string[]; + successCriteria?: string[]; + keyRisks?: Array<{ risk: string; whyItMatters: string }>; + proofStrategy?: Array<{ riskOrUnknown: string; retireIn: string; whatWillBeProven: string }>; + verificationContract?: string; + verificationIntegration?: string; + verificationOperational?: string; + verificationUat?: string; + definitionOfDone?: string[]; + requirementCoverage?: string; + boundaryMapMarkdown?: string; + }, + basePath?: string, + ) => Promise; + executePlanSlice: ( + params: { + milestoneId: string; + sliceId: string; + goal: string; + tasks: Array<{ + taskId: string; + title: string; + description: string; + estimate: string; + files: string[]; + verify: string; + inputs: string[]; + expectedOutput: string[]; + observabilityImpact?: string; + }>; + successCriteria?: string; + proofLevel?: string; + integrationClosure?: string; + observabilityImpact?: string; + }, + basePath?: string, + ) => Promise; executeSummarySave: ( params: { milestone_id: string; @@ -72,6 +127,84 @@ async function withProjectDir(projectDir: string, fn: () => Promise): Prom } export function registerWorkflowTools(server: McpToolServer): void { + server.tool( + "gsd_plan_milestone", + "Write milestone planning state to the GSD database and render ROADMAP.md from DB.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + milestoneId: z.string().describe("Milestone ID (e.g. M001)"), + title: z.string().describe("Milestone title"), + vision: z.string().describe("Milestone vision"), + slices: z.array(z.object({ + sliceId: z.string(), + title: z.string(), + risk: z.string(), + depends: z.array(z.string()), + demo: z.string(), + goal: z.string(), + successCriteria: z.string(), + proofLevel: z.string(), + integrationClosure: z.string(), + observabilityImpact: z.string(), + })).describe("Planned slices for the milestone"), + status: z.string().optional().describe("Milestone status"), + dependsOn: z.array(z.string()).optional().describe("Milestone dependencies"), + successCriteria: z.array(z.string()).optional().describe("Top-level success criteria bullets"), + keyRisks: z.array(z.object({ + risk: z.string(), + whyItMatters: z.string(), + })).optional().describe("Structured risk entries"), + proofStrategy: z.array(z.object({ + riskOrUnknown: z.string(), + retireIn: z.string(), + whatWillBeProven: z.string(), + })).optional().describe("Structured proof strategy entries"), + verificationContract: z.string().optional(), + verificationIntegration: z.string().optional(), + verificationOperational: z.string().optional(), + verificationUat: z.string().optional(), + definitionOfDone: z.array(z.string()).optional(), + requirementCoverage: z.string().optional(), + boundaryMapMarkdown: z.string().optional(), + }, + async (args: Record) => { + const { projectDir, ...params } = args as { projectDir: string } & Record; + const { executePlanMilestone } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executePlanMilestone(params as any, projectDir)); + }, + ); + + server.tool( + "gsd_plan_slice", + "Write slice/task planning state to the GSD database and render plan artifacts from DB.", + { + projectDir: z.string().describe("Absolute path to the project directory"), + milestoneId: z.string().describe("Milestone ID (e.g. M001)"), + sliceId: z.string().describe("Slice ID (e.g. S01)"), + goal: z.string().describe("Slice goal"), + tasks: z.array(z.object({ + taskId: z.string(), + title: z.string(), + description: z.string(), + estimate: z.string(), + files: z.array(z.string()), + verify: z.string(), + inputs: z.array(z.string()), + expectedOutput: z.array(z.string()), + observabilityImpact: z.string().optional(), + })).describe("Planned tasks for the slice"), + successCriteria: z.string().optional(), + proofLevel: z.string().optional(), + integrationClosure: z.string().optional(), + observabilityImpact: z.string().optional(), + }, + async (args: Record) => { + const { projectDir, ...params } = args as { projectDir: string } & Record; + const { executePlanSlice } = await getWorkflowToolExecutors(); + return withProjectDir(projectDir, () => executePlanSlice(params as any, projectDir)); + }, + ); + server.tool( "gsd_summary_save", "Save a GSD summary/research/context/assessment artifact to the database and disk.", diff --git a/src/resources/extensions/gsd/bootstrap/db-tools.ts b/src/resources/extensions/gsd/bootstrap/db-tools.ts index df8575abd..1f7c15d2d 100644 --- a/src/resources/extensions/gsd/bootstrap/db-tools.ts +++ b/src/resources/extensions/gsd/bootstrap/db-tools.ts @@ -9,6 +9,8 @@ import { StringEnum } from "@gsd/pi-ai"; import { logError } from "../workflow-logger.js"; import { getErrorMessage } from "../error-utils.js"; import { + executePlanMilestone, + executePlanSlice, executeSummarySave, executeTaskComplete, } from "../tools/workflow-tool-executors.js"; @@ -414,38 +416,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // ─── gsd_plan_milestone (gsd_milestone_plan alias) ───────────────────── const planMilestoneExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan milestone." }], - details: { operation: "plan_milestone", error: "db_unavailable" } as any, - }; - } - try { - const { handlePlanMilestone } = await import("../tools/plan-milestone.js"); - const result = await handlePlanMilestone(params, process.cwd()); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: `Error planning milestone: ${result.error}` }], - details: { operation: "plan_milestone", error: result.error } as any, - }; - } - return { - content: [{ type: "text" as const, text: `Planned milestone ${result.milestoneId}` }], - details: { - operation: "plan_milestone", - milestoneId: result.milestoneId, - roadmapPath: result.roadmapPath, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `plan_milestone tool failed: ${msg}`, { tool: "gsd_plan_milestone", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error planning milestone: ${msg}` }], - details: { operation: "plan_milestone", error: msg } as any, - }; - } + return executePlanMilestone(params, process.cwd()); }; const planMilestoneTool = { @@ -507,40 +478,7 @@ export function registerDbTools(pi: ExtensionAPI): void { // ─── gsd_plan_slice (gsd_slice_plan alias) ───────────────────────────── const planSliceExecute = async (_toolCallId: string, params: any, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: unknown) => { - const dbAvailable = await ensureDbOpen(); - if (!dbAvailable) { - return { - content: [{ type: "text" as const, text: "Error: GSD database is not available. Cannot plan slice." }], - details: { operation: "plan_slice", error: "db_unavailable" } as any, - }; - } - try { - const { handlePlanSlice } = await import("../tools/plan-slice.js"); - const result = await handlePlanSlice(params, process.cwd()); - if ("error" in result) { - return { - content: [{ type: "text" as const, text: `Error planning slice: ${result.error}` }], - details: { operation: "plan_slice", error: result.error } as any, - }; - } - return { - content: [{ type: "text" as const, text: `Planned slice ${result.sliceId} (${result.milestoneId})` }], - details: { - operation: "plan_slice", - milestoneId: result.milestoneId, - sliceId: result.sliceId, - planPath: result.planPath, - taskPlanPaths: result.taskPlanPaths, - } as any, - }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - logError("tool", `plan_slice tool failed: ${msg}`, { tool: "gsd_plan_slice", error: String(err) }); - return { - content: [{ type: "text" as const, text: `Error planning slice: ${msg}` }], - details: { operation: "plan_slice", error: msg } as any, - }; - } + return executePlanSlice(params, process.cwd()); }; const planSliceTool = { diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 6ba981121..76ae0fd7d 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -107,18 +107,18 @@ test("transport compatibility fails cleanly when MCP server is unavailable", () test("transport compatibility fails cleanly when unit requires unsupported tools", () => { const error = getWorkflowTransportSupportError( "claude-code", - ["gsd_plan_slice"], + ["gsd_complete_task"], { projectRoot: "/tmp/project", env: { GSD_WORKFLOW_MCP_COMMAND: "node" }, surface: "auto-mode", - unitType: "plan-slice", + unitType: "execute-task", authMode: "externalCli", baseUrl: "local://claude-code", }, ); - assert.match(error ?? "", /requires gsd_plan_slice/); + assert.match(error ?? "", /requires gsd_complete_task/); assert.match(error ?? "", /currently exposes only/); }); @@ -139,6 +139,23 @@ test("transport compatibility ignores API-backed providers", () => { assert.equal(error, null); }); +test("transport compatibility now allows plan-slice over workflow MCP surface", () => { + const error = getWorkflowTransportSupportError( + "claude-code", + ["gsd_plan_slice"], + { + projectRoot: "/tmp/project", + env: { GSD_WORKFLOW_MCP_COMMAND: "node" }, + surface: "auto-mode", + unitType: "plan-slice", + authMode: "externalCli", + baseUrl: "local://claude-code", + }, + ); + + assert.equal(error, null); +}); + test("guided-flow source enforces workflow compatibility preflight", () => { const src = readSrc("guided-flow.ts"); assert.match(src, /getRequiredWorkflowToolsForGuidedUnit/); diff --git a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts index a54d83f13..46418c613 100644 --- a/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts @@ -14,6 +14,8 @@ import { executeSummarySave, executeTaskComplete, executeMilestoneStatus, + executePlanMilestone, + executePlanSlice, } from "../tools/workflow-tool-executors.ts"; function makeTmpBase(): string { @@ -143,3 +145,92 @@ test("executeMilestoneStatus returns milestone metadata and slice counts", async cleanup(base); } }); + +test("executePlanMilestone writes roadmap state and rendered roadmap path", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + + const result = await inProjectDir(base, () => executePlanMilestone({ + milestoneId: "M001", + title: "Workflow MCP planning", + vision: "Plan milestone over shared executors.", + slices: [ + { + sliceId: "S01", + title: "Bridge planning", + risk: "medium", + depends: [], + demo: "Milestone plan persists through MCP.", + goal: "Persist roadmap state.", + successCriteria: "ROADMAP.md renders from DB.", + proofLevel: "integration", + integrationClosure: "Prompts and MCP call the same handler.", + observabilityImpact: "Executor tests cover output paths.", + }, + ], + }, base)); + + assert.equal(result.details.operation, "plan_milestone"); + assert.equal(result.details.milestoneId, "M001"); + const roadmapPath = String(result.details.roadmapPath); + assert.ok(existsSync(roadmapPath), "roadmap should be rendered to disk"); + assert.match(readFileSync(roadmapPath, "utf-8"), /Workflow MCP planning/); + } finally { + closeDatabase(); + cleanup(base); + } +}); + +test("executePlanSlice writes task planning state and rendered plan artifacts", async () => { + const base = makeTmpBase(); + try { + openTestDb(base); + await inProjectDir(base, () => executePlanMilestone({ + milestoneId: "M001", + title: "Workflow MCP planning", + vision: "Plan milestone over shared executors.", + slices: [ + { + sliceId: "S01", + title: "Bridge planning", + risk: "medium", + depends: [], + demo: "Milestone plan persists through MCP.", + goal: "Persist roadmap state.", + successCriteria: "ROADMAP.md renders from DB.", + proofLevel: "integration", + integrationClosure: "Prompts and MCP call the same handler.", + observabilityImpact: "Executor tests cover output paths.", + }, + ], + }, base)); + + const result = await inProjectDir(base, () => executePlanSlice({ + milestoneId: "M001", + sliceId: "S01", + goal: "Persist slice plan over MCP.", + tasks: [ + { + taskId: "T01", + title: "Add planning bridge", + description: "Implement the shared executor path.", + estimate: "15m", + files: ["src/resources/extensions/gsd/tools/workflow-tool-executors.ts"], + verify: "node --test", + inputs: ["ROADMAP.md"], + expectedOutput: ["S01-PLAN.md", "T01-PLAN.md"], + }, + ], + }, base)); + + assert.equal(result.details.operation, "plan_slice"); + assert.equal(result.details.sliceId, "S01"); + const planPath = String(result.details.planPath); + assert.ok(existsSync(planPath), "slice plan should be rendered to disk"); + assert.match(readFileSync(planPath, "utf-8"), /Persist slice plan over MCP/); + } finally { + closeDatabase(); + cleanup(base); + } +}); diff --git a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts index 60e638d4d..d5b2f3564 100644 --- a/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +++ b/src/resources/extensions/gsd/tools/workflow-tool-executors.ts @@ -8,6 +8,10 @@ import { } from "../gsd-db.js"; import { saveArtifactToDb } from "../db-writer.js"; import { handleCompleteTask } from "./complete-task.js"; +import type { PlanMilestoneParams } from "./plan-milestone.js"; +import { handlePlanMilestone } from "./plan-milestone.js"; +import type { PlanSliceParams } from "./plan-slice.js"; +import { handlePlanSlice } from "./plan-slice.js"; import { logError, logWarning } from "../workflow-logger.js"; export const SUPPORTED_SUMMARY_ARTIFACT_TYPES = ["SUMMARY", "RESEARCH", "CONTEXT", "ASSESSMENT", "CONTEXT-DRAFT"] as const; @@ -118,6 +122,9 @@ export interface TaskCompleteParams { verificationEvidence?: VerificationEvidenceInput[]; } +export type PlanMilestoneExecutorParams = PlanMilestoneParams; +export type PlanSliceExecutorParams = PlanSliceParams; + export async function executeTaskComplete( params: TaskCompleteParams, basePath: string = process.cwd(), @@ -162,6 +169,82 @@ export async function executeTaskComplete( } } +export async function executePlanMilestone( + params: PlanMilestoneExecutorParams, + basePath: string = process.cwd(), +): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan milestone." }], + details: { operation: "plan_milestone", error: "db_unavailable" }, + }; + } + try { + const result = await handlePlanMilestone(params, basePath); + if ("error" in result) { + return { + content: [{ type: "text", text: `Error planning milestone: ${result.error}` }], + details: { operation: "plan_milestone", error: result.error }, + }; + } + return { + content: [{ type: "text", text: `Planned milestone ${result.milestoneId}` }], + details: { + operation: "plan_milestone", + milestoneId: result.milestoneId, + roadmapPath: result.roadmapPath, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `plan_milestone tool failed: ${msg}`, { tool: "gsd_plan_milestone", error: String(err) }); + return { + content: [{ type: "text", text: `Error planning milestone: ${msg}` }], + details: { operation: "plan_milestone", error: msg }, + }; + } +} + +export async function executePlanSlice( + params: PlanSliceExecutorParams, + basePath: string = process.cwd(), +): Promise { + const dbAvailable = await ensureDbOpen(); + if (!dbAvailable) { + return { + content: [{ type: "text", text: "Error: GSD database is not available. Cannot plan slice." }], + details: { operation: "plan_slice", error: "db_unavailable" }, + }; + } + try { + const result = await handlePlanSlice(params, basePath); + if ("error" in result) { + return { + content: [{ type: "text", text: `Error planning slice: ${result.error}` }], + details: { operation: "plan_slice", error: result.error }, + }; + } + return { + content: [{ type: "text", text: `Planned slice ${result.sliceId} (${result.milestoneId})` }], + details: { + operation: "plan_slice", + milestoneId: result.milestoneId, + sliceId: result.sliceId, + planPath: result.planPath, + taskPlanPaths: result.taskPlanPaths, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logError("tool", `plan_slice tool failed: ${msg}`, { tool: "gsd_plan_slice", error: String(err) }); + return { + content: [{ type: "text", text: `Error planning slice: ${msg}` }], + details: { operation: "plan_slice", error: msg }, + }; + } +} + export interface MilestoneStatusParams { milestoneId: string; } diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index 357c8db73..4aa99b5f1 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -21,6 +21,8 @@ export interface WorkflowCapabilityOptions { const MCP_WORKFLOW_TOOL_SURFACE = new Set([ "gsd_milestone_status", + "gsd_plan_milestone", + "gsd_plan_slice", "gsd_summary_save", "gsd_task_complete", ]);