From ad8405c372727697bd493ea20806a77156de5766 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 10 Apr 2026 19:22:24 -0500 Subject: [PATCH 1/2] fix(mcp-server): importLocalModule resolves src/ paths from dist/ context importLocalModule() resolved paths relative to import.meta.url. When running from dist/, paths like ../../../src/.../foo.js pointed to source where no .js files exist. Now tries src/<->dist/ swap and .ts fallback so the same code works in both dev (tsx) and prod (compiled) contexts. Also adds dist/ candidates to write-gate and workflow-executor lookups. Fixes #3954 --- packages/mcp-server/src/workflow-tools.ts | 30 ++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/workflow-tools.ts b/packages/mcp-server/src/workflow-tools.ts index 99abb9b2d..8f617f719 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, ); @@ -337,7 +338,33 @@ function toFileUrl(modulePath: string): string { } async function importLocalModule(relativePath: string): Promise { - return import(new URL(relativePath, import.meta.url).href) as Promise; + // 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[] = [ + new URL(relativePath, import.meta.url).href, + ]; + const swapped = relativePath.includes("/src/") + ? relativePath.replace("/src/", "/dist/") + : relativePath.includes("/dist/") + ? relativePath.replace("/dist/", "/src/") + : null; + if (swapped) candidates.push(new URL(swapped, import.meta.url).href); + // Also try .ts variants for dev-mode tsx execution + if (relativePath.endsWith(".js")) { + candidates.push(new URL(relativePath.replace(/\.js$/, ".ts"), import.meta.url).href); + if (swapped) candidates.push(new URL(swapped.replace(/\.js$/, ".ts"), 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 +379,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, ); From 67767c2527c489bcec37214925aacfbda12b10d3 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Fri, 10 Apr 2026 19:33:00 -0500 Subject: [PATCH 2/2] test(mcp-server): add regression tests for importLocalModule candidate resolution Extracts _buildImportCandidates() as a testable export and adds 5 tests verifying src/<->dist/ path swapping and .ts extension fallback logic. Refs #3954 --- .../mcp-server/src/import-candidates.test.ts | 48 +++++++++++++++++++ packages/mcp-server/src/workflow-tools.ts | 19 +++++--- 2 files changed, 60 insertions(+), 7 deletions(-) create mode 100644 packages/mcp-server/src/import-candidates.test.ts 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 8f617f719..130eac7eb 100644 --- a/packages/mcp-server/src/workflow-tools.ts +++ b/packages/mcp-server/src/workflow-tools.ts @@ -337,24 +337,29 @@ function toFileUrl(modulePath: string): string { return pathToFileURL(resolve(modulePath)).href; } -async function importLocalModule(relativePath: string): Promise { +/** @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[] = [ - new URL(relativePath, import.meta.url).href, - ]; + 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(new URL(swapped, import.meta.url).href); + if (swapped) candidates.push(swapped); // Also try .ts variants for dev-mode tsx execution if (relativePath.endsWith(".js")) { - candidates.push(new URL(relativePath.replace(/\.js$/, ".ts"), import.meta.url).href); - if (swapped) candidates.push(new URL(swapped.replace(/\.js$/, ".ts"), import.meta.url).href); + candidates.push(relativePath.replace(/\.js$/, ".ts")); + if (swapped) candidates.push(swapped.replace(/\.js$/, ".ts")); } + return candidates; +} + +async function importLocalModule(relativePath: string): Promise { + const candidates = _buildImportCandidates(relativePath) + .map((p) => new URL(p, import.meta.url).href); let lastErr: unknown; for (const candidate of candidates) {