Guide · TypeScript

TypeScript build configuration for MCP servers

MCP servers run in environments with specific requirements: Node.js with ESM, Cloudflare Workers with V8 isolates, Lambda with CJS bundles, or Deno with TypeScript-native execution. Each demands different tsconfig flags, module resolution strategies, and bundler settings. This guide covers the build configuration decisions that matter for production MCP servers — not the default tsconfig boilerplate.

TL;DR

For Node.js: use "module": "NodeNext" and "moduleResolution": "NodeNext" — this enforces ESM-correct import paths (with .js extensions) and prevents the CommonJS/ESM interop issues that cause require is not defined errors at runtime. Enable all strict flags. For edge runtimes: bundle to a single file with esbuild (--platform=browser, --bundle); run wrangler dev or deno check to catch native module usage before deploy. For publishable MCP server packages: use tsc to emit declarations alongside your build output. Monitor production deployments with AliveMCP — build configuration errors that survive testing show up as protocol failures the first time a client connects.

Baseline tsconfig for a Node.js MCP server

The MCP TypeScript SDK is published as ESM. Node.js ESM requires import paths with explicit .js extensions. The NodeNext module system enforces this at compile time:

// tsconfig.json — Node.js 18+ MCP server (ESM)
{
  "compilerOptions": {
    // Target
    "target": "ES2022",          // Node 18 supports ES2022 natively; no polyfills needed
    "lib": ["ES2022"],

    // Module system — ESM for Node 18+
    "module": "NodeNext",        // Emit ESM; enforce .js extensions in imports
    "moduleResolution": "NodeNext",

    // Output
    "outDir": "./dist",
    "rootDir": "./src",
    "sourceMap": true,           // Stack traces in production point to source lines
    "declaration": false,        // Set to true if publishing as an npm package

    // Strict — all of these, no exceptions
    "strict": true,              // Enables all strict* flags below
    "noUncheckedIndexedAccess": true,  // arr[0] is T | undefined, not T
    "exactOptionalPropertyTypes": true, // Prevents assigning undefined to optional props
    "noImplicitReturns": true,   // All code paths must return
    "noFallthroughCasesInSwitch": true,
    "noImplicitOverride": true,
    "forceConsistentCasingInFileNames": true,

    // Import/output behavior
    "esModuleInterop": false,    // Not needed with NodeNext and ESM-first packages
    "isolatedModules": true,     // Required for esbuild compat; catches const enum issues
    "verbatimModuleSyntax": true // Requires explicit 'import type' for type-only imports

  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

The two most important flags for MCP servers specifically:

ESM vs CJS: the interop problem and how to avoid it

The MCP TypeScript SDK is ESM-only ("type": "module" in its package.json). If your project uses CommonJS ("type": "commonjs"), you'll hit this error:

// Error at runtime:
// Error [ERR_REQUIRE_ESM]: require() of ES Module node_modules/@modelcontextprotocol/sdk/dist/...
// from dist/server.js not supported.

// This happens when:
// 1. Your tsconfig uses "module": "CommonJS"
// 2. Your package.json has "type": "commonjs" (the default when unset)

// Fix option 1 — convert your project to ESM:
// package.json:
{ "type": "module" }
// tsconfig.json:
{ "module": "NodeNext", "moduleResolution": "NodeNext" }

// Fix option 2 — use dynamic import for the ESM-only SDK (not recommended):
// In a CJS file:
const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js");
// This works but defeats TypeScript's static analysis and is fragile

The correct fix is always option 1: convert your project to ESM. The MCP ecosystem is ESM-first; fighting against this with dynamic imports adds complexity and removes type safety. If you have existing CJS code that can't be migrated, create a separate ESM package for the MCP server and run it as a subprocess or separate process.

When using ESM with NodeNext, all imports must include the .js extension (even though the source files are .ts):

// Correct — NodeNext requires .js extension in source imports
import { SearchTools } from "./tools/search-tools.js";
import { withTimeout } from "./lib/timeout.js";

// Wrong — TypeScript accepts this but Node.js will fail at runtime
import { SearchTools } from "./tools/search-tools";
// Error: Cannot find module './tools/search-tools'

esbuild for fast bundling

esbuild produces a single bundled JS file from your TypeScript source in under 100ms for most MCP servers. This is the right choice for edge runtime deployments and for Lambda/Cloud Functions where a single-file bundle is easier to manage:

# Build for Cloudflare Workers (browser-compatible, no Node.js globals)
esbuild src/worker.ts \
  --bundle \
  --platform=browser \
  --target=es2022 \
  --outfile=dist/worker.js \
  --external:__STATIC_CONTENT_MANIFEST

# Build for Node.js Lambda (CJS bundle, Node.js built-ins externalized)
esbuild src/handler.ts \
  --bundle \
  --platform=node \
  --target=node18 \
  --format=cjs \
  --outfile=dist/handler.js \
  --external:@aws-sdk/*   # AWS SDK is available in Lambda runtime, don't bundle it

# Build for Node.js server (ESM, dependencies externalized — use node_modules as-is)
esbuild src/server.ts \
  --bundle=false \
  --platform=node \
  --format=esm \
  --outdir=dist

esbuild does not type-check — it transpiles only. Run tsc --noEmit in CI for type checking separately from the build:

# package.json scripts
{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "build": "esbuild src/server.ts --bundle --platform=node --format=esm --outfile=dist/server.js",
    "build:check": "npm run typecheck && npm run build",
    "dev": "tsx watch src/server.ts"  // tsx for development — instant reload, no build step
  }
}

Edge runtime compatibility checks

Edge runtimes run a subset of Node.js APIs. Modules that use fs, child_process, net, or native addons fail silently during bundling but crash at runtime. Catch these before deploy:

# Cloudflare Workers — check for Node.js incompatibilities before deploy
wrangler dev src/worker.ts
# wrangler will error on import of any Node.js module unavailable in Workers

# Alternative: use esbuild's --conditions flag to simulate Workers environment
esbuild src/worker.ts --bundle --platform=browser --conditions=worker 2>&1 | grep -i "error\|warning"

# Deno — check compatibility
deno check src/server.ts  # Fails if you import Node.js-only modules not shimmed by Deno

# For MCP SDK specifically — the SDK supports both Node.js and edge environments
# but verify your transport choice is compatible:
# - StdioServerTransport: Node.js only (uses process.stdin/stdout)
# - SSEServerTransport: requires a persistent HTTP server
# - StreamableHTTPServerTransport: works on all runtimes

A common gotcha: crypto.randomUUID() is available in all modern environments (Cloudflare Workers, Deno, Node 19+, browser). But require('crypto').randomBytes() is Node.js-only. Use the Web Crypto API throughout your MCP server code for cross-runtime compatibility.

Monorepo setup with composite builds

If your MCP server shares types with a frontend, API, or other service in a monorepo, TypeScript project references and composite builds enable incremental compilation and correct cross-project type checking:

// packages/mcp-server/tsconfig.json
{
  "compilerOptions": {
    "composite": true,       // Required for project references — enables incremental build
    "declaration": true,     // Emit .d.ts files for consumers
    "declarationMap": true,  // Source maps for .d.ts files — enables go-to-definition across packages
    "outDir": "./dist",
    "rootDir": "./src",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true
  },
  "include": ["src/**/*.ts"],
  "references": [
    { "path": "../shared-types" }  // Depends on a shared-types package in the monorepo
  ]
}

// packages/shared-types/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "declaration": true,
    "declarationMap": true,
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true
  },
  "include": ["src/**/*.ts"]
}

// Root tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./packages/shared-types" },
    { "path": "./packages/mcp-server" }
  ]
}
# Build all packages in dependency order
tsc --build --verbose

# Build only changed packages (incremental)
tsc --build --incremental

# Watch mode for development
tsc --build --watch

With composite builds, TypeScript rebuilds only changed packages — a change to shared-types triggers a rebuild of mcp-server but not unrelated packages. For large monorepos, this reduces CI build time significantly.

Publishing an MCP server as an npm package

Publishable MCP servers (stdio transport, installed by users via npm install) need declaration files and a correct exports map in package.json:

// tsconfig.json for a publishable MCP server package
{
  "compilerOptions": {
    "declaration": true,      // Emit .d.ts
    "declarationMap": true,   // Source maps for declarations
    "outDir": "./dist",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2020",       // Slightly lower target for broader compatibility
    "strict": true,
    "isolatedModules": true
  }
}

// package.json — dual publish (ESM + CJS) for broad compatibility
{
  "type": "module",
  "main": "./dist/index.js",      // ESM entry
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",  // CJS build (use rollup or esbuild to generate)
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsc && node scripts/build-cjs.js",  // tsc for ESM + types, script for CJS
    "prepublishOnly": "npm run build"
  }
}

For most MCP servers distributed as standalone tools (not library packages), you don't need a dual publish. Use ESM-only and document that Node.js 18+ is required. Most Claude Desktop and MCP client integrations run the server via npx or a global install, not via require().

Build artifacts and AliveMCP monitoring

Build configuration errors that survive local testing often show up in production as protocol failures: a bundler that leaves out the MCP SDK because of an external flag, a tsconfig that targets a runtime API not available on the deploy target, or a module setting mismatch that causes the initialization to fail silently. AliveMCP catches these by probing the actual initialize endpoint after every deploy — if the MCP handshake fails for any reason, you know within 60 seconds instead of from a user report.

Add a post-deploy verification step to your CI pipeline alongside AliveMCP monitoring:

# Post-deploy health check in CI
DEPLOY_URL="https://your-mcp-server.com"
MAX_RETRIES=5
for i in $(seq 1 $MAX_RETRIES); do
  RESPONSE=$(curl -sf -X POST "$DEPLOY_URL" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json, text/event-stream" \
    -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"ci-check","version":"1"}}}' \
    2>/dev/null)
  if echo "$RESPONSE" | grep -q '"result"'; then
    echo "MCP server responding correctly"
    exit 0
  fi
  echo "Attempt $i failed, retrying..."
  sleep 5
done
echo "MCP server failed to respond after $MAX_RETRIES attempts"
exit 1

Frequently asked questions

Should I use tsx or ts-node for development?

tsx is the better choice for MCP server development. It uses esbuild under the hood for fast transpilation, supports both ESM and CJS, works with --watch for hot reload, and has no configuration overhead. ts-node is older, slower for cold starts, and has historically had more friction with the NodeNext module system. Both skip type checking during execution — run tsc --noEmit separately for type errors. For production, always build with tsc or esbuild — never run tsx in production.

What's the correct target for a Cloudflare Workers bundle?

Use --target=es2022 and --platform=browser with esbuild for Cloudflare Workers. The Workers runtime is V8-based and supports ES2022 natively (async generators, top-level await, Object.hasOwn, etc.). Don't use --platform=node — it adds Node.js-specific shims that aren't available in Workers. The --platform=browser flag tells esbuild that built-in Node.js modules (fs, path, net) should be treated as external and will error if your code imports them, which is the correct behavior for edge bundles.

How do I handle path aliases (@/...) in production builds?

TypeScript path aliases ("paths": { "@/*": ["./src/*"] }) are a compile-time convenience. tsc does not rewrite import paths at compile time — it only type-checks. When Node.js runs the compiled JS, import "@/lib/utils" fails because there's no @ in the Node module resolution algorithm. Fix this with: (1) esbuild's --alias:@=./src flag (esbuild rewrites the paths during bundling), or (2) the tsconfig-paths package for tsc output without bundling. Alternatively, avoid path aliases entirely and use relative imports — they work without any build tooling.

Can I use Bun's built-in TypeScript runner instead of a separate build step?

Yes — Bun natively transpiles TypeScript without a build step. For development and self-hosted VPS deployment, bun run src/server.ts works with zero configuration. Bun respects your tsconfig.json for type checking (via bun tsc) but doesn't enforce isolatedModules at runtime since it transpiles the whole program. For edge runtime deployments (Cloudflare Workers, Lambda), you still need to bundle to a single file — Bun's bundler (bun build) is a fast alternative to esbuild with similar semantics.

Why does my MCP server work in development but fail in production after bundling?

The most common causes: (1) a package marked as external in your esbuild config isn't available in the production runtime (fix: don't externalize it, bundle it), (2) native Node.js modules used by a transitive dependency fail on an edge runtime (fix: run wrangler dev or deno check to catch before deploy), (3) environment variables are present in dev but missing in production (fix: use zod to validate all required env vars at startup and fail fast with a clear error message), (4) ESM/CJS interop issue that only appears with the bundled output (fix: check that your output format matches the runtime expectation).

Further reading

Know when your MCP server is down — before users do

AliveMCP probes your server's MCP endpoint every minute, detects protocol errors and transport failures, and pages you before users notice.

Start monitoring free