Skip to content

Commit f172626

Browse files
authored
feat(packaging): ship CommonJS builds alongside ESM for v2 packages (#2405)
1 parent 2f4ad13 commit f172626

22 files changed

Lines changed: 293 additions & 117 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@modelcontextprotocol/server': patch
3+
'@modelcontextprotocol/client': patch
4+
'@modelcontextprotocol/core': patch
5+
'@modelcontextprotocol/server-legacy': patch
6+
'@modelcontextprotocol/codemod': patch
7+
'@modelcontextprotocol/express': patch
8+
'@modelcontextprotocol/hono': patch
9+
'@modelcontextprotocol/fastify': patch
10+
'@modelcontextprotocol/node': patch
11+
---
12+
13+
Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
14+
and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
15+
adds a `require` condition so `require('@modelcontextprotocol/…')` works from
16+
CommonJS consumers. Output extensions are normalized across all packages
17+
(`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
18+
public import paths are unchanged.

docs/behavior-surface-pins.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@ CI pass — that reopens the silent-drift hole the pin exists to close.
2323

2424
## Where pins live
2525

26-
| Surface | File |
27-
| --- | --- |
28-
| Wire error-code tables, error classes, version constants | `packages/core-internal/test/types/errorSurfacePins.test.ts` |
29-
| Schema strict/strip/loose boundaries, key existence | `packages/core-internal/test/types/schemaBoundaryPins.test.ts` |
30-
| Published package set, export maps, ESM-only topology | `packages/core-internal/test/packageTopologyPins.test.ts` |
31-
| stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` |
32-
| 2025-11-25 wire method-registry membership, schema identity | `packages/core-internal/test/types/registryPins.test.ts` |
26+
| Surface | File |
27+
| ----------------------------------------------------------- | -------------------------------------------------------------- |
28+
| Wire error-code tables, error classes, version constants | `packages/core-internal/test/types/errorSurfacePins.test.ts` |
29+
| Schema strict/strip/loose boundaries, key existence | `packages/core-internal/test/types/schemaBoundaryPins.test.ts` |
30+
| Published package set, export maps, dual ESM/CJS topology | `packages/core-internal/test/packageTopologyPins.test.ts` |
31+
| stdio environment-inheritance safelist | `packages/client/test/client/stdioEnvPins.test.ts` |
32+
| 2025-11-25 wire method-registry membership, schema identity | `packages/core-internal/test/types/registryPins.test.ts` |
3333

3434
## Writing a new pin
3535

docs/migration/upgrade-to-v2.md

Lines changed: 15 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ If you are already on v2 and want to adopt the **2026-07-28 protocol revision**,
1515

1616
## TL;DR — quick path
1717

18-
1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM
19-
only; CommonJS callers must use dynamic `import()`.
18+
1. **Prerequisites.** Node.js 20+. v2 is ESM-first but ships a CommonJS build too, so
19+
both `import` and `require('@modelcontextprotocol/…')` resolve natively.
2020
2. **Run the codemod.**
2121
```bash
2222
npx @modelcontextprotocol/codemod@beta v1-to-v2 .
@@ -212,8 +212,9 @@ depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion
212212
does **not** require the `hono` framework — your package manager may emit a harmless
213213
unmet-peer warning for `hono` (upstream `@hono/node-server` declares it).
214214
215-
v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS
216-
(`require()`), either migrate to ESM or use dynamic `import()`.
215+
v2 requires **Node.js 20+**. It is ESM-first but ships a **CommonJS build alongside
216+
ESM**, so CommonJS projects can `require('@modelcontextprotocol/…')` directly — no
217+
dynamic `import()` shim required.
217218
218219
Repo-local tooling that encodes the literal v1 package name — dependency-pin lints,
219220
version allowlists, CI checks, scripts — fails after the manifest swap and is invisible
@@ -225,13 +226,13 @@ directly, and a surviving cast keeps suppressing type checking that would otherw
225226
catch real errors.
226227
227228
Tooling that pins SDK **dist text** (reading a constant out of a built file with
228-
`require.resolve` + a regex) breaks in three stacked ways: the v2 exports maps offer
229-
nothing a CJS `require.resolve` can find; the literal usually lives in a
229+
`require.resolve` + a regex) breaks in two stacked ways: the literal usually lives in a
230230
content-hashed sibling chunk (`dist/sse-<hash>.mjs`), not the subpath's entry module,
231231
so fixed-path reads do not survive a rebuild — scan the package's `dist/` directory
232232
for the literal instead; and the emitted quote style differs from v1, so a
233-
quote-anchored pattern misses silently — match either quote. v2 also ships ESM only:
234-
`/dist/cjs/``/dist/esm/` flavor-pair path swaps have no equivalent.
233+
quote-anchored pattern misses silently — match either quote. The build layout also
234+
changed: v2 emits `.mjs`/`.cjs` siblings in a flat `dist/`, so v1's `/dist/cjs/` ↔
235+
`/dist/esm/` flavor-pair path swaps have no equivalent.
235236
236237
#### Registry availability during the beta
237238
@@ -250,41 +251,13 @@ window:
250251
(`pnpm install && pnpm build`, then `pnpm pack` in the package directory) and
251252
reference it with a committed `file:` dependency.
252253
253-
#### CommonJS test runners (Jest) cannot resolve the v2 packages
254-
255-
Every leaf of the v2 packages' `exports` maps carries only the `types` and `import`
256-
conditions — there is no `require` or `default` leaf — so the packages cannot be
257-
resolved by CJS resolvers at all. Jest under its default CommonJS resolution (including
258-
`next/jest` setups) fails with `Cannot find module '@modelcontextprotocol/client'` even
259-
when a transform that handles ESM is configured: resolution fails before any transform
260-
runs. Vitest and native Node ESM are unaffected.
261-
262-
The interim recipe — interim because the packaging shape is still under discussion and
263-
a later alpha may make it unnecessary — maps the bare specifiers straight to the dist
264-
ESM files and lets the transform convert them (the dists contain no `import.meta`, so
265-
an ESM→CJS transform such as SWC or Babel handles them cleanly):
266-
267-
```js
268-
// jest.config.js
269-
transformIgnorePatterns: [], // or a pattern that still transforms @modelcontextprotocol/*
270-
moduleNameMapper: {
271-
'^@modelcontextprotocol/client$': '<rootDir>/node_modules/@modelcontextprotocol/client/dist/index.mjs',
272-
'^@modelcontextprotocol/server$': '<rootDir>/node_modules/@modelcontextprotocol/server/dist/index.mjs',
273-
// `_shims` is the packages' internal runtime-selection self-reference;
274-
// pin it to the Node build under jest.
275-
'^@modelcontextprotocol/client/_shims$': '<rootDir>/node_modules/@modelcontextprotocol/client/dist/shimsNode.mjs',
276-
'^@modelcontextprotocol/server/_shims$': '<rootDir>/node_modules/@modelcontextprotocol/server/dist/shimsNode.mjs',
277-
},
278-
```
254+
#### CommonJS test runners (Jest)
279255
280-
The entries are exact-anchored — add one per subpath you import (`/stdio` →
281-
`dist/stdio.mjs`, `/validators/cf-worker` → `dist/validators/cfWorker.mjs`) and one for
282-
`@modelcontextprotocol/core` (`dist/index.js`) if you import the raw schemas. The
283-
`_shims` mappings are required whenever the matching root mapping is present: the dist
284-
entry files import `@modelcontextprotocol/client/_shims` (a package self-reference)
285-
internally, and that specifier fails CJS resolution the same way. In a hoisted
286-
monorepo, point the paths at the `node_modules` directory your package manager actually
287-
installs into.
256+
v2 ships a CommonJS build, so CJS test runners resolve the packages natively through the
257+
`require` export condition — Jest (including `next/jest` setups) no longer needs a
258+
`moduleNameMapper` workaround to import `@modelcontextprotocol/*`. If you carried a
259+
v1-era mapping that pinned these packages to their `dist/*.mjs` files, remove it. Vitest
260+
and native Node ESM are unaffected.
288261
289262
#### Bundlers: nested `zod` copies in zod@3-pinned monorepos
290263

packages/client/package.json

Lines changed: 65 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,40 +21,89 @@
2121
],
2222
"exports": {
2323
".": {
24-
"types": "./dist/index.d.mts",
25-
"import": "./dist/index.mjs"
24+
"import": {
25+
"types": "./dist/index.d.mts",
26+
"default": "./dist/index.mjs"
27+
},
28+
"require": {
29+
"types": "./dist/index.d.cts",
30+
"default": "./dist/index.cjs"
31+
}
2632
},
2733
"./stdio": {
28-
"types": "./dist/stdio.d.mts",
29-
"import": "./dist/stdio.mjs"
34+
"import": {
35+
"types": "./dist/stdio.d.mts",
36+
"default": "./dist/stdio.mjs"
37+
},
38+
"require": {
39+
"types": "./dist/stdio.d.cts",
40+
"default": "./dist/stdio.cjs"
41+
}
3042
},
3143
"./validators/ajv": {
32-
"types": "./dist/validators/ajv.d.mts",
33-
"import": "./dist/validators/ajv.mjs"
44+
"import": {
45+
"types": "./dist/validators/ajv.d.mts",
46+
"default": "./dist/validators/ajv.mjs"
47+
},
48+
"require": {
49+
"types": "./dist/validators/ajv.d.cts",
50+
"default": "./dist/validators/ajv.cjs"
51+
}
3452
},
3553
"./validators/cf-worker": {
36-
"types": "./dist/validators/cfWorker.d.mts",
37-
"import": "./dist/validators/cfWorker.mjs"
54+
"import": {
55+
"types": "./dist/validators/cfWorker.d.mts",
56+
"default": "./dist/validators/cfWorker.mjs"
57+
},
58+
"require": {
59+
"types": "./dist/validators/cfWorker.d.cts",
60+
"default": "./dist/validators/cfWorker.cjs"
61+
}
3862
},
3963
"./_shims": {
4064
"workerd": {
41-
"types": "./dist/shimsWorkerd.d.mts",
42-
"import": "./dist/shimsWorkerd.mjs"
65+
"import": {
66+
"types": "./dist/shimsWorkerd.d.mts",
67+
"default": "./dist/shimsWorkerd.mjs"
68+
},
69+
"require": {
70+
"types": "./dist/shimsWorkerd.d.cts",
71+
"default": "./dist/shimsWorkerd.cjs"
72+
}
4373
},
4474
"browser": {
45-
"types": "./dist/shimsBrowser.d.mts",
46-
"import": "./dist/shimsBrowser.mjs"
75+
"import": {
76+
"types": "./dist/shimsBrowser.d.mts",
77+
"default": "./dist/shimsBrowser.mjs"
78+
},
79+
"require": {
80+
"types": "./dist/shimsBrowser.d.cts",
81+
"default": "./dist/shimsBrowser.cjs"
82+
}
4783
},
4884
"node": {
49-
"types": "./dist/shimsNode.d.mts",
50-
"import": "./dist/shimsNode.mjs"
85+
"import": {
86+
"types": "./dist/shimsNode.d.mts",
87+
"default": "./dist/shimsNode.mjs"
88+
},
89+
"require": {
90+
"types": "./dist/shimsNode.d.cts",
91+
"default": "./dist/shimsNode.cjs"
92+
}
5193
},
5294
"default": {
53-
"types": "./dist/shimsNode.d.mts",
54-
"import": "./dist/shimsNode.mjs"
95+
"import": {
96+
"types": "./dist/shimsNode.d.mts",
97+
"default": "./dist/shimsNode.mjs"
98+
},
99+
"require": {
100+
"types": "./dist/shimsNode.d.cts",
101+
"default": "./dist/shimsNode.cjs"
102+
}
55103
}
56104
}
57105
},
106+
"main": "./dist/index.cjs",
58107
"types": "./dist/index.d.mts",
59108
"typesVersions": {
60109
"*": {

packages/client/tsdown.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ export default defineConfig({
1111
'src/validators/ajv.ts',
1212
'src/validators/cfWorker.ts'
1313
],
14-
format: ['esm'],
14+
format: ['esm', 'cjs'],
15+
fixedExtension: true,
1516
outDir: 'dist',
1617
clean: true,
1718
sourcemap: true,

packages/codemod/package.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,17 @@
2525
},
2626
"exports": {
2727
".": {
28-
"types": "./dist/index.d.mts",
29-
"import": "./dist/index.mjs"
28+
"import": {
29+
"types": "./dist/index.d.mts",
30+
"default": "./dist/index.mjs"
31+
},
32+
"require": {
33+
"types": "./dist/index.d.cts",
34+
"default": "./dist/index.cjs"
35+
}
3036
}
3137
},
38+
"main": "./dist/index.cjs",
3239
"files": [
3340
"dist"
3441
],

packages/codemod/tsdown.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { defineConfig } from 'tsdown';
33
export default defineConfig({
44
failOnWarn: 'ci-only',
55
entry: ['src/cli.ts', 'src/index.ts'],
6-
format: ['esm'],
6+
format: ['esm', 'cjs'],
7+
fixedExtension: true,
78
outDir: 'dist',
89
clean: true,
910
sourcemap: true,

packages/core-internal/test/packageTopologyPins.test.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ interface PackageManifest {
2323
name: string;
2424
private?: boolean;
2525
type?: string;
26+
main?: string;
2627
files?: string[];
2728
bin?: Record<string, string>;
2829
exports?: Record<string, unknown>;
@@ -75,16 +76,32 @@ describe('public package topology', () => {
7576
expect(Object.keys(manifest.exports ?? {})).toEqual(expected.exportKeys);
7677
});
7778

78-
test('ships ESM only', () => {
79+
test('ships dual ESM + CJS', () => {
80+
// The v2 packages are ESM-first but ship a CommonJS build too, so
81+
// require('@modelcontextprotocol/…') resolves natively. Pin that
82+
// deliberate dual surface: type stays 'module', a `.cjs` main is
83+
// present for bare-require fallback, and every export condition
84+
// that resolves a module format offers BOTH an `import` (ESM) and a
85+
// `require` (CJS) branch — recursively, so nested runtime conditions
86+
// (e.g. ./_shims → workerd/browser/node/default) are covered.
7987
expect(manifest.type).toBe('module');
80-
// No entry may grow a 'require' condition: the v2 packages are
81-
// ESM-only by design (a CJS build would be a new public surface).
82-
const conditionsOf = (entry: unknown): string[] =>
83-
entry !== null && typeof entry === 'object'
84-
? Object.entries(entry).flatMap(([key, value]) => [key, ...conditionsOf(value)])
85-
: [];
88+
expect(manifest.main).toMatch(/\.cjs$/);
89+
90+
const assertDual = (node: unknown): void => {
91+
if (node === null || typeof node !== 'object') {
92+
return;
93+
}
94+
const keys = Object.keys(node);
95+
if (keys.includes('import') || keys.includes('require')) {
96+
expect(keys).toContain('import');
97+
expect(keys).toContain('require');
98+
}
99+
for (const value of Object.values(node)) {
100+
assertDual(value);
101+
}
102+
};
86103
for (const entry of Object.values(manifest.exports ?? {})) {
87-
expect(conditionsOf(entry)).not.toContain('require');
104+
assertDual(entry);
88105
}
89106
});
90107

packages/core/package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,18 @@
2222
],
2323
"exports": {
2424
".": {
25-
"types": "./dist/index.d.ts",
26-
"import": "./dist/index.js"
25+
"import": {
26+
"types": "./dist/index.d.mts",
27+
"default": "./dist/index.mjs"
28+
},
29+
"require": {
30+
"types": "./dist/index.d.cts",
31+
"default": "./dist/index.cjs"
32+
}
2733
}
2834
},
29-
"types": "./dist/index.d.ts",
35+
"main": "./dist/index.cjs",
36+
"types": "./dist/index.d.mts",
3037
"files": [
3138
"dist"
3239
],

packages/core/tsdown.config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import { defineConfig } from 'tsdown';
1212
export default defineConfig({
1313
failOnWarn: 'ci-only',
1414
entry: ['src/index.ts'],
15-
format: ['esm'],
15+
format: ['esm', 'cjs'],
16+
fixedExtension: true,
1617
outDir: 'dist',
1718
clean: true,
1819
sourcemap: true,

0 commit comments

Comments
 (0)