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
| Symptom | Cause | Fix |
|---|---|---|
| Tool calls silently route to wrong handler | Duplicate @modelcontextprotocol/sdk copies in node_modules | Run pnpm why @modelcontextprotocol/sdk; align peer dep ranges across packages |
ERR_PNPM_PEER_DEP_ISSUES on install | Incompatible peer dependency version ranges | Update the older package to match the workspace-wide SDK version |
| Internal package not found at runtime | Using relative path in dependencies instead of workspace: protocol | Change "../mcp-core" to "workspace:*" in package.json |
| CI fails with lockfile not up to date | Developer ran pnpm install without committing updated pnpm-lock.yaml | Run pnpm install locally and commit the updated lockfile |
Package imports a module that isn't in its dependencies | pnpm's strict isolation blocks access to undeclared transitive deps | Add the missing package to the consuming package's dependencies |
pnpm -r build builds packages in wrong order | Missing dependency declarations between workspace packages | Ensure each package lists its sibling dependencies with workspace:* |