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
This commit is contained in:
Jeremy 2026-04-11 08:35:19 -05:00
parent ef91a17bb2
commit 4301a72522
9 changed files with 359 additions and 28 deletions

View file

@ -184,6 +184,7 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent:
*/
export function buildSdkOptions(modelId: string, prompt: string): Record<string, unknown> {
const mcpServers = buildWorkflowMcpServers();
const disallowedTools = ["AskUserQuestion"];
return {
pathToClaudeCodeExecutable: getClaudePath(),
model: modelId,
@ -194,6 +195,7 @@ export function buildSdkOptions(modelId: string, prompt: string): Record<string,
allowDangerouslySkipPermissions: true,
settingSources: ["project"],
systemPrompt: { type: "preset", preset: "claude_code" },
disallowedTools,
...(mcpServers ? { mcpServers } : {}),
betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
};

View file

@ -217,6 +217,35 @@ 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, "/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<string, any>;
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 });

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, unknown>; required?: string[] };
};
}).params ?? request as {
message: string;
requestedSchema: { properties: Record<string, unknown>; 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<string, unknown>; required?: string[] };
} | null = null;
client.setRequestHandler(ElicitRequestSchema, async (request) => {
const params = (
request as {
params?: {
message: string;
requestedSchema: { properties: Record<string, unknown>; required?: string[] };
};
}
).params ?? request as {
message: string;
requestedSchema: { properties: Record<string, unknown>; 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<string, string>,
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 \./);
});

View file

@ -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<ExtensionContext["ui"], "notify">;
}
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;
}
}

View file

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