Skip to content

codemod iterations 5#2398

Open
KKonstantinov wants to merge 1 commit into
mainfrom
feature/codemod-iterations-5
Open

codemod iterations 5#2398
KKonstantinov wants to merge 1 commit into
mainfrom
feature/codemod-iterations-5

Conversation

@KKonstantinov

@KKonstantinov KKonstantinov commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Teach the v1→v2 codemod to migrate CommonJS projects onto the ESM-only v2 packages: detect the target's module system, auto-convert v2 value imports to runtime dynamic import() where it is safe, and emit actionable diagnostics where it is not — all without changing the target's tsconfig.

Motivation and Context

The v2 SDK packages are ESM-only ("type": "module", exports expose only an import condition, the bundles use top-level await). A CommonJS project — no package.json "type", tsconfig module: commonjs — that runs the codemod gets a migration that typechecks and builds but fails at load: the emitted CJS require()s an ESM-only package and throws ERR_PACKAGE_PATH_NOT_EXPORTED / ERR_MODULE_NOT_FOUND. The codemod previously had no awareness of the target's module system and silently produced this runtime-broken output.

This was surfaced by the batch test against firebase-tools (a large CommonJS CLI): the migration was typecheck-green, but its MCP server (src/mcp/onemcp/onemcp_server.ts) died at load with ERR_MODULE_NOT_FOUND.

Two facts shaped the design:

  • Simply changing the target's tsconfig to ESM is a non-starter. Measured: flipping firebase's module: commonjsnodenext produces 1144 new type errors across its own code (its pervasive import * as x from 'y'; x() CJS-interop idiom), unrelated to MCP. So the codemod must keep the project CommonJS.
  • Under module: commonjs, TypeScript downlevels a literal import() back into require(), so even hand-written dynamic imports re-break. A real runtime import() only survives if it's hidden from the compiler.

What this PR adds:

  • Module-system detectionanalyzeProject now resolves a moduleSystem ('esm' | 'commonjs' | 'unknown') from package.json "type" + tsconfig module (via ts.parseConfigFileTextToJson, never throws).
  • A new commonjsInterop transform that runs last (after every specifier-rewriting transform, so it observes the final v2 import shape). For a CommonJS target, for each file where every v2 value usage is inside an async block, it rewrites the static v2 value imports to runtime dynamic import() via a compiler-proof new Function("s","return import(s)") indirection, with a import type * as _PkgTypes from "<pkg>" declaration supplying types (kept type-only so it's erased at runtime, and recognized by the dependency-detection so the package stays in package.json). The destructured value bindings load inside each async function; call sites are unchanged.
  • It keys off value usage, not import syntax — type-only-used imports are left static (TS erases them, so they don't break CJS load).
  • It diagnoses (with inline action-required comments) what it cannot safely auto-convert: value usages in sync contexts (constructors / top-level / field initializers), default imports, value re-exports, symbols used as both value and type, and unknown module systems.
  • No-op for ESM targets and for files with only type-only v2 imports.

How Has This Been Tested?

  • Unit tests for detection (all package.json/tsconfig precedence rules) and the transform: value conversion shape, the namespace/alias/idempotency edge cases, and every diagnose path (sync constructor, default import, value re-export, async parameter default, value-vs-type, both-value-and-type). Full codemod suite green.
  • Integration test running the entire v1→v2 transform pipeline over a CommonJS server file and asserting the dynamic-import output (incl. that the converted package is referenced via a real import declaration so it lands in package.json).
  • End-to-end against firebase-tools via the batch test (--codemod local --sdk published), reproduced across multiple fresh clones:
    • Baseline tc=0 test=0 lint=0 → post-codemod tc=0 test=0, 0 new typecheck errors.
    • src/mcp/onemcp/onemcp_server.ts converts to dynamic import() and its spec goes from ERR_MODULE_NOT_FOUND to 16 passing; @modelcontextprotocol/core is correctly added to package.json and installed.
    • The genuinely-unconvertible files (index.ts's sync constructor + SSEServerTransport used as both value and type; logging-transport.ts's sync StdioServerTransport) are correctly diagnosed for manual ESM work rather than mis-converted.
    • Post lint flags only prettier/prettier formatting of the generated code (auto-fixable; eslint --fix → 0 errors) — the usual "run your formatter after migration" cosmetic, not a functional issue.

Breaking Changes

None to any public API. The change is additive to the codemod. For ESM targets and type-only imports it is a no-op (existing migration behavior is unchanged). For CommonJS targets it changes the codemod's output for the better — previously it emitted runtime-broken static imports; it now converts where safe and diagnoses where not.

Types of changes

  • Bug fix (non-breaking change which fixes an issue) — the codemod previously produced runtime-broken migrations for CommonJS projects
  • New feature (non-breaking change which adds functionality) — CommonJS module-system detection + dynamic-import interop
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Why new Function indirection: under module: commonjs TS downlevels a literal import() to require(), which re-breaks ESM-only v2. Hiding the import behind new Function("s","return import(s)") keeps a real native import() at runtime. It's contained to one helper per converted file with an eslint-disable. Trade-off: opaque to bundlers — fine for Node apps shipping node_modules (e.g. firebase-tools); noted as a caveat for bundled consumers.
  • Why not touch tsconfig: keeping the project CommonJS is a deliberate constraint — the nodenext flip's 1144-error blast radius (above) makes it unviable as an automatic codemod step.
  • All-or-nothing per file: a single surviving static value import re-breaks the module load, so a file is either fully converted or fully diagnosed (never partially), and a value used in a sync context diagnoses the whole file.
  • Formatter: the generated code (line-shared destructures, merged imports) isn't prettier-clean, consistent with the codemod's other transforms — run your formatter after migrating. The firebase lint delta is entirely auto-fixable formatting.

@KKonstantinov KKonstantinov requested a review from a team as a code owner June 30, 2026 19:33
@changeset-bot

changeset-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 2feb37a

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 30, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2398

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2398

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2398

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2398

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2398

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2398

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2398

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2398

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2398

commit: 2feb37a

Comment on lines +196 to +222
// both reasons target the same import declaration and the diagnostic dedups per declaration. The
// sync usage is the more fundamental blocker (it cannot await even after migrating the type usage),
// so it is recorded first and wins.
for (const local of localBindings(imp)) {
for (const ref of findValueRefs(sourceFile, local)) {
if (!isAwaitSafe(ref)) {
blockers.push({ symbol: local, line: ref.getStartLineNumber(), decl: imp.decl });
}
}
}
// A named binding referenced as both a value and a type cannot be converted: removing its static
// import to load the value dynamically would orphan the surviving `: name` / `as name` type usage
// (TS2304). Diagnose rather than convert (the conservative, type-safe choice — we do not auto-split
// the value and type usages into a retained `import type` in this pass).
for (const n of imp.named) {
const local = n.getAliasNode()?.getText() ?? n.getName();
if (hasTypeReference(sourceFile, local)) {
blockers.push({ symbol: local, line: imp.decl.getStartLineNumber(), decl: imp.decl, isValueAndType: true });
}
}
}
return { convertible: blockers.length === 0, blockers };
}

const HELPER_ID = '_mcpDynImport';
const HELPER_DECL =
'// eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval\n' +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The value-and-type guard in analyzeConvertibility() only checks named import specifiers (imp.named), so a namespace import (import * as mcp from "@modelcontextprotocol/core") that is used as a value inside an async function and as a type qualifier elsewhere (e.g. let x: mcp.SomeType) is still deemed convertible — convertFile() then removes the whole namespace import and only injects const mcp = await … inside the async body, orphaning the type reference (TS2304). Run the same hasTypeReference() check on imp.namespace (and imp.defaultImport) so this case is diagnosed like the equivalent named-import case.

Extended reasoning...

The bug. In analyzeConvertibility() (packages/codemod/src/migrations/v1-to-v2/transforms/commonjsInterop.ts, ~lines 196–222), the "used as both a value and a type" guard is implemented as a loop over only the named specifiers:

for (const n of imp.named) {
    const local = n.getAliasNode()?.getText() ?? n.getName();
    if (hasTypeReference(sourceFile, local)) {
        blockers.push({ symbol: local, ..., isValueAndType: true });
    }
}

imp.namespace is never passed through hasTypeReference(). So a namespace binding that is referenced both as a value and as a type-position qualifier slips past every blocker check.

The code path that triggers it. Consider a CommonJS file:

import * as mcp from "@modelcontextprotocol/core";

let cached: mcp.CallToolResult | undefined;          // module-level TYPE usage

export async function call(body: unknown) {
    return mcp.CallToolResultSchema.parse(body);     // VALUE usage inside async fn
}

Step-by-step:

  1. collectV2ValueImports() collects the namespace binding because the value usage inside call() gives findValueRefs('mcp') ≥ 1 reference.
  2. The sync-context loop over localBindings(imp) does not flag the module-level mcp.CallToolResult annotation: for that identifier the parent is a QualifiedName, and isTypePositionReference() walks up the qualified-name chain to the TypeReference and returns true, so findValueRefs() excludes it. No sync-context blocker is recorded (correctly — it isn't a value usage).
  3. The value-and-type loop only iterates imp.named, which is empty here, so no isValueAndType blocker is recorded either.
  4. Result: convertible === true, and convertFile() runs. Its namespace branch calls imp.decl.remove(), deleting the entire import * as mcp declaration, and injects const mcp = await _mcpCore; only inside the async function body.
  5. The module-level mcp.CallToolResult annotation now references a binding that no longer exists → TS2304/TS2503. The generated import type * as _McpCoreTypes from "@modelcontextprotocol/core" uses a different identifier, so it does not rescue the orphaned reference.

Why nothing else prevents it. The exact same scenario with a named binding is correctly diagnosed via the isValueAndType blocker (and covered by the test "diagnoses (does not convert) a symbol used as both a value and a type"), so this is an unintended gap in the namespace path rather than a design choice. The PR's own contract is all-or-nothing: "diagnose rather than emit broken code" — this path emits code that fails typecheck instead. The trigger is realistic: the import * as x idiom (which the PR itself cites as pervasive in firebase-tools) combined with x.SomeType qualified type annotations is common in CommonJS projects, the very target of this transform.

Impact. A convertible-looking file produces type-broken output (new typecheck errors after the codemod), undermining the PR's headline guarantee of "0 new typecheck errors" for files it chooses to convert. The post-codemod typecheck would surface the breakage, but the user gets broken code rather than the actionable diagnostic the transform is designed to emit.

Fix. In analyzeConvertibility(), run the same hasTypeReference() check for the namespace binding (and imp.defaultImport, though defaults are already always diagnosed), e.g. push an isValueAndType blocker when imp.namespace && hasTypeReference(sourceFile, imp.namespace.getText()). Alternatively, convertFile() could rewrite namespace type qualifiers to the generated _…Types namespace, but the conservative diagnose-first fix matches the existing named-import behavior and the PR's stated design.

@felixweinberger felixweinberger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting back in your queue for fixing the build issue + bot finding

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants