feat(mcp-server): expose ask_user_questions via elicitation
This commit is contained in:
parent
2ad315b9fb
commit
b3275a182d
8 changed files with 747 additions and 45 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =======================================================================
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue