Merge pull request #3984 from mastertyko/fix/3973-mcp-inline-db-open

fix(mcp-server): open the DB for inline workflow tools
This commit is contained in:
Jeremy McSpadden 2026-04-11 22:52:52 -05:00 committed by GitHub
commit 56ee5616a5
2 changed files with 141 additions and 11 deletions

View file

@ -384,6 +384,116 @@ describe("workflow MCP tools", () => {
}
});
it("gsd_requirement_save opens the DB before inline requirement writes", async () => {
const base = makeTmpBase();
try {
const server = makeMockServer();
registerWorkflowTools(server as any);
const requirementTool = server.tools.find((t) => t.name === "gsd_requirement_save");
assert.ok(requirementTool, "requirement tool should be registered");
closeDatabase();
const result = await requirementTool!.handler({
projectDir: base,
class: "operability",
description: "Inline MCP requirement save regression",
why: "Reproduce missing ensureDbOpen in workflow-tools",
source: "user",
status: "active",
primary_owner: "M010/S10",
validation: "n/a",
});
assert.match((result as any).content[0].text as string, /Saved requirement R\d+/);
assert.ok(existsSync(join(base, ".gsd", "REQUIREMENTS.md")), "REQUIREMENTS.md should be written to disk");
const row = _getAdapter()!
.prepare("SELECT id, class, description FROM requirements WHERE description = ?")
.get("Inline MCP requirement save regression") as Record<string, unknown> | undefined;
assert.ok(row, "requirement should be written to the database");
assert.equal(row["class"], "operability");
} finally {
cleanup(base);
}
});
it("gsd_plan_task reopens the DB before inline task planning writes", async () => {
const base = makeTmpBase();
try {
const server = makeMockServer();
registerWorkflowTools(server as any);
const milestoneTool = server.tools.find((t) => t.name === "gsd_plan_milestone");
const sliceTool = server.tools.find((t) => t.name === "gsd_plan_slice");
const taskTool = server.tools.find((t) => t.name === "gsd_plan_task");
assert.ok(milestoneTool, "milestone planning tool should be registered");
assert.ok(sliceTool, "slice planning tool should be registered");
assert.ok(taskTool, "task planning tool should be registered");
await milestoneTool!.handler({
projectDir: base,
milestoneId: "M010",
title: "Inline task planning DB reopen",
vision: "Seed a slice, close the DB, then plan another task inline.",
slices: [
{
sliceId: "S10",
title: "Inline task planning",
risk: "medium",
depends: [],
demo: "Inline gsd_plan_task reopens the DB after it was closed.",
goal: "Preserve MCP task planning after the DB adapter is closed.",
successCriteria: "The second task plan persists after a closed DB is reopened.",
proofLevel: "integration",
integrationClosure: "The inline MCP handler reopens the DB before planning.",
observabilityImpact: "workflow-tools MCP tests cover the inline reopen path.",
},
],
});
await sliceTool!.handler({
projectDir: base,
milestoneId: "M010",
sliceId: "S10",
goal: "Create the initial slice plan before closing the DB.",
tasks: [
{
taskId: "T10",
title: "Seed existing task",
description: "Create the initial task plan before closing the DB.",
estimate: "5m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M010-ROADMAP.md"],
expectedOutput: ["T10-PLAN.md"],
},
],
});
closeDatabase();
const result = await taskTool!.handler({
projectDir: base,
milestoneId: "M010",
sliceId: "S10",
taskId: "T11",
title: "Reopen and plan",
description: "Exercise the inline plan-task path after the DB was closed.",
estimate: "5m",
files: ["packages/mcp-server/src/workflow-tools.ts"],
verify: "node --test",
inputs: ["M010-ROADMAP.md", "S10-PLAN.md"],
expectedOutput: ["T11-PLAN.md"],
});
assert.match((result as any).content[0].text as string, /Planned task T11/);
assert.ok(
existsSync(join(base, ".gsd", "milestones", "M010", "slices", "S10", "tasks", "T11-PLAN.md")),
"T11 plan should be written after reopening the DB",
);
} finally {
cleanup(base);
}
});
it("gsd_replan_slice and gsd_slice_replan work end-to-end", async () => {
const base = makeTmpBase();
try {

View file

@ -244,6 +244,10 @@ type WorkflowWriteGateModule = {
) => { block: boolean; reason?: string };
};
type WorkflowDbBootstrapModule = {
ensureDbOpen: (basePath?: string) => Promise<boolean>;
};
let workflowToolExecutorsPromise: Promise<WorkflowToolExecutors> | null = null;
let workflowExecutionQueue: Promise<void> = Promise.resolve();
let workflowWriteGatePromise: Promise<WorkflowWriteGateModule> | null = null;
@ -506,6 +510,22 @@ async function runSerializedWorkflowOperation<T>(fn: () => Promise<T>): Promise<
}
}
async function runSerializedWorkflowDbOperation<T>(
projectDir: string,
fn: () => Promise<T>,
): Promise<T> {
return runSerializedWorkflowOperation(async () => {
const { ensureDbOpen } = await importLocalModule<WorkflowDbBootstrapModule>(
"../../../src/resources/extensions/gsd/bootstrap/dynamic-tools.js",
);
const dbAvailable = await ensureDbOpen(projectDir);
if (!dbAvailable) {
throw new Error("GSD database is not available");
}
return fn();
});
}
async function enforceWorkflowWriteGate(
toolName: string,
projectDir: string,
@ -969,7 +989,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(decisionSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveDecisionToDb(params, projectDir);
});
@ -985,7 +1005,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(decisionSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_decision_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { saveDecisionToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveDecisionToDb(params, projectDir);
});
@ -1001,7 +1021,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(requirementUpdateSchema, args);
const { projectDir, id, ...updates } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
await runSerializedWorkflowOperation(async () => {
await runSerializedWorkflowDbOperation(projectDir, async () => {
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return updateRequirementInDb(id, updates, projectDir);
});
@ -1017,7 +1037,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(requirementUpdateSchema, args);
const { projectDir, id, ...updates } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_update", projectDir);
await runSerializedWorkflowOperation(async () => {
await runSerializedWorkflowDbOperation(projectDir, async () => {
const { updateRequirementInDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return updateRequirementInDb(id, updates, projectDir);
});
@ -1033,7 +1053,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(requirementSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveRequirementToDb(params, projectDir);
});
@ -1049,7 +1069,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(requirementSaveSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_requirement_save", projectDir);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { saveRequirementToDb } = await importLocalModule<any>("../../../src/resources/extensions/gsd/db-writer.js");
return saveRequirementToDb(params, projectDir);
});
@ -1064,7 +1084,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args);
await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir);
const id = await runSerializedWorkflowOperation(async () => {
const id = await runSerializedWorkflowDbOperation(projectDir, async () => {
const {
claimReservedId,
findMilestoneIds,
@ -1092,7 +1112,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir } = parseWorkflowArgs(milestoneGenerateIdSchema, args);
await enforceWorkflowWriteGate("gsd_milestone_generate_id", projectDir);
const id = await runSerializedWorkflowOperation(async () => {
const id = await runSerializedWorkflowDbOperation(projectDir, async () => {
const {
claimReservedId,
findMilestoneIds,
@ -1147,7 +1167,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(planTaskSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
return handlePlanTask(params, projectDir);
});
@ -1168,7 +1188,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
const parsed = parseWorkflowArgs(planTaskSchema, args);
const { projectDir, ...params } = parsed;
await enforceWorkflowWriteGate("gsd_plan_task", projectDir, params.milestoneId);
const result = await runSerializedWorkflowOperation(async () => {
const result = await runSerializedWorkflowDbOperation(projectDir, async () => {
const { handlePlanTask } = await importLocalModule<any>("../../../src/resources/extensions/gsd/tools/plan-task.js");
return handlePlanTask(params, projectDir);
});
@ -1228,7 +1248,7 @@ export function registerWorkflowTools(server: McpToolServer): void {
async (args: Record<string, unknown>) => {
const { projectDir, milestoneId, sliceId, reason } = parseWorkflowArgs(skipSliceSchema, args);
await enforceWorkflowWriteGate("gsd_skip_slice", projectDir, milestoneId);
await runSerializedWorkflowOperation(async () => {
await runSerializedWorkflowDbOperation(projectDir, 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");