feat: gate workflow MCP units by provider transport capabilities
This commit is contained in:
parent
4ea87a33d6
commit
7cc2d11d34
5 changed files with 432 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
159
src/resources/extensions/gsd/tests/workflow-mcp.test.ts
Normal file
159
src/resources/extensions/gsd/tests/workflow-mcp.test.ts
Normal 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/);
|
||||
});
|
||||
204
src/resources/extensions/gsd/workflow-mcp.ts
Normal file
204
src/resources/extensions/gsd/workflow-mcp.ts
Normal 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(", ")}.`;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue