Skip to content

chore: switch to module ESNext + moduleResolution bundler#2095

Open
mattzcarey wants to merge 1 commit into
mainfrom
chore/bundler-tsconfig
Open

chore: switch to module ESNext + moduleResolution bundler#2095
mattzcarey wants to merge 1 commit into
mainfrom
chore/bundler-tsconfig

Conversation

@mattzcarey
Copy link
Copy Markdown
Contributor

Summary

  • Flip the shared common/tsconfig/tsconfig.json from module: NodeNext / moduleResolution: NodeNext to module: ESNext / moduleResolution: bundler.
  • Also flip the two Node16 overrides in examples/client-quickstart/tsconfig.json and examples/server-quickstart/tsconfig.json.
  • Strip .js extensions from every relative TypeScript import across packages/, examples/, scripts/, and test/.
  • Update CLAUDE.md to reflect the new import convention.

Why

Under moduleResolution: NodeNext, every relative TS import has to be written with a .js extension (import x from './foo.js') — a long-standing footgun for new contributors and a frequent source of confusion when scripts/codemods generate imports. moduleResolution: bundler treats the path as a module reference, matching what tsdown, vitest, and downstream consumers' bundlers already do at runtime.

This is an internal-only build configuration change. Consumers are not affected: they continue to import from the built .mjs/.d.mts files declared in each package's exports map, which still carry the .js extensions Node's NodeNext resolver requires at runtime.

Test plan

  • pnpm typecheck:all passes
  • pnpm lint:all passes (including sync:snippets --check)
  • pnpm test:all passes
  • pnpm build:all passes
  • CI green

Internal-only build configuration change. Consumers are not affected:
they continue to import from the built `.mjs`/`.d.mts` files declared
in each package's `exports` map.

What changed:
- `common/tsconfig/tsconfig.json`: `module: NodeNext` → `module: ESNext`,
  `moduleResolution: NodeNext` → `moduleResolution: bundler`.
- `examples/{client,server}-quickstart/tsconfig.json`: same flip
  (they extend a different base and overrode the resolution).
- Strip `.js` extensions from every relative TypeScript import across
  packages/, examples/, scripts/, test/.
- Update CLAUDE.md to reflect the new import convention.

Why:
- Removes the long-standing footgun of having to write `from './foo.js'`
  in `.ts` source files. Bundler resolution treats the path as a module
  reference and lets the tooling resolve it.
- Aligns with what the bundler (`tsdown`), vitest, and downstream
  consumers' bundlers actually do at runtime.

Verification: `pnpm typecheck:all`, `pnpm lint:all`, `pnpm test:all`,
`pnpm build:all` all pass.
@mattzcarey mattzcarey requested a review from a team as a code owner May 15, 2026 14:38
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 15, 2026

⚠️ No Changeset found

Latest commit: e886551

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
Copy link
Copy Markdown

pkg-pr-new Bot commented May 15, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: e886551

Comment on lines +5 to +6
"module": "ESNext",
"moduleResolution": "bundler",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 This change flips the two quickstart tsconfig.json files to ESNext/bundler, but docs/client-quickstart.md (lines 88–104) and docs/server-quickstart.md (lines 103–118) still tell users to create a tsconfig with "module": "Node16" / "moduleResolution": "Node16" — those blocks are plain ```json fences (no source=), so `sync:snippets --check` doesn't catch the drift. Since the quickstarts are user-facing templates that build with plain `tsc` and run the emitted `./build/index.js` directly under Node (no bundler involved), I'd lean toward leaving these two tsconfigs on `Node16`/`NodeNext` and only flipping the shared `common/tsconfig`; otherwise the two doc files need updating to match.

Extended reasoning...

What the issue is. This PR changes examples/client-quickstart/tsconfig.json and examples/server-quickstart/tsconfig.json from "module": "Node16" / "moduleResolution": "Node16" to "module": "ESNext" / "moduleResolution": "bundler". However, the user-facing quickstart guides that these example projects mirror — docs/client-quickstart.md (the "Create a tsconfig.json" block at lines 88–104) and docs/server-quickstart.md (lines 103–118) — were not updated and still instruct users to write "module": "Node16" / "moduleResolution": "Node16". Before this PR the two example tsconfigs and the two doc snippets were field-for-field identical; after it they diverge on module/moduleResolution.

Why CI doesn't catch it. Only the .ts code regions in those docs are auto-synced via source=-tagged fences; the tsconfig blocks are plain ```json fences with no source= attribute, so `pnpm sync:snippets --check` (which the PR description lists as passing under `pnpm lint:all`) does not compare them against the example files. This is exactly the case the repo review checklist calls out: "behavior change: check whether docs/**/.md describes the old behavior and needs updating; flag prose that now contradicts the implementation"*.

Why bundler is questionable for these two projects specifically. The PR description justifies moduleResolution: bundler on the basis that "tsdown, vitest, and downstream consumers' bundlers" handle resolution. That is true for the published packages, but the quickstarts are different: examples/client-quickstart/package.json has "build": "tsc" and "bin": { "mcp-client-cli": "./build/index.js" }, and examples/server-quickstart/package.json has "build": "tsc && chmod 755 build/index.js" with "bin": "./build/index.js" and "type": "module". They are compiled with raw tsc and the emitted .js is executed directly by Node — no bundler is involved at any stage. These two tsconfigs were standalone (they don't extend common/tsconfig) and were deliberately kept on Node16 precisely because they are copy-paste starter templates for end users running under Node.

Step-by-step proof.

  1. docs/client-quickstart.md:93–94 reads "module": "Node16", "moduleResolution": "Node16".
  2. After this PR, examples/client-quickstart/tsconfig.json:5–6 reads "module": "ESNext", "moduleResolution": "bundler".
  3. The doc fence at line 88 is ```json (no source=…), so sync:snippets --check ignores it.
  4. The same holds for docs/server-quickstart.md:107–108 vs examples/server-quickstart/tsconfig.json:4–5.
  5. Result: the published quickstart docs and the in-repo example projects they are meant to mirror now disagree on module/moduleResolution.

Impact. Today each quickstart has only a single src/index.ts with no relative imports, so the example build itself doesn't break, and a user who follows the (stale) docs verbatim still gets a working project. But the docs and examples are now inconsistent, and if a user following the new example config adds a second source file, module: ESNext + moduleResolution: bundler will let tsc accept extensionless relative imports and emit them unchanged — which then fail at runtime under Node's ESM loader (ERR_MODULE_NOT_FOUND). The Node16 setting that the docs still recommend is the one that protects against that.

How to fix. Two options:

  • Preferred: revert examples/client-quickstart/tsconfig.json and examples/server-quickstart/tsconfig.json to Node16/Node16 (or NodeNext/NodeNext) and only keep the ESNext/bundler flip in common/tsconfig/tsconfig.json. The quickstarts are "how a downstream consumer sets up a project," not "how this monorepo builds," so the internal-build rationale doesn't apply to them.
  • Alternatively: update the tsconfig.json blocks in docs/client-quickstart.md and docs/server-quickstart.md to match ESNext/bundler (and consider giving those blocks a source= attribute so sync:snippets catches future drift).

Comment on lines 7 to +8
vi.mock('../../src/client/auth.js', async () => {
const actual = await vi.importActual<typeof import('../../src/client/auth.js')>('../../src/client/auth.js');
const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth.js');
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 codemod missed the runtime string arguments to vi.mock('../../src/client/auth.js', ...) and vi.importActual('../../src/client/auth.js') on lines 7-8 — these are the only two surviving relative .js specifiers in any .ts source after this PR. Line 8 is even half-migrated: the typeof import('../../src/client/auth') type argument was stripped but the adjacent runtime string was not. Not a runtime break (vitest normalizes both forms to the same module so the mock still applies), just a consistency cleanup to finish the migration.

Extended reasoning...

What was missed

This PR migrates every relative TypeScript import in the repo from the ./foo.js form to extensionless ./foo, and updates CLAUDE.md to say "no .js extension on relative imports". A repo-wide grep after the change shows exactly two surviving relative .js specifiers in .ts sources, both in packages/client/test/client/middleware.test.ts:

vi.mock('../../src/client/auth.js', async () => {
    const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth.js');
    ...
});

The PR explicitly edited this file — it changed lines 4, 5, 16, and even the typeof import('../../src/client/auth') type argument on line 8 itself — but left the two adjacent runtime string literals untouched. Line 8 in particular is now visibly half-migrated: the type argument has no .js, the runtime argument right next to it still does.

Why the codemod missed it

The migration appears to have targeted ESM import/export declarations and import() expressions (it did update dynamic await import('../../src/client/auth.js') calls in streamableHttp.test.ts and validators.test.ts). vi.mock(...) and vi.importActual(...) take their specifier as an ordinary string argument to a function call, not as a syntactic import, so they would not be caught by an AST pass over import nodes.

Why nothing breaks

Vitest's module resolver normalizes '../../src/client/auth.js' and '../../src/client/auth' to the same on-disk file (packages/client/src/client/auth.ts). The system-under-test (middleware.ts) was migrated to import from './auth', and the test file's static import { auth, extractWWWAuthenticateParams } from '../../src/client/auth' on line 16 was also migrated. Because all three resolve to the same module record, the vi.mock('.../auth.js') factory still intercepts the extensionless imports, mockAuth / mockExtractWWWAuthenticateParams are still the mocked functions, and the test suite continues to pass.

Step-by-step proof of the leftover

  1. Before the PR, line 8 read: vi.importActual<typeof import('../../src/client/auth.js')>('../../src/client/auth.js').
  2. The PR diff for this file changes the type argument to typeof import('../../src/client/auth') (no .js), and changes the static import on line 16 to from '../../src/client/auth'.
  3. The runtime argument '../../src/client/auth.js' on line 8 and the vi.mock argument on line 7 are unchanged in the diff.
  4. grep -rn "'\.\./.*\.js'" packages/**/*.ts after the PR returns only these two lines (the one other repo hit is a spawn arg pointing at a built dist/ file, which is unrelated to source-import resolution).
  5. Therefore the PR's stated goal — "strip .js extensions from every relative TypeScript import" — is incomplete by exactly these two specifiers, in a file the PR otherwise edited.

Impact and fix

Pure consistency/cleanup: the file now mixes both conventions on adjacent lines, and these two strings are the only thing standing between the PR and a fully clean grep for the old pattern. Fix is to drop the .js suffix from both string literals:

vi.mock('../../src/client/auth', async () => {
    const actual = await vi.importActual<typeof import('../../src/client/auth')>('../../src/client/auth');
    ...
});

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.

1 participant