feat: gate workflow MCP units by provider transport capabilities

This commit is contained in:
Jeremy 2026-04-09 11:30:08 -05:00
parent 4ea87a33d6
commit 7cc2d11d34
5 changed files with 432 additions and 0 deletions

View file

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

View file

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

View file

@ -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).

View file

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

View file

@ -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<string, string>;
}
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<T>(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<unknown>(env, "GSD_WORKFLOW_MCP_ARGS");
const explicitEnv = parseJsonEnv<Record<string, string>>(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<string, Record<string, unknown>> | 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(", ")}.`;
}