Guide · Modern Build Toolchain

MCP Server esbuild — fast TypeScript bundling, platform node, packages external

Three esbuild behaviours surprise MCP server authors on first contact: esbuild does not type-check and does not generate .d.ts declaration files — it strips TypeScript annotations at near-zero cost and emits JavaScript, but produces no type information; if you publish your MCP server as an npm library, TypeScript consumers will see "Could not find a declaration file for module" errors unless you run tsc --declaration --emitDeclarationOnly as a separate step; platform defaults to 'browser' — without platform: 'node', esbuild bundles Node.js built-in modules (fs, path, child_process) into the output or replaces them with empty browser shims, causing MCP stdio servers to fail at runtime with module-not-found or silent no-op errors; and dependencies are bundled by default — without packages: 'external', esbuild copies your entire node_modules tree into the output bundle, producing files that are hundreds of megabytes and that fail npm publish size checks.

TL;DR

Install esbuild as a devDependency. Run esbuild src/index.ts --bundle --platform=node --packages=external --outfile=dist/index.js --sourcemap for a deployable single-file MCP server. For npm-published packages that need types, add a separate tsc --declaration --emitDeclarationOnly --outDir dist step. Keep tsc --noEmit in CI for type checking — esbuild never replaces it.

Why esbuild for MCP servers

The TypeScript compiler (tsc) is correct and generates full type information, but its single-threaded JavaScript implementation makes it 10–100× slower than esbuild for bundling. For a self-contained MCP stdio server that ships as a single deployable file — not a library — esbuild is the right tool: build times drop from 10–30 seconds to under 100 ms, hot rebuilds are instant, and the output is identical JavaScript.

For MCP servers that need to be imported by other TypeScript projects (library mode), esbuild handles the JavaScript transpilation and Rollup or tsc --build handles the declaration files. The two tools are complementary, not mutually exclusive.

Basic build script for an MCP stdio server

The minimum correct esbuild invocation for an MCP server that runs under Node.js sets platform, marks external packages, and writes a source map for debugging.

// package.json
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "bin": { "my-mcp-server": "dist/index.js" },
  "scripts": {
    "build":     "node build.mjs",
    "dev":       "node build.mjs --watch",
    "typecheck": "tsc --noEmit",
    "prepublishOnly": "npm run typecheck && npm run build"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.4.0"
  },
  "devDependencies": {
    "esbuild": "^0.24.0",
    "typescript": "^5.5.0"
  }
}
// build.mjs — esbuild config as a Node.js script
import * as esbuild from 'esbuild';

const watch = process.argv.includes('--watch');

const buildOptions = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',       // REQUIRED: prevents browser shims for fs, path, etc.
  target: 'node22',       // emit syntax your Node.js version supports natively
  format: 'esm',          // match "type": "module" in package.json
  outfile: 'dist/index.js',
  sourcemap: true,
  packages: 'external',   // do NOT bundle anything from node_modules
  // "packages: external" is equivalent to listing every dep in "external" array,
  // but automatic — new dependencies don't require updating the build script
};

if (watch) {
  const ctx = await esbuild.context(buildOptions);
  await ctx.watch();
  console.log('Watching for changes...');
} else {
  await esbuild.build(buildOptions);
}

The packages: 'external' option (esbuild ≥ 0.18) marks every import that would resolve into node_modules as external. The alternative — manually listing each package in the external array — breaks whenever a new dependency is added. Use packages: 'external' unless you have a specific reason to bundle a particular dependency.

The .d.ts gap: esbuild + tsc for npm-published MCP packages

If your MCP server is installed by users via npm install (rather than run directly from a deployment), TypeScript consumers need .d.ts declaration files so that tool input schemas and server interfaces are typed. esbuild never produces these. The standard pattern is two separate steps: esbuild for fast JavaScript output, tsc for declaration files only.

// package.json — dual build for a library-style MCP server
{
  "name": "@myorg/mcp-server-search",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "types":  "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "npm run build:js && npm run build:types",
    "build:js":    "node build.mjs",
    "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist"
  }
}

// tsconfig.json — for the types-only tsc step
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "declaration": true,
    "emitDeclarationOnly": true,   // tsc outputs only .d.ts, esbuild outputs .js
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}

The emitDeclarationOnly flag tells tsc to skip JavaScript emission — that's esbuild's job. The combined build runs both in sequence: esbuild first (fast), then tsc (slower, but only generating .d.ts files). CI should also run tsc --noEmit for full type checking — emitDeclarationOnly does perform type checking, but a separate tsc --noEmit step is clearer in CI output and can run in parallel with the esbuild step.

CJS vs ESM output and the MCP stdio shebang

MCP servers distributed as CLI tools need an executable shebang line. esbuild doesn't add it automatically. The banner option injects arbitrary text at the top of the output file.

// build.mjs — with shebang for CLI MCP servers
const buildOptions = {
  entryPoints: ['src/index.ts'],
  bundle: true,
  platform: 'node',
  target: 'node22',
  format: 'esm',
  outfile: 'dist/index.js',
  sourcemap: true,
  packages: 'external',
  banner: {
    // Adds #!/usr/bin/env node to the top of the output
    // Required for "npm link" and global installs to work as CLI commands
    js: '#!/usr/bin/env node',
  },
};

await esbuild.build(buildOptions);

// After build: make the file executable
import { chmodSync } from 'fs';
chmodSync('dist/index.js', 0o755);

If your package.json uses "type": "commonjs" (or omits type, which defaults to CJS), set format: 'cjs' instead of 'esm'. Mixed-format monorepos need to match esbuild's format to each package's type field — a CJS package built with format: 'esm' will fail at runtime with require() of ES Module not supported.

Watch mode and incremental builds during development

esbuild's watch mode rebuilds in under 10 ms on typical MCP server source files. Combined with the MCP Inspector (which re-launches the server on change), this gives a tight feedback loop: edit a tool handler, esbuild rebuilds, Inspector reloads the server, and the updated tool is testable within a second.

// package.json scripts for development workflow
{
  "scripts": {
    "dev": "node build.mjs --watch",
    "dev:inspect": "MCP_INSPECTOR=1 node build.mjs --watch"
  }
}

// build.mjs — watch mode with rebuild notification
if (watch) {
  const ctx = await esbuild.context({
    ...buildOptions,
    plugins: [{
      name: 'rebuild-notify',
      setup(build) {
        build.onEnd(result => {
          if (result.errors.length === 0) {
            console.log(`[${new Date().toISOString()}] Build succeeded`);
          } else {
            console.error(`[${new Date().toISOString()}] Build failed: ${result.errors.length} error(s)`);
          }
        });
      },
    }],
  });
  await ctx.watch();
}

For MCP servers that embed server-sent events (SSE) or HTTP handlers alongside the MCP protocol, the watch build also picks up changes to route handlers and middleware — no separate process manager is needed during development.

Common failure modes

SymptomCauseFix
Cannot find module 'fs' at runtimeMissing platform: 'node' — esbuild used browser platform which stubs or omits Node built-insAdd platform: 'node' to build options
Output bundle is 50–500 MBMissing packages: 'external' — entire node_modules bundledAdd packages: 'external'
"Could not find declaration file" for consumersesbuild doesn't emit .d.tsAdd tsc --declaration --emitDeclarationOnly step
Type errors not caught in CIesbuild strips types without checkingRun tsc --noEmit in CI separately
require() of ES Module not supportedFormat mismatch: format: 'esm' with CJS package typeMatch format to package.json type field
CLI command not executable after installShebang missing from outputAdd banner: { js: '#!/usr/bin/env node' }