Guide · Development Toolchain 2026

MCP Server pnpm — workspace protocol, peer deps, frozen lockfile in CI

pnpm is the preferred package manager for MCP server monorepos because its content-addressable store deduplicates the @modelcontextprotocol/sdk across every package in the workspace, and its strict peer dependency enforcement catches the duplicate-SDK problem that causes silent tool mis-routing before it reaches production. Three pnpm behaviours that trip MCP developers up: the workspace: protocol must be used for internal package references (not relative paths); shamefully-hoist is false by default — packages that import undeclared transitive dependencies fail in pnpm but accidentally work in npm (this is a feature, not a bug); and pnpm install --frozen-lockfile in CI must be a hard requirement to prevent lockfile drift from breaking production deploys.

TL;DR

Create a pnpm-workspace.yaml at the repo root listing your packages. Use workspace:* in package.json to reference sibling packages. Run pnpm install --frozen-lockfile in CI. Set auto-install-peers=true in .npmrc to avoid manual peer dependency resolution for the MCP SDK and Zod. Never commit node_modules.

Why pnpm for MCP server monorepos

A typical MCP monorepo has a shared @myorg/mcp-core package (shared types, base server setup, common Zod schemas) and several server packages (@myorg/mcp-github, @myorg/mcp-notion, @myorg/mcp-linear). Each server package lists @modelcontextprotocol/sdk as a peer dependency.

With npm or Yarn hoisting, it is easy for multiple copies of the SDK to end up in node_modules — one per package, plus one at the root — and the tool routing in McpServer breaks silently because each copy has its own class registry. pnpm's symlinked store prevents this: every package in the workspace shares a single SDK resolution, and peer dependency errors are reported as install-time errors rather than runtime mysteries.

Workspace setup for an MCP monorepo

# pnpm-workspace.yaml (repo root)
packages:
  - "packages/*"        # shared libraries
  - "servers/*"         # individual MCP servers
  - "apps/*"            # companion web apps, status dashboards
# Exclude test fixtures and generated directories
  - "!**/test-fixtures/**"
  - "!**/dist/**"
# .npmrc (repo root) — pnpm configuration
# Automatically install peer dependencies instead of warning
auto-install-peers=true

# Report missing peer deps as errors (not warnings) in CI
strict-peer-dependencies=false   # set to true after all peer deps are explicit

# Keep the virtual store flat to reduce symlink depth
shamefully-hoist=false           # keep strict isolation (default)

# Use the workspace's local packages.json "engines" field
engine-strict=true
# Repo structure
my-mcp-workspace/
├── pnpm-workspace.yaml
├── .npmrc
├── package.json             # root package (private: true, no dist)
├── packages/
│   ├── mcp-core/            # @myorg/mcp-core — shared types and utilities
│   │   └── package.json
│   └── mcp-zod-schemas/     # @myorg/mcp-zod-schemas — reusable Zod schemas
│       └── package.json
└── servers/
    ├── mcp-github/          # @myorg/mcp-github
    │   └── package.json
    └── mcp-notion/          # @myorg/mcp-notion
        └── package.json
// servers/mcp-github/package.json
{
  "name": "@myorg/mcp-github",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.4.0",
    "@myorg/mcp-core": "workspace:*",     // workspace: protocol — not a semver range
    "@myorg/mcp-zod-schemas": "workspace:^",  // workspace:^ resolves to ^version on publish
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "tsx": "^4.15.0",
    "typescript": "^5.5.0"
  },
  "scripts": {
    "dev":   "tsx watch src/index.ts",
    "build": "node build.mjs",
    "test":  "vitest run"
  }
}

The workspace:* protocol tells pnpm to link the local @myorg/mcp-core package from the workspace instead of downloading it from the npm registry. On publish, pnpm replaces workspace:* with the actual version number automatically. workspace:^ becomes ^1.2.3; workspace:~ becomes ~1.2.3; workspace:* becomes the exact current version.

Peer dependency strictness and the duplicate SDK problem

The @modelcontextprotocol/sdk should be a peer dependency in shared packages, not a direct dependency. This ensures all packages in the workspace resolve to the same SDK copy — critical for tool routing, capability negotiation, and the McpServer class registry.

// packages/mcp-core/package.json — shared utilities package
{
  "name": "@myorg/mcp-core",
  "version": "1.0.0",
  "type": "module",
  "peerDependencies": {
    "@modelcontextprotocol/sdk": ">=1.4.0",  // peer dep, not direct dep
    "zod": ">=3.22.0"
  },
  "peerDependenciesMeta": {
    "@modelcontextprotocol/sdk": { "optional": false },
    "zod": { "optional": false }
  },
  "devDependencies": {
    "@modelcontextprotocol/sdk": "^1.4.0",  // dev dep for local testing only
    "zod": "^3.22.0",
    "typescript": "^5.5.0"
  }
}
# Check for duplicate SDK copies in the workspace
pnpm why @modelcontextprotocol/sdk
# Should show a single resolved version across all packages.
# Multiple versions listed here means a peer dep version range conflict —
# check which package has a stricter or older SDK version requirement.

If pnpm why shows two SDK versions, the fix is to align the peer dependency ranges across all packages. pnpm will warn at install time when peer dependency ranges are incompatible — treat these warnings as errors.

Running scripts across the workspace

pnpm's -r (recursive) and --filter flags run scripts across all or selected packages. Combined with --parallel for independent operations and sequential ordering for build pipelines, these replace a large part of what Turborepo's task graph does for small monorepos.

# Build all packages in dependency order (pnpm respects package.json dependencies)
pnpm -r build

# Run tests for all packages in parallel
pnpm -r --parallel test

# Watch only the servers (not shared libs) during development
pnpm --filter "./servers/*" -r --parallel dev

# Run a script only in packages that have changed since the last commit
pnpm --filter "...[HEAD~1]" -r build

# Add a dependency to a specific workspace package
pnpm --filter @myorg/mcp-github add octokit

# Add a devDependency to ALL packages in the workspace
pnpm -r add -D tsx
# package.json (root) — convenience scripts for the workspace
{
  "name": "my-mcp-workspace",
  "private": true,
  "scripts": {
    "dev":       "pnpm --filter './servers/*' -r --parallel dev",
    "build":     "pnpm -r build",
    "test":      "pnpm -r --parallel test",
    "typecheck": "pnpm -r --parallel typecheck",
    "clean":     "pnpm -r --parallel exec rm -rf dist node_modules/.cache"
  }
}

CI configuration with frozen lockfile

In CI, always use pnpm install --frozen-lockfile. This fails the build if the lockfile is out of sync with package.json, catching the common mistake of a developer adding a dependency locally without committing the updated lockfile.

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

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

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9          # pin to a specific pnpm major version

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"   # or "node-version": "22"
          cache: "pnpm"                 # caches ~/.pnpm-store between runs

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Type check
        run: pnpm -r --parallel typecheck

      - name: Test
        run: pnpm -r --parallel test

      - name: Build
        run: pnpm -r build

The cache: "pnpm" option in setup-node caches the pnpm content-addressable store at ~/.pnpm-store. On a warm cache, pnpm install --frozen-lockfile takes under 10 seconds for a mid-sized monorepo because packages are hardlinked from the store rather than downloaded.

Common failure modes

SymptomCauseFix
Tool calls silently route to wrong handlerDuplicate @modelcontextprotocol/sdk copies in node_modulesRun pnpm why @modelcontextprotocol/sdk; align peer dep ranges across packages
ERR_PNPM_PEER_DEP_ISSUES on installIncompatible peer dependency version rangesUpdate the older package to match the workspace-wide SDK version
Internal package not found at runtimeUsing relative path in dependencies instead of workspace: protocolChange "../mcp-core" to "workspace:*" in package.json
CI fails with lockfile not up to dateDeveloper ran pnpm install without committing updated pnpm-lock.yamlRun pnpm install locally and commit the updated lockfile
Package imports a module that isn't in its dependenciespnpm's strict isolation blocks access to undeclared transitive depsAdd the missing package to the consuming package's dependencies
pnpm -r build builds packages in wrong orderMissing dependency declarations between workspace packagesEnsure each package lists its sibling dependencies with workspace:*