Guide · Development Toolchain 2026

MCP Server tsx — run TypeScript without compilation, watch mode, ESM support

tsx (TypeScript Execute) is a zero-configuration Node.js enhancement that runs .ts and .mts files directly without a prior compile step — it strips types at near-esbuild speed using esbuild under the hood, handles both CJS and ESM projects transparently, and supports import.meta.url and __dirname in ESM files out of the box. For MCP server development, tsx eliminates the edit → build → restart loop: tsx watch src/index.ts restarts the server process on any file change in under 100 ms, making tool-handler iteration as fast as scripted Node.js. The three things that trip developers up first are: tsx never type-checks (tsc --noEmit is still required in CI), tsx watch restarts the whole process (not hot-module replacement), and tsconfig-paths are respected automatically in tsx but require extra setup in ts-node ESM mode.

TL;DR

Install tsx as a devDependency: npm install -D tsx. Run your MCP server with npx tsx src/index.ts or add "dev": "tsx watch src/index.ts" to package.json. For production, compile with esbuild or tsc — tsx is a development tool, not a production runtime. Keep tsc --noEmit in CI; tsx's type stripping means type errors never surface at startup.

Why tsx for MCP server development

The classic MCP development loop with tsc --watch compiles to dist/, then a separate process manager (nodemon, pm2) watches dist/ for changes and restarts. Three processes, two directories, and a 2–10 second compile-then-restart delay per edit.

tsx collapses this to one command. It hooks into Node.js's module loader to transform TypeScript on-demand as each file is require()d or imported. The first run takes 50–200 ms (transforms happen on load); restarts in watch mode take under 100 ms because the esbuild transform cache is warm.

For MCP stdio servers — which are short-lived processes spawned by the MCP client — this is especially valuable: the server starts fast, you test a tool call, tweak the handler, the server restarts, and you re-test. The entire round-trip from save to re-testable server is under two seconds.

Basic dev setup for an MCP stdio server

The minimum setup to run and watch a TypeScript MCP server without any compilation step:

// package.json
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev":       "tsx watch src/index.ts",
    "start":     "node dist/index.js",
    "build":     "esbuild src/index.ts --bundle --platform=node --packages=external --outfile=dist/index.js",
    "typecheck": "tsc --noEmit",
    "prepublishOnly": "npm run typecheck && npm run build"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.4.0"
  },
  "devDependencies": {
    "tsx": "^4.15.0",
    "typescript": "^5.5.0",
    "esbuild": "^0.24.0"
  }
}

// tsconfig.json — used by tsc --noEmit and tsx for path resolution
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "strict": true,
    "outDir": "dist",
    "rootDir": "src",
    "skipLibCheck": true
  },
  "include": ["src"]
}
// src/index.ts — minimal MCP stdio server
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "my-mcp-server",
  version: "1.0.0",
});

server.tool(
  "greet",
  "Return a greeting for the given name",
  { name: z.string().describe("Name to greet") },
  async ({ name }) => ({
    content: [{ type: "text", text: `Hello, ${name}!` }],
  })
);

const transport = new StdioServerTransport();
await server.connect(transport);
// tsx watch restarts the process here when any .ts file changes

With this setup, npm run dev starts the server via tsx and restarts it on any save. No dist/ directory is created during development — tsx serves TypeScript directly to Node.js's runtime.

tsx vs ts-node: choosing the right tool

ts-node has been the standard TypeScript executor since 2015, but its ESM support requires --esm flag and --loader (deprecated in Node.js 22), and it runs tsc under the hood which means full type-checking on every startup. tsx replaces the loader with a lighter esbuild-based transform.

Featuretsxts-node
Type checking on runNo (esbuild strips types only)Yes (unless --transpileOnly)
ESM supportNative, no flags neededRequires --esm + Node.js loader
import.meta.urlWorks out of the boxBroken in many configurations
tsconfig-pathsRespected automaticallyRequires tsconfig-paths/register
Node.js 22+ compatibilityYes (no deprecated --loader)Loader deprecation warnings
Watch modetsx watch built-inUse nodemon separately
CJS + ESM mixed projectsHandles both transparentlyCJS works; ESM requires config
Startup speed~50–200 ms~200–2000 ms (type-check overhead)

The practical rule: use tsx for development, esbuild for production builds, and tsc for type checking. All three serve different purposes; tsx does not replace either of the others.

ESM vs CJS and the import.meta gap

MCP servers built on the TypeScript SDK use "type": "module" in package.json (ESM). Two Node.js globals that MCP server code often needs — __dirname and __filename — are not available in native ESM. tsx makes this transparent: it provides import.meta.url, import.meta.dirname, and import.meta.filename as ESM equivalents.

// ESM-compatible path resolution in an MCP server (works with tsx)
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';

// Standard ESM idiom — tsx supports this natively
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// Or use import.meta.dirname (Node.js 21.2+, also works in tsx)
const dataDir = join(import.meta.dirname, 'data');

// tsx also provides import.meta.resolve() for package-relative resolution
const configPath = new URL('./config.json', import.meta.url).pathname;

CJS projects (with "type": "commonjs" or no type field) keep the standard __dirname and require() globals — tsx does not alter CJS semantics. Mixed projects that use both CJS and ESM files by extension (.mts vs .cts) work because tsx applies the correct transform per-file based on file extension and package type.

tsx watch mode and MCP Inspector integration

tsx watch restarts the entire Node.js process on file change — not hot-module replacement. For stateless MCP tool handlers (the common case), a full process restart is exactly what you want: the server re-reads configuration, re-initialises connections, and presents the updated tool list to whatever MCP client is connected.

// package.json — development workflow with MCP Inspector
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "dev:inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts",
    "dev:watch-inspect": "tsx watch --clear-screen=false src/index.ts"
  }
}

// tsx watch flags that matter for MCP development:
//   --clear-screen=false  — keep prior output visible (useful for seeing prior tool calls)
//   --ignore     — don't restart on changes to data/ or dist/ directories
//   --env-file .env.local — load environment variables without dotenv package
// Running tsx watch with ignore patterns (avoids restart loops)
// package.json scripts
{
  "dev": "tsx watch --ignore 'data/**' --ignore 'logs/**' src/index.ts"
}

// Or use an .env file for configuration instead of environment variable injection
// tsx --env-file .env.local src/index.ts
// Equivalent to: dotenv -e .env.local -- tsx src/index.ts

The MCP Inspector integrates with tsx by spawning the server as a child process. Pass tsx src/index.ts as the server command in Inspector's UI — Inspector manages the lifecycle, so you don't use tsx watch here (Inspector restarts the server after each test run automatically).

tsconfig paths and module aliases

tsx reads your tsconfig.json paths automatically, so import aliases work in development without any extra registration step. This is the main ergonomic advantage over ts-node ESM mode, which requires manually loading tsconfig-paths/register via --require.

// tsconfig.json with path aliases for an MCP server
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@tools/*":   ["src/tools/*"],
      "@handlers/*": ["src/handlers/*"],
      "@shared/*":  ["src/shared/*"]
    }
  }
}

// src/index.ts — alias imports work in tsx without extra setup
import { registerAllTools } from "@tools/registry.js";
import { createDatabase } from "@shared/db.js";

One caveat: path aliases must still be resolved at runtime in the compiled output. esbuild's alias option or a separate tsc-alias post-processing step is needed for production builds. tsx handles them only in the development runtime — production bundles need their own alias resolution.

Common failure modes

SymptomCauseFix
Type errors not caught on startuptsx strips types without checkingRun tsc --noEmit in CI separately
ERR_UNKNOWN_FILE_EXTENSION for .ts filesRunning node directly instead of tsxUse npx tsx src/index.ts or add tsx to PATH via devDependencies
Watch restarts on every keystrokeEditor writes temp files inside src/Add --ignore '**/*.tmp' or configure editor to write atomically
Path aliases not resolved in production buildtsx resolves aliases at runtime; esbuild needs its own alias mapAdd alias config to esbuild or use tsc-alias post-build
import.meta.dirname is undefinedNode.js version < 21.2Use dirname(fileURLToPath(import.meta.url)) idiom for compatibility
tsx watch misses changes to .json config filestsx only watches .ts/.mts/.cts by defaultAdd --watch config.json or restart manually after config changes