Development Toolchain 2026 · 2026-08-13 · Development Toolchain 2026 arc

Development Toolchain 2026 for MCP Servers: tsx, pnpm, Nx, fnm, and Dev Containers — Zero-Compile Loops, Node.js Version Enforcement, and Differential CI

Five tools — tsx, pnpm, Nx, fnm, and Dev Containers — are each adopted independently to solve a specific friction point in MCP server development: tsx eliminates the compile-and-restart loop; pnpm deduplicates the @modelcontextprotocol/sdk across workspace packages to prevent silent tool mis-routing; Nx adds selective CI so a change to one shared package only rebuilds the packages that depend on it; fnm auto-switches Node.js versions per-directory so the team is always on the right runtime; Dev Containers pin the entire development environment into a versioned, reproducible container that works identically in VS Code, GitHub Codespaces, and CI. Adopted together, these five tools share four cross-cutting structural patterns that are not obvious from any single tool's documentation. Pattern 1 — The zero-compile development loop: tsx watch restarts your MCP server in under 100 ms without a build step, but it never type-checks TypeScript — type errors deploy silently, and tsc --noEmit must run as a separate CI step; combined with pnpm's content-addressable store, the loop from code change to running server is faster than any compile-and-restart approach, but the speed comes with the specific caveat that build success is not type-correctness. Pattern 2 — Three-layer Node.js version enforcement: a .nvmrc at the repo root tells contributors which Node.js version to use; an "engines" field in package.json fails the install with a clear error when the wrong version is active; engine-strict=true in .npmrc makes pnpm enforce it; a Dev Container pins the system Node.js version so the container image, local development, and CI all use the same runtime — eliminating the entire class of "works on Node 18, fails on 22" bugs that MCP server teams hit when --env-file, import.meta.dirname, or native fetch is assumed to be available. Pattern 3 — The duplicate SDK trap: pnpm's workspace: protocol and peer dependency enforcement prevent the most dangerous MCP server bug — multiple copies of @modelcontextprotocol/sdk in node_modules — which causes tool calls to silently route to the wrong handler without any error or stack trace; Nx's project graph makes the dependency structure visible so the team can audit which packages share the SDK; the Dev Container's named volume mount for node_modules prevents a different manifestation of the same class of problem — Linux-compiled native modules overwriting macOS-compiled ones when a developer mounts their host node_modules into the container. Pattern 4 — Differential CI with Nx affected: npx nx affected -t build test runs tasks only for packages impacted by the current git diff, but it requires fetch-depth: 0 in the GitHub Actions checkout step — without full git history, Nx cannot compute which packages changed and falls back to running all packages; combined with "dependsOn": ["^build"] in nx.json target defaults and the outputs field pointing to each package's dist/, Nx builds dependencies before dependents and caches build artifacts between CI runs, reducing a 10-package build from 8 minutes to under 90 seconds on warm cache.

TL;DR

Four patterns, five tools. (1) Zero-compile loop: tsx watch gives you sub-100ms restarts without any build step, but never type-checks — always pair it with tsc --noEmit in CI. (2) Three-layer Node.js enforcement: .nvmrc + engines field + Dev Container image pin ensure every contributor, every CI run, and every container uses the same Node.js version — choose 22 (Active LTS through 2027) to get native fetch, --env-file, and import.meta.dirname without workarounds. (3) The duplicate SDK trap: use pnpm's workspace: protocol and declare @modelcontextprotocol/sdk as a peerDependency in shared packages — one SDK copy in the entire monorepo, verified with pnpm why @modelcontextprotocol/sdk; run Nx's project graph to visualize which packages share the SDK. (4) Differential CI: npx nx affected -t build test typecheck with fetch-depth: 0 + "dependsOn": ["^build"] + "outputs": ["{projectRoot}/dist"] — three config lines that together give you cached, ordered, selective CI.

Pattern 1 — The zero-compile development loop: tsx + pnpm as the speed foundation

The slowest part of the traditional TypeScript MCP server development workflow is the edit → compile → restart cycle. Running tsc --watch and restarting the server adds 2–15 seconds between a code change and a running server, depending on project size. In practice, developers stop waiting for restarts and start batching changes — which means longer feedback loops and slower debugging of tool handler behaviour.

tsx eliminates the compilation step entirely. It is a Node.js enhancement that uses esbuild internally to strip TypeScript type annotations at parse time, then executes the resulting JavaScript directly. The startup overhead compared to running plain JavaScript with node is 50–200 ms — imperceptible in development. tsx watch src/index.ts restarts the entire server process in under 100 ms whenever a .ts, .mts, or .cts file changes. Compared to tsc --watch + nodemon, the restart is 10–30× faster and requires zero configuration.

# Development scripts (package.json)
{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "dev:inspector": "npx @modelcontextprotocol/inspector tsx src/index.ts",
    "build": "esbuild src/index.ts --bundle --platform=node --packages=external --outfile=dist/index.js",
    "typecheck": "tsc --noEmit",
    "ci": "npm run typecheck && npm run build && npm test"
  }
}

# Watch flags worth knowing:
# --ignore 'pattern'        don't restart on temp files (editor temp files, .DS_Store)
# --env-file .env.local     load env vars on Node.js 20.6+ (no dotenv package needed)
# --clear-screen=false      keep prior output when running alongside Inspector

The critical caveat: tsx never type-checks TypeScript. It uses esbuild to strip type annotations and emit JavaScript — the same no-type-check gap covered in the Modern Build Toolchain guide for esbuild, SWC, and Biome. Type errors do not cause tsx to fail; a mistyped tool input schema, an incorrect return type annotation, or a missing required field in a Zod schema will run without error in development and deploy without error in production. The only type-checking tool is tsc --noEmit, and it must be a mandatory CI step separate from the development workflow.

Where tsx solves iteration speed, pnpm solves installation speed and dependency correctness. pnpm's content-addressable store hardlinks packages from a shared store rather than copying them into each project's node_modules. For a monorepo with 10 MCP server packages all depending on @modelcontextprotocol/sdk@1.4.0, pnpm downloads the SDK exactly once and hardlinks it into each package's node_modules. On a warm cache, pnpm install --frozen-lockfile runs in under 10 seconds for a 10-package workspace. The combined development workflow is: make a code change, see the server restart in under 100 ms (tsx watch), run pnpm install from cache when adding dependencies (under 10 seconds).

pnpm's strict isolation adds a correctness benefit alongside the speed benefit. Because pnpm sets shamefully-hoist=false by default, packages cannot accidentally import transitive dependencies that are not in their own package.json. In npm and yarn classic, a package can import some-transitive-dep that it doesn't declare, because the dependency appears in a parent's node_modules. The import works in development but breaks when the package is published and consumers don't have the transitive dep in their tree. With pnpm, the same import fails at development time — which is the right place to catch it.

# .npmrc — pnpm configuration that enforces correctness
auto-install-peers=true
strict-peer-dependencies=false   # warn, not fail, on peer dep issues
shamefully-hoist=false           # DEFAULT: strict isolation (do not change this)
engine-strict=true               # fail install if Node.js version doesn't match engines field

# Confirm pnpm is running correctly in development:
pnpm why @modelcontextprotocol/sdk
# Should show a single resolved version:
# @modelcontextprotocol/sdk@1.4.0
# └── mcp-core@1.0.0  (the shared package)
# NOT multiple rows with different versions — that's the duplicate SDK trap (Pattern 3).

The tsx + pnpm combination forms the baseline of a zero-compile, sub-100ms development loop: tsx for instant server restarts during code changes, pnpm for fast, correct dependency management. The remaining three patterns add Node.js version enforcement, duplicate SDK prevention, and selective CI — each building on this foundation.

Pattern 2 — Three-layer Node.js version enforcement: fnm, .nvmrc, engines, and Dev Containers

MCP server authors routinely develop on one Node.js version and deploy to another. A developer running Node.js 18 locally cannot use --env-file (added in 20.6) or import.meta.dirname (added in 21.2) — both of which are commonly used in MCP server projects to load configuration and resolve file paths. A developer on Node.js 22 using native fetch without a fallback will encounter failures when deployed to a CI image still pinned to 18. The gap between "it works on my machine" and "it fails in CI" is almost always a Node.js version gap.

The solution is three-layer enforcement rather than a single check. Each layer catches a different failure mode:

Layer 1 — .nvmrc at the repo root. A single file containing 22 (or a specific version like 22.11.0) is the team convention. fnm with --use-on-cd auto-switches to the declared version every time a developer cds into the directory. nvm does the same via its use hook. GitHub Actions actions/setup-node reads the .nvmrc via node-version-file: ".nvmrc". The file is the canonical declaration of the required version and is read by every tool in the chain without duplication.

# .nvmrc — at repo root
22

# fnm setup in ~/.zshrc or ~/.bashrc
eval "$(fnm env --use-on-cd --shell zsh)"
# After this, cd into the repo auto-runs: fnm use
# Output: Using Node.js v22.11.0 (fnm)

# GitHub Actions — reads .nvmrc automatically
- uses: actions/setup-node@v4
  with:
    node-version-file: ".nvmrc"   # reads the version from .nvmrc
    cache: "pnpm"                  # caches pnpm store between runs

Layer 2 — engines field + engine-strict=true. The "engines" field in package.json declares the minimum required Node.js version as metadata. Combined with engine-strict=true in .npmrc, pnpm fails the install with ERR_PNPM_UNSUPPORTED_ENGINE if the active Node.js version doesn't satisfy the declared range. This catches the case where a developer has fnm installed but forgets to run fnm use after updating their system.

# package.json (root package or each server package)
{
  "engines": {
    "node": ">=22.0.0",
    "pnpm": ">=9.0.0"
  }
}

# .npmrc
engine-strict=true

# If the developer is running Node.js 18 and runs pnpm install:
# ERR_PNPM_UNSUPPORTED_ENGINE  your Node version is incompatible with
# "my-mcp-server > node@>=22.0.0"
# Current Node.js: v18.20.4
# Run: fnm use   (to switch to the version in .nvmrc)

Layer 3 — Dev Container image pin. A Dev Container using mcr.microsoft.com/devcontainers/typescript-node:22 as its base image pins the system Node.js version at the container level — fnm use is not needed, the engines check always passes, and CI running the same Docker image is guaranteed to use the same runtime. This layer eliminates the entire class of "nvm isn't installed" and "wrong version in CI" failures.

// .devcontainer/devcontainer.json
{
  "name": "MCP Server Dev",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:22",
  "forwardPorts": [5173, 3000],
  "portsAttributes": {
    "5173": { "label": "MCP Inspector UI", "onAutoForward": "openBrowser" },
    "3000": { "label": "MCP Inspector Proxy", "onAutoForward": "silent" }
  },
  "postCreateCommand": "corepack enable && corepack prepare pnpm@latest --activate && pnpm install",
  "customizations": {
    "vscode": {
      "extensions": [
        "biomejs.biome",
        "ms-vscode.vscode-typescript-next",
        "github.copilot"
      ],
      "settings": {
        "editor.formatOnSave": true,
        "typescript.tsdk": "node_modules/typescript/lib"
      }
    }
  },
  "mounts": [
    "source=mcp-server-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
  ]
}

Choosing Node.js 22 specifically is the right default for MCP server projects in 2026. It is the Active LTS release (supported through April 2027), and it includes every feature commonly used in MCP servers without requiring workarounds:

FeatureFirst AvailableWhy It Matters for MCP Servers
Native fetchNode.js 18.0HTTP calls from tool handlers without the node-fetch package
AsyncLocalStorage.snapshot()Node.js 18.2Request context propagation in middleware and tool handlers
--env-file flagNode.js 20.6Load .env without the dotenv package: tsx --env-file .env.local src/index.ts
import.meta.dirnameNode.js 21.2ESM equivalent of __dirname — no more dirname(fileURLToPath(import.meta.url))
Active LTS (production ready)Node.js 22.0Security patches and stability through 2027-04

The three-layer approach is intentionally redundant: .nvmrc is a convention; engines + engine-strict is a build-time guard; the Dev Container is a system-level guarantee. A developer who ignores .nvmrc hits the build-time guard when they run pnpm install. A developer who disables engine-strict in their local .npmrc is still running inside the Dev Container on the correct version. The combination means the "wrong Node.js version" class of bugs is structurally impossible to reach CI without at least two manual overrides, and it does not require any runtime version-checking code in the MCP server itself.

Pattern 3 — The duplicate SDK trap: pnpm peer deps, Nx project graph, and the ELF header failure

The most dangerous bug specific to MCP server monorepos is silent — no error, no stack trace, no warning from the MCP client. When multiple copies of @modelcontextprotocol/sdk exist in node_modules, tool calls are routed using the SDK instance that registered them. If a shared package registers tools using its own SDK copy and the main server registers tools using a different SDK copy, the tool call dispatcher in the SDK does not see cross-instance registrations, and calls arrive at an empty dispatch table. Claude Desktop, Cursor, and most MCP clients surface this as a generic "tool call failed" error.

The root cause is how the SDK's internal tool registry works: it's a module-level singleton. Two copies of the SDK in node_modules means two independent registries. Tools registered with instance A are not visible to instance B. The bug is reproducible by running pnpm why @modelcontextprotocol/sdk in a monorepo — if it shows two rows with different version paths, the duplicate trap has been triggered.

# pnpm why shows the duplicate SDK trap:
$ pnpm why @modelcontextprotocol/sdk

Legend: production dependency, optional only, dev only

packages/mcp-tools/node_modules/@modelcontextprotocol/sdk@1.3.0
packages/mcp-tools
└── @modelcontextprotocol/sdk 1.3.0

node_modules/@modelcontextprotocol/sdk@1.4.0
servers/mcp-github
└── @modelcontextprotocol/sdk 1.4.0 peer

# Two versions = tool calls can silently mis-route.
# Solution: align all packages to the same SDK version range.

pnpm's workspace: protocol prevents the duplicate trap through two mechanisms. First, the workspace: prefix in internal package dependencies ("mcp-core": "workspace:*") tells pnpm to link the local package directly from the workspace rather than resolving it from the npm registry — eliminating the possibility of a local package and its registry-published counterpart coexisting in the dependency tree. Second, declaring @modelcontextprotocol/sdk as a peerDependency in shared packages (rather than a direct dependency) forces the consuming package to provide the SDK, ensuring all packages share the same instance.

// packages/mcp-core/package.json — a shared utility package used by all servers
{
  "name": "@myorg/mcp-core",
  "version": "1.0.0",
  "peerDependencies": {
    "@modelcontextprotocol/sdk": ">=1.4.0",
    "zod": ">=3.22.0"
  },
  "peerDependenciesMeta": {
    "@modelcontextprotocol/sdk": { "optional": false }
  },
  "devDependencies": {
    "@modelcontextprotocol/sdk": "^1.4.0"   // dev only — for building and testing
  }
}

// servers/mcp-github/package.json — a server package that uses mcp-core
{
  "name": "@myorg/mcp-github",
  "dependencies": {
    "@myorg/mcp-core": "workspace:*",        // links to local package
    "@modelcontextprotocol/sdk": "^1.4.0"   // owns the SDK instance shared with mcp-core
  }
}

Nx's project graph adds visibility to the dependency structure. Running npx nx graph opens a browser-based visualization showing which packages depend on which, with edges representing workspace dependencies. When a developer adds a new shared package, the graph immediately shows which servers are affected by changes to that package — which is also what nx affected uses to determine which packages to rebuild in CI. The graph is the answer to "if I change mcp-core, what else breaks?"

# Visualize the project graph
npx nx graph                              # opens browser visualization
npx nx graph --focus=@myorg/mcp-core     # show mcp-core + all dependents
npx nx affected:graph --base=main        # show only packages affected by current branch

# Dry-run affected — see what would be built without building
npx nx affected --print-affected --base=origin/main --head=HEAD

The Dev Container adds a third protection against a related class of problem: the ELF header failure. When a developer works on macOS but mounts their host node_modules into a Linux container (or vice versa), native compiled addons — packages with .node binary files — fail with Error: invalid ELF header because the macOS binary format (Mach-O) is not the Linux binary format (ELF). The solution in devcontainer.json is a named volume mount for node_modules:

// devcontainer.json — named volume mount prevents ELF conflicts
{
  "mounts": [
    "source=mcp-server-node-modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
  ]
}

// This creates a Docker volume (Linux filesystem) for node_modules, completely
// separate from the host filesystem. pnpm install inside the container populates
// the volume with Linux-compiled binaries. The host's node_modules (macOS-compiled)
// is never mounted into the container. Result: no ELF header errors, and the
// node_modules inside the container are the pnpm store's Linux-format hardlinks.

The duplicate SDK trap (pnpm peer deps), the dependency graph (Nx), and the ELF header failure (Dev Container named volume) are three manifestations of the same underlying category: the node_modules isolation problem. All three are caused by the same structural issue — the dependency resolution environment not being unambiguously defined — and all three are resolved by making that environment explicit and enforced rather than assumed.

Pattern 4 — Differential CI with Nx affected: fetch-depth, dependsOn, and outputs caching

A monorepo with 10 MCP server packages that all share a common utility package has a straightforward CI problem: a change to the shared utility package requires rebuilding and retesting all 10 server packages. A change to one server package requires only rebuilding and retesting that package. Without differential CI, both changes trigger the same 10-package build — the first is necessary, the second is waste. At 45 seconds per package (a realistic esbuild + Vitest time), "build everything" costs 7.5 minutes for a change that only touches 10% of the codebase.

Nx affected solves this with the project graph. Given a base ref (usually origin/main) and a head ref (usually HEAD), Nx computes the diff, walks the project graph to find which packages have changed or have a dependency that changed, and runs the requested task only for that set. A change to one server package triggers one build. A change to the shared utility package triggers 11 builds (the utility + all 10 servers). The CI time scales with the actual impact of the change, not the total number of packages.

Three configuration values work together to make this reliable:

1 — fetch-depth: 0 in the GitHub Actions checkout step. This is the most commonly missing piece. By default, actions/checkout@v4 uses a shallow clone (fetch-depth: 1), which gives Nx only the current commit's files with no git history. Nx cannot determine which files changed relative to origin/main without the full history of both the current branch and the main branch. Without fetch-depth: 0, nx affected falls back to treating all projects as affected — equivalent to "build everything", the worst case.

# .github/workflows/ci.yml — three required configuration elements
name: CI
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0               # REQUIRED: full git history for nx affected
                                       # Without this, nx affected = build everything

      - uses: pnpm/action-setup@v4
        with: { version: 9 }

      - uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"  # reads version from .nvmrc (Pattern 2)
          cache: "pnpm"

      - run: pnpm install --frozen-lockfile

      - name: Build and test affected packages
        run: npx nx affected -t build test typecheck --parallel=3 --base=origin/main --head=HEAD

2 — "dependsOn": ["^build"] in nx.json target defaults. The ^ prefix means "the same target on all dependencies". When Nx runs the build target on mcp-github, it first checks whether mcp-core (the dependency) has been built. If not, it runs mcp-core:build before mcp-github:build. This replaces the common antipattern of manually ordering build scripts in a root-level package.json ("prebuild": "pnpm -r --filter mcp-core build") — Nx infers the order from the dependency graph automatically.

// nx.json — task orchestration configuration
{
  "$schema": "./node_modules/nx/schemas/nx-schema.json",
  "defaultBase": "main",           // default base for nx affected comparisons

  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],     // build all dependencies before self (CRITICAL)
      "inputs": ["production"],    // only rebuild if production files changed
      "outputs": ["{projectRoot}/dist"],  // cache the dist/ output
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],      // test requires the current package to be built
      "inputs": ["default", "^production"],
      "cache": true
    },
    "typecheck": {
      "dependsOn": ["^build"],     // typecheck needs dependency .d.ts files
      "cache": true
    },
    "dev": {
      "cache": false               // never cache watch mode
    }
  },

  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/src/**/*.spec.ts",
      "!{projectRoot}/vitest.config.*"
    ],
    "sharedGlobals": ["{workspaceRoot}/tsconfig.base.json"]
  }
}

3 — "outputs": ["{projectRoot}/dist"] in each package's target config. Nx's task caching stores the output of completed tasks using a hash of the inputs. When the same inputs (source files + configuration + dependency outputs) are seen again, Nx replays the cached output instead of re-running the task. Without the outputs field pointing to the correct build artifact directory, Nx cannot capture the output and every run is a cache miss. With it, a developer who runs nx affected -t build on a feature branch, then switches to main and back, gets instant replay of the build output they already ran — no recompilation needed.

// servers/mcp-github/project.json — per-package config
{
  "name": "@myorg/mcp-github",
  "$schema": "../../node_modules/nx/schemas/project-schema.json",
  "sourceRoot": "servers/mcp-github/src",
  "projectType": "application",
  "targets": {
    "build": {
      "executor": "nx:run-commands",
      "options": {
        "command": "node build.mjs",
        "cwd": "{projectRoot}"
      },
      "outputs": ["{projectRoot}/dist"],   // REQUIRED: Nx can't cache without this
      "cache": true
    },
    "dev": {
      "executor": "nx:run-commands",
      "options": {
        "command": "tsx watch src/index.ts",
        "cwd": "{projectRoot}"
      },
      "cache": false                        // watch mode is never cached
    }
  }
}

The three values together — fetch-depth: 0, "dependsOn": ["^build"], and "outputs": ["{projectRoot}/dist"] — are a minimal, complete Nx differential CI configuration. Each is necessary; none alone is sufficient. The expected CI time reduction on a 10-package monorepo where a typical PR touches 1–2 packages: from 7–10 minutes ("build everything") to 60–120 seconds (build the changed package + cache hit on all others). The cache is invalidated correctly when dependencies change — when mcp-core changes, all 10 servers rebuild because their inputs include the production output of their dependencies via "^production" in the named inputs.

SymptomCauseFix
nx affected always runs all projectsShallow clone — Nx can't find base commitAdd fetch-depth: 0 to checkout action
Build succeeds but dist/ is empty on cache hitoutputs not pointing to correct directorySet "outputs": ["{projectRoot}/dist"]
Shared package builds after the servers that depend on itMissing "dependsOn": ["^build"]Add to build target defaults in nx.json
Nx graph shows no edges between packagesPackages missing from pnpm-workspace.yamlAdd missing glob patterns; run npx nx init
Cache replays stale output after a dependency changesnamedInputs not including dependency outputAdd "^production" to test inputs
npx nx graph shows no browser windowPort blocked in Dev ContainerAdd 4211 to forwardPorts in devcontainer.json

Putting it together: a complete minimal workspace configuration

The four patterns together produce a workspace that is fast in development (tsx, pnpm cache), correct in production (type-checked in CI, duplicate SDK detected at install), and efficient in CI (Nx differential builds). The following is a minimal but complete configuration for a two-package monorepo — one shared utility package and one MCP server — using all five tools.

# Directory structure
my-mcp-workspace/
├── .devcontainer/
│   └── devcontainer.json         # Pattern 2: pins Node.js 22, forwards Inspector ports
├── .github/
│   └── workflows/
│       └── ci.yml                # Pattern 4: fetch-depth: 0, nx affected
├── packages/
│   └── mcp-core/
│       ├── package.json          # Pattern 3: SDK as peerDependency
│       └── src/
│           └── index.ts
├── servers/
│   └── mcp-github/
│       ├── build.mjs             # Pattern 1: esbuild build (not tsx — for production)
│       ├── package.json          # sdk as direct dependency; mcp-core as workspace:*
│       ├── project.json          # Pattern 4: outputs: [dist/], cache: true
│       └── src/
│           └── index.ts
├── .npmrc                        # Pattern 2: engine-strict=true
├── .nvmrc                        # Pattern 2: 22
├── nx.json                       # Pattern 4: dependsOn, namedInputs, defaultBase
├── package.json                  # Pattern 2: engines field; Pattern 1: typecheck script
├── pnpm-workspace.yaml           # Pattern 3: packages glob
└── tsconfig.base.json            # Shared compiler options
# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "servers/*"

# .nvmrc
22

# .npmrc
auto-install-peers=true
engine-strict=true
shamefully-hoist=false

# package.json (root)
{
  "private": true,
  "engines": { "node": ">=22.0.0", "pnpm": ">=9.0.0" },
  "scripts": {
    "dev": "pnpm --filter './servers/*' -r --parallel exec tsx watch src/index.ts",
    "typecheck": "nx run-many -t typecheck --all",
    "build": "nx run-many -t build --all",
    "ci": "nx affected -t build test typecheck --base=origin/main"
  }
}
# servers/mcp-github/package.json
{
  "name": "@myorg/mcp-github",
  "version": "1.0.0",
  "type": "module",
  "engines": { "node": ">=22.0.0" },
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "dev:inspect": "npx @modelcontextprotocol/inspector tsx src/index.ts",
    "build": "node build.mjs",
    "typecheck": "tsc --noEmit"
  },
  "dependencies": {
    "@myorg/mcp-core": "workspace:*",
    "@modelcontextprotocol/sdk": "^1.4.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "esbuild": "^0.24.0",
    "typescript": "^5.5.0",
    "vitest": "^2.0.0"
  }
}

The development workflow with this setup is: cd my-mcp-workspace (fnm auto-switches to Node 22), pnpm install (reads from pnpm store, under 10 seconds on warm cache), pnpm run dev (starts tsx watch on all servers in parallel). Changes to any server restart in under 100 ms. Changes to mcp-core cause all servers to restart. pnpm typecheck runs Nx's parallel typecheck across all packages (typically 5–20 seconds for a well-structured TypeScript project). pnpm run ci runs only the affected packages — the typical PR build finishes in under 2 minutes.

Failure modes table — combined Development Toolchain 2026

SymptomToolRoot CauseFix
Type error deploys silently to productiontsx / alltsx strips types without checkingAdd tsc --noEmit as mandatory CI step
tsx watch doesn't restart on .json changestsxtsx only watches .ts/.mts/.ctsAdd --watch config.json to watch command
MCP Inspector not accessible from browserdevcontainerPort 5173 / 3000 not forwardedAdd "forwardPorts": [5173, 3000] to devcontainer.json
Tool calls route to wrong handler, no errorpnpmDuplicate @modelcontextprotocol/sdk in node_modulesRun pnpm why @modelcontextprotocol/sdk; use peerDependencies in shared packages
workspace:* package not found at runtimepnpmUsing relative path instead of workspace: protocolChange "../mcp-core" to "workspace:*"
fetch is not defined at startupfnmRunning Node.js 17 or earlierUpdate .nvmrc to 18+; run fnm use
--env-file unknown flagfnmNode.js < 20.6Update .nvmrc to 22; run fnm use
CI uses different Node.js than localfnm + actionsCI workflow hardcodes versionUse node-version-file: ".nvmrc" in setup-node
Error: invalid ELF header for native modulesdevcontainermacOS node_modules mounted in Linux containerAdd named volume mount for node_modules
pnpm strict isolation blocks transitive importpnpmPackage imports undeclared dependencyAdd missing package to the consuming package's dependencies
nx affected always rebuilds all packagesNxShallow git clone — no history for base comparisonAdd fetch-depth: 0 to actions/checkout
Nx cache hit but dist/ is missingNxoutputs field not pointing to dist/Set "outputs": ["{projectRoot}/dist"] in project.json
Shared package builds after its dependentsNxMissing dependsOn: ["^build"]Add to build target defaults in nx.json
fnm doesn't auto-switch on cdfnmMissing --use-on-cd in shell evalAdd eval "$(fnm env --use-on-cd)" to shell profile
pnpm install fails: ERR_PNPM_UNSUPPORTED_ENGINEpnpm + fnmActive Node.js doesn't match engines fieldRun fnm use in the project directory
pnpm lockfile out of date in CIpnpmDeveloper ran pnpm install without committing lockfileAlways commit pnpm-lock.yaml; CI uses --frozen-lockfile

MCP server uptime: the final validation of toolchain correctness

Development toolchain correctness and production uptime are connected. MCP servers that fail silently — due to undetected type errors (no tsc --noEmit), wrong Node.js version (no engine enforcement), or duplicate SDK instances (no pnpm peer dep alignment) — do not produce build failures or deployment errors. They produce runtime failures that appear only when a user calls a tool, and appear only in the MCP client as a generic error message.

We run the AliveMCP public dashboard, which pings every registered MCP endpoint every 60 seconds. The patterns we see at the endpoint level map directly to the toolchain failures described in this post: servers that are down during working hours but recover after business hours are typically running the wrong Node.js version on CI (Pattern 2 — the CI image was updated, local .nvmrc was not); servers that return 200 on the health endpoint but fail on tool calls are often the duplicate SDK trap (Pattern 3 — the shared utility package registered tools with a different SDK instance); servers that go down immediately after a monorepo update often have missing build order (Pattern 4 — a downstream package was built before its dependency).

The toolchain recommendations in this post are not abstract best practices — they are the upstream fixes for failure modes we observe in the endpoint data. A MCP server with tsc --noEmit in CI, three-layer Node.js enforcement, correct pnpm peer dep alignment, and Nx differential CI produces far fewer of the downtime events visible in the public dashboard. The dashboard itself (AliveMCP.com) is the canary that tells you whether the toolchain is actually working in production — not the build log.

If your MCP server appears in our public dashboard, you can claim your listing to add custom alert webhooks, verified-author badges, and 90-day response-time history. The monitoring that the development toolchain cannot provide — production uptime from external probes — is what the Author and Team tiers add on top of the free public feed.