feat(mcp-server): expose ask_user_questions via elicitation

This commit is contained in:
Jeremy 2026-04-10 15:44:08 -05:00
parent 2ad315b9fb
commit b3275a182d
8 changed files with 747 additions and 45 deletions

View file

@ -7,7 +7,8 @@ Start GSD auto-mode sessions, poll progress, resolve blockers, and retrieve resu
This package now exposes two tool surfaces:
- session/read tools for starting and inspecting GSD sessions
- workflow mutation tools for planning, completion, validation, reassessment, and gate persistence
- MCP-native interactive tools for structured user input
- headless-safe workflow tools for planning, completion, validation, reassessment, metadata persistence, and journal reads
## Installation
@ -74,18 +75,29 @@ Add to `.cursor/mcp.json`:
## Tools
### Workflow mutation tools
### Workflow tools
The workflow MCP surface includes:
- `gsd_decision_save`
- `gsd_save_decision`
- `gsd_requirement_update`
- `gsd_update_requirement`
- `gsd_requirement_save`
- `gsd_save_requirement`
- `gsd_milestone_generate_id`
- `gsd_generate_milestone_id`
- `gsd_plan_milestone`
- `gsd_plan_slice`
- `gsd_plan_task`
- `gsd_task_plan`
- `gsd_replan_slice`
- `gsd_slice_replan`
- `gsd_task_complete`
- `gsd_complete_task`
- `gsd_slice_complete`
- `gsd_complete_slice`
- `gsd_skip_slice`
- `gsd_validate_milestone`
- `gsd_milestone_validate`
- `gsd_complete_milestone`
@ -95,13 +107,21 @@ The workflow MCP surface includes:
- `gsd_save_gate_result`
- `gsd_summary_save`
- `gsd_milestone_status`
- `gsd_journal_query`
These mutation tools use the same GSD workflow handlers as the native in-process tool path.
These tools use the same GSD workflow handlers as the native in-process tool path wherever a shared handler exists.
### Interactive tools
The packaged server now exposes `ask_user_questions` through MCP form elicitation. This keeps the existing GSD answer payload shape while allowing Claude Code CLI and other elicitation-capable clients to surface structured user choices.
`secure_env_collect` is still not exposed by this package. That path needs MCP URL elicitation or an equivalent secure bridge because secrets should not flow through form elicitation.
Current support boundary:
- when running inside the GSD monorepo checkout, the MCP server auto-discovers the shared workflow executor module
- outside the monorepo, set `GSD_WORKFLOW_EXECUTORS_MODULE` to an importable `workflow-tool-executors` module path if you want the mutation tools enabled
- `ask_user_questions` requires an MCP client that supports form elicitation
- session/read tools do not depend on this bridge
If the executor bridge cannot be loaded, workflow mutation calls will fail with a precise configuration error instead of silently degrading.

View file

@ -1,5 +1,3 @@
#!/usr/bin/env node
/**
* @gsd-build/mcp-server CLI stdio transport entry point.
*
@ -15,7 +13,8 @@ const MCP_PKG = '@modelcontextprotocol/sdk';
async function main(): Promise<void> {
const sessionManager = new SessionManager();
// Create the configured MCP server with all 12 tools (6 session + 6 read-only)
// Create the configured MCP server with session, interactive, read-only,
// and workflow tools.
const { server } = await createMcpServer(sessionManager);
// Dynamic import for StdioServerTransport (same TS subpath workaround)

View file

@ -16,7 +16,11 @@ import { resolve } from 'node:path';
import { EventEmitter } from 'node:events';
import { SessionManager } from './session-manager.js';
import { createMcpServer } from './server.js';
import {
buildAskUserQuestionsElicitRequest,
createMcpServer,
formatAskUserQuestionsElicitResult,
} from './server.js';
import { MAX_EVENTS } from './types.js';
import type { ManagedSession, CostAccumulator, PendingBlocker } from './types.js';
@ -574,6 +578,8 @@ describe('createMcpServer tool registration', () => {
it('creates server successfully with all required methods', async () => {
const { server } = await createMcpServer(sm);
assert.ok(server);
assert.ok(server.server);
assert.equal(typeof server.server.elicitInput, 'function');
assert.ok(typeof server.connect === 'function');
assert.ok(typeof server.close === 'function');
});
@ -625,4 +631,82 @@ describe('createMcpServer tool registration', () => {
const session = sm.getSession(sessionId)!;
assert.equal(session.status, 'cancelled');
});
it('buildAskUserQuestionsElicitRequest adds None of the above note field for single-select questions', () => {
const request = buildAskUserQuestionsElicitRequest([
{
id: 'depth_verification_M001',
header: 'Depth Check',
question: 'Did I capture the depth right?',
options: [
{ label: 'Yes, you got it (Recommended)', description: 'Continue with the current summary.' },
{ label: 'Not quite', description: 'I need to clarify the depth further.' },
],
},
{
id: 'focus_areas',
header: 'Focus',
question: 'Which areas matter most?',
allowMultiple: true,
options: [
{ label: 'Frontend', description: 'Prioritize the UI.' },
{ label: 'Backend', description: 'Prioritize server logic.' },
],
},
]);
assert.equal(request.mode, 'form');
assert.deepEqual(request.requestedSchema.required, ['depth_verification_M001', 'focus_areas']);
assert.ok(request.requestedSchema.properties['depth_verification_M001']);
assert.ok(request.requestedSchema.properties['depth_verification_M001__note']);
assert.ok(!request.requestedSchema.properties['focus_areas__note']);
});
it('formatAskUserQuestionsElicitResult preserves the existing answers JSON shape', () => {
const result = formatAskUserQuestionsElicitResult(
[
{
id: 'depth_verification_M001',
header: 'Depth Check',
question: 'Did I capture the depth right?',
options: [
{ label: 'Yes, you got it (Recommended)', description: 'Continue with the current summary.' },
{ label: 'Not quite', description: 'I need to clarify the depth further.' },
],
},
{
id: 'focus_areas',
header: 'Focus',
question: 'Which areas matter most?',
allowMultiple: true,
options: [
{ label: 'Frontend', description: 'Prioritize the UI.' },
{ label: 'Backend', description: 'Prioritize server logic.' },
],
},
],
{
action: 'accept',
content: {
depth_verification_M001: 'None of the above',
depth_verification_M001__note: 'Need more implementation detail.',
focus_areas: ['Frontend', 'Backend'],
},
},
);
assert.equal(
result,
JSON.stringify({
answers: {
depth_verification_M001: {
answers: ['None of the above', 'user_note: Need more implementation detail.'],
},
focus_areas: {
answers: ['Frontend', 'Backend'],
},
},
}),
);
});
});

View file

@ -2,8 +2,9 @@
* MCP Server registers GSD orchestration, project-state, and workflow tools.
*
* Session tools (6): gsd_execute, gsd_status, gsd_result, gsd_cancel, gsd_query, gsd_resolve_blocker
* Interactive tools (1): ask_user_questions via MCP form elicitation
* Read-only tools (6): gsd_progress, gsd_roadmap, gsd_history, gsd_doctor, gsd_captures, gsd_knowledge
* Workflow tools (17): planning, replanning, completion, validation, reassessment, gate result, and milestone status tools
* Workflow tools (29): headless-safe planning, metadata persistence, replanning, completion, validation, reassessment, gate result, status, and journal tools
*
* Uses dynamic imports for @modelcontextprotocol/sdk because TS Node16
* cannot resolve the SDK's subpath exports statically (same pattern as
@ -44,6 +45,11 @@ function errorContent(message: string): { isError: true; content: Array<{ type:
return { isError: true, content: [{ type: 'text' as const, text: message }] };
}
/** Return raw text content without JSON wrapping. */
function textContent(text: string): { content: Array<{ type: 'text'; text: string }> } {
return { content: [{ type: 'text' as const, text }] };
}
// ---------------------------------------------------------------------------
// gsd_query filesystem reader
// ---------------------------------------------------------------------------
@ -108,10 +114,155 @@ async function fileExists(path: string): Promise<boolean> {
interface McpServerInstance {
tool(name: string, description: string, params: Record<string, unknown>, handler: (args: Record<string, unknown>) => Promise<unknown>): unknown;
server: {
elicitInput(
params: AskUserQuestionsElicitRequest,
options?: unknown,
): Promise<AskUserQuestionsElicitResult>;
};
connect(transport: unknown): Promise<void>;
close(): Promise<void>;
}
interface AskUserQuestionOption {
label: string;
description: string;
}
interface AskUserQuestion {
id: string;
header: string;
question: string;
options: AskUserQuestionOption[];
allowMultiple?: boolean;
}
interface AskUserQuestionsParams {
questions: AskUserQuestion[];
}
type AskUserQuestionsContentValue = string | number | boolean | string[];
interface AskUserQuestionsElicitResult {
action: 'accept' | 'decline' | 'cancel';
content?: Record<string, AskUserQuestionsContentValue>;
}
interface AskUserQuestionsElicitRequest {
mode: 'form';
message: string;
requestedSchema: {
type: 'object';
properties: Record<string, Record<string, unknown>>;
required?: string[];
};
}
const OTHER_OPTION_LABEL = 'None of the above';
function normalizeAskUserQuestionsNote(value: AskUserQuestionsContentValue | undefined): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeAskUserQuestionsAnswers(
value: AskUserQuestionsContentValue | undefined,
allowMultiple: boolean,
): string[] {
if (allowMultiple) {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
}
return typeof value === 'string' && value.length > 0 ? [value] : [];
}
function validateAskUserQuestionsPayload(questions: AskUserQuestion[]): string | null {
if (questions.length === 0 || questions.length > 3) {
return 'Error: questions must contain 1-3 items';
}
for (const question of questions) {
if (!question.options || question.options.length === 0) {
return `Error: ask_user_questions requires non-empty options for every question (question "${question.id}" has none)`;
}
}
return null;
}
export function buildAskUserQuestionsElicitRequest(questions: AskUserQuestion[]): AskUserQuestionsElicitRequest {
const properties: Record<string, Record<string, unknown>> = {};
const required = questions.map((question) => question.id);
for (const question of questions) {
if (question.allowMultiple) {
properties[question.id] = {
type: 'array',
title: question.header,
description: question.question,
minItems: 1,
maxItems: question.options.length,
items: {
anyOf: question.options.map((option) => ({
const: option.label,
title: option.label,
})),
},
};
continue;
}
properties[question.id] = {
type: 'string',
title: question.header,
description: question.question,
oneOf: [...question.options, { label: OTHER_OPTION_LABEL, description: 'Choose this when the listed options do not fit.' }].map((option) => ({
const: option.label,
title: option.label,
})),
};
properties[`${question.id}__note`] = {
type: 'string',
title: `${question.header} Note`,
description: `Optional note for "${OTHER_OPTION_LABEL}".`,
maxLength: 500,
};
}
return {
mode: 'form',
message: 'Please answer the following question(s). For single-select questions, choose "None of the above" and add a note if the provided options do not fit.',
requestedSchema: {
type: 'object',
properties,
required,
},
};
}
export function formatAskUserQuestionsElicitResult(
questions: AskUserQuestion[],
result: AskUserQuestionsElicitResult,
): string {
const answers: Record<string, { answers: string[] }> = {};
const content = result.content ?? {};
for (const question of questions) {
const answerList = normalizeAskUserQuestionsAnswers(content[question.id], !!question.allowMultiple);
if (!question.allowMultiple && answerList[0] === OTHER_OPTION_LABEL) {
const note = normalizeAskUserQuestionsNote(content[`${question.id}__note`]);
if (note) {
answerList.push(`user_note: ${note}`);
}
}
answers[question.id] = { answers: answerList };
}
return JSON.stringify({ answers });
}
// ---------------------------------------------------------------------------
// createMcpServer
// ---------------------------------------------------------------------------
@ -285,6 +436,42 @@ export async function createMcpServer(sessionManager: SessionManager): Promise<{
},
);
// -----------------------------------------------------------------------
// ask_user_questions — structured user input via MCP form elicitation
// -----------------------------------------------------------------------
server.tool(
'ask_user_questions',
'Request user input for one to three short questions and wait for the response. Single-select questions include a free-form "None of the above" path. Multi-select questions allow multiple choices.',
{
questions: z.array(z.object({
id: z.string().describe('Stable identifier for mapping answers (snake_case)'),
header: z.string().describe('Short header label shown in the UI (12 or fewer chars)'),
question: z.string().describe('Single-sentence prompt shown to the user'),
options: z.array(z.object({
label: z.string().describe('User-facing label (1-5 words)'),
description: z.string().describe('One short sentence explaining impact/tradeoff if selected'),
})).describe('Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with "(Recommended)". Do not include an "Other" option for single-select questions.'),
allowMultiple: z.boolean().optional().describe('If true, the user can select multiple options. No "None of the above" option is added.'),
})).describe('Questions to show the user. Prefer 1 and do not exceed 3.'),
},
async (args: Record<string, unknown>) => {
const { questions } = args as unknown as AskUserQuestionsParams;
try {
const validationError = validateAskUserQuestionsPayload(questions);
if (validationError) return errorContent(validationError);
const elicitation = await server.server.elicitInput(buildAskUserQuestionsElicitRequest(questions));
if (elicitation.action !== 'accept' || !elicitation.content) {
return textContent('ask_user_questions was cancelled before receiving a response');
}
return textContent(formatAskUserQuestionsElicitResult(questions, elicitation));
} catch (err) {
return errorContent(err instanceof Error ? err.message : String(err));
}
},
);
// =======================================================================
// READ-ONLY TOOLS — no session required, pure filesystem reads
// =======================================================================

View file

@ -6,7 +6,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { _getAdapter, closeDatabase } from "../../../src/resources/extensions/gsd/gsd-db.ts";
import { registerWorkflowTools } from "./workflow-tools.ts";
import { registerWorkflowTools, WORKFLOW_TOOL_NAMES } from "./workflow-tools.ts";
function makeTmpBase(): string {
const base = join(tmpdir(), `gsd-mcp-workflow-${randomUUID()}`);
@ -68,33 +68,12 @@ function makeMockServer() {
}
describe("workflow MCP tools", () => {
it("registers the seventeen workflow tools", () => {
it("registers the full headless-safe workflow tool surface", () => {
const server = makeMockServer();
registerWorkflowTools(server as any);
assert.equal(server.tools.length, 17);
assert.deepEqual(
server.tools.map((t) => t.name),
[
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_replan_slice",
"gsd_slice_replan",
"gsd_slice_complete",
"gsd_complete_slice",
"gsd_complete_milestone",
"gsd_milestone_complete",
"gsd_validate_milestone",
"gsd_milestone_validate",
"gsd_reassess_roadmap",
"gsd_roadmap_reassess",
"gsd_save_gate_result",
"gsd_summary_save",
"gsd_task_complete",
"gsd_complete_task",
"gsd_milestone_status",
],
);
assert.equal(server.tools.length, WORKFLOW_TOOL_NAMES.length);
assert.deepEqual(server.tools.map((t) => t.name), [...WORKFLOW_TOOL_NAMES]);
});
it("gsd_summary_save writes artifact through the shared executor", async () => {

View file

@ -336,6 +336,10 @@ function toFileUrl(modulePath: string): string {
return pathToFileURL(resolve(modulePath)).href;
}
async function importLocalModule<T>(relativePath: string): Promise<T> {
return import(new URL(relativePath, import.meta.url).href) as Promise<T>;
}
function getWorkflowExecutorModuleCandidates(env: NodeJS.ProcessEnv = process.env): string[] {
const candidates: string[] = [];
const explicitModule = env.GSD_WORKFLOW_EXECUTORS_MODULE?.trim();
@ -420,6 +424,38 @@ interface McpToolServer {
): unknown;
}
export const WORKFLOW_TOOL_NAMES = [
"gsd_decision_save",
"gsd_save_decision",
"gsd_requirement_update",
"gsd_update_requirement",
"gsd_requirement_save",
"gsd_save_requirement",
"gsd_milestone_generate_id",
"gsd_generate_milestone_id",
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_plan_task",
"gsd_task_plan",
"gsd_replan_slice",
"gsd_slice_replan",
"gsd_slice_complete",
"gsd_complete_slice",
"gsd_skip_slice",
"gsd_complete_milestone",
"gsd_milestone_complete",
"gsd_validate_milestone",
"gsd_milestone_validate",
"gsd_reassess_roadmap",
"gsd_roadmap_reassess",
"gsd_save_gate_result",
"gsd_summary_save",
"gsd_task_complete",
"gsd_complete_task",
"gsd_milestone_status",
"gsd_journal_query",
] as const;
async function runSerializedWorkflowOperation<T>(fn: () => Promise<T>): Promise<T> {
// The shared DB adapter and workflow log base path are process-global, so
// workflow MCP mutations must not overlap within a single server process.
@ -566,6 +602,15 @@ async function handleSaveGateResult(
return runSerializedWorkflowOperation(() => executeSaveGateResult(params, projectDir));
}
async function ensureMilestoneDbRow(milestoneId: string): Promise<void> {
try {
const { insertMilestone } = await importLocalModule<any>("../../../src/resources/extensions/gsd/gsd-db.js");
insertMilestone({ id: milestoneId, status: "queued" });
} catch {
// Ignore pre-existing rows or transient DB availability issues.
}
}
const projectDirParam = z.string().describe("Absolute path to the project directory within the configured workflow root");
const planMilestoneParams = {
@ -772,6 +817,73 @@ const summarySaveParams = {
};
const summarySaveSchema = z.object(summarySaveParams);
const decisionSaveParams = {
projectDir: projectDirParam,
scope: z.string().describe("Scope of the decision (e.g. architecture, library, observability)"),
decision: z.string().describe("What is being decided"),
choice: z.string().describe("The choice made"),
rationale: z.string().describe("Why this choice was made"),
revisable: z.string().optional().describe("Whether this can be revisited"),
when_context: z.string().optional().describe("When/context for the decision"),
made_by: z.enum(["human", "agent", "collaborative"]).optional().describe("Who made the decision"),
};
const decisionSaveSchema = z.object(decisionSaveParams);
const requirementUpdateParams = {
projectDir: projectDirParam,
id: z.string().describe("Requirement ID (e.g. R001)"),
status: z.string().optional().describe("New status"),
validation: z.string().optional().describe("Validation criteria or proof"),
notes: z.string().optional().describe("Additional notes"),
description: z.string().optional().describe("Updated description"),
primary_owner: z.string().optional().describe("Primary owning slice"),
supporting_slices: z.string().optional().describe("Supporting slices"),
};
const requirementUpdateSchema = z.object(requirementUpdateParams);
const requirementSaveParams = {
projectDir: projectDirParam,
class: z.string().describe("Requirement class"),
description: z.string().describe("Short description of the requirement"),
why: z.string().describe("Why this requirement matters"),
source: z.string().describe("Origin of the requirement"),
status: z.string().optional().describe("Requirement status"),
primary_owner: z.string().optional().describe("Primary owning slice"),
supporting_slices: z.string().optional().describe("Supporting slices"),
validation: z.string().optional().describe("Validation criteria"),
notes: z.string().optional().describe("Additional notes"),
};
const requirementSaveSchema = z.object(requirementSaveParams);
const milestoneGenerateIdParams = {
projectDir: projectDirParam,
};
const milestoneGenerateIdSchema = z.object(milestoneGenerateIdParams);
const planTaskParams = {
projectDir: projectDirParam,
milestoneId: z.string().describe("Milestone ID (e.g. M001)"),
sliceId: z.string().describe("Slice ID (e.g. S01)"),
taskId: z.string().describe("Task ID (e.g. T01)"),
title: z.string().describe("Task title"),
description: z.string().describe("Task description / steps block"),
estimate: z.string().describe("Task estimate"),
files: z.array(z.string()).describe("Files likely touched"),
verify: z.string().describe("Verification command or block"),
inputs: z.array(z.string()).describe("Input files or references"),
expectedOutput: z.array(z.string()).describe("Expected output files or artifacts"),
observabilityImpact: z.string().optional().describe("Task observability impact"),
};
const planTaskSchema = z.object(planTaskParams);
const skipSliceParams = {
projectDir: projectDirParam,
sliceId: z.string().describe("Slice ID (e.g. S02)"),
milestoneId: z.string().describe("Milestone ID (e.g. M003)"),
reason: z.string().optional().describe("Reason for skipping this slice"),
};
const skipSliceSchema = z.object(skipSliceParams);
const taskCompleteParams = {
projectDir: projectDirParam,
taskId: z.string().describe("Task ID (e.g. T01)"),
@ -803,7 +915,171 @@ const milestoneStatusParams = {
};
const milestoneStatusSchema = z.object(milestoneStatusParams);
const journalQueryParams = {
projectDir: projectDirParam,
flowId: z.string().optional().describe("Filter by flow ID"),
unitId: z.string().optional().describe("Filter by unit ID"),
rule: z.string().optional().describe("Filter by rule name"),
eventType: z.string().optional().describe("Filter by event type"),
after: z.string().optional().describe("ISO-8601 lower bound (inclusive)"),
before: z.string().optional().describe("ISO-8601 upper bound (inclusive)"),
limit: z.number().optional().describe("Maximum entries to return"),
};
const journalQuerySchema = z.object(journalQueryParams);
export function registerWorkflowTools(server: McpToolServer): void {
server.tool(
"gsd_decision_save",
"Record a project decision to the GSD database and regenerate DECISIONS.md.",
decisionSaveParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(decisionSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveDecisionToDb(params, projectDir);
});
return { content: [{ type: "text" as const, text: `Saved decision ${result.id}` }] };
},
);
server.tool(
"gsd_save_decision",
"Alias for gsd_decision_save. Record a project decision to the GSD database and regenerate DECISIONS.md.",
decisionSaveParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(decisionSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveDecisionToDb(params, projectDir);
});
return { content: [{ type: "text" as const, text: `Saved decision ${result.id}` }] };
},
);
server.tool(
"gsd_requirement_update",
"Update an existing requirement in the GSD database and regenerate REQUIREMENTS.md.",
requirementUpdateParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(requirementUpdateSchema, args);
const { projectDir, id, ...updates } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
await runSerializedWorkflowOperation(async () => {
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return updateRequirementInDb(id, updates, projectDir);
});
return { content: [{ type: "text" as const, text: `Updated requirement ${id}` }] };
},
);
server.tool(
"gsd_update_requirement",
"Alias for gsd_requirement_update. Update an existing requirement in the GSD database and regenerate REQUIREMENTS.md.",
requirementUpdateParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(requirementUpdateSchema, args);
const { projectDir, id, ...updates } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
await runSerializedWorkflowOperation(async () => {
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return updateRequirementInDb(id, updates, projectDir);
});
return { content: [{ type: "text" as const, text: `Updated requirement ${id}` }] };
},
);
server.tool(
"gsd_requirement_save",
"Record a new requirement to the GSD database and regenerate REQUIREMENTS.md.",
requirementSaveParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(requirementSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveRequirementToDb(params, projectDir);
});
return { content: [{ type: "text" as const, text: `Saved requirement ${result.id}` }] };
},
);
server.tool(
"gsd_save_requirement",
"Alias for gsd_requirement_save. Record a new requirement to the GSD database and regenerate REQUIREMENTS.md.",
requirementSaveParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(requirementSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveRequirementToDb(params, projectDir);
});
return { content: [{ type: "text" as const, text: `Saved requirement ${result.id}` }] };
},
);
server.tool(
"gsd_milestone_generate_id",
"Generate the next milestone ID for a new GSD milestone.",
milestoneGenerateIdParams,
async (args: Record<string, unknown>) => {
const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args);
await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir);
const id = await runSerializedWorkflowOperation(async () => {
const {
claimReservedId,
findMilestoneIds,
getReservedMilestoneIds,
nextMilestoneId,
} = await importLocalModule<any>("../../../src/resources/extensions/gsd/milestone-ids.js");
const reserved = claimReservedId();
if (reserved) {
await ensureMilestoneDbRow(reserved);
return reserved;
}
const allIds = [...new Set([...findMilestoneIds(projectDir), ...getReservedMilestoneIds()])];
const nextId = nextMilestoneId(allIds);
await ensureMilestoneDbRow(nextId);
return nextId;
});
return { content: [{ type: "text" as const, text: id }] };
},
);
server.tool(
"gsd_generate_milestone_id",
"Alias for gsd_milestone_generate_id. Generate the next milestone ID for a new GSD milestone.",
milestoneGenerateIdParams,
async (args: Record<string, unknown>) => {
const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args);
await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir);
const id = await runSerializedWorkflowOperation(async () => {
const {
claimReservedId,
findMilestoneIds,
getReservedMilestoneIds,
nextMilestoneId,
} = await importLocalModule<any>("../../../src/resources/extensions/gsd/milestone-ids.js");
const reserved = claimReservedId();
if (reserved) {
await ensureMilestoneDbRow(reserved);
return reserved;
}
const allIds = [...new Set([...findMilestoneIds(projectDir), ...getReservedMilestoneIds()])];
const nextId = nextMilestoneId(allIds);
await ensureMilestoneDbRow(nextId);
return nextId;
});
return { content: [{ type: "text" as const, text: id }] };
},
);
server.tool(
"gsd_plan_milestone",
"Write milestone planning state to the GSD database and render ROADMAP.md from DB.",
@ -830,6 +1106,48 @@ export function registerWorkflowTools(server: McpToolServer): void {
},
);
server.tool(
"gsd_plan_task",
"Write task planning state to the GSD database and render tasks/T##-PLAN.md from DB.",
planTaskParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(planTaskSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
const result = await runSerializedWorkflowOperation(async () => {
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
return handlePlanTask(params, projectDir);
});
if ("error" in result) {
throw new Error(result.error);
}
return {
content: [{ type: "text" as const, text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }],
};
},
);
server.tool(
"gsd_task_plan",
"Alias for gsd_plan_task. Write task planning state to the GSD database and render tasks/T##-PLAN.md from DB.",
planTaskParams,
async (args: Record<string, unknown>) => {
const parsed = parseWorkflowArgs(planTaskSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
const result = await runSerializedWorkflowOperation(async () => {
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
return handlePlanTask(params, projectDir);
});
if ("error" in result) {
throw new Error(result.error);
}
return {
content: [{ type: "text" as const, text: `Planned task ${result.taskId} (${result.sliceId}/${result.milestoneId})` }],
};
},
);
server.tool(
"gsd_replan_slice",
"Replan a slice after a blocker is discovered, preserving completed tasks and re-rendering PLAN.md + REPLAN.md.",
@ -870,6 +1188,36 @@ export function registerWorkflowTools(server: McpToolServer): void {
},
);
server.tool(
"gsd_skip_slice",
"Mark a slice as skipped so auto-mode advances past it without executing.",
skipSliceParams,
async (args: Record<string, unknown>) => {
const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs(skipSliceSchema, args);
await enforceWorkflowWriteGate("gsd_skip_slice", projectDir, milestoneId);
await runSerializedWorkflowOperation(async () => {
const { getSlice, updateSliceStatus } = await importLocalModule<any>("../../../src/resources/extensions/gsd/gsd-db.js");
const { invalidateStateCache } = await importLocalModule<any>("../../../src/resources/extensions/gsd/state.js");
const { rebuildState } = await importLocalModule<any>("../../../src/resources/extensions/gsd/doctor.js");
const slice = getSlice(milestoneId, sliceId);
if (!slice) {
throw new Error(`Slice ${sliceId} not found in milestone ${milestoneId}`);
}
if (slice.status === "complete" || slice.status === "done") {
throw new Error(`Slice ${sliceId} is already complete and cannot be skipped`);
}
if (slice.status !== "skipped") {
updateSliceStatus(milestoneId, sliceId, "skipped");
invalidateStateCache();
await rebuildState(projectDir);
}
});
return {
content: [{ type: "text" as const, text: `Skipped slice ${sliceId} (${milestoneId}). Reason: ${reason ?? "User-directed skip"}.` }],
};
},
);
server.tool(
"gsd_complete_milestone",
"Record a completed milestone to the GSD database and render its SUMMARY.md.",
@ -994,4 +1342,19 @@ export function registerWorkflowTools(server: McpToolServer): void {
return runSerializedWorkflowOperation(() => executeMilestoneStatus({ milestoneId }, projectDir));
},
);
server.tool(
"gsd_journal_query",
"Query the structured event journal for auto-mode iterations.",
journalQueryParams,
async (args: Record<string, unknown>) => {
const { projectDir, limit, ...filters } = parseWorkflowArgs(journalQuerySchema, args);
const { queryJournal } = await importLocalModule<any>("../../../src/resources/extensions/gsd/journal.js");
const entries = queryJournal(projectDir, filters).slice(0, limit ?? 100);
if (entries.length === 0) {
return { content: [{ type: "text" as const, text: "No matching journal entries found." }] };
}
return { content: [{ type: "text" as const, text: JSON.stringify(entries, null, 2) }] };
},
);
}

View file

@ -141,7 +141,11 @@ test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the
assert.match(launch?.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "", /workflow-tool-executors\.(js|ts)$/);
assert.match(launch?.env?.GSD_WORKFLOW_WRITE_GATE_MODULE ?? "", /write-gate\.(js|ts)$/);
assert.equal(typeof launch?.args?.[0], "string");
assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\]dist[\/\\]cli\.js$/);
assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\](dist[\/\\]cli\.js|src[\/\\]cli\.ts)$/);
if ((launch?.args?.[0] ?? "").endsWith(".ts")) {
assert.match(launch?.env?.NODE_OPTIONS ?? "", /--experimental-strip-types/);
assert.match(launch?.env?.NODE_OPTIONS ?? "", /resolve-ts\.mjs/);
}
});
test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the package without env hints", () => {
@ -154,7 +158,11 @@ test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the
assert.match(launch?.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "", /workflow-tool-executors\.(js|ts)$/);
assert.match(launch?.env?.GSD_WORKFLOW_WRITE_GATE_MODULE ?? "", /write-gate\.(js|ts)$/);
assert.equal(typeof launch?.args?.[0], "string");
assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\]dist[\/\\]cli\.js$/);
assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\](dist[\/\\]cli\.js|src[\/\\]cli\.ts)$/);
if ((launch?.args?.[0] ?? "").endsWith(".ts")) {
assert.match(launch?.env?.NODE_OPTIONS ?? "", /--experimental-strip-types/);
assert.match(launch?.env?.NODE_OPTIONS ?? "", /resolve-ts\.mjs/);
}
});
test("workflow MCP launch config reaches mutation tools over stdio", async () => {
@ -165,12 +173,16 @@ test("workflow MCP launch config reaches mutation tools over stdio", async () =>
assert.ok(launch, "expected a workflow MCP launch config");
assert.match(
launch.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "",
/dist[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]tools[\/\\]workflow-tool-executors\.js$/,
/(dist[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]tools[\/\\]workflow-tool-executors\.js|src[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]tools[\/\\]workflow-tool-executors\.(js|ts))$/,
);
assert.match(
launch.env?.GSD_WORKFLOW_WRITE_GATE_MODULE ?? "",
/dist[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]bootstrap[\/\\]write-gate\.js$/,
/(dist[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]bootstrap[\/\\]write-gate\.js|src[\/\\]resources[\/\\]extensions[\/\\]gsd[\/\\]bootstrap[\/\\]write-gate\.(js|ts))$/,
);
if ((launch.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "").endsWith(".ts")) {
assert.match(launch.env?.NODE_OPTIONS ?? "", /--experimental-strip-types/);
assert.match(launch.env?.NODE_OPTIONS ?? "", /resolve-ts\.mjs/);
}
const client = new Client({ name: "workflow-mcp-transport-test", version: "1.0.0" });
const transport = new StdioClientTransport({
@ -189,6 +201,10 @@ test("workflow MCP launch config reaches mutation tools over stdio", async () =>
(tools.tools ?? []).some((tool) => tool.name === "gsd_plan_slice"),
"expected workflow MCP surface to expose gsd_plan_slice",
);
assert.ok(
(tools.tools ?? []).some((tool) => tool.name === "ask_user_questions"),
"expected workflow MCP surface to expose ask_user_questions",
);
const milestoneResult = await client.callTool(
{
@ -465,18 +481,18 @@ test("transport compatibility now allows replan-slice over workflow MCP surface"
test("transport compatibility still blocks units whose MCP tools are not exposed", () => {
const error = getWorkflowTransportSupportError(
"claude-code",
["gsd_skip_slice"],
["secure_env_collect"],
{
projectRoot: "/tmp/project",
env: { GSD_WORKFLOW_MCP_COMMAND: "node" },
surface: "auto-mode",
unitType: "skip-slice",
unitType: "guided-discussion",
authMode: "externalCli",
baseUrl: "local://claude-code",
},
);
assert.match(error ?? "", /requires gsd_skip_slice/);
assert.match(error ?? "", /requires secure_env_collect/);
assert.match(error ?? "", /currently exposes only/);
});

View file

@ -1,7 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
export interface WorkflowMcpLaunchConfig {
name: string;
@ -21,22 +21,35 @@ export interface WorkflowCapabilityOptions {
}
const MCP_WORKFLOW_TOOL_SURFACE = new Set([
"ask_user_questions",
"gsd_decision_save",
"gsd_complete_milestone",
"gsd_complete_task",
"gsd_complete_slice",
"gsd_generate_milestone_id",
"gsd_journal_query",
"gsd_milestone_complete",
"gsd_milestone_generate_id",
"gsd_milestone_status",
"gsd_milestone_validate",
"gsd_plan_task",
"gsd_plan_milestone",
"gsd_plan_slice",
"gsd_replan_slice",
"gsd_reassess_roadmap",
"gsd_requirement_save",
"gsd_requirement_update",
"gsd_roadmap_reassess",
"gsd_save_decision",
"gsd_save_gate_result",
"gsd_save_requirement",
"gsd_skip_slice",
"gsd_slice_replan",
"gsd_slice_complete",
"gsd_summary_save",
"gsd_task_plan",
"gsd_task_complete",
"gsd_update_requirement",
"gsd_validate_milestone",
]);
@ -95,6 +108,8 @@ function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null {
}
const candidates = [
resolve(fileURLToPath(new URL("../../../../packages/mcp-server/src/cli.ts", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../../packages/mcp-server/src/cli.ts", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../../packages/mcp-server/dist/cli.js", import.meta.url))),
];
@ -108,9 +123,9 @@ function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null {
function getBundledWorkflowExecutorModulePath(): string | null {
const candidates = [
resolve(fileURLToPath(new URL("../../../../dist/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url))),
resolve(fileURLToPath(new URL("./tools/workflow-tool-executors.js", import.meta.url))),
resolve(fileURLToPath(new URL("./tools/workflow-tool-executors.ts", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../dist/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url))),
];
for (const candidate of candidates) {
@ -122,9 +137,9 @@ function getBundledWorkflowExecutorModulePath(): string | null {
function getBundledWorkflowWriteGateModulePath(): string | null {
const candidates = [
resolve(fileURLToPath(new URL("../../../../dist/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url))),
resolve(fileURLToPath(new URL("./bootstrap/write-gate.js", import.meta.url))),
resolve(fileURLToPath(new URL("./bootstrap/write-gate.ts", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../dist/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url))),
];
for (const candidate of candidates) {
@ -134,19 +149,58 @@ function getBundledWorkflowWriteGateModulePath(): string | null {
return null;
}
function getResolveTsHookPath(): string | null {
const candidates = [
resolve(fileURLToPath(new URL("./tests/resolve-ts.mjs", import.meta.url))),
resolve(fileURLToPath(new URL("../../../../src/resources/extensions/gsd/tests/resolve-ts.mjs", import.meta.url))),
];
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate;
}
return null;
}
function mergeNodeOptions(existing: string | undefined, additions: string[]): string | undefined {
const tokens = (existing ?? "").split(/\s+/).map((value) => value.trim()).filter(Boolean);
for (const addition of additions) {
if (!tokens.includes(addition)) {
tokens.push(addition);
}
}
return tokens.length > 0 ? tokens.join(" ") : undefined;
}
function buildWorkflowLaunchEnv(
projectRoot: string,
gsdCliPath: string | undefined,
explicitEnv?: Record<string, string>,
workflowCliPath?: string,
): Record<string, string> {
const executorModulePath = getBundledWorkflowExecutorModulePath();
const writeGateModulePath = getBundledWorkflowWriteGateModulePath();
const resolveTsHookPath = getResolveTsHookPath();
const wantsSourceTs =
Boolean(resolveTsHookPath) &&
(
(workflowCliPath?.endsWith(".ts") ?? false) ||
(executorModulePath?.endsWith(".ts") ?? false) ||
(writeGateModulePath?.endsWith(".ts") ?? false)
);
const nodeOptions = wantsSourceTs
? mergeNodeOptions(explicitEnv?.NODE_OPTIONS, [
"--experimental-strip-types",
`--import=${pathToFileURL(resolveTsHookPath!).href}`,
])
: explicitEnv?.NODE_OPTIONS;
return {
...(explicitEnv ?? {}),
...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),
...(executorModulePath ? { GSD_WORKFLOW_EXECUTORS_MODULE: executorModulePath } : {}),
...(writeGateModulePath ? { GSD_WORKFLOW_WRITE_GATE_MODULE: writeGateModulePath } : {}),
...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: projectRoot,
};
@ -188,7 +242,7 @@ export function detectWorkflowMcpLaunchConfig(
command: process.execPath,
args: [distCli],
cwd: resolvedWorkflowProjectRoot,
env: buildWorkflowLaunchEnv(resolvedWorkflowProjectRoot, gsdCliPath),
env: buildWorkflowLaunchEnv(resolvedWorkflowProjectRoot, gsdCliPath, undefined, distCli),
};
}
@ -199,7 +253,7 @@ export function detectWorkflowMcpLaunchConfig(
command: process.execPath,
args: [bundledCli],
cwd: resolvedWorkflowProjectRoot,
env: buildWorkflowLaunchEnv(resolvedWorkflowProjectRoot, gsdCliPath),
env: buildWorkflowLaunchEnv(resolvedWorkflowProjectRoot, gsdCliPath, undefined, bundledCli),
};
}