Code Quality & Release Tooling · 2026-08-07 · Code Quality arc
Code Quality & Release Tooling for MCP Server Projects: ESLint v9 Flat Config, Pre-Push Type Checking, Version Packages PR, and Turborepo Output Completeness
Five tools — ESLint v9, Prettier, Husky, Changesets, and Turborepo — are typically adopted one at a time, each solving an immediate pain point. The result is a toolchain that checks the right things at the wrong times, runs formatters that fight ESLint on every save, ships packages missing their type declarations, or opens a "Version Packages" PR that the team waits weeks to auto-merge — not realising it never will. The five tools form a connected system with four structural patterns that explain the most common configuration mistakes. The first pattern is the pre-commit / pre-push divide: pre-commit hooks run on every commit, so they must be fast and operate only on staged files — Prettier and ESLint via lint-staged both work on individual files and complete in under 5 seconds; tsc --noEmit ignores staged file paths and type-checks the entire project every time, taking 5–30 seconds on a medium codebase and training developers to commit less frequently, so it belongs in a pre-push hook where the longer wait is acceptable; Husky v9 skips all hooks automatically when CI=true is set (GitHub Actions sets this by default), so the old pattern of adding HUSKY_SKIP_HOOKS=1 or HUSKY=0 to CI YAML is obsolete. The second pattern is ESLint v9 flat config and the TypeScript integration stack: ESLint v9 replaced .eslintrc files with a flat eslint.config.js that exports an array of config objects — the extends key is gone, replaced by direct array spreading; the modern unified typescript-eslint package (not the older split @typescript-eslint/parser + @typescript-eslint/eslint-plugin) provides a tseslint.config() helper; parserOptions.project: true enables type-aware rules including no-floating-promises — without it, recommendedTypeChecked silently falls back to rules that don't require type information; eslint-config-prettier (not eslint-plugin-prettier) must be the last entry in the config array so it wins over any conflicting formatting rules. The third pattern is the floating-promise MCP kill zone: MCP servers using the stdio transport communicate over stdin/stdout, and an unhandled promise rejection causes Node.js to print to stderr and — depending on the Node.js version and flags — either continue or exit silently; in MCP clients like Claude Desktop or Cursor, the stderr output is often invisible, making @typescript-eslint/no-floating-promises the single highest-value lint rule for MCP server code; the companion rule @typescript-eslint/no-misused-promises catches async callbacks passed to Array.forEach where the promise is silently dropped; and n/no-process-exit prevents any process.exit() call in a tool handler from killing the entire MCP server process rather than just the current tool call. The fourth pattern is the Version Packages PR and Turborepo output completeness: Changesets separates intent from effect — npx changeset creates a .changeset/*.md file describing a version bump but changes nothing in package.json; changeset version applies the bump and updates CHANGELOG.md; the @changesets/action GitHub Action opens a "Version Packages" PR to accumulate these bumps but never auto-merges it, so teams waiting for automatic releases never receive them; and Turborepo's outputs array must include dist/** (not just dist/**/*.js), because the wildcard captures .d.ts type declaration files alongside the JavaScript output — a cache hit on a build that only listed *.js will restore the compiled JavaScript but leave type declarations missing, causing downstream TypeScript packages to fail with "Cannot find module" errors that look like build failures rather than cache misconfigurations. This post covers all four patterns with annotated code, 15 failure modes with root cause and fix, and an integration decision table for the complete toolchain.
TL;DR
Four patterns, five tools. (1) Pre-commit / pre-push divide: pre-commit runs npx lint-staged only — Prettier first, then ESLint on staged files (<5s); pre-push runs npm run typecheck (tsc --noEmit on the whole project, 10–30s is fine once per push); Husky v9 skips both hooks when CI=true — no extra config needed. (2) ESLint v9 flat config stack: install eslint @eslint/js typescript-eslint eslint-plugin-n eslint-config-prettier prettier; create eslint.config.js (not .eslintrc) using tseslint.config(); set parserOptions.project: true to enable type-aware rules; add prettierConfig as the last array entry to disable conflicting formatting rules — if it is not last, another config can re-enable a rule Prettier also controls, causing fights on every save. (3) Floating-promise MCP kill zone: enable '@typescript-eslint/no-floating-promises': 'error' and '@typescript-eslint/no-misused-promises': 'error' in every MCP server project — both require parserOptions.project: true; add 'n/no-process-exit': 'error' so tool handlers can't crash the whole server; use the void operator to explicitly mark intentional fire-and-forget operations. (4) Version Packages PR and Turborepo cache: Changesets — run npx changeset per feature branch, commit the .changeset/*.md file, let the bot open the Version Packages PR, then merge it manually before running npx changeset publish; use fetch-depth: 0 in GitHub Actions checkout; Turborepo — set "outputs": ["dist/**", "!dist/**/*.map"] (the dist/** glob captures .d.ts files), add "dependsOn": ["^build"] for topological order, use "inputs" to narrow cache keys so README changes don't bust builds.
Pattern 1 — The Pre-Commit / Pre-Push Divide: Assigning Checks to the Right Hook
The most common Husky misconfiguration is placing tsc --noEmit in the pre-commit hook, either directly or via lint-staged. The result is a type check that takes 5–30 seconds on every commit — a pace that trains developers to batch changes into fewer, larger commits rather than making small, reviewable ones. The fix requires understanding that Husky provides two distinct hooks with different performance contracts: pre-commit and pre-push.
Pre-commit hooks run on every git commit. The only checks that belong here are ones that can operate on individual staged files and complete in under 5 seconds. Prettier and ESLint both fit: lint-staged passes only staged file paths to each command, and both formatters process files independently. tsc --noEmit does not fit — it reads tsconfig.json and type-checks the entire project graph regardless of which files are staged, and passing staged file paths to tsc is silently ignored.
# .husky/pre-commit — fast, staged-files-only (completes in <5 seconds)
npx lint-staged
# .husky/pre-push — full project type check (10–30 seconds is fine here;
# push happens far less often than commit)
npm run typecheck
// package.json — lint-staged config and scripts
{
"scripts": {
"prepare": "husky", // added by npx husky init; runs on npm install
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"lint-staged": {
"*.{ts,js,mjs,cjs}": [
"prettier --write", // ← Prettier FIRST — formats before ESLint sees the file
"eslint --fix --max-warnings=0"
],
"*.{json,md,yaml,yml}": [
"prettier --write"
]
// Do NOT include tsc here — it ignores staged file lists
}
}
The order of commands within a lint-staged pattern matters: Prettier runs before ESLint to avoid a cycle where ESLint rewrites whitespace that Prettier then reformats on the next commit. Running Prettier first gives ESLint a consistently formatted input, so eslint --fix only applies semantic fixes rather than fighting over indentation.
Husky v9 CI auto-skip
Husky v9 reads the CI environment variable at hook execution time. When CI=true is set — which GitHub Actions, CircleCI, GitLab CI, and most other CI systems do automatically — all hooks exit immediately without running. This is the correct behavior for CI pipelines, which have their own dedicated lint and typecheck steps. The old CI configurations that manually set HUSKY_SKIP_HOOKS=1 (v4) or HUSKY=0 (v8) in YAML files are no longer needed and can be removed.
# GitHub Actions — no special config needed
# CI=true is set automatically; Husky v9 skips all hooks
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci # prepare script runs husky, but CI=true means hooks are skipped
- run: npm run lint
- run: npm run format:check
- run: npm run typecheck
- run: npm test
# If you need to skip hooks outside of CI (e.g., in a release script):
HUSKY=0 git push origin main
Failure modes and root causes
| Symptom | Root cause | Fix |
|---|---|---|
| Every commit takes 15–30 seconds | tsc --noEmit in pre-commit / lint-staged | Move to .husky/pre-push |
CI npm ci fails with husky - not found | Husky not installed as devDependency | npm install -D husky |
| New contributors get no pre-commit checks after clone | Missing "prepare": "husky" in package.json scripts | npx husky init adds it automatically |
| ESLint and Prettier fight over formatting on every save | eslint-plugin-prettier installed; ESLint runs Prettier as a rule | Uninstall eslint-plugin-prettier; use eslint-config-prettier instead |
Pattern 2 — ESLint v9 Flat Config and the Unified TypeScript Integration Stack
ESLint v9 introduced flat config as the default configuration format, replacing the cascade-and-extend model of .eslintrc files. For TypeScript MCP server projects, the migration involves more than renaming a file — the way plugins are imported, how TypeScript-aware rules are activated, and how Prettier's formatting rules are disabled all change in flat config.
The modern flat config stack for a TypeScript MCP server has four layers, each with a specific position in the config array:
# Install the complete tool stack in one command
npm install -D \
eslint \
@eslint/js \
typescript-eslint \
eslint-plugin-n \
eslint-config-prettier \
prettier \
husky \
lint-staged
# Note: typescript-eslint (no @ prefix) is the modern unified package.
# Do NOT install @typescript-eslint/parser and @typescript-eslint/eslint-plugin
# separately — those are the older split packages; the unified package supersedes them.
// eslint.config.js — full flat config for a TypeScript MCP server
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import pluginN from 'eslint-plugin-n';
import prettierConfig from 'eslint-config-prettier';
export default tseslint.config(
// Layer 0: ignore generated/built output — never lint these
{ ignores: ['dist/**', 'node_modules/**', '*.d.ts'] },
// Layer 1: base JavaScript recommended rules
eslint.configs.recommended,
// Layer 2: TypeScript rules (type-aware requires parserOptions.project)
// recommendedTypeChecked enables no-floating-promises, no-misused-promises, etc.
...tseslint.configs.recommendedTypeChecked,
// Layer 3: project-specific overrides
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
// `true` = find the nearest tsconfig.json for each file being linted.
// Without this, recommendedTypeChecked silently falls back to syntax-only
// rules — no-floating-promises and no-misused-promises are disabled.
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: { n: pluginN },
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'n/no-process-exit': 'error',
'n/no-missing-import': 'error',
},
},
// Layer 4: Prettier config MUST be last — disables ESLint rules that conflict
// with Prettier's formatting (max-len, no-tabs, quotes, semi, indent, ~20 others)
// If another config comes after this and re-enables a conflicting rule,
// ESLint and Prettier will fight on every save.
prettierConfig,
);
Why eslint-config-prettier, not eslint-plugin-prettier
Two distinct Prettier packages exist for ESLint integration with opposite approaches. eslint-config-prettier disables ESLint rules that overlap with Prettier's formatting — it does not run Prettier at all. eslint-plugin-prettier runs Prettier as an ESLint rule on every lint pass, doubling lint time and producing error messages like "Replace · with ↵·" instead of just formatting the file. The Prettier team actively recommends against eslint-plugin-prettier for most setups. The correct pattern: run Prettier via prettier --write in lint-staged (as in Pattern 1), and use eslint-config-prettier to prevent ESLint from re-introducing formatting conflicts.
// .prettierrc — settings for TypeScript MCP server projects
{
"semi": true,
"singleQuote": true,
"trailingComma": "all", // cleaner diffs when adding zod fields to tool schemas
"printWidth": 100, // MCP tool handlers have deep nesting; 80 triggers wrapping
"tabWidth": 2,
"endOfLine": "lf"
}
// .prettierignore — files to exclude from formatting
dist/
node_modules/
*.d.ts
*.js.map
package-lock.json
yarn.lock
pnpm-lock.yaml
tests/fixtures/**/*.json // auto-generated test fixtures
The parserOptions.project: true option is the most commonly missed setting. Without it, ESLint uses the TypeScript parser for syntax understanding but cannot evaluate type-aware rules. The recommendedTypeChecked config silently falls back to its syntax-only subset, and the most valuable rules — no-floating-promises and no-misused-promises — never fire. When these rules are enabled but appear to produce no output, missing parserOptions.project is almost always the cause.
// Common mistake: parserOptions.project missing
{
files: ['**/*.ts'],
// No languageOptions.parserOptions.project — type-aware rules are disabled silently
rules: {
'@typescript-eslint/no-floating-promises': 'error', // ← never fires
},
}
// Fix: add parserOptions.project
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-floating-promises': 'error', // ← now fires correctly
},
}
Pattern 3 — The Floating-Promise MCP Kill Zone
MCP servers using the stdio transport (the most common deployment pattern for locally installed MCP servers) communicate over stdin/stdout. An unhandled promise rejection causes Node.js to emit the rejection reason to stderr and — depending on the --unhandled-rejections flag and Node.js version — either log and continue or exit the process. In MCP clients like Claude Desktop, Cursor, or Windsurf, the stderr output is typically not surfaced in the UI: the tool call either hangs, returns a generic error, or the MCP client disconnects without a useful diagnostic. The server may continue running but with a corrupted state, or it may exit — the developer sees nothing that points to the specific line of code that failed.
Three ESLint rules together close the most common paths to this failure:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import * as fs from 'node:fs/promises';
const server = new McpServer({ name: 'example', version: '1.0.0' });
// ──────────────────────────────────────────────────
// PATTERN A: Unhandled rejection in a tool handler
// ──────────────────────────────────────────────────
// ❌ WRONG — no-floating-promises doesn't directly catch this, but
// no-misused-promises catches if the tool callback is typed incorrectly,
// and the try/catch pattern below is what actually prevents the kill zone.
// The real issue: no error boundary means the rejection propagates.
server.tool(
'read_config',
{ path: z.string() },
async ({ path }) => {
const content = await fs.readFile(path, 'utf-8'); // throws on ENOENT — unhandled
return { content: [{ type: 'text', text: content }] };
}
);
// ✓ CORRECT — always wrap async tool handlers in try/catch
server.tool(
'read_config',
{ path: z.string() },
async ({ path }) => {
try {
const content = await fs.readFile(path, 'utf-8');
return { content: [{ type: 'text', text: content }] };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return {
content: [{ type: 'text', text: `Error: ${message}` }],
isError: true,
};
}
}
);
// ──────────────────────────────────────────────────
// PATTERN B: Floating promise at module scope
// ──────────────────────────────────────────────────
async function initializeDatabasePool(): Promise<void> {
// ... expensive setup
}
// ❌ LINT ERROR — no-floating-promises: Promise must be awaited or .catch()ed
initializeDatabasePool();
// ✓ CORRECT OPTION 1 — await at top level (Node.js ESM)
await initializeDatabasePool();
// ✓ CORRECT OPTION 2 — explicit void for intentional fire-and-forget
// The void operator tells no-floating-promises: "I know this returns a promise
// and I am intentionally not awaiting it."
void initializeDatabasePool();
// ──────────────────────────────────────────────────
// PATTERN C: Async callback in forEach (no-misused-promises)
// ──────────────────────────────────────────────────
const toolNames = ['search', 'summarize', 'classify'];
// ❌ LINT ERROR — no-misused-promises: forEach doesn't await async callbacks;
// each async callback returns a Promise that is silently dropped.
toolNames.forEach(async (name) => {
await server.connect(name); // none of these actually complete before forEach returns
});
// ✓ CORRECT — Promise.all with .map for async iteration
await Promise.all(
toolNames.map(async (name) => {
await server.connect(name);
})
);
// ──────────────────────────────────────────────────
// PATTERN D: process.exit() in a tool handler (n/no-process-exit)
// ──────────────────────────────────────────────────
server.tool(
'dangerous_tool',
{ action: z.string() },
async ({ action }) => {
if (action === 'quit') {
// ❌ LINT ERROR — n/no-process-exit: this kills the ENTIRE MCP server process,
// not just the current tool call. All other active tool calls are terminated.
// The MCP client sees a transport disconnect, not a clean tool error.
process.exit(1);
}
return { content: [{ type: 'text', text: 'done' }] };
}
);
The void operator is the correct way to signal an intentional fire-and-forget to no-floating-promises. Use it sparingly — for example, when kicking off a background telemetry flush after a tool handler has already returned its result. Never use void to silence a promise that should actually be awaited; the rule exists to make those cases explicit.
Why these three rules are coupled
The three rules address different entry points into the same failure mode. no-floating-promises catches bare promise expressions at statement level. no-misused-promises catches promises passed where a synchronous value is expected (forEach callbacks, Promise constructors that take non-async executors, event handlers that expect void). n/no-process-exit catches the case where a developer correctly handled an error but then chose to exit the process rather than return an error result from the tool. All three require parserOptions.project: true because they rely on TypeScript's type information to identify which expressions are Promises.
// All three rules enabled together in eslint.config.js
rules: {
// Catches: bare promise expressions at statement level
'@typescript-eslint/no-floating-promises': 'error',
// Catches: async callbacks passed where sync callbacks are expected
'@typescript-eslint/no-misused-promises': 'error',
// Catches: process.exit() calls that would kill the whole MCP server
'n/no-process-exit': 'error',
// Bonus: catches require() in ESM MCP servers (missing package + wrong module system)
'n/no-missing-import': 'error',
}
Pattern 4 — The Version Packages PR Workflow and Turborepo Output Completeness
Changesets and Turborepo address different parts of the release pipeline — one manages what version to publish, the other manages what artifacts to cache — but they share a common failure category: teams configure the tool correctly and then wait for an outcome that the tool is designed never to produce automatically. In Changesets, the Version Packages PR requires a human merge. In Turborepo, a cache hit that restores an incomplete build artifact produces downstream failures that look like source-code errors rather than cache configuration issues.
The Changesets two-step release model
The core design of Changesets separates intent (which packages changed and at what semver level) from effect (the actual version bump). When a developer runs npx changeset, they create a .changeset/*.md file describing the intended bump — this file changes nothing in package.json. The changeset version command, run later by the GitHub Actions bot, reads all pending changeset files and applies the highest bump type across all of them.
# Step 1: Create a changeset after finishing a feature
# (in a feature branch, as part of the PR)
npx changeset
# Interactive CLI:
# ? Which packages? › @myorg/mcp-server-search
# ? Bump type? › minor (new MCP tool = new feature = backward compatible)
# ? Summary? › add web_search tool with DuckDuckGo backend
#
# Creates: .changeset/purple-lions-jump.md
# ---
# "@myorg/mcp-server-search": minor
# ---
# add web_search tool with DuckDuckGo backend
# Step 2: Commit the changeset file in the feature PR
git add .changeset/purple-lions-jump.md
git commit -m "feat(tools): add web_search tool"
git push
# Step 3: After the feature PR is merged, @changesets/action opens a new PR titled
# "chore: version packages". This PR contains:
# - package.json bumped from 1.2.0 → 1.3.0 (minor bump)
# - CHANGELOG.md updated with the changeset summary
# - .changeset/purple-lions-jump.md deleted
#
# ⚠️ @changesets/action NEVER auto-merges this PR.
# Teams that configure the action and wait for automatic releases wait forever.
# The PR must be reviewed and merged manually.
# Step 4: After the Version Packages PR is merged, publish:
npx changeset publish
# Publishes @myorg/mcp-server-search@1.3.0 to npm with tag "latest"
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Required: Changesets compares with git tags to find pending changesets.
# A shallow clone (fetch-depth: 1, the default) hides older tags,
# causing Changesets to treat all packages as changed every run.
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- uses: changesets/action@v1
with:
publish: npx changeset publish
title: 'chore: version packages'
commit: 'chore: version packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
The bump level for the Version Packages PR is always the highest bump type across all pending changeset files. Three patch changesets produce one patch bump. One minor changeset and two patch changesets produce one minor bump. This aggregation prevents the case where 10 small features each independently triggered their own release, flooding npm with incremental versions.
Snapshot releases for pre-release testing
When a breaking MCP tool schema change needs to be tested by collaborators before the major release is committed, snapshot releases publish to npm under an auto-generated version number (e.g., 0.0.0-20260807143012) without consuming the pending changeset files or incrementing the stable version:
# Publish a snapshot for testing — pending changesets are NOT consumed
npx changeset version --snapshot
npx changeset publish --tag next
# Collaborators install the snapshot:
npm install @myorg/mcp-server-search@next
# After testing, discard the version bump (do NOT commit it):
git checkout -- .
# The original .changeset/*.md files are intact — run the normal release when ready.
Turborepo output completeness — the .d.ts cache gap
Turborepo's outputs array controls which files are captured when a task completes successfully and restored when a cache hit occurs. The most consequential mistake in a TypeScript MCP server monorepo is listing only dist/**/*.js in the build outputs: TypeScript declaration files (.d.ts) are also written to dist/ during compilation, but they match *.js — not *.d.ts. A cache hit will restore the JavaScript output but not the type declarations. Downstream packages that import your MCP server as a library will fail at TypeScript compilation time with "Cannot find module" or "Could not find a declaration file", which looks like a source code error rather than a build artifact problem.
// turbo.json — correct outputs for TypeScript MCP server packages
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // build all workspace dependencies first
"outputs": [
"dist/**", // ← captures BOTH .js AND .d.ts files
"!dist/**/*.map" // exclude source maps (large, not needed by consumers)
// WRONG: "dist/**/*.js" — .d.ts files are missed, cache hits break TS consumers
// WRONG: ["dist/**/*.js", "dist/**/*.d.ts"] — works, but the first pattern
// already matches both; being explicit is fine, being incomplete is not.
],
"inputs": [
"src/**/*.ts",
"package.json",
"tsconfig.json"
// README.md and .prettierrc are deliberately omitted — editing docs should
// not invalidate the build cache for a package.
]
},
"typecheck": {
"dependsOn": ["^build"], // needs .d.ts from built dependencies
"outputs": [], // tsc --noEmit writes no files
"inputs": ["src/**/*.ts", "tsconfig.json"]
},
"lint": {
// Does not depend on build — can run in parallel with build
"outputs": [],
"inputs": ["src/**/*.ts", "eslint.config.js"]
},
"test": {
"dependsOn": ["build"], // tests import from dist/
"outputs": ["coverage/**"],
"inputs": ["src/**/*.ts", "tests/**/*.ts", "vitest.config.ts"]
}
}
}
The dependsOn: ["^build"] notation is the Turborepo convention for topological ordering across workspace packages. The caret (^) means "this task depends on the build task in all direct workspace dependencies of the current package." Without it, Turborepo may attempt to build a package before its dependencies have produced their output, causing import errors even when the source files are correct.
# Remote cache setup — without this, cache only persists locally within one CI run
# .github/workflows/ci.yml
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 2 # Turborepo needs the previous commit for change detection
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- run: npm ci
- run: turbo build lint typecheck test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} # from Vercel Settings → Tokens
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} # your Vercel team slug
# With remote cache: first CI run uploads artifacts; subsequent runs on the same code
# download artifacts instead of rebuilding. "FULL TURBO" in the output = 100% cache hit.
# Without remote cache: each CI run starts from scratch — parallelization helps within
# the run but nothing is shared across runs or between developer machines.
Per-package SDK dependency in monorepos
Each MCP server package in a Turborepo monorepo must declare its own dependency on @modelcontextprotocol/sdk. Listing the SDK only in the workspace root does not make it available to individual packages — npm workspaces hoist devDependencies to the root for deduplication, but peer dependencies and runtime dependencies must be declared in each package's own package.json. A package that omits its own SDK dependency may work in development (because the workspace root's node_modules/@modelcontextprotocol is reachable by path traversal) but fail in production when the package is installed in isolation.
// servers/mcp-server-search/package.json
{
"name": "@myorg/mcp-server-search",
"version": "1.0.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.4.0" // ← must be declared in each MCP server package
},
"devDependencies": {
"@myorg/tsconfig": "workspace:*", // shared TypeScript config from packages/
"@myorg/eslint-config": "workspace:*", // shared ESLint config from packages/
"typescript": "^5.5.0"
}
}
Failure modes comparison table
| Tool | Symptom | Root cause | Fix |
|---|---|---|---|
| Changesets | Version Packages PR never arrives | @changesets/action not installed or push trigger missing | Add release workflow to .github/workflows/ |
| Changesets | Version Packages PR opens but never auto-merges | Expected behavior — Changesets never auto-merges this PR | Merge manually after review |
| Changesets | All packages bump on every release regardless of changes | fetch-depth: 1 (default) hides git tags | Set fetch-depth: 0 in the checkout step |
| Changesets | Monorepo package silently ignored by changeset version | Package not in .changeset/config.json packages glob | Update the glob or use workspace wildcard |
| Turborepo | "Cannot find module" after cache hit | outputs lists only *.js, missing .d.ts | Change to "dist/**" (captures both) |
| Turborepo | Packages build in wrong order, import errors | Missing "dependsOn": ["^build"] | Add upstream dependency declaration |
| Turborepo | README edits bust build cache | No inputs array — Turborepo hashes all files | Add "inputs": ["src/**/*.ts", "package.json", "tsconfig.json"] |
| Turborepo | Cache only helps locally, not across CI runs | Remote cache not configured | Add TURBO_TOKEN + TURBO_TEAM to CI env |
Toolchain integration decision table
The five tools in this arc form a layer stack where each layer depends on the one below it being correctly configured:
| Layer | Tool | What it does | Key decision | Common wrong choice |
|---|---|---|---|---|
| Formatting | Prettier | Enforces consistent code style | Run as standalone command via lint-staged | eslint-plugin-prettier (doubles lint time) |
| Linting | ESLint v9 | Catches logic errors, type misuse, Node.js pitfalls | eslint.config.js + parserOptions.project: true |
.eslintrc + missing project option |
| Git hooks | Husky v9 | Runs lint-staged on commit, tsc on push | tsc in pre-push, lint-staged in pre-commit | tsc in pre-commit (slow, breaks dev flow) |
| Versioning | Changesets | Tracks semver intent, generates Version PR | Manual merge of Version Packages PR before publish | Waiting for auto-merge (never happens) |
| Build orchestration | Turborepo | Caches builds, parallelizes tasks across packages | "outputs": ["dist/**"] (captures .d.ts files) |
"dist/**/*.js" only (breaks TypeScript consumers) |
When to use each tool for a solo MCP server project vs a monorepo
For a single-package MCP server published to npm, the minimum useful toolchain is Prettier + ESLint + Husky + Changesets. Turborepo adds value only when there are multiple packages with inter-dependencies that benefit from build caching and topological ordering. A solo MCP server with no shared packages should not add Turborepo — it introduces configuration complexity without the caching benefit that justifies it. The rule of thumb: add Turborepo when you have three or more TypeScript packages that share code and whose build times noticeably compound in CI.
For the Changesets decision: even solo MCP server authors publishing to npm benefit from Changesets because the changeset file in .changeset/ serves as a reviewable record of what version bump is intended and why, separate from the commit message. This is especially valuable when a breaking change to the MCP tool schema needs to be communicated in the CHANGELOG.md entry rather than discovered by users at runtime.
Full failure mode reference (15 across all five tools)
| # | Tool | Symptom | Root cause | Fix |
|---|---|---|---|---|
| 1 | Husky | Commits take 15–30 seconds | tsc --noEmit in pre-commit | Move to .husky/pre-push |
| 2 | Husky | No hooks run for new contributors after clone | Missing "prepare": "husky" script | npx husky init adds it automatically |
| 3 | Husky | CI npm ci fails on missing husky | Husky not in devDependencies | Add to devDependencies |
| 4 | ESLint | no-floating-promises never fires | Missing parserOptions.project: true | Add to languageOptions.parserOptions |
| 5 | ESLint | ESLint and Prettier fight over formatting | eslint-plugin-prettier installed | Replace with eslint-config-prettier (last in array) |
| 6 | ESLint | Monorepo per-package tsconfig not found | tsconfigRootDir points to wrong directory | Use tsconfigRootDir: import.meta.dirname |
| 7 | Prettier | Format pass takes 10+ seconds | Missing .prettierignore — formats node_modules/ or dist/ | Add dist/, node_modules/, *.d.ts to .prettierignore |
| 8 | Prettier | Files reformatted on every save in editor | Mismatched .editorconfig and .prettierrc settings | Align indent_style, end_of_line across both files |
| 9 | MCP stdio | Tool call hangs or server exits silently | Unhandled promise rejection in tool handler | Wrap handler in try/catch; enable no-floating-promises |
| 10 | MCP stdio | All active tool calls terminate on one error | process.exit() in a tool handler | Return { isError: true } instead; enable n/no-process-exit |
| 11 | Changesets | Version Packages PR never auto-merges | Expected behavior — requires human merge | Merge manually after review |
| 12 | Changesets | All packages bump on every release | fetch-depth: 1 hides git tags | Set fetch-depth: 0 |
| 13 | Turborepo | "Cannot find module" after cache hit | outputs: ["dist/**/*.js"] misses .d.ts | Change to "dist/**" |
| 14 | Turborepo | Packages build in wrong dependency order | Missing "dependsOn": ["^build"] | Add to tasks.build |
| 15 | Turborepo | MCP server works locally, fails in production install | SDK declared only in workspace root, not package | Add @modelcontextprotocol/sdk to each package's dependencies |