Guide · Modern Build Toolchain
MCP Server Rollup — dual ESM/CJS output, tree-shaking, npm library bundling
Three Rollup behaviours catch MCP server library authors off guard: Rollup does not generate TypeScript declaration files by default — rollup-plugin-typescript2 or the separate rollup-plugin-dts step is required to produce .d.ts files; without them, TypeScript consumers of your MCP server package see "Could not find a declaration file" even though the JavaScript works; the external array must cover both direct dependencies and their transitive peer dependencies — if @modelcontextprotocol/sdk is in peerDependencies but not listed in external, Rollup bundles it into the output creating a second copy at runtime that conflicts with the host application's copy of the SDK, producing duplicate symbol errors or silent mis-routing of tool calls; and dual ESM + CJS output requires two separate output blocks in rollup.config.js — there is no single-output "isomorphic" mode; each format gets its own output directory and the package.json exports map must reference both.
TL;DR
Use Rollup with rollup-plugin-esbuild for fast TypeScript transforms and rollup-plugin-node-externals to mark all node_modules external. Define two output objects in rollup.config.js: one with format: 'esm' writing to dist/esm/, one with format: 'cjs' writing to dist/cjs/. Run a separate tsc --declaration --emitDeclarationOnly step for .d.ts files. Wire the exports map in package.json to point to both outputs.
When to use Rollup vs esbuild for MCP server packages
Rollup is the conventional choice when your MCP server is a reusable library — a package that other projects install via npm rather than deploy directly. Its strengths for library distribution are tree-shaking (unused exports are dropped from consumer bundles), clean dual ESM/CJS output, and first-class support for generating declaration files via plugins.
For a self-contained MCP server that runs as a process (stdio, SSE, or HTTP), esbuild is simpler: one entry point, one output file, done. Rollup adds complexity that pays off only when downstream consumers import named exports from your package.
rollup.config.js with dual ESM and CJS output
// rollup.config.js
import { defineConfig } from 'rollup';
import esbuild from 'rollup-plugin-esbuild'; // fast TS transform
import nodeExternals from 'rollup-plugin-node-externals'; // auto-externals
import dts from 'rollup-plugin-dts'; // .d.ts bundle
const src = 'src/index.ts';
export default defineConfig([
// --- JavaScript output (ESM) ---
{
input: src,
output: {
dir: 'dist/esm',
format: 'esm',
preserveModules: true, // one output file per input file (better tree-shaking)
preserveModulesRoot: 'src', // strip 'src/' prefix from output paths
sourcemap: true,
},
plugins: [
nodeExternals(), // marks everything in node_modules as external automatically
esbuild({
target: 'node22',
// esbuild handles TS transpilation; Rollup handles module graph + tree-shaking
}),
],
},
// --- JavaScript output (CJS) ---
{
input: src,
output: {
dir: 'dist/cjs',
format: 'cjs',
preserveModules: true,
preserveModulesRoot: 'src',
sourcemap: true,
exports: 'named', // required for CJS: tells consumers how exports are structured
},
plugins: [
nodeExternals(),
esbuild({ target: 'node22' }),
],
},
// --- TypeScript declarations (.d.ts) ---
{
input: src,
output: {
file: 'dist/index.d.ts',
format: 'esm', // format doesn't matter for .d.ts — pick either
},
plugins: [
nodeExternals(),
dts(), // bundles all .d.ts into a single declaration file
],
},
]);
preserveModules: true is preferred over single-file bundling for library output: it preserves the module structure so bundlers used by downstream consumers (webpack, Rollup, esbuild) can tree-shake individual exports. A single-file bundle prevents tree-shaking because all exports are in one file.
package.json exports map for dual-format MCP packages
The exports field in package.json tells Node.js and bundlers which file to use for each import condition. A correctly structured exports map ensures that ESM consumers get .js files from dist/esm/, CJS consumers get files from dist/cjs/, and TypeScript consumers find the declaration file.
// package.json — for a dual-format MCP server library
{
"name": "@myorg/mcp-server-search",
"version": "1.0.0",
"type": "module", // default module system for .js files
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/cjs/index.js"
}
}
},
"main": "./dist/cjs/index.js", // fallback for old bundlers that ignore exports
"module": "./dist/esm/index.js", // unofficial but understood by some bundlers
"types": "./dist/index.d.ts", // fallback for TypeScript <4.7 (no exports resolution)
"files": ["dist"],
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.4.0"
// Declared as peer — consumers provide their own copy.
// rollup-plugin-node-externals reads peerDependencies and marks them external.
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.4.0",
"rollup": "^4.0.0",
"rollup-plugin-esbuild": "^6.0.0",
"rollup-plugin-node-externals": "^7.0.0",
"rollup-plugin-dts": "^6.0.0",
"typescript": "^5.5.0"
},
"scripts": {
"build": "rollup -c",
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run typecheck && npm run build"
}
}
The types condition inside exports must come before default within each block — TypeScript resolves conditions in order and stops at the first match. If default appears first, TypeScript never sees the types pointer and falls back to the top-level "types" field, which works but produces worse IDE autocomplete in monorepos.
Manual external array vs rollup-plugin-node-externals
Without rollup-plugin-node-externals, you must list every package that should not be bundled. For MCP server packages with many dependencies this becomes error-prone — a missing entry means that package gets bundled, which can cause version conflicts at runtime.
// Without the plugin — manual, fragile:
export default {
external: [
'@modelcontextprotocol/sdk',
'zod',
'undici',
// ...must be kept in sync with package.json manually
],
// ...
};
// With rollup-plugin-node-externals — automatic:
import nodeExternals from 'rollup-plugin-node-externals';
export default {
plugins: [
nodeExternals({
// By default, marks dependencies, peerDependencies, and optionalDependencies
// as external. devDependencies are bundled unless include: 'devDependencies'.
// Pass devDeps: true to also exclude devDependencies (rarely needed).
}),
],
// ...
};
One exception: if your MCP server depends on a pure-ESM package that doesn't ship CJS output, you may need to bundle it into the CJS build rather than marking it external. In that case, use the include option of rollup-plugin-node-externals to exclude specific packages from the auto-external list, allowing Rollup to bundle them.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Duplicate MCP SDK symbols / tool routing breaks | @modelcontextprotocol/sdk bundled into output — not marked external | Add to peerDependencies + use rollup-plugin-node-externals |
| "Could not find declaration file" for consumers | Missing rollup-plugin-dts step | Add a third Rollup config block with dts() plugin |
CJS consumers get exports is not defined | Missing exports: 'named' in CJS output config | Add exports: 'named' to the CJS output block |
TypeScript can't resolve types from exports | "types" condition after "default" in exports map | Move "types" before "default" in each condition block |
| Downstream bundle includes the whole MCP server | preserveModules: false — single-file output prevents tree-shaking | Set preserveModules: true for library output |