Guide · Development Toolchain 2026

MCP Server fnm — Node.js version management, .nvmrc, engines field validation

The @modelcontextprotocol/sdk requires Node.js 18 or higher, and many MCP server features — native ESM, import.meta.dirname, AsyncLocalStorage stability, and the --env-file flag — have different behaviour and availability across Node.js 18, 20, 22, and 24. Running a Node.js 18 development environment against a production deployment on Node.js 22 is a latent bug. fnm (Fast Node Manager) is the recommended version manager for MCP development teams: it installs in milliseconds (written in Rust), auto-switches on cd by reading .nvmrc or .node-version files, and works on macOS, Linux, and Windows without WSL. The three version management mistakes that cause MCP server issues: no engines field in package.json means users install your server on Node.js 16 and get a cryptic error; no .nvmrc means contributors use whatever version happens to be active; CI pinned to a different version than local means "works on my machine" bugs that only surface in production.

TL;DR

Install fnm: curl -fsSL https://fnm.vercel.app/install | bash. Add eval "$(fnm env --use-on-cd)" to your shell profile for auto-switching. Create .nvmrc with 22 (or lts/*) at the repo root. Add "engines": {"node": ">=22.0.0"} to every MCP server's package.json. Pin CI with node-version-file: ".nvmrc" in setup-node.

Why Node.js version management matters for MCP servers

MCP server authors often distribute their servers as npm packages that users install globally via npx or npm install -g. The user's Node.js version is whatever version they happen to have installed — often the LTS they installed 18 months ago and never updated. Without an explicit engines field, your MCP server gets installed silently and then fails at runtime with a cryptic error about ESM import syntax, fetch not being defined, or --env-file being an unknown flag.

The feature delta between Node.js versions that directly affects MCP servers:

FeatureFirst availableNotes
Native fetchNode.js 18.0 (unflagged)Required if your MCP server makes HTTP calls without node-fetch
AsyncLocalStorage.snapshot()Node.js 18.2Used for request context propagation in MCP middleware
--env-file flagNode.js 20.6Load .env files without dotenv package
import.meta.dirnameNode.js 21.2ESM equivalent of __dirname
Native ESM .ts loader (experimental)Node.js 22.6Run .ts files with --experimental-strip-types
Stable --experimental-strip-typesNode.js 23.xtsx and SWC remain faster alternatives
Active LTS (recommended for production)Node.js 22 (through 2027-04)Pin production MCP servers to Node.js 22 LTS

fnm installation and shell integration

fnm is a single binary with no dependencies. The auto-cd feature is the key difference from manually running fnm use: when you cd into a directory containing .nvmrc or .node-version, fnm switches Node.js versions automatically.

# Install fnm (macOS / Linux)
curl -fsSL https://fnm.vercel.app/install | bash

# Or via Homebrew (macOS)
brew install fnm

# Or via Winget (Windows)
winget install Schniz.fnm

# Add to ~/.zshrc or ~/.bashrc:
eval "$(fnm env --use-on-cd --shell zsh)"
# For bash:
eval "$(fnm env --use-on-cd --shell bash)"
# For fish:
fnm env --use-on-cd --shell fish | source

# Install a Node.js version
fnm install 22           # install Node.js 22 LTS
fnm install lts/jod      # install by LTS codename (Node.js 22 = "Jod")
fnm install --lts        # install the current LTS

# Switch the active version
fnm use 22               # switch to Node.js 22
fnm use lts/jod          # switch by LTS codename
fnm use                  # switch to version in .nvmrc / .node-version

# List installed versions
fnm list
fnm current              # show currently active version
# .nvmrc — place at the repo root
22
# Or pin to a specific minor/patch:
# 22.11.0
# Or use an LTS alias (fnm supports this; nvm does too):
# lts/jod

fnm reads .nvmrc and .node-version files (both are supported). Use .nvmrc for compatibility with nvm users on your team. The file contents can be a major version (22), a semver string (22.11.0), or an LTS alias (lts/jod).

engines field and pnpm engine enforcement

The engines field in package.json declares the Node.js version range your MCP server supports. By default, npm and pnpm treat this as advisory only. To turn it into an error, add engine-strict=true to .npmrc.

// package.json — explicit engines constraint
{
  "name": "my-mcp-server",
  "version": "1.0.0",
  "engines": {
    "node": ">=22.0.0",    // minimum version your server supports
    "pnpm": ">=9.0.0"      // optional: pin pnpm version too
  }
}

// For a server distributed on npm that must support older Node.js:
// "node": ">=18.0.0"       // safe minimum for MCP SDK v1.x

// For a server using --env-file (Node.js 20.6+):
// "node": ">=20.6.0"

// For a server using import.meta.dirname (Node.js 21.2+):
// "node": ">=22.0.0"       // jump to 22 LTS rather than pinning 21 (non-LTS)
# .npmrc — enforce engines field as errors
engine-strict=true

# pnpm will now fail with ERR_PNPM_UNSUPPORTED_ENGINE if Node.js version
# doesn't satisfy the engines.node range in any package in the workspace
# Validate engines constraints without installing
node -e "
const pkg = require('./package.json');
const range = pkg.engines && pkg.engines.node;
const semver = require('semver');
if (range && !semver.satisfies(process.version, range)) {
  console.error('Node.js version ' + process.version + ' does not satisfy ' + range);
  process.exit(1);
}
console.log('Node.js ' + process.version + ' satisfies engines.node: ' + range);
"

Comparing fnm vs nvm vs volta

Featurefnmnvmvolta
Written inRustBashRust
Shell startup overhead<1 ms50–200 ms<1 ms
Auto-switch on cdYes (--use-on-cd)Yes (with hook)Yes (shim-based)
Windows supportYes (native)No (WSL only)Yes (native)
Project-pinning mechanism.nvmrc / .node-version.nvmrcvolta pin (in package.json)
Per-project pnpm/npm versionNoNoYes (volta pin pnpm)
CI compatibilitysetup-node reads .nvmrcsetup-node reads .nvmrcSetup via volta's GH Action
Corepack integrationManualManualReplaces corepack

For most MCP server projects, fnm is the best default: fast, cross-platform, reads .nvmrc that the whole team already understands, and integrates with setup-node in GitHub Actions without any extra action. Volta is better when you need to pin pnpm or npm versions per-project (via volta pin pnpm) and want those pins enforced on Windows team members without WSL.

CI version pinning with GitHub Actions

# .github/workflows/ci.yml — pin Node.js version to .nvmrc
name: CI
on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"   # reads Node.js version from .nvmrc
          cache: "pnpm"

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9

      - run: pnpm install --frozen-lockfile
      - run: pnpm -r typecheck
      - run: pnpm -r test

The node-version-file: ".nvmrc" option ensures CI uses the exact same Node.js version as local development. Changing the .nvmrc file automatically updates both local (via fnm auto-switch) and CI (via setup-node) without requiring a separate change in the workflow file.

Common failure modes

SymptomCauseFix
fnm doesn't auto-switch on cdMissing --use-on-cd in shell evalAdd eval "$(fnm env --use-on-cd)" to shell profile and restart terminal
fetch is not defined at runtimeRunning on Node.js 17 or earlierUpdate .nvmrc to 18+; add "engines": {"node": ">=18"}
--env-file unknown flagNode.js < 20.6Update .nvmrc to 20 or 22; or use dotenv package instead
CI uses different Node.js than localCI workflow hardcodes a version; .nvmrc not usedChange CI to node-version-file: ".nvmrc"
ERR_PNPM_UNSUPPORTED_ENGINE for contributorsengine-strict=true in .npmrc; contributor has older Node.jsRun fnm use in the project directory to switch to the correct version
fnm version not found: "22"fnm hasn't downloaded Node.js 22 yetRun fnm install 22 once to download; subsequent fnm use is instant