diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index dcc32aa94..f1544b895 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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. diff --git a/packages/mcp-server/src/cli.ts b/packages/mcp-server/src/cli.ts index 744749d03..141c4083f 100644 --- a/packages/mcp-server/src/cli.ts +++ b/packages/mcp-server/src/cli.ts @@ -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 { 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) diff --git a/packages/mcp-server/src/mcp-server.test.ts b/packages/mcp-server/src/mcp-server.test.ts index 6d7ce156e..c3ba68065 100644 --- a/packages/mcp-server/src/mcp-server.test.ts +++ b/packages/mcp-server/src/mcp-server.test.ts @@ -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'], + }, + }, + }), + ); + }); }); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index f4f5fe206..1f969462e 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -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 { interface McpServerInstance { tool(name: string, description: string, params: Record, handler: (args: Record) => Promise): unknown; + server: { + elicitInput( + params: AskUserQuestionsElicitRequest, + options?: unknown, + ): Promise; + }; connect(transport: unknown): Promise; close(): Promise; } +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; +} + +interface AskUserQuestionsElicitRequest { + mode: 'form'; + message: string; + requestedSchema: { + type: 'object'; + properties: Record>; + 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> = {}; + 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 = {}; + 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) => { + 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 // ======================================================================= diff --git a/packages/mcp-server/src/workflow-tools.test.ts b/packages/mcp-server/src/workflow-tools.test.ts index 74ee74a85..b03d5e2b9 100644 --- a/packages/mcp-server/src/workflow-tools.test.ts +++ b/packages/mcp-server/src/workflow-tools.test.ts @@ -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 () => { diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index 1f3b3af97..99abb9b2d 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -336,6 +336,10 @@ function toFileUrl(modulePath: string): string { return pathToFileURL(resolve(modulePath)).href; } +async function importLocalModule(relativePath: string): Promise { + return import(new URL(relativePath, import.meta.url).href) as Promise; +} + 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(fn: () => Promise): Promise { // 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 { + try { + const { insertMilestone } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(decisionSaveSchema, args); + const { projectDir, ...params } = parsed; + await enforceWorkflowWriteGate("gsd_decision_save", projectDir); + const result = await runSerializedWorkflowOperation(async () => { + const { saveDecisionToDb } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(decisionSaveSchema, args); + const { projectDir, ...params } = parsed; + await enforceWorkflowWriteGate("gsd_decision_save", projectDir); + const result = await runSerializedWorkflowOperation(async () => { + const { saveDecisionToDb } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(requirementUpdateSchema, args); + const { projectDir, id, ...updates } = parsed; + await enforceWorkflowWriteGate("gsd_requirement_update", projectDir); + await runSerializedWorkflowOperation(async () => { + const { updateRequirementInDb } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(requirementUpdateSchema, args); + const { projectDir, id, ...updates } = parsed; + await enforceWorkflowWriteGate("gsd_requirement_update", projectDir); + await runSerializedWorkflowOperation(async () => { + const { updateRequirementInDb } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(requirementSaveSchema, args); + const { projectDir, ...params } = parsed; + await enforceWorkflowWriteGate("gsd_requirement_save", projectDir); + const result = await runSerializedWorkflowOperation(async () => { + const { saveRequirementToDb } = await importLocalModule("../../../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) => { + const parsed = parseWorkflowArgs(requirementSaveSchema, args); + const { projectDir, ...params } = parsed; + await enforceWorkflowWriteGate("gsd_requirement_save", projectDir); + const result = await runSerializedWorkflowOperation(async () => { + const { saveRequirementToDb } = await importLocalModule("../../../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) => { + const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args); + await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir); + const id = await runSerializedWorkflowOperation(async () => { + const { + claimReservedId, + findMilestoneIds, + getReservedMilestoneIds, + nextMilestoneId, + } = await importLocalModule("../../../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) => { + const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args); + await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir); + const id = await runSerializedWorkflowOperation(async () => { + const { + claimReservedId, + findMilestoneIds, + getReservedMilestoneIds, + nextMilestoneId, + } = await importLocalModule("../../../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) => { + 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("../../../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) => { + 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("../../../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) => { + const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs(skipSliceSchema, args); + await enforceWorkflowWriteGate("gsd_skip_slice", projectDir, milestoneId); + await runSerializedWorkflowOperation(async () => { + const { getSlice, updateSliceStatus } = await importLocalModule("../../../src/resources/extensions/gsd/gsd-db.js"); + const { invalidateStateCache } = await importLocalModule("../../../src/resources/extensions/gsd/state.js"); + const { rebuildState } = await importLocalModule("../../../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) => { + const { projectDir, limit, ...filters } = parseWorkflowArgs(journalQuerySchema, args); + const { queryJournal } = await importLocalModule("../../../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) }] }; + }, + ); } diff --git a/src/resources/extensions/gsd/tests/mcp-project-config.test.ts b/src/resources/extensions/gsd/tests/mcp-project-config.test.ts index 0c2cdba5c..7638a7e74 100644 --- a/src/resources/extensions/gsd/tests/mcp-project-config.test.ts +++ b/src/resources/extensions/gsd/tests/mcp-project-config.test.ts @@ -26,8 +26,12 @@ test("ensureProjectWorkflowMcpConfig creates .mcp.json with the workflow server" assert.equal(typeof server?.command, "string"); assert.equal(Array.isArray(server?.args), true); assert.equal(server?.env?.GSD_WORKFLOW_PROJECT_ROOT, projectRoot); - assert.match(server?.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "", /workflow-tool-executors\.js$/); - assert.match(server?.env?.GSD_WORKFLOW_WRITE_GATE_MODULE ?? "", /write-gate\.js$/); + assert.match(server?.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "", /workflow-tool-executors\.(js|ts)$/); + assert.match(server?.env?.GSD_WORKFLOW_WRITE_GATE_MODULE ?? "", /write-gate\.(js|ts)$/); + if ((server?.env?.GSD_WORKFLOW_EXECUTORS_MODULE ?? "").endsWith(".ts")) { + assert.match(server?.env?.NODE_OPTIONS ?? "", /--experimental-strip-types/); + assert.match(server?.env?.NODE_OPTIONS ?? "", /resolve-ts\.mjs/); + } } finally { rmSync(projectRoot, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index fb91a1b94..8e0575096 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -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/); }); diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index 797f127f5..809aa1543 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -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, + workflowCliPath?: string, ): Record { 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), }; } diff --git a/src/tests/package-mcp-server-elicitation.test.ts b/src/tests/package-mcp-server-elicitation.test.ts new file mode 100644 index 000000000..a746d8094 --- /dev/null +++ b/src/tests/package-mcp-server-elicitation.test.ts @@ -0,0 +1,227 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js' + +import { + buildAskUserQuestionsElicitRequest, + createMcpServer, + formatAskUserQuestionsElicitResult, +} from '../../packages/mcp-server/src/server.js' + +function createSessionManagerStub() { + return { + startSession: async () => { + throw new Error('not implemented in test') + }, + getSession: () => undefined, + getResult: () => undefined, + cancelSession: async () => {}, + resolveBlocker: async () => {}, + } +} + +async function createConnectedClient(options?: { + onElicit?: (params: unknown) => Promise, +}) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + + const { server } = await createMcpServer(createSessionManagerStub() as never) + const client = new Client({ + name: 'test-client', + version: '0.0.0', + }, { + capabilities: { + elicitation: {}, + }, + }) + + if (options?.onElicit) { + client.setRequestHandler(ElicitRequestSchema, options.onElicit) + } + + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]) + + return { + client, + close: async () => { + await client.close() + await server.close() + }, + } +} + +test('package MCP server exposes ask_user_questions over listTools', async () => { + const { client, close } = await createConnectedClient() + + try { + const tools = await client.listTools() + assert.ok(tools.tools.some(tool => tool.name === 'ask_user_questions')) + } finally { + await close() + } +}) + +test('ask_user_questions returns the packaged answers JSON shape for form elicitation', async () => { + const { client, close } = await createConnectedClient({ + onElicit: 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.deployment) + assert.ok(elicitation.requestedSchema.properties['deployment__note']) + assert.ok(elicitation.requestedSchema.required?.includes('deployment')) + + return { + action: 'accept', + content: { + deployment: 'None of the above', + deployment__note: 'Need hybrid deployment.', + }, + } + }, + }) + + try { + 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.' }, + ], + }, + ], + }, + }) + + 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 close() + } +}) + +test('ask_user_questions returns an error result for invalid question payloads', async () => { + const { client, close } = await createConnectedClient() + + try { + const result = await client.callTool({ + name: 'ask_user_questions', + arguments: { + questions: [ + { + id: 'broken', + header: 'Broken', + question: 'This payload is invalid', + options: [], + }, + ], + }, + }) + + const text = result.content.find(item => item.type === 'text') + assert.ok(text && 'text' in text) + assert.equal(result.isError, true) + assert.match(text.text, /requires non-empty options/i) + } finally { + await close() + } +}) + +test('ask_user_questions returns the cancellation message when elicitation is declined', async () => { + const { client, close } = await createConnectedClient({ + onElicit: async () => ({ + action: 'decline', + }), + }) + + try { + const result = await client.callTool({ + name: 'ask_user_questions', + arguments: { + questions: [ + { + id: 'continue', + header: 'Continue', + question: 'Continue?', + options: [ + { label: 'Yes', description: 'Proceed.' }, + { label: 'No', description: 'Stop here.' }, + ], + }, + ], + }, + }) + + const text = result.content.find(item => item.type === 'text') + assert.ok(text && 'text' in text) + assert.equal(text.text, 'ask_user_questions was cancelled before receiving a response') + } finally { + await close() + } +}) + +test('helper formatting stays aligned with the tool contract', () => { + const questions = [ + { + 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.' }, + ], + }, + ] + + const request = buildAskUserQuestionsElicitRequest(questions) + assert.equal(request.mode, 'form') + assert.ok(request.requestedSchema.properties.focus_areas) + assert.ok(!request.requestedSchema.properties['focus_areas__note']) + + const formatted = formatAskUserQuestionsElicitResult(questions, { + action: 'accept', + content: { + focus_areas: ['Frontend', 'Backend'], + }, + }) + + assert.equal( + formatted, + JSON.stringify({ + answers: { + focus_areas: { + answers: ['Frontend', 'Backend'], + }, + }, + }), + ) +})