Merge pull request #3905 from jeremymcs/fix/workflow-mcp-auto-discovery

Fix workflow MCP auto-discovery for Claude Code auto-mode
This commit is contained in:
Jeremy McSpadden 2026-04-09 19:01:52 -05:00 committed by GitHub
commit 0c37a88024
10 changed files with 190 additions and 33 deletions

View file

@ -46,8 +46,10 @@
"build:pi-agent-core": "npm run build -w @gsd/pi-agent-core",
"build:pi-coding-agent": "npm run build -w @gsd/pi-coding-agent",
"build:native-pkg": "npm run build -w @gsd/native",
"build:rpc-client": "npm run build -w @gsd-build/rpc-client",
"build:pi": "npm run build:native-pkg && npm run build:pi-tui && npm run build:pi-ai && npm run build:pi-agent-core && npm run build:pi-coding-agent",
"build": "npm run build:pi && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs",
"build:mcp-server": "npm run build -w @gsd-build/mcp-server",
"build": "npm run build:pi && npm run build:rpc-client && npm run build:mcp-server && tsc && npm run copy-resources && npm run copy-themes && npm run copy-export-html && node scripts/build-web-if-stale.cjs",
"stage:web-host": "node scripts/stage-web-standalone.cjs",
"build:web-host": "npm --prefix web run build && npm run stage:web-host",
"copy-resources": "node scripts/copy-resources.cjs",

View file

@ -20,5 +20,5 @@
"rootDir": "./src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts"]
"exclude": ["node_modules", "dist", "**/*.d.ts", "src/**/*.d.ts", "src/**/*.test.ts"]
}

View file

@ -97,6 +97,8 @@ if (require.main === module) {
'pi-ai',
'pi-agent-core',
'pi-coding-agent',
'rpc-client',
'mcp-server',
]
const stale = detectStalePackages(root, WORKSPACE_PACKAGES)

View file

@ -2,7 +2,8 @@
/**
* link-workspace-packages.cjs
*
* Creates node_modules/@gsd/* symlinks pointing to packages/* directories.
* Creates node_modules/@gsd/* and node_modules/@gsd-build/* symlinks pointing
* to shipped packages/* directories.
*
* During development, npm workspaces creates these automatically. But in the
* published tarball, workspace packages are shipped under packages/ (via the
@ -20,27 +21,33 @@ const { resolve, join } = require('path')
const root = resolve(__dirname, '..')
const packagesDir = join(root, 'packages')
const nodeModulesGsd = join(root, 'node_modules', '@gsd')
// Map directory names to package names
const packageMap = {
'native': 'native',
'pi-agent-core': 'pi-agent-core',
'pi-ai': 'pi-ai',
'pi-coding-agent': 'pi-coding-agent',
'pi-tui': 'pi-tui',
const scopeDirs = {
'@gsd': join(root, 'node_modules', '@gsd'),
'@gsd-build': join(root, 'node_modules', '@gsd-build'),
}
// Ensure @gsd scope directory exists
if (!existsSync(nodeModulesGsd)) {
mkdirSync(nodeModulesGsd, { recursive: true })
// Map directory names to scoped package names
const packageMap = {
'native': { scope: '@gsd', name: 'native' },
'pi-agent-core': { scope: '@gsd', name: 'pi-agent-core' },
'pi-ai': { scope: '@gsd', name: 'pi-ai' },
'pi-coding-agent': { scope: '@gsd', name: 'pi-coding-agent' },
'pi-tui': { scope: '@gsd', name: 'pi-tui' },
'rpc-client': { scope: '@gsd-build', name: 'rpc-client' },
}
for (const scopeDir of Object.values(scopeDirs)) {
if (!existsSync(scopeDir)) {
mkdirSync(scopeDir, { recursive: true })
}
}
let linked = 0
let copied = 0
for (const [dir, name] of Object.entries(packageMap)) {
for (const [dir, pkg] of Object.entries(packageMap)) {
const source = join(packagesDir, dir)
const target = join(nodeModulesGsd, name)
const scopeDir = scopeDirs[pkg.scope]
const target = join(scopeDir, pkg.name)
if (!existsSync(source)) continue
@ -50,7 +57,7 @@ for (const [dir, name] of Object.entries(packageMap)) {
const stat = lstatSync(target)
if (stat.isSymbolicLink()) {
const linkTarget = readlinkSync(target)
if (resolve(join(nodeModulesGsd, linkTarget)) === source || linkTarget === source) {
if (resolve(join(scopeDir, linkTarget)) === source || linkTarget === source) {
continue // Already correct
}
unlinkSync(target) // Wrong target, relink

View file

@ -65,6 +65,8 @@ try {
const requiredFiles = [
'dist/loader.js',
'packages/pi-coding-agent/dist/index.js',
'packages/rpc-client/dist/index.js',
'packages/mcp-server/dist/cli.js',
'scripts/link-workspace-packages.cjs',
'dist/web/standalone/server.js',
];
@ -109,16 +111,19 @@ try {
// node_modules/@gsd/ is never populated, causing ERR_MODULE_NOT_FOUND at runtime.
console.log('==> Verifying @gsd/* workspace package resolution...');
const installedRoot = join(installDir, 'node_modules', 'gsd-pi');
const criticalPkgs = ['pi-coding-agent'];
const criticalPackages = [
{ scope: '@gsd', name: 'pi-coding-agent' },
{ scope: '@gsd-build', name: 'rpc-client' },
];
let resolutionFailed = false;
for (const pkg of criticalPkgs) {
const pkgPath = join(installedRoot, 'node_modules', '@gsd', pkg);
const fallbackPath = join(installedRoot, 'packages', pkg);
for (const pkg of criticalPackages) {
const pkgPath = join(installedRoot, 'node_modules', pkg.scope, pkg.name);
const fallbackPath = join(installedRoot, 'packages', pkg.name);
if (!existsSync(pkgPath)) {
if (existsSync(fallbackPath)) {
console.log(` MISSING symlink/copy: node_modules/@gsd/${pkg} (packages/${pkg} exists — postinstall may not have run)`);
console.log(` MISSING symlink/copy: node_modules/${pkg.scope}/${pkg.name} (packages/${pkg.name} exists — postinstall may not have run)`);
} else {
console.log(` MISSING: node_modules/@gsd/${pkg} (packages/${pkg} also absent — package is broken)`);
console.log(` MISSING: node_modules/${pkg.scope}/${pkg.name} (packages/${pkg.name} also absent — package is broken)`);
}
resolutionFailed = true;
}
@ -133,6 +138,12 @@ try {
// --- Run the binary to confirm end-to-end resolution ---
console.log('==> Running installed binary (gsd -v)...');
const loaderPath = join(installedRoot, 'dist', 'loader.js');
const bundledWorkflowMcpCliPath = join(installedRoot, 'packages', 'mcp-server', 'dist', 'cli.js');
if (!existsSync(bundledWorkflowMcpCliPath)) {
console.log('ERROR: Bundled workflow MCP CLI missing after install.');
console.log(` Expected: ${bundledWorkflowMcpCliPath}`);
process.exit(1);
}
try {
const versionOutput = execSync(`node "${loaderPath}" -v`, {
cwd: installDir,

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,21 @@ 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 candidates = [
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))),
];
for (const bundledCli of candidates) {
if (existsSync(bundledCli)) return bundledCli;
}
return null;
}
export function detectWorkflowMcpLaunchConfig(
projectRoot = process.cwd(),
env: NodeJS.ProcessEnv = process.env,
@ -75,16 +91,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 +116,32 @@ export function detectWorkflowMcpLaunchConfig(
};
}
const distCli = resolve(projectRoot, "packages", "mcp-server", "dist", "cli.js");
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,
},
};
}
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,
},
};
}
@ -118,9 +152,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,
},
};
}