Fix workflow MCP auto-discovery for Claude Code

This commit is contained in:
Jeremy 2026-04-09 17:45:28 -05:00
parent c1d1d3e5db
commit c19830b702
5 changed files with 138 additions and 9 deletions

View file

@ -241,6 +241,29 @@ const s = new AutoSession();
/** Throttle STATE.md rebuilds — at most once per 30 seconds */
const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
function captureProjectRootEnv(projectRoot: string): void {
if (!s.projectRootEnvCaptured) {
s.hadProjectRootEnv = Object.prototype.hasOwnProperty.call(process.env, "GSD_PROJECT_ROOT");
s.previousProjectRootEnv = process.env.GSD_PROJECT_ROOT ?? null;
s.projectRootEnvCaptured = true;
}
process.env.GSD_PROJECT_ROOT = projectRoot;
}
function restoreProjectRootEnv(): void {
if (!s.projectRootEnvCaptured) return;
if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) {
process.env.GSD_PROJECT_ROOT = s.previousProjectRootEnv;
} else {
delete process.env.GSD_PROJECT_ROOT;
}
s.previousProjectRootEnv = null;
s.hadProjectRootEnv = false;
s.projectRootEnvCaptured = false;
}
export function shouldUseWorktreeIsolation(): boolean {
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
if (prefs?.isolation === "worktree") return true;
@ -542,6 +565,7 @@ function handleLostSessionLock(
s.active = false;
s.paused = false;
clearUnitTimeout();
restoreProjectRootEnv();
deregisterSigtermHandler();
clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences);
const base = lockBase();
@ -577,6 +601,7 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void {
s.currentUnit = null;
s.active = false;
clearUnitTimeout();
restoreProjectRootEnv();
// Clear crash lock and release session lock so the next `/gsd next` does
// not see a stale lock with the current PID and treat it as a "remote"
@ -846,6 +871,7 @@ export async function stopAuto(
ctx?.ui.setStatus("gsd-auto", undefined);
ctx?.ui.setWidget("gsd-progress", undefined);
ctx?.ui.setFooter(undefined);
restoreProjectRootEnv();
// Reset all session state in one call
s.reset();
@ -934,6 +960,7 @@ export async function pauseAuto(
s.active = false;
s.paused = true;
restoreProjectRootEnv();
s.pendingVerificationRetry = null;
s.verificationRetryCount.clear();
ctx?.ui.setStatus("gsd-auto", "paused");
@ -1305,6 +1332,7 @@ export async function startAuto(
);
logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress");
captureProjectRootEnv(s.originalBasePath || s.basePath);
await autoLoop(ctx, pi, s, buildLoopDeps());
cleanupAfterLoopExit(ctx);
return;
@ -1329,6 +1357,7 @@ export async function startAuto(
);
if (!ready) return;
captureProjectRootEnv(s.originalBasePath || s.basePath);
try {
syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath));
} catch (err) {
@ -1569,4 +1598,3 @@ export {
buildLoopRemediationSteps,
} from "./auto-recovery.js";
export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js";

View file

@ -84,6 +84,9 @@ export class AutoSession {
// ── Paths ────────────────────────────────────────────────────────────────
basePath = "";
originalBasePath = "";
previousProjectRootEnv: string | null = null;
hadProjectRootEnv = false;
projectRootEnvCaptured = false;
gitService: GitServiceImpl | null = null;
// ── Dispatch counters ────────────────────────────────────────────────────
@ -192,6 +195,9 @@ export class AutoSession {
// Paths
this.basePath = "";
this.originalBasePath = "";
this.previousProjectRootEnv = null;
this.hadProjectRootEnv = false;
this.projectRootEnvCaptured = false;
this.gitService = null;
// Dispatch

View file

@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const sourcePath = join(import.meta.dirname, "..", "auto.ts");
const source = readFileSync(sourcePath, "utf-8");
test("auto-mode captures GSD_PROJECT_ROOT before entering the dispatch loop", () => {
const captureDeclIdx = source.indexOf("function captureProjectRootEnv(projectRoot: string): void {");
assert.ok(captureDeclIdx > -1, "auto.ts should define captureProjectRootEnv()");
const resumeCallIdx = source.indexOf("captureProjectRootEnv(s.originalBasePath || s.basePath);");
assert.ok(resumeCallIdx > -1, "auto.ts should capture GSD_PROJECT_ROOT before resume autoLoop");
const firstAutoLoopIdx = source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());");
assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke autoLoop()");
assert.ok(
resumeCallIdx < firstAutoLoopIdx,
"auto.ts must set GSD_PROJECT_ROOT before the first autoLoop() call",
);
});
test("auto-mode restores GSD_PROJECT_ROOT when execution stops or pauses", () => {
assert.match(source, /function restoreProjectRootEnv\(\): void \{/);
assert.match(source, /cleanupAfterLoopExit\(ctx: ExtensionContext\): void \{[\s\S]*restoreProjectRootEnv\(\);/);
assert.match(source, /export async function pauseAuto\([\s\S]*restoreProjectRootEnv\(\);/);
assert.match(source, /\} finally \{[\s\S]*restoreProjectRootEnv\(\);[\s\S]*s\.reset\(\);/);
});

View file

@ -1,7 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import {
@ -70,6 +71,43 @@ test("buildWorkflowMcpServers mirrors explicit launch config", () => {
});
});
test("detectWorkflowMcpLaunchConfig resolves the bundled server from GSD_PROJECT_ROOT", () => {
const repoRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-root-"));
const worktreeRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-worktree-"));
const cliPath = join(repoRoot, "packages", "mcp-server", "dist", "cli.js");
mkdirSync(join(repoRoot, "packages", "mcp-server", "dist"), { recursive: true });
writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8");
const launch = detectWorkflowMcpLaunchConfig(worktreeRoot, {
GSD_PROJECT_ROOT: repoRoot,
});
assert.deepEqual(launch, {
name: "gsd-workflow",
command: process.execPath,
args: [cliPath],
cwd: repoRoot,
env: {
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: repoRoot,
},
});
});
test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the installed GSD package", () => {
const launch = detectWorkflowMcpLaunchConfig("/tmp/project", {
GSD_BIN_PATH: "/tmp/gsd-loader.js",
});
assert.equal(launch?.command, process.execPath);
assert.equal(launch?.cwd, "/tmp/project");
assert.equal(launch?.env?.GSD_CLI_PATH, "/tmp/gsd-loader.js");
assert.equal(launch?.env?.GSD_WORKFLOW_PROJECT_ROOT, "/tmp/project");
assert.equal(typeof launch?.args?.[0], "string");
assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\]dist[\/\\]cli\.js$/);
});
test("usesWorkflowMcpTransport matches local externalCli providers", () => {
assert.equal(usesWorkflowMcpTransport("externalCli", "local://claude-code"), true);
assert.equal(usesWorkflowMcpTransport("externalCli", "https://api.example.com"), false);

View file

@ -1,6 +1,7 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
export interface WorkflowMcpLaunchConfig {
name: string;
@ -66,6 +67,15 @@ function lookupCommand(command: string, platform: NodeJS.Platform = process.plat
}
}
function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null {
if (!env.GSD_BIN_PATH?.trim() && !env.GSD_CLI_PATH?.trim()) return null;
const bundledCli = resolve(
fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url)),
);
return existsSync(bundledCli) ? bundledCli : null;
}
export function detectWorkflowMcpLaunchConfig(
projectRoot = process.cwd(),
env: NodeJS.ProcessEnv = process.env,
@ -75,16 +85,19 @@ export function detectWorkflowMcpLaunchConfig(
const explicitArgs = parseJsonEnv<unknown>(env, "GSD_WORKFLOW_MCP_ARGS");
const explicitEnv = parseJsonEnv<Record<string, string>>(env, "GSD_WORKFLOW_MCP_ENV");
const explicitCwd = env.GSD_WORKFLOW_MCP_CWD?.trim();
const gsdCliPath = env.GSD_CLI_PATH?.trim() || env.GSD_BIN_PATH?.trim();
const workflowProjectRoot =
explicitEnv?.GSD_WORKFLOW_PROJECT_ROOT?.trim() ||
env.GSD_WORKFLOW_PROJECT_ROOT?.trim() ||
env.GSD_PROJECT_ROOT?.trim() ||
explicitCwd ||
projectRoot;
const resolvedWorkflowProjectRoot = resolve(workflowProjectRoot);
if (explicitCommand) {
const launchEnv = {
...(explicitEnv ?? {}),
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(workflowProjectRoot),
};
@ -97,17 +110,32 @@ export function detectWorkflowMcpLaunchConfig(
};
}
const distCli = resolve(projectRoot, "packages", "mcp-server", "dist", "cli.js");
const bundledCli = getBundledWorkflowMcpCliPath(env);
if (bundledCli) {
return {
name,
command: process.execPath,
args: [bundledCli],
cwd: resolvedWorkflowProjectRoot,
env: {
...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot,
},
};
}
const distCli = resolve(resolvedWorkflowProjectRoot, "packages", "mcp-server", "dist", "cli.js");
if (existsSync(distCli)) {
return {
name,
command: process.execPath,
args: [distCli],
cwd: projectRoot,
cwd: resolvedWorkflowProjectRoot,
env: {
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot,
},
};
}
@ -118,9 +146,9 @@ export function detectWorkflowMcpLaunchConfig(
name,
command: binPath,
env: {
...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}),
...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}),
GSD_PERSIST_WRITE_GATE_STATE: "1",
GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot),
GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot,
},
};
}