Modern Build Toolchain · 2026-08-08 · Modern Build Toolchain arc

Modern Build Toolchain for MCP Servers: esbuild, Rollup, Vite, SWC, and Biome — the No-Type-Check Gap, .d.ts Gap, and platform:node Requirement

Five tools — esbuild, Rollup, Vite, SWC, and Biome — are adopted one by one to speed up MCP server builds, eliminate slow tsc compilation cycles, and unify linting and formatting. Each brings a specific trade-off that is not obvious from the documentation. The five tools share three hidden gaps that intersect in MCP server projects: none of esbuild, SWC, or Biome type-checks TypeScript — all three strip type annotations and emit JavaScript without validating that the types are correct, meaning a mistyped tool input schema, an incorrect return type, or a missing required field in your Zod schema will compile and deploy without error; esbuild and Vite lib mode do not generate .d.ts declaration files — both produce JavaScript output only, so npm-published MCP server packages are missing type information for their consumers until an explicit tsc --declaration --emitDeclarationOnly or vite-plugin-dts step is added; and esbuild and Vite default to a browser build target — without platform: 'node' (esbuild) or build.target: 'node22' (Vite), both tools stub or remove Node.js built-in modules like fs, path, child_process, and Buffer, causing MCP stdio servers to fail at runtime with module-not-found errors that are invisible in Claude Desktop, Cursor, and most MCP clients. This post covers four structural patterns that unify the modern build toolchain for MCP servers, with annotated configurations, a 16-row failure modes table, and a tool selection guide for six MCP server scenarios.

TL;DR

Four patterns, five tools. (1) The no-type-check gap: esbuild, SWC, and Biome all strip TypeScript types without checking them — always add tsc --noEmit as a mandatory CI step or type errors deploy silently. (2) The .d.ts generation gap: esbuild never generates .d.ts files; Vite lib mode only generates them via vite-plugin-dts; for npm-published MCP server packages, always add a separate tsc --declaration --emitDeclarationOnly step alongside the bundler — the two are not in competition and run in sequence. (3) The platform:node requirement: set platform: 'node' in every esbuild config and build.target: 'node22' in every Vite lib build — the default browser target stubs fs, path, Buffer, and every other Node.js built-in, causing runtime failures that are indistinguishable from MCP server crashes. (4) Tool selection by use case: esbuild for self-contained deployable MCP servers; Rollup for npm-published MCP server libraries; Vite for companion web UIs (OAuth pages, status dashboards); SWC for zero-build development iteration; Biome for lint+format with the caveat that no-floating-promises is not yet stable.

Pattern 1 — The no-type-check gap: esbuild, SWC, and Biome all strip types silently

The most consequential shared behaviour across the modern build toolchain is one that is rarely stated in getting-started documentation: esbuild, SWC, and Biome do not type-check TypeScript. All three are designed for speed, and type checking is slow — tsc must build a complete type graph of your project and validate every expression against it, a process that takes 5–30 seconds even on medium codebases. esbuild strips type annotations in microseconds; SWC strips them in under 50 ms; Biome's linter inspects the AST without building a type graph. All three succeed even when the TypeScript types are wrong.

This gap matters most in MCP server projects because the consequence of a type error is not a build failure — it's silent runtime misbehaviour. A tool handler that declares its input as z.object({ url: z.string() }) but actually receives a number will compile, bundle, and deploy with esbuild or SWC. The first signal of the problem arrives when a user calls the tool and the handler crashes or returns unexpected output. In Claude Desktop and Cursor, the MCP client typically shows a generic "tool call failed" message with no stack trace, so the failure is hard to diagnose.

// This type error compiles silently with esbuild, SWC, and tsc --transpileOnly:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { z } from 'zod';

const server = new Server({ name: 'my-server', version: '1.0.0' });

const inputSchema = z.object({ count: z.number() });

server.tool('get_items', inputSchema, async (args) => {
  // Type error: args.count is a number, but we call .toLowerCase() (string method)
  // esbuild strips the types and produces: args.count.toLowerCase()
  // SWC strips the types and produces: args.count.toLowerCase()
  // Biome lints the AST and sees no issue (no type graph)
  // At runtime: TypeError: args.count.toLowerCase is not a function
  const prefix = args.count.toLowerCase();
  return { content: [{ type: 'text', text: prefix }] };
});

// Only tsc catches this:
// error TS2339: Property 'toLowerCase' does not exist on type 'number'.

The correct pattern is a split responsibility model: use the fast tool (esbuild, SWC, Vite) for transpilation and bundling, and use tsc --noEmit as a separate CI step for type validation. The two responsibilities do not overlap and the tools are not alternatives — they are complements.

# Correct CI pipeline with esbuild development + tsc type checking
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22', cache: 'npm' }
      - run: npm ci

      # Type checking — runs tsc, never esbuild/SWC:
      - name: Type check
        run: npx tsc --noEmit
        # REQUIRED: esbuild build succeeding is NOT a signal that types are correct.
        # This step is the only safeguard against type errors deploying.

      # Build (esbuild is fast — runs after type check)
      - name: Build
        run: npm run build   # node build.mjs with esbuild

      # Tests
      - name: Test
        run: npm test        # Vitest or Jest, also benefit from Biome/SWC but not type-checked

The same split applies to Biome: Biome's linter runs on the AST without type information and cannot enforce type-aware rules like no-floating-promises. The Biome guide covers this in detail, but the core implication is that any team adopting Biome should keep tsc --noEmit in CI and keep the ESLint @typescript-eslint/no-floating-promises rule active, since an unhandled promise rejection in a MCP stdio server crashes the entire server process without surfacing an error to the MCP client.

Pattern 2 — The .d.ts generation gap: bundlers produce JavaScript, not type declarations

The second gap affects MCP server packages published to npm for use by other TypeScript projects. When a downstream developer runs npm install @myorg/mcp-server-search and imports from it in TypeScript, their editor and build pipeline need .d.ts declaration files to know the types of exported functions, the shape of tool input schemas, and the return types of server constructors. Without declaration files, every import from the package shows as type any, autocomplete is broken, and most strict TypeScript configs will reject the package entirely.

esbuild never generates .d.ts files. This is by design: esbuild's job is to produce JavaScript as fast as possible, and declaration file generation requires building the type graph. Even with every esbuild option enabled, the output is JavaScript and source maps — no .d.ts files. The esbuild guide documents the correct two-step pattern: run esbuild for JavaScript output, then run tsc --declaration --emitDeclarationOnly for type declarations.

Vite lib mode does not generate .d.ts files by default. Vite uses Rollup internally for lib mode builds, and Rollup's core does not produce TypeScript declarations. The vite-plugin-dts plugin adds this capability. Without it, a Vite lib build produces dist/index.mjs and dist/index.cjs but no dist/index.d.ts — the package ships with JavaScript and missing types.

Rollup with rollup-plugin-dts is the exception: the plugin adds a third build config block that takes your TypeScript source and the intermediate .d.ts files produced by tsc --emitDeclarationOnly, bundles them into a single declaration file, and writes it to dist/index.d.ts. This is the canonical pattern for npm-published MCP server libraries using Rollup.

// Correct two-step setup for an esbuild-built npm MCP server package
// package.json scripts
{
  "scripts": {
    "build":           "npm run build:js && npm run build:types",
    "build:js":        "node build.mjs",             // esbuild: produces .js + sourcemap
    "build:types":     "tsc --declaration --emitDeclarationOnly --outDir dist",
    "typecheck":       "tsc --noEmit",               // separate from build — checks types only
    "prepublishOnly":  "npm run typecheck && npm run build"
  },
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "require": { "types": "./dist/index.d.ts", "default": "./dist/index.cjs" }
    }
  }
}

// tsconfig.json — used for the types-only step (and typecheck)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "declaration": true,
    "emitDeclarationOnly": true,    // tsc only writes .d.ts — esbuild writes .js
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}
// Correct Vite lib mode setup with vite-plugin-dts
// vite.config.ts
import { defineConfig } from 'vite';
import dts from 'vite-plugin-dts';   // REQUIRED: Vite lib mode alone does not generate .d.ts
import { resolve } from 'path';

export default defineConfig({
  build: {
    lib: {
      entry: resolve(__dirname, 'src/index.ts'),
      formats: ['es', 'cjs'],
      fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
    },
    rollupOptions: {
      external: ['@modelcontextprotocol/sdk'],  // never bundle the SDK into a library
    },
    minify: false,     // library output should be readable for consumers debugging node_modules
    sourcemap: true,
  },
  plugins: [
    dts({
      include: ['src'],
      // Remap output paths to match the lib output directory structure
      beforeWriteFile: (filePath, content) => ({
        filePath: filePath.replace('/src/', '/'),
        content,
      }),
    }),
  ],
});

The exports map in package.json must place the "types" condition before "default" in each block — TypeScript resolves export conditions in order and stops at the first match. If "default" appears first, TypeScript falls back to the top-level "types" field, which works but produces worse autocomplete in monorepos where package consumers use moduleResolution: bundler or node16. The Rollup guide covers the full dual-format exports map.

Pattern 3 — The platform:node requirement: browser defaults break MCP stdio servers

esbuild and Vite are primarily frontend build tools, and their defaults reflect that heritage. esbuild's platform option defaults to 'browser'; Vite's build.target defaults to browser-compatible output. Both tools have the same consequence when their defaults are used for MCP server code: Node.js built-in modules (fs, path, child_process, crypto, stream, Buffer, process) are either stubbed with empty browser shims or removed entirely from the bundle.

An MCP stdio server reads JSON-RPC messages from process.stdin and writes responses to process.stdout. Without process being a real Node.js global, the server cannot start. Without fs and path, any tool that reads files, resolves directories, or accesses the filesystem fails at the first call. Without child_process, tools that execute shell commands or spawn subprocesses fail silently with "module not found". The errors are not build-time errors — they appear at runtime, inside the MCP client, with no stack trace visible to the developer.

// esbuild — the single required option for MCP servers
import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',        // REQUIRED: without this, esbuild stubs fs/path/child_process
  target: 'node22',        // emit syntax that Node.js 22 supports natively
  format: 'esm',           // match "type": "module" in package.json
  packages: 'external',    // do NOT bundle node_modules into the output
  outfile: 'dist/index.js',
  sourcemap: true,
});

// What happens without platform: 'node':
// - import { readFileSync } from 'fs' — esbuild uses browser-fs-access shim (silently returns empty)
// - import { resolve } from 'path' — esbuild uses path-browserify (returns wrong paths on Windows)
// - process.stdin.on('data', ...) — esbuild uses a no-op stream shim (server never starts)
// - The build succeeds. The MCP client reports: "MCP server failed to start".
// Vite lib mode — required build target for an MCP server library
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['es', 'cjs'],
    },
    target: 'node22',        // REQUIRED: default is browser-compatible output
    rollupOptions: {
      external: ['@modelcontextprotocol/sdk'],
    },
  },
});

// Vite SSR mode (for MCP HTTP servers):
// export default defineConfig({
//   build: { ssr: 'src/entry-server.ts' },  // ssr mode auto-sets node target
// });

SWC avoids this problem entirely because it is a transpiler, not a bundler. @swc-node/register patches the Node.js module loading system and transpiles TypeScript files on demand — it doesn't bundle anything, so Node.js built-ins are never removed or stubbed. The SWC guide explains the --require vs --import distinction for CJS and ESM projects respectively. For development workflows, SWC is the simplest choice: run node --require @swc-node/register src/index.ts and your TypeScript MCP server starts immediately without a build step.

Rollup also avoids this problem when rollup-plugin-node-externals is configured: the plugin marks every package from node_modules as external, which prevents Rollup from attempting to bundle Node.js built-in modules. The key distinction from esbuild and Vite is that Rollup doesn't stub absent modules — it simply leaves them as require('fs') or import { readFileSync } from 'fs' in the output, which Node.js resolves at runtime using its built-in module resolver. The Rollup guide covers the rollup-plugin-node-externals configuration and why automatic externalization is preferred over manually maintaining an external array.

Pattern 4 — Tool selection by MCP server use case

The five tools in the Modern Build Toolchain arc are not competitors for the same role — they target distinct phases of the development lifecycle and distinct output types. Choosing the wrong tool for a use case produces either unnecessary complexity (Rollup for a simple deployable server) or missing capabilities (esbuild for an npm library without adding a tsc declaration step).

esbuild: self-contained deployable MCP server process

When your MCP server is a process that users run directly — via node dist/index.js, npx, or a binary install — esbuild is the correct choice. Its output is a single bundled JavaScript file that starts in under 100 ms, has no runtime compilation cost, and ships with source maps for debugging. The critical configuration is platform: 'node' and packages: 'external'. For CLI tools distributed via npm, add banner: { js: '#!/usr/bin/env node' } and chmod 755 the output file. For development iteration, esbuild.context().watch() rebuilds in under 10 ms on file change — pair it with nodemon or a custom file watcher to restart the server on rebuild.

Rollup: npm-published MCP server library

When your MCP server is a reusable library — a package that other TypeScript projects install and import from — Rollup is the correct choice. Its strengths for library output are: tree-shaking removes unused exports from consumer bundles; dual ESM + CJS output with two separate output blocks covers every consumer environment; and rollup-plugin-dts bundles all .d.ts files into a single declaration file. The critical configuration is rollup-plugin-node-externals to automatically mark peerDependencies as external (preventing the MCP SDK from being bundled into the library and creating a second SDK instance at runtime), and preserveModules: true to preserve the module structure for downstream tree-shaking.

Vite: MCP server companion web UI

When your MCP server ships with a web companion — an OAuth authorization page, a public endpoint registry, an admin panel, or a status dashboard — Vite is the correct choice for the web layer. The MCP protocol layer (stdio or SSE) runs as a separate Node.js process, while Vite handles the React or Vue app with HMR. In development, Vite's proxy configuration routes /mcp and /oauth API calls to the MCP server port so the frontend and protocol layers can be developed concurrently. For the companion web app itself, Vite's default browser target is correct — it's only when using Vite lib mode for an npm-published MCP server package that the build.target: 'node22' override is needed.

SWC (@swc-node/register): zero-build development iteration

When you want to run TypeScript MCP server code without a build step during development, @swc-node/register is the correct choice. It patches Node.js's module loading to transpile .ts files on demand in under 50 ms, eliminating the 1–20 second tsc or esbuild compilation that would otherwise interrupt the edit-run-test cycle. Use node --require @swc-node/register src/index.ts for CJS projects and node --import @swc-node/register/esm src/index.ts for ESM projects. Combined with nodemon for automatic restarts, this gives a development experience equivalent to a scripting language while retaining TypeScript's authoring benefits. SWC is not used for production — the production build uses esbuild or Rollup.

Biome: lint + format replacing ESLint + Prettier

When you want to replace ESLint and Prettier with a single tool that runs in under 100 ms, Biome is the correct choice — with one caveat. Biome's biome check --apply src/ runs the linter, formatter, and import organizer in a single Rust process and applies safe fixes. It replaces the separate prettier --write + eslint --fix sequence in lint-staged. However, Biome's nursery/noFloatingPromises rule is not yet stable — MCP server projects that adopt Biome should either keep ESLint alongside Biome specifically for @typescript-eslint/no-floating-promises and @typescript-eslint/no-misused-promises, or add a tsc --noEmit pre-push hook as a compensating control for unhandled promise rejections. An unhandled rejection in a stdio MCP server crashes the server process silently, making the floating-promise gap a reliability issue, not just a code quality issue.

Tool selection by MCP server use case
Use case Recommended tool Key configuration Gap to fill manually
Deployable MCP stdio/HTTP server process esbuild platform: 'node', packages: 'external' tsc --noEmit in CI (no type checking)
npm-published MCP server library Rollup rollup-plugin-node-externals, rollup-plugin-dts, dual output blocks tsc --noEmit in CI; peerDependencies for MCP SDK
MCP server companion web UI (OAuth, dashboard) Vite Standard browser config; proxy to MCP server port tsc --noEmit in CI; vite-plugin-dts if publishing as lib
Development iteration (zero-build) SWC (@swc-node/register) --require (CJS) or --import (ESM); .swcrc with jsc.target: "es2022" tsc --noEmit in CI (SWC never type-checks)
Lint + format in pre-commit hook Biome biome check --apply --staged in lint-staged Keep ESLint no-floating-promises until Biome rule stabilizes
npm lib built with Vite lib mode Vite + vite-plugin-dts build.target: 'node22', rollupOptions.external: ['@modelcontextprotocol/sdk'] tsc --noEmit in CI; minify: false for readable library output

The combined build pipeline: esbuild + tsc + Biome for a production MCP server

For a self-contained MCP server that is deployed as a process (not published to npm), the recommended toolchain combines esbuild for production bundling, SWC for development, and Biome for lint+format:

// package.json — production MCP server combining esbuild + SWC + Biome
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "bin": { "my-mcp-server": "dist/index.js" },
  "scripts": {
    // Development: SWC transpiles on demand — no build step, starts in <100ms
    "dev":           "node --import @swc-node/register/esm src/index.ts",
    "dev:watch":     "nodemon --exec 'node --import @swc-node/register/esm' src/index.ts --ext ts",

    // Build: esbuild bundles for production deployment
    "build":         "node build.mjs",

    // Type checking: tsc validates types (CI mandatory step)
    "typecheck":     "tsc --noEmit",

    // Lint + format: Biome in one pass (CI uses 'biome ci' — no auto-fix)
    "check":         "biome check --apply src/",
    "check:ci":      "biome ci src/",

    // Pre-publish: type check + build (never relies on SWC or Biome for correctness)
    "prepublishOnly": "npm run typecheck && npm run build"
  },
  "lint-staged": {
    "*.ts": [
      "npx @biomejs/biome check --apply --no-errors-on-unmatched --staged"
      // NOTE: does NOT run tsc --noEmit — that belongs in pre-push, not pre-commit
    ]
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.0",
    "@swc-node/register": "^1.10.0",
    "@swc/core": "^1.7.0",
    "esbuild": "^0.24.0",
    "husky": "^9.0.0",
    "lint-staged": "^15.0.0",
    "nodemon": "^3.0.0",
    "typescript": "^5.5.0"
  }
}

// .husky/pre-commit — fast: lint-staged with Biome (<200ms)
#!/usr/bin/env sh
npx lint-staged

// .husky/pre-push — slower: full type check (5-30s, acceptable once per push)
#!/usr/bin/env sh
npx tsc --noEmit

For a npm-published MCP server library, swap esbuild for Rollup and add a declaration step. The SWC development script and Biome lint+format remain the same — they don't know or care whether the package is a library or a deployable server.

Failure modes table

Symptom Tool Root cause Fix
Type errors deploy silently — broken tool handlers reach production esbuild, SWC, Biome None of these tools type-check — they strip types without validating them Add tsc --noEmit as a required CI step; never skip it because the build succeeded
Floating promise rejection crashes MCP server silently Biome nursery/noFloatingPromises is unstable in Biome 1.x — not enforced by default Keep ESLint @typescript-eslint/no-floating-promises: 'error' alongside Biome
"Could not find declaration file" — TypeScript consumers of npm package fail esbuild, Vite lib mode Neither tool generates .d.ts files by default Add tsc --declaration --emitDeclarationOnly (esbuild) or vite-plugin-dts (Vite)
Cannot find module 'fs' at runtime esbuild Missing platform: 'node' — esbuild stubs Node.js built-ins with browser shims Add platform: 'node' to esbuild options
process is not defined in Vite lib build Vite Vite defaults to browser target — process global is removed Add build.target: 'node22' or use ssr mode
Output bundle is 50–500 MB esbuild Missing packages: 'external' — entire node_modules bundled Add packages: 'external' to bundle options
Duplicate MCP SDK symbols / tool routing breaks Rollup, Vite lib @modelcontextprotocol/sdk bundled into library output, creating a second instance Add SDK to peerDependencies and to external or use rollup-plugin-node-externals
require() of ES Module not supported at runtime esbuild Format mismatch: format: 'esm' with "type": "commonjs" in package.json Match esbuild's format to the "type" field in package.json
--require @swc-node/register has no effect on TypeScript files SWC CJS require hook doesn't intercept ESM import statements Use --import @swc-node/register/esm for ESM projects (Node.js 18.19+)
Decorators not applied at runtime SWC Missing both jsc.parser.decorators: true AND jsc.transform.legacyDecorator: true in .swcrc Set both flags in .swcrc — either flag alone is insufficient
CJS consumers get exports is not defined Rollup Missing exports: 'named' in the CJS output block Add exports: 'named' to the CJS output configuration
TypeScript can't resolve types from exports map Rollup, Vite "types" condition appears after "default" in the exports map Move "types" before "default" within each condition block
Large one-time formatting diff on Biome adoption Biome Biome's formatter output differs from Prettier on most files Commit the formatting migration as a standalone chore: migrate to Biome commit separate from logic changes
SSR Vite build fails to import a pure-ESM package Vite SSR mode externalizes packages — pure-ESM packages without CJS fallback fail to load Add the package to ssr.noExternal to force Vite to bundle it
esbuild watch mode doesn't restart MCP server after rebuild esbuild esbuild's watch rebuilds the file but doesn't manage the running process Use build.onEnd plugin to signal nodemon or a custom process manager
Biome --staged flag unknown error Biome Biome version < 1.5 doesn't support the --staged flag Upgrade to Biome ≥ 1.5 or use lint-staged file patterns without --staged

MCP server uptime and the build toolchain

The gaps documented above — silent type errors, missing declaration files, browser-default build targets — all share a common consequence for production MCP servers: they produce runtime failures that are invisible at build time and silent to the MCP client. Claude Desktop, Cursor, and most MCP client implementations surface tool call failures as generic error messages without stack traces or error details. This makes the feedback loop between a build toolchain misconfiguration and a runtime failure much longer than in traditional web application development.

AliveMCP monitors public MCP endpoints with 60-second pings, so you know when your server goes down rather than discovering it through user reports. A server that starts successfully but crashes on the first tool call due to a missing Node.js built-in (platform: 'browser' in esbuild) will show up in the uptime dashboard as a failed liveness check the moment a real tool call is made. Pairing the build toolchain patterns above with endpoint monitoring closes the feedback loop: the build pipeline catches type errors before deployment, and the uptime monitor catches runtime failures after.