diff --git a/.github/workflows/mcp-ci.yml b/.github/workflows/mcp-ci.yml index 5bf65c4f7..ae15b700d 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 c293ea020..3b6700543 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 43cee5809..e59b21328 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 9f35700d5..ca79d664c 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 ../core/dist && 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 000000000..402f01508 --- /dev/null +++ b/scripts/fix-node-esm-imports.ts @@ -0,0 +1,81 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import ts from 'typescript' + +const distDir = resolve(process.cwd(), process.argv[2] ?? 'dist') + +function emittedModuleFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) return emittedModuleFiles(path) + return entry.isFile() && (entry.name.endsWith('.js') || entry.name.endsWith('.d.ts')) + ? [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]!) + } else if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + specifiers.push(node.argument.literal) + } + ts.forEachChild(node, visit) + } + visit(sourceFile) + return specifiers +} + +function resolvedSpecifier(file: string, specifier: string): string { + if (!specifier.startsWith('.') || /\.(?:[cm]?js|json|node)$/.test(specifier)) return specifier + const target = resolve(dirname(file), specifier) + 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 emittedModuleFiles(distDir)) { + const source = readFileSync(file, 'utf8') + 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, + 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) +}