diff --git a/packages/mcp-server/src/import-candidates.test.ts b/packages/mcp-server/src/import-candidates.test.ts new file mode 100644 index 000000000..5b0171f3f --- /dev/null +++ b/packages/mcp-server/src/import-candidates.test.ts @@ -0,0 +1,48 @@ +// GSD-2 — Regression tests for importLocalModule candidate resolution (#3954) +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { _buildImportCandidates } from "./workflow-tools.js"; + +describe("_buildImportCandidates", () => { + it("includes dist/ fallback for src/ paths", () => { + const candidates = _buildImportCandidates("../../../src/resources/extensions/gsd/db-writer.js"); + assert.ok( + candidates.some((c) => c.includes("/dist/resources/extensions/gsd/db-writer.js")), + "should include dist/ swapped candidate", + ); + }); + + it("includes src/ fallback for dist/ paths", () => { + const candidates = _buildImportCandidates("../../../dist/resources/extensions/gsd/db-writer.js"); + assert.ok( + candidates.some((c) => c.includes("/src/resources/extensions/gsd/db-writer.js")), + "should include src/ swapped candidate", + ); + }); + + it("includes .ts variants for .js paths", () => { + const candidates = _buildImportCandidates("../../../src/resources/extensions/gsd/db-writer.js"); + assert.ok( + candidates.some((c) => c.endsWith("db-writer.ts") && c.includes("/src/")), + "should include .ts variant for original src/ path", + ); + assert.ok( + candidates.some((c) => c.endsWith("db-writer.ts") && c.includes("/dist/")), + "should include .ts variant for swapped dist/ path", + ); + }); + + it("returns original path first", () => { + const input = "../../../src/resources/extensions/gsd/db-writer.js"; + const candidates = _buildImportCandidates(input); + assert.equal(candidates[0], input, "first candidate should be the original path"); + }); + + it("handles paths without src/ or dist/ gracefully", () => { + const candidates = _buildImportCandidates("./local-module.js"); + assert.equal(candidates.length, 2, "should have original + .ts variant only"); + assert.equal(candidates[0], "./local-module.js"); + assert.equal(candidates[1], "./local-module.ts"); + }); +}); diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index 99abb9b2d..130eac7eb 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -326,6 +326,7 @@ function getWriteGateModuleCandidates(): string[] { candidates.push( new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href, + new URL("../../../dist/resources/extensions/gsd/bootstrap/write-gate.js", import.meta.url).href, new URL("../../../src/resources/extensions/gsd/bootstrap/write-gate.ts", import.meta.url).href, ); @@ -336,8 +337,39 @@ function toFileUrl(modulePath: string): string { return pathToFileURL(resolve(modulePath)).href; } +/** @internal — exported for testing only */ +export function _buildImportCandidates(relativePath: string): string[] { + // Build candidate paths: try the given path first, then swap src/<->dist/ + // and try .ts extension. This handles both dev (tsx from src/) and prod + // (compiled from dist/) execution contexts. + const candidates: string[] = [relativePath]; + const swapped = relativePath.includes("/src/") + ? relativePath.replace("/src/", "/dist/") + : relativePath.includes("/dist/") + ? relativePath.replace("/dist/", "/src/") + : null; + if (swapped) candidates.push(swapped); + // Also try .ts variants for dev-mode tsx execution + if (relativePath.endsWith(".js")) { + candidates.push(relativePath.replace(/\.js$/, ".ts")); + if (swapped) candidates.push(swapped.replace(/\.js$/, ".ts")); + } + return candidates; +} + async function importLocalModule(relativePath: string): Promise { - return import(new URL(relativePath, import.meta.url).href) as Promise; + const candidates = _buildImportCandidates(relativePath) + .map((p) => new URL(p, import.meta.url).href); + + let lastErr: unknown; + for (const candidate of candidates) { + try { + return await import(candidate) as T; + } catch (err) { + lastErr = err; + } + } + throw lastErr; } function getWorkflowExecutorModuleCandidates(env: NodeJS.ProcessEnv = process.env): string[] { @@ -352,6 +384,7 @@ function getWorkflowExecutorModuleCandidates(env: NodeJS.ProcessEnv = process.en candidates.push( new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url).href, + new URL("../../../dist/resources/extensions/gsd/tools/workflow-tool-executors.js", import.meta.url).href, new URL("../../../src/resources/extensions/gsd/tools/workflow-tool-executors.ts", import.meta.url).href, );