From 4301a7252291ed1683f133fe251ae891615ee13e Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sat, 11 Apr 2026 08:35:19 -0500 Subject: [PATCH] fix(gsd): harden claude-code workflow MCP bootstrap Ensure startup/session/init paths auto-prepare workflow MCP for Claude Code. Disable native AskUserQuestion in Claude Code SDK options to avoid broken host prompts. Add explicit /gsd mcp init . guidance when workflow MCP is missing. Refs #3964 --- .../claude-code-cli/stream-adapter.ts | 2 + .../tests/stream-adapter.test.ts | 33 ++++ src/resources/extensions/gsd/auto-start.ts | 16 +- .../gsd/bootstrap/register-hooks.ts | 4 + src/resources/extensions/gsd/init-wizard.ts | 16 +- .../gsd/tests/workflow-mcp-auto-prep.test.ts | 76 ++++++++ .../extensions/gsd/tests/workflow-mcp.test.ts | 162 +++++++++++++++++- .../extensions/gsd/workflow-mcp-auto-prep.ts | 76 ++++++++ src/resources/extensions/gsd/workflow-mcp.ts | 2 +- 9 files changed, 359 insertions(+), 28 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts create mode 100644 src/resources/extensions/gsd/workflow-mcp-auto-prep.ts diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 465d48759..46c376fd1 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -184,6 +184,7 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: */ export function buildSdkOptions(modelId: string, prompt: string): Record { const mcpServers = buildWorkflowMcpServers(); + const disallowedTools = ["AskUserQuestion"]; return { pathToClaudeCodeExecutable: getClaudePath(), model: modelId, @@ -194,6 +195,7 @@ export function buildSdkOptions(modelId: string, prompt: string): Record { assert.equal(srv.env.GSD_CLI_PATH, "/tmp/gsd"); assert.equal(srv.env.GSD_PERSIST_WRITE_GATE_STATE, "1"); assert.equal(srv.env.GSD_WORKFLOW_PROJECT_ROOT, "/tmp/project"); + assert.deepEqual(options.disallowedTools, ["AskUserQuestion"]); + } finally { + process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; + process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; + process.env.GSD_WORKFLOW_MCP_ARGS = prev.GSD_WORKFLOW_MCP_ARGS; + process.env.GSD_WORKFLOW_MCP_ENV = prev.GSD_WORKFLOW_MCP_ENV; + process.env.GSD_WORKFLOW_MCP_CWD = prev.GSD_WORKFLOW_MCP_CWD; + } + }); + + test("buildSdkOptions disables AskUserQuestion for custom workflow MCP server names", () => { + const prev = { + GSD_WORKFLOW_MCP_COMMAND: process.env.GSD_WORKFLOW_MCP_COMMAND, + GSD_WORKFLOW_MCP_NAME: process.env.GSD_WORKFLOW_MCP_NAME, + GSD_WORKFLOW_MCP_ARGS: process.env.GSD_WORKFLOW_MCP_ARGS, + GSD_WORKFLOW_MCP_ENV: process.env.GSD_WORKFLOW_MCP_ENV, + GSD_WORKFLOW_MCP_CWD: process.env.GSD_WORKFLOW_MCP_CWD, + }; + try { + process.env.GSD_WORKFLOW_MCP_COMMAND = "node"; + process.env.GSD_WORKFLOW_MCP_NAME = "custom-workflow"; + process.env.GSD_WORKFLOW_MCP_ARGS = JSON.stringify(["packages/mcp-server/dist/cli.js"]); + process.env.GSD_WORKFLOW_MCP_ENV = JSON.stringify({ GSD_CLI_PATH: "/tmp/gsd" }); + process.env.GSD_WORKFLOW_MCP_CWD = "/tmp/project"; + + const options = buildSdkOptions("claude-sonnet-4-20250514", "test"); + const mcpServers = options.mcpServers as Record; + assert.ok(mcpServers?.["custom-workflow"], "expected custom workflow server config"); + assert.deepEqual(options.disallowedTools, ["AskUserQuestion"]); } finally { process.env.GSD_WORKFLOW_MCP_COMMAND = prev.GSD_WORKFLOW_MCP_COMMAND; process.env.GSD_WORKFLOW_MCP_NAME = prev.GSD_WORKFLOW_MCP_NAME; @@ -252,6 +281,9 @@ describe("stream-adapter — session persistence (#2859)", () => { const mcpServers = (options as any).mcpServers; if (mcpServers) { assert.ok(mcpServers["gsd-workflow"], "if present, must be gsd-workflow"); + assert.deepEqual((options as any).disallowedTools, ["AskUserQuestion"]); + } else { + assert.deepEqual((options as any).disallowedTools, ["AskUserQuestion"]); } rmSync(emptyDir, { recursive: true, force: true }); } finally { @@ -298,6 +330,7 @@ describe("stream-adapter — session persistence (#2859)", () => { assert.equal(srv.env.GSD_CLI_PATH, "/tmp/gsd"); assert.equal(srv.env.GSD_PERSIST_WRITE_GATE_STATE, "1"); assert.equal(srv.env.GSD_WORKFLOW_PROJECT_ROOT, resolvedRepoDir); + assert.deepEqual(options.disallowedTools, ["AskUserQuestion"]); } finally { process.chdir(originalCwd); rmSync(repoDir, { recursive: true, force: true }); diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 3f737c638..1009cbeb0 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -335,19 +335,9 @@ export async function bootstrapAutoSession( } } - if (ctx.model?.provider === "claude-code") { - try { - const { ensureProjectWorkflowMcpConfig } = await import("./mcp-project-config.js"); - const result = ensureProjectWorkflowMcpConfig(base); - if (result.status !== "unchanged") { - ctx.ui.notify(`Claude Code MCP prepared at ${result.configPath}`, "info"); - } - } catch (err) { - ctx.ui.notify( - `Claude Code MCP prep failed: ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - } + { + const { prepareWorkflowMcpForProject } = await import("./workflow-mcp-auto-prep.js"); + prepareWorkflowMcpForProject(ctx, base); } // Initialize GitServiceImpl diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 6c88de385..7b131d86e 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -45,6 +45,8 @@ export function registerHooks(pi: ExtensionAPI): void { resetToolCallLoopGuard(); resetAskUserQuestionsCache(); await syncServiceTierStatus(ctx); + const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js"); + prepareWorkflowMcpForProject(ctx, process.cwd()); // Apply show_token_cost preference (#1515) try { @@ -85,6 +87,8 @@ export function registerHooks(pi: ExtensionAPI): void { resetAskUserQuestionsCache(); clearDiscussionFlowState(); await syncServiceTierStatus(ctx); + const { prepareWorkflowMcpForProject } = await import("../workflow-mcp-auto-prep.js"); + prepareWorkflowMcpForProject(ctx, process.cwd()); loadToolApiKeys(); }); diff --git a/src/resources/extensions/gsd/init-wizard.ts b/src/resources/extensions/gsd/init-wizard.ts index 40f3e5b64..b7251471e 100644 --- a/src/resources/extensions/gsd/init-wizard.ts +++ b/src/resources/extensions/gsd/init-wizard.ts @@ -274,19 +274,9 @@ export async function showProjectInit( // Non-fatal — STATE.md will be regenerated on next /gsd invocation } - if (ctx.model?.provider === "claude-code") { - try { - const { ensureProjectWorkflowMcpConfig } = await import("./mcp-project-config.js"); - const result = ensureProjectWorkflowMcpConfig(basePath); - if (result.status !== "unchanged") { - ctx.ui.notify(`Claude Code MCP prepared at ${result.configPath}`, "info"); - } - } catch (err) { - ctx.ui.notify( - `Claude Code MCP prep failed: ${err instanceof Error ? err.message : String(err)}`, - "warning", - ); - } + { + const { prepareWorkflowMcpForProject } = await import("./workflow-mcp-auto-prep.js"); + prepareWorkflowMcpForProject(ctx, basePath); } ctx.ui.notify("GSD initialized. Starting your first milestone...", "info"); diff --git a/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts new file mode 100644 index 000000000..9d9e8d257 --- /dev/null +++ b/src/resources/extensions/gsd/tests/workflow-mcp-auto-prep.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { prepareWorkflowMcpForProject, shouldAutoPrepareWorkflowMcp } from "../workflow-mcp-auto-prep.ts"; + +test("shouldAutoPrepareWorkflowMcp enables prep for externalCli local transport", () => { + const result = shouldAutoPrepareWorkflowMcp({ + model: { provider: "claude-code", baseUrl: "local://claude-code" }, + modelRegistry: { + getProviderAuthMode: () => "externalCli", + isProviderRequestReady: () => false, + }, + }); + + assert.equal(result, true); +}); + +test("shouldAutoPrepareWorkflowMcp enables prep when claude-code provider is ready", () => { + const result = shouldAutoPrepareWorkflowMcp({ + model: { provider: "openai", baseUrl: "https://api.openai.com" }, + modelRegistry: { + getProviderAuthMode: () => "apiKey", + isProviderRequestReady: (provider: string) => provider === "claude-code", + }, + }); + + assert.equal(result, true); +}); + +test("shouldAutoPrepareWorkflowMcp enables prep when claude-code provider is registered", () => { + const result = shouldAutoPrepareWorkflowMcp({ + model: { provider: "openai", baseUrl: "https://api.openai.com" }, + modelRegistry: { + getProviderAuthMode: (provider: string) => provider === "claude-code" ? "externalCli" : "apiKey", + isProviderRequestReady: () => false, + }, + }); + + assert.equal(result, true); +}); + +test("shouldAutoPrepareWorkflowMcp stays disabled when neither transport nor provider readiness match", () => { + const result = shouldAutoPrepareWorkflowMcp({ + model: { provider: "openai", baseUrl: "https://api.openai.com" }, + modelRegistry: { + getProviderAuthMode: () => "apiKey", + isProviderRequestReady: () => false, + }, + }); + + assert.equal(result, false); +}); + +test("prepareWorkflowMcpForProject warns with /gsd mcp init guidance when prep fails", () => { + const notifications: Array<{ message: string; level: string }> = []; + const result = prepareWorkflowMcpForProject( + { + model: { provider: "claude-code", baseUrl: "local://claude-code" }, + modelRegistry: { + getProviderAuthMode: () => "externalCli", + isProviderRequestReady: () => true, + }, + ui: { + notify: (message: string, level: string) => { + notifications.push({ message, level }); + }, + }, + }, + "/", + ); + + assert.equal(result, null); + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "warning"); + assert.match(notifications[0].message, /Please run \/gsd mcp init \./); +}); diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 8e0575096..e17c5043b 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { buildWorkflowMcpServers, @@ -184,7 +185,34 @@ test("workflow MCP launch config reaches mutation tools over stdio", async () => assert.match(launch.env?.NODE_OPTIONS ?? "", /resolve-ts\.mjs/); } - const client = new Client({ name: "workflow-mcp-transport-test", version: "1.0.0" }); + const client = new Client( + { name: "workflow-mcp-transport-test", version: "1.0.0" }, + { capabilities: { elicitation: {} } }, + ); + client.setRequestHandler(ElicitRequestSchema, async (request) => { + const elicitation = (request as { + params?: { + message: string; + requestedSchema: { properties: Record; required?: string[] }; + }; + }).params ?? request as { + message: string; + requestedSchema: { properties: Record; required?: string[] }; + }; + + assert.match(elicitation.message, /Please answer the following question/); + assert.ok(elicitation.requestedSchema.properties.transport_mode); + assert.ok(elicitation.requestedSchema.properties["transport_mode__note"]); + assert.ok(elicitation.requestedSchema.required?.includes("transport_mode")); + + return { + action: "accept", + content: { + transport_mode: "None of the above", + transport_mode__note: "Need Windows-safe MCP elicitation.", + }, + }; + }); const transport = new StdioClientTransport({ command: launch.command, args: launch.args, @@ -206,6 +234,38 @@ test("workflow MCP launch config reaches mutation tools over stdio", async () => "expected workflow MCP surface to expose ask_user_questions", ); + const askResult = await client.callTool( + { + name: "ask_user_questions", + arguments: { + questions: [ + { + id: "transport_mode", + header: "Transport", + question: "How should the workflow prompt be delivered?", + options: [ + { label: "Local UI", description: "Use the host tool UI." }, + { label: "Remote UI", description: "Use a remote response channel." }, + ], + }, + ], + }, + }, + undefined, + { timeout: 30_000 }, + ); + assert.equal(askResult.isError, undefined); + assert.equal( + ((askResult.content as Array<{ text?: string }>)?.[0])?.text ?? "", + JSON.stringify({ + answers: { + transport_mode: { + answers: ["None of the above", "user_note: Need Windows-safe MCP elicitation."], + }, + }, + }), + ); + const milestoneResult = await client.callTool( { name: "gsd_plan_milestone", @@ -285,6 +345,101 @@ test("workflow MCP launch config reaches mutation tools over stdio", async () => } }); +test("workflow MCP ask_user_questions uses stdio elicitation round-trip", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-elicit-")); + mkdirSync(join(projectRoot, ".gsd"), { recursive: true }); + + const launch = detectWorkflowMcpLaunchConfig(projectRoot, {}); + assert.ok(launch, "expected a workflow MCP launch config"); + + const client = new Client( + { name: "workflow-mcp-elicit-test", version: "1.0.0" }, + { capabilities: { elicitation: {} } }, + ); + let requestSeen: { + message: string; + requestedSchema: { properties: Record; required?: string[] }; + } | null = null; + + client.setRequestHandler(ElicitRequestSchema, async (request) => { + const params = ( + request as { + params?: { + message: string; + requestedSchema: { properties: Record; required?: string[] }; + }; + } + ).params ?? request as { + message: string; + requestedSchema: { properties: Record; required?: string[] }; + }; + + requestSeen = params; + + return { + action: "accept", + content: { + deployment: "None of the above", + deployment__note: "Need hybrid deployment.", + }, + }; + }); + + const transport = new StdioClientTransport({ + command: launch.command, + args: launch.args, + env: { ...process.env, ...launch.env } as Record, + cwd: launch.cwd, + stderr: "pipe", + }); + + try { + await client.connect(transport, { timeout: 30_000 }); + + const result = await client.callTool( + { + name: "ask_user_questions", + arguments: { + questions: [ + { + id: "deployment", + header: "Deploy", + question: "Where will this run?", + options: [ + { label: "Cloud", description: "Managed hosting." }, + { label: "On-prem", description: "Runs in customer infrastructure." }, + ], + }, + ], + }, + }, + undefined, + { timeout: 30_000 }, + ); + + assert.ok(requestSeen, "expected stdio transport to forward an elicitation request"); + assert.match(requestSeen.message, /Please answer the following question/); + assert.ok(requestSeen.requestedSchema.properties.deployment); + assert.ok(requestSeen.requestedSchema.properties.deployment__note); + assert.ok(requestSeen.requestedSchema.required?.includes("deployment")); + + const text = result.content.find((item) => item.type === "text"); + assert.ok(text && "text" in text); + assert.equal( + text.text, + JSON.stringify({ + answers: { + deployment: { + answers: ["None of the above", "user_note: Need hybrid deployment."], + }, + }, + }), + ); + } finally { + await client.close(); + } +}); + test("usesWorkflowMcpTransport matches local externalCli providers", () => { assert.equal(usesWorkflowMcpTransport("externalCli", "local://claude-code"), true); assert.equal(usesWorkflowMcpTransport("externalCli", "https://api.example.com"), false); @@ -514,3 +669,8 @@ test("auto phases source enforces workflow compatibility preflight", () => { assert.match(src, /getWorkflowTransportSupportError/); assert.match(src, /workflow-capability/); }); + +test("workflow transport error guidance includes /gsd mcp init hint", () => { + const src = readSrc("workflow-mcp.ts"); + assert.match(src, /Please run \/gsd mcp init \./); +}); diff --git a/src/resources/extensions/gsd/workflow-mcp-auto-prep.ts b/src/resources/extensions/gsd/workflow-mcp-auto-prep.ts new file mode 100644 index 000000000..1d69ebc00 --- /dev/null +++ b/src/resources/extensions/gsd/workflow-mcp-auto-prep.ts @@ -0,0 +1,76 @@ +import type { ExtensionContext } from "@gsd/pi-coding-agent"; + +import { + type EnsureProjectWorkflowMcpConfigResult, + ensureProjectWorkflowMcpConfig, +} from "./mcp-project-config.js"; +import { usesWorkflowMcpTransport } from "./workflow-mcp.js"; + +interface WorkflowMcpAutoPrepContext { + model?: { provider?: string; baseUrl?: string }; + modelRegistry?: { + getProviderAuthMode?: (provider: string) => string; + isProviderRequestReady?: (provider: string) => boolean; + }; + ui?: Pick; +} + +function getAuthModeSafe( + ctx: WorkflowMcpAutoPrepContext, + provider: string | undefined, +): string | undefined { + if (!provider) return undefined; + const getAuthMode = ctx.modelRegistry?.getProviderAuthMode; + if (typeof getAuthMode !== "function") return undefined; + try { + return getAuthMode(provider); + } catch { + return undefined; + } +} + +function hasClaudeCodeProvider(ctx: WorkflowMcpAutoPrepContext): boolean { + return getAuthModeSafe(ctx, "claude-code") === "externalCli"; +} + +function isClaudeCodeProviderReady(ctx: WorkflowMcpAutoPrepContext): boolean { + const readyCheck = ctx.modelRegistry?.isProviderRequestReady; + if (typeof readyCheck !== "function") return false; + try { + return readyCheck("claude-code"); + } catch { + return false; + } +} + +export function shouldAutoPrepareWorkflowMcp(ctx: WorkflowMcpAutoPrepContext): boolean { + const provider = ctx.model?.provider; + const baseUrl = ctx.model?.baseUrl; + const authMode = getAuthModeSafe(ctx, provider); + + if (usesWorkflowMcpTransport(authMode as any, baseUrl)) return true; + if (provider === "claude-code") return true; + if (hasClaudeCodeProvider(ctx)) return true; + return isClaudeCodeProviderReady(ctx); +} + +export function prepareWorkflowMcpForProject( + ctx: WorkflowMcpAutoPrepContext, + projectRoot: string, +): EnsureProjectWorkflowMcpConfigResult | null { + if (!shouldAutoPrepareWorkflowMcp(ctx)) return null; + + try { + const result = ensureProjectWorkflowMcpConfig(projectRoot); + if (result.status !== "unchanged") { + ctx.ui?.notify?.(`Claude Code MCP prepared at ${result.configPath}`, "info"); + } + return result; + } catch (err) { + ctx.ui?.notify?.( + `Claude Code MCP prep failed: ${err instanceof Error ? err.message : String(err)}. Detected Claude Code model but no workflow MCP. Please run /gsd mcp init . from your project root.`, + "warning", + ); + return null; + } +} diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index 809aa1543..1ef4be1cf 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -364,7 +364,7 @@ export function getWorkflowTransportSupportError( 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.`; + return `Provider ${providerLabel} cannot run ${surface}${unitLabel}: the GSD workflow MCP server is not configured or discoverable. Detected Claude Code model but no workflow MCP. Please run /gsd mcp init . from your project root. You can also 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));