feat: expose GSD planning tools over MCP

This commit is contained in:
Jeremy 2026-04-09 11:43:26 -05:00
parent 7cc2d11d34
commit f7008107fb
7 changed files with 400 additions and 72 deletions

View file

@ -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);
}
});
});

View file

@ -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<unknown>;
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<unknown>;
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<unknown>;
executeSummarySave: (
params: {
milestone_id: string;
@ -72,6 +127,84 @@ async function withProjectDir<T>(projectDir: string, fn: () => Promise<T>): 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<string, unknown>) => {
const { projectDir, ...params } = args as { projectDir: string } & Record<string, unknown>;
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<string, unknown>) => {
const { projectDir, ...params } = args as { projectDir: string } & Record<string, unknown>;
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.",

View file

@ -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 = {

View file

@ -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/);

View file

@ -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);
}
});

View file

@ -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<ToolExecutionResult> {
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<ToolExecutionResult> {
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;
}

View file

@ -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",
]);