From 7cc2d11d3469a7e8505cecd4e3da14983ba7487f Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 11:30:08 -0500 Subject: [PATCH] feat: gate workflow MCP units by provider transport capabilities --- .../extensions/gsd/auto-direct-dispatch.ts | 20 ++ src/resources/extensions/gsd/auto/phases.ts | 25 +++ src/resources/extensions/gsd/guided-flow.ts | 24 +++ .../extensions/gsd/tests/workflow-mcp.test.ts | 159 ++++++++++++++ src/resources/extensions/gsd/workflow-mcp.ts | 204 ++++++++++++++++++ 5 files changed, 432 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/workflow-mcp.test.ts create mode 100644 src/resources/extensions/gsd/workflow-mcp.ts diff --git a/src/resources/extensions/gsd/auto-direct-dispatch.ts b/src/resources/extensions/gsd/auto-direct-dispatch.ts index ab89687be..306bca441 100644 --- a/src/resources/extensions/gsd/auto-direct-dispatch.ts +++ b/src/resources/extensions/gsd/auto-direct-dispatch.ts @@ -29,6 +29,10 @@ import { } from "./auto-prompts.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; import { pauseAuto } from "./auto.js"; +import { + getWorkflowTransportSupportError, + getRequiredWorkflowToolsForAutoUnit, +} from "./workflow-mcp.js"; export async function dispatchDirectPhase( ctx: ExtensionCommandContext, @@ -243,6 +247,22 @@ export async function dispatchDirectPhase( return; } + const compatibilityError = getWorkflowTransportSupportError( + ctx.model?.provider, + getRequiredWorkflowToolsForAutoUnit(unitType), + { + projectRoot: base, + surface: "direct phase dispatch", + unitType, + authMode: ctx.model?.provider ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) : undefined, + baseUrl: ctx.model?.baseUrl, + }, + ); + if (compatibilityError) { + ctx.ui.notify(compatibilityError, "error"); + return; + } + ctx.ui.notify(`Dispatching ${unitType} for ${unitId}...`, "info"); const result = await ctx.newSession(); if (result.cancelled) { diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index d313053fe..a3591e6ca 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -41,6 +41,10 @@ import { isDbAvailable, getMilestoneSlices } from "../gsd-db.js"; import { resetEvidence } from "../safety/evidence-collector.js"; import { createCheckpoint, cleanupCheckpoint, rollbackToCheckpoint } from "../safety/git-checkpoint.js"; import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js"; +import { + getWorkflowTransportSupportError, + getRequiredWorkflowToolsForAutoUnit, +} from "../workflow-mcp.js"; // ─── generateMilestoneReport ────────────────────────────────────────────────── @@ -1216,6 +1220,27 @@ export async function runUnitPhase( ? `${(s.currentUnitModel as any).provider ?? ""}/${(s.currentUnitModel as any).id ?? ""}` : null; + const compatibilityError = getWorkflowTransportSupportError( + s.currentUnitModel?.provider ?? ctx.model?.provider, + getRequiredWorkflowToolsForAutoUnit(unitType), + { + projectRoot: s.basePath, + surface: "auto-mode", + unitType, + authMode: s.currentUnitModel?.provider + ? ctx.modelRegistry.getProviderAuthMode(s.currentUnitModel.provider) + : ctx.model?.provider + ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) + : undefined, + baseUrl: (s.currentUnitModel as any)?.baseUrl ?? ctx.model?.baseUrl, + }, + ); + if (compatibilityError) { + ctx.ui.notify(compatibilityError, "error"); + await deps.stopAuto(ctx, pi, compatibilityError); + return { action: "break", reason: "workflow-capability" }; + } + // Progress widget + preconditions — deferred to after model selection so the // widget's first render tick shows the correct model (#2899). deps.updateProgressWidget(ctx, unitType, unitId, state); diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 179567c49..45fbec1c3 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -40,6 +40,10 @@ import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMiles import { parkMilestone, discardMilestone } from "./milestone-actions.js"; import { selectAndApplyModel } from "./auto-model-selection.js"; import { DISCUSS_TOOLS_ALLOWLIST } from "./constants.js"; +import { + getWorkflowTransportSupportError, + getRequiredWorkflowToolsForGuidedUnit, +} from "./workflow-mcp.js"; import { runPreparation, formatCodebaseBrief, @@ -318,6 +322,26 @@ async function dispatchWorkflow( routing: result.routing, }); } + + const compatibilityError = getWorkflowTransportSupportError( + result.appliedModel?.provider ?? ctx.model?.provider, + getRequiredWorkflowToolsForGuidedUnit(unitType), + { + projectRoot: process.cwd(), + surface: "guided flow", + unitType, + authMode: result.appliedModel?.provider + ? ctx.modelRegistry.getProviderAuthMode(result.appliedModel.provider) + : ctx.model?.provider + ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider) + : undefined, + baseUrl: result.appliedModel?.baseUrl ?? ctx.model?.baseUrl, + }, + ); + if (compatibilityError) { + ctx.ui.notify(compatibilityError, "error"); + return; + } } // Scope tools for discuss flows (#2949). diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts new file mode 100644 index 000000000..6ba981121 --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -0,0 +1,159 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + buildWorkflowMcpServers, + detectWorkflowMcpLaunchConfig, + getWorkflowTransportSupportError, + getRequiredWorkflowToolsForAutoUnit, + getRequiredWorkflowToolsForGuidedUnit, + usesWorkflowMcpTransport, +} from "../workflow-mcp.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const gsdDir = join(__dirname, ".."); + +function readSrc(file: string): string { + return readFileSync(join(gsdDir, file), "utf-8"); +} + +test("guided execute-task requires canonical task completion tool", () => { + assert.deepEqual(getRequiredWorkflowToolsForGuidedUnit("execute-task"), ["gsd_task_complete"]); +}); + +test("auto execute-task requires legacy completion alias until prompt contract is aligned", () => { + assert.deepEqual(getRequiredWorkflowToolsForAutoUnit("execute-task"), ["gsd_complete_task"]); +}); + +test("detectWorkflowMcpLaunchConfig prefers explicit env override", () => { + const launch = detectWorkflowMcpLaunchConfig("/tmp/project", { + GSD_WORKFLOW_MCP_NAME: "workflow-tools", + GSD_WORKFLOW_MCP_COMMAND: "node", + GSD_WORKFLOW_MCP_ARGS: JSON.stringify(["dist/cli.js"]), + GSD_WORKFLOW_MCP_ENV: JSON.stringify({ FOO: "bar" }), + GSD_WORKFLOW_MCP_CWD: "/tmp/project", + GSD_CLI_PATH: "/tmp/gsd", + }); + + assert.deepEqual(launch, { + name: "workflow-tools", + command: "node", + args: ["dist/cli.js"], + cwd: "/tmp/project", + env: { + FOO: "bar", + GSD_CLI_PATH: "/tmp/gsd", + }, + }); +}); + +test("buildWorkflowMcpServers mirrors explicit launch config", () => { + const servers = buildWorkflowMcpServers("/tmp/project", { + GSD_WORKFLOW_MCP_COMMAND: "node", + GSD_WORKFLOW_MCP_ARGS: JSON.stringify(["dist/cli.js"]), + }); + + assert.deepEqual(servers, { + "gsd-workflow": { + command: "node", + args: ["dist/cli.js"], + }, + }); +}); + +test("usesWorkflowMcpTransport matches local externalCli providers", () => { + assert.equal(usesWorkflowMcpTransport("externalCli", "local://claude-code"), true); + assert.equal(usesWorkflowMcpTransport("externalCli", "https://api.example.com"), false); + assert.equal(usesWorkflowMcpTransport("oauth", "local://custom"), false); +}); + +test("transport compatibility passes when required tools fit current MCP surface", () => { + const error = getWorkflowTransportSupportError( + "claude-code", + ["gsd_task_complete"], + { + projectRoot: "/tmp/project", + env: { GSD_WORKFLOW_MCP_COMMAND: "node" }, + surface: "guided flow", + unitType: "execute-task", + authMode: "externalCli", + baseUrl: "local://claude-code", + }, + ); + + assert.equal(error, null); +}); + +test("transport compatibility fails cleanly when MCP server is unavailable", () => { + const error = getWorkflowTransportSupportError( + "claude-code", + ["gsd_task_complete"], + { + projectRoot: "/tmp/project", + env: {}, + surface: "auto-mode", + unitType: "execute-task", + authMode: "externalCli", + baseUrl: "local://claude-code", + }, + ); + + assert.match(error ?? "", /workflow MCP server is not configured or discoverable/); +}); + +test("transport compatibility fails cleanly when unit requires unsupported tools", () => { + 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.match(error ?? "", /requires gsd_plan_slice/); + assert.match(error ?? "", /currently exposes only/); +}); + +test("transport compatibility ignores API-backed providers", () => { + const error = getWorkflowTransportSupportError( + "openai-codex", + ["gsd_plan_slice"], + { + projectRoot: "/tmp/project", + env: {}, + surface: "auto-mode", + unitType: "plan-slice", + authMode: "oauth", + baseUrl: "https://api.openai.com", + }, + ); + + assert.equal(error, null); +}); + +test("guided-flow source enforces workflow compatibility preflight", () => { + const src = readSrc("guided-flow.ts"); + assert.match(src, /getRequiredWorkflowToolsForGuidedUnit/); + assert.match(src, /getWorkflowTransportSupportError/); +}); + +test("auto direct dispatch source enforces workflow compatibility preflight", () => { + const src = readSrc("auto-direct-dispatch.ts"); + assert.match(src, /getRequiredWorkflowToolsForAutoUnit/); + assert.match(src, /getWorkflowTransportSupportError/); +}); + +test("auto phases source enforces workflow compatibility preflight", () => { + const src = readSrc(join("auto", "phases.ts")); + assert.match(src, /getRequiredWorkflowToolsForAutoUnit/); + assert.match(src, /getWorkflowTransportSupportError/); + assert.match(src, /workflow-capability/); +}); diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts new file mode 100644 index 000000000..357c8db73 --- /dev/null +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -0,0 +1,204 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface WorkflowMcpLaunchConfig { + name: string; + command: string; + args?: string[]; + cwd?: string; + env?: Record; +} + +export interface WorkflowCapabilityOptions { + projectRoot?: string; + env?: NodeJS.ProcessEnv; + surface?: string; + unitType?: string; + authMode?: "apiKey" | "oauth" | "externalCli" | "none"; + baseUrl?: string; +} + +const MCP_WORKFLOW_TOOL_SURFACE = new Set([ + "gsd_milestone_status", + "gsd_summary_save", + "gsd_task_complete", +]); + +function parseLookupOutput(output: Buffer | string): string { + return output + .toString() + .trim() + .split(/\r?\n/)[0] ?? ""; +} + +function parseJsonEnv(env: NodeJS.ProcessEnv, name: string): T | undefined { + const raw = env[name]; + if (!raw) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + throw new Error(`Invalid JSON in ${name}`); + } +} + +function lookupCommand(command: string, platform: NodeJS.Platform = process.platform): string | null { + const lookup = platform === "win32" ? `where ${command}` : `which ${command}`; + try { + const resolved = parseLookupOutput(execSync(lookup, { timeout: 5_000, stdio: "pipe" })); + return resolved || null; + } catch { + return null; + } +} + +export function detectWorkflowMcpLaunchConfig( + projectRoot = process.cwd(), + env: NodeJS.ProcessEnv = process.env, +): WorkflowMcpLaunchConfig | null { + const name = env.GSD_WORKFLOW_MCP_NAME?.trim() || "gsd-workflow"; + const explicitCommand = env.GSD_WORKFLOW_MCP_COMMAND?.trim(); + const explicitArgs = parseJsonEnv(env, "GSD_WORKFLOW_MCP_ARGS"); + const explicitEnv = parseJsonEnv>(env, "GSD_WORKFLOW_MCP_ENV"); + const explicitCwd = env.GSD_WORKFLOW_MCP_CWD?.trim(); + + if (explicitCommand) { + const launchEnv = { + ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(explicitEnv ?? {}), + }; + return { + name, + command: explicitCommand, + args: Array.isArray(explicitArgs) && explicitArgs.length > 0 ? explicitArgs.map(String) : undefined, + cwd: explicitCwd || undefined, + env: Object.keys(launchEnv).length > 0 ? launchEnv : undefined, + }; + } + + const distCli = resolve(projectRoot, "packages", "mcp-server", "dist", "cli.js"); + if (existsSync(distCli)) { + return { + name, + command: process.execPath, + args: [distCli], + cwd: projectRoot, + env: env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : undefined, + }; + } + + const binPath = lookupCommand("gsd-mcp-server"); + if (binPath) { + return { + name, + command: binPath, + env: env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : undefined, + }; + } + + return null; +} + +export function buildWorkflowMcpServers( + projectRoot = process.cwd(), + env: NodeJS.ProcessEnv = process.env, +): Record> | undefined { + const launch = detectWorkflowMcpLaunchConfig(projectRoot, env); + if (!launch) return undefined; + + return { + [launch.name]: { + command: launch.command, + ...(launch.args && launch.args.length > 0 ? { args: launch.args } : {}), + ...(launch.env ? { env: launch.env } : {}), + ...(launch.cwd ? { cwd: launch.cwd } : {}), + }, + }; +} + +export function getRequiredWorkflowToolsForGuidedUnit(unitType: string): string[] { + switch (unitType) { + case "discuss-milestone": + return ["gsd_summary_save", "gsd_plan_milestone"]; + case "discuss-slice": + return ["gsd_summary_save"]; + case "research-milestone": + case "research-slice": + return ["gsd_summary_save"]; + case "plan-milestone": + return ["gsd_plan_milestone"]; + case "plan-slice": + return ["gsd_plan_slice"]; + case "execute-task": + return ["gsd_task_complete"]; + case "complete-slice": + return ["gsd_slice_complete"]; + default: + return []; + } +} + +export function getRequiredWorkflowToolsForAutoUnit(unitType: string): string[] { + switch (unitType) { + case "discuss-milestone": + return ["gsd_summary_save", "gsd_plan_milestone"]; + case "research-milestone": + case "research-slice": + case "run-uat": + return ["gsd_summary_save"]; + case "plan-milestone": + return ["gsd_plan_milestone"]; + case "plan-slice": + return ["gsd_plan_slice"]; + case "execute-task": + case "execute-task-simple": + case "reactive-execute": + return ["gsd_complete_task"]; + case "complete-slice": + return ["gsd_complete_slice"]; + case "replan-slice": + return ["gsd_replan_slice"]; + case "reassess-roadmap": + return ["gsd_milestone_status", "gsd_reassess_roadmap"]; + case "gate-evaluate": + return ["gsd_save_gate_result"]; + case "validate-milestone": + return ["gsd_milestone_status", "gsd_validate_milestone"]; + case "complete-milestone": + return ["gsd_milestone_status", "gsd_complete_milestone"]; + default: + return []; + } +} + +export function usesWorkflowMcpTransport( + authMode: WorkflowCapabilityOptions["authMode"], + baseUrl: string | undefined, +): boolean { + return authMode === "externalCli" && typeof baseUrl === "string" && baseUrl.startsWith("local://"); +} + +export function getWorkflowTransportSupportError( + provider: string | undefined, + requiredTools: string[], + options: WorkflowCapabilityOptions = {}, +): string | null { + if (!provider || requiredTools.length === 0) return null; + if (!usesWorkflowMcpTransport(options.authMode, options.baseUrl)) return null; + + const projectRoot = options.projectRoot ?? process.cwd(); + const env = options.env ?? process.env; + const launch = detectWorkflowMcpLaunchConfig(projectRoot, env); + const surface = options.surface ?? "workflow dispatch"; + const unitLabel = options.unitType ? ` for ${options.unitType}` : ""; + const providerLabel = `"${provider}"`; + + if (!launch) { + return `Provider ${providerLabel} cannot run ${surface}${unitLabel}: the GSD workflow MCP server is not configured or discoverable. Configure GSD_WORKFLOW_MCP_COMMAND, build packages/mcp-server/dist/cli.js, or install gsd-mcp-server on PATH.`; + } + + const missing = [...new Set(requiredTools)].filter((tool) => !MCP_WORKFLOW_TOOL_SURFACE.has(tool)); + if (missing.length === 0) return null; + + return `Provider ${providerLabel} cannot run ${surface}${unitLabel}: this unit requires ${missing.join(", ")}, but the workflow MCP transport currently exposes only ${Array.from(MCP_WORKFLOW_TOOL_SURFACE).sort().join(", ")}.`; +}