From 2b1e350b3fc37d012c9fbc7c7fb26e02d93b5020 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 12 Sep 2026 00:53:52 -0400 Subject: [PATCH 1/3] fix(mcp): emit Node-resolvable ESM imports --- .github/workflows/mcp-ci.yml | 3 ++ packages/core/package.json | 5 +-- packages/mcp/CHANGELOG.md | 3 ++ packages/mcp/package.json | 5 +-- scripts/fix-node-esm-imports.ts | 64 +++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 scripts/fix-node-esm-imports.ts diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml index 5bf65c4f74..ae15b700d5 100644 --- a/.github/workflows/mcp-ci.yml +++ b/.github/workflows/mcp-ci.yml @@ -42,6 +42,9 @@ jobs: - name: Build mcp run: bun run --cwd packages/mcp build + - name: Smoke-test the published Node entrypoint + run: bun run --cwd packages/mcp smoke:node + - name: Test mcp run: bun test --cwd packages/mcp diff --git a/packages/core/package.json b/packages/core/package.json index c293ea0206..3b67005430 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,12 +72,13 @@ "README.md" ], "scripts": { - "build": "tsc --build", + "build": "tsc --build && bun ../../scripts/fix-node-esm-imports.ts dist", "dev": "tsgo --build --watch", "test": "bun test src", "bench:registry": "bun run src/registry/__bench__/relations-resolver.bench.ts", "bench:schema": "bun run src/schema/__bench__/node-parsers.bench.ts", - "prepublishOnly": "npm run build" + "smoke:node": "node --input-type=module -e \"await import('./dist/index.js')\"", + "prepublishOnly": "npm run build && npm run smoke:node" }, "peerDependencies": { "@react-three/drei": "^10", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 43cee58090..e59b21328e 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- The published ESM output now resolves relative JavaScript imports under Node, + so `pascal-mcp` and package subpath exports no longer require Bun's + extensionless import fallback. - Tool schemas in `tools/list` now declare the JSON Schema 2020-12 dialect instead of the SDK default `draft-07`, so clients that enforce 2020-12 no longer reject every tool call. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 9f35700d53..1d81074b8f 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -47,12 +47,13 @@ "CHANGELOG.md" ], "scripts": { - "build": "tsc --build", + "build": "tsc --build && bun ../../scripts/fix-node-esm-imports.ts dist", "dev": "tsgo --build --watch", "start": "bun dist/bin/pascal-mcp.js", "test": "bun test", "smoke": "bun run scripts/smoke.ts", - "prepublishOnly": "bun run build && bun test" + "smoke:node": "node dist/bin/pascal-mcp.js --help", + "prepublishOnly": "bun run build && bun test && bun run smoke:node" }, "peerDependencies": { "@pascal-app/core": "^1.0.0" diff --git a/scripts/fix-node-esm-imports.ts b/scripts/fix-node-esm-imports.ts new file mode 100644 index 0000000000..e3fefc5d08 --- /dev/null +++ b/scripts/fix-node-esm-imports.ts @@ -0,0 +1,64 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, extname, join, resolve } from 'node:path' +import ts from 'typescript' + +const distDir = resolve(process.cwd(), process.argv[2] ?? 'dist') + +function javascriptFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return javascriptFiles(path) + return entry.isFile() && entry.name.endsWith('.js') ? [path] : [] + }) +} + +function moduleSpecifiers(sourceFile: ts.SourceFile): ts.StringLiteral[] { + const specifiers: ts.StringLiteral[] = [] + const visit = (node: ts.Node) => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier) + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length === 1 && + ts.isStringLiteral(node.arguments[0]!) + ) { + specifiers.push(node.arguments[0]!) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return specifiers +} + +function resolvedSpecifier(file: string, specifier: string): string { + if (!specifier.startsWith('.') || extname(specifier)) return specifier + const target = resolve(dirname(file), specifier) + if (existsSync(`${target}.js`)) return `${specifier}.js` + if (existsSync(join(target, 'index.js'))) return `${specifier.replace(/\/$/, '')}/index.js` + throw new Error(`Cannot resolve extensionless ESM import ${specifier} from ${file}`) +} + +for (const file of javascriptFiles(distDir)) { + const source = readFileSync(file, 'utf8') + const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const replacements = moduleSpecifiers(parsed) + .map((node) => ({ + start: node.getStart(parsed) + 1, + end: node.getEnd() - 1, + value: resolvedSpecifier(file, node.text), + })) + .filter((replacement) => source.slice(replacement.start, replacement.end) !== replacement.value) + .sort((a, b) => b.start - a.start) + + let rewritten = source + for (const replacement of replacements) { + rewritten = + rewritten.slice(0, replacement.start) + replacement.value + rewritten.slice(replacement.end) + } + if (rewritten !== source) writeFileSync(file, rewritten) +} From 38939514e40408072b331873a7397b9a2eddcd26 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 12 Sep 2026 01:00:20 -0400 Subject: [PATCH 2/3] fix(mcp): rewrite referenced core output --- packages/mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1d81074b8f..ca79d664c7 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -47,7 +47,7 @@ "CHANGELOG.md" ], "scripts": { - "build": "tsc --build && bun ../../scripts/fix-node-esm-imports.ts dist", + "build": "tsc --build && bun ../../scripts/fix-node-esm-imports.ts ../core/dist && bun ../../scripts/fix-node-esm-imports.ts dist", "dev": "tsgo --build --watch", "start": "bun dist/bin/pascal-mcp.js", "test": "bun test", From 6fbcc6e3b08639f6c88a12bab07d3cba323f84a4 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Sat, 12 Sep 2026 01:04:50 -0400 Subject: [PATCH 3/3] fix(build): rewrite declaration imports for Node ESM --- scripts/fix-node-esm-imports.ts | 35 ++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/scripts/fix-node-esm-imports.ts b/scripts/fix-node-esm-imports.ts index e3fefc5d08..402f01508d 100644 --- a/scripts/fix-node-esm-imports.ts +++ b/scripts/fix-node-esm-imports.ts @@ -1,14 +1,16 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname, extname, join, resolve } from 'node:path' +import { dirname, join, resolve } from 'node:path' import ts from 'typescript' const distDir = resolve(process.cwd(), process.argv[2] ?? 'dist') -function javascriptFiles(directory: string): string[] { +function emittedModuleFiles(directory: string): string[] { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const path = join(directory, entry.name) - if (entry.isDirectory()) return javascriptFiles(path) - return entry.isFile() && entry.name.endsWith('.js') ? [path] : [] + if (entry.isDirectory()) return emittedModuleFiles(path) + return entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.d.ts')) + ? [path] + : [] }) } @@ -28,6 +30,12 @@ function moduleSpecifiers(sourceFile: ts.SourceFile): ts.StringLiteral[] { ts.isStringLiteral(node.arguments[0]!) ) { specifiers.push(node.arguments[0]!) + } else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + specifiers.push(node.argument.literal) } ts.forEachChild(node, visit) } @@ -36,16 +44,25 @@ function moduleSpecifiers(sourceFile: ts.SourceFile): ts.StringLiteral[] { } function resolvedSpecifier(file: string, specifier: string): string { - if (!specifier.startsWith('.') || extname(specifier)) return specifier + if (!specifier.startsWith('.') || /\.(?:[cm]?js|json|node)$/.test(specifier)) return specifier const target = resolve(dirname(file), specifier) - if (existsSync(`${target}.js`)) return `${specifier}.js` - if (existsSync(join(target, 'index.js'))) return `${specifier.replace(/\/$/, '')}/index.js` + const emittedExtension = file.endsWith('.d.ts') ? '.d.ts' : '.js' + if (existsSync(`${target}${emittedExtension}`)) return `${specifier}.js` + if (existsSync(join(target, `index${emittedExtension}`))) { + return `${specifier.replace(/\/$/, '')}/index.js` + } throw new Error(`Cannot resolve extensionless ESM import ${specifier} from ${file}`) } -for (const file of javascriptFiles(distDir)) { +for (const file of emittedModuleFiles(distDir)) { const source = readFileSync(file, 'utf8') - const parsed = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) + const parsed = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true, + file.endsWith('.d.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS, + ) const replacements = moduleSpecifiers(parsed) .map((node) => ({ start: node.getStart(parsed) + 1,