Guide · Code Quality & Release Tooling
MCP Server Husky — pre-commit hooks, lint-staged, pre-push type checking
Three Husky mistakes cost MCP server teams time: putting tsc --noEmit in the pre-commit hook via lint-staged — TypeScript's type checker operates on the whole project graph, not just staged files; lint-staged passes only staged file paths to the command, so tsc either ignores them or fails because the project tsconfig is not provided, and even when it works correctly a full type check takes 5–30 seconds on every commit; not knowing that Husky v9 automatically skips hooks in CI environments — Husky v9 checks the CI environment variable and the HUSKY variable; if CI=true is set (as it is in GitHub Actions, CircleCI, and most CI systems), hooks are skipped without any additional configuration, so the old pattern of adding HUSKY_SKIP_HOOKS=1 or HUSKY=0 in CI is no longer needed; and missing the prepare script in package.json so Husky never installs for new contributors who clone the repo.
TL;DR
Run npx husky init to create .husky/pre-commit and add the prepare script to package.json. Put lint-staged in .husky/pre-commit (fast, staged-files-only lint + format). Put tsc --noEmit in .husky/pre-push (full type check runs before push, not on every commit). Husky v9 skips both hooks automatically when CI=true — no extra config needed.
Installing Husky v9 in a TypeScript MCP server project
Husky v9 changed the installation process from v4 and v8. The npx husky init command creates the .husky/ directory, a starter .husky/pre-commit hook file, and adds "prepare": "husky" to package.json. The prepare script runs automatically on npm install, which installs the Git hooks for every contributor who clones the repo.
# Install Husky and lint-staged
npm install -D husky lint-staged
# Initialize Husky — creates .husky/ directory and adds prepare script
npx husky init
# The init command creates .husky/pre-commit with a placeholder command
# Edit it to run lint-staged instead
# .husky/pre-commit — runs before every git commit
# Goal: fast feedback on staged files only (should complete in <5 seconds)
npx lint-staged
# .husky/pre-push — runs before git push
# Goal: full type check (slower, but only runs on push, not every commit)
npm run typecheck
// package.json — the prepare script runs husky init for new contributors
{
"scripts": {
"prepare": "husky", // ← added by `npx husky init`
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"lint-staged": {
"*.{ts,js,mjs}": [
"prettier --write",
"eslint --fix --max-warnings=0"
],
"*.{json,md,yaml,yml}": [
"prettier --write"
]
}
}
After running npx husky init, commit the .husky/ directory and the updated package.json. The hook files in .husky/ are shell scripts — they must be committed to the repository so Git uses them. The hook files do not contain credentials or environment-specific values, so committing them is safe.
Why type checking belongs in pre-push, not pre-commit
The distinction matters for developer experience. Pre-commit hooks run on every commit — developers commit many times per hour during active development. A 10-second type check on every commit trains developers to avoid committing frequently, which leads to large batched commits that are harder to review and debug. Pre-push hooks run only before git push, which happens far less often (typically once before opening a PR or deploying), so a longer-running check is acceptable there.
# Why tsc --noEmit doesn't work well with lint-staged:
#
# lint-staged passes only staged file paths to each command:
# prettier --write src/tools/search.ts src/tools/summarize.ts
# eslint --fix src/tools/search.ts src/tools/summarize.ts
#
# But tsc --noEmit ignores file path arguments — it reads tsconfig.json and
# type-checks the ENTIRE project (all files in "include" or referenced by imports).
# Passing staged files to tsc does nothing useful. The type check is always full.
#
# If your project has 200 TypeScript files and tsconfig has "strict: true",
# tsc --noEmit takes 10–30 seconds. On a busy commit day with 20+ commits,
# that's 5–10 minutes of waiting for pre-commit hooks.
#
# Solution: put tsc --noEmit in pre-push only.
# .husky/pre-push — full type check (10–30 seconds is fine here)
npm run typecheck
# .husky/pre-commit — fast checks on staged files only (<5 seconds)
npx lint-staged
If you use a monorepo (e.g., Turborepo) where each package has its own tsconfig.json, the pre-push hook should run turbo typecheck to type-check all packages in parallel rather than running tsc sequentially in each package directory.
Husky v9 CI auto-skip and the HUSKY environment variable
Husky v9 reads the HUSKY environment variable and the standard CI variable at hook execution time. When either is set, hooks exit immediately without running the hook script. This means your GitHub Actions, CircleCI, or GitLab CI pipelines do not run pre-commit or pre-push hooks during git operations in CI — which is the correct behavior, since CI has its own lint and typecheck steps that run on the full codebase.
# GitHub Actions — no special config needed; CI=true is set automatically
# Husky v9 detects CI=true and 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 skips hooks
- run: npm run lint # full ESLint check (all files, not just staged)
- run: npm run format:check # Prettier check (fails if any file needs formatting)
- run: npm run typecheck # tsc --noEmit on full project
- run: npm test # vitest or jest
# To manually skip Husky hooks outside of CI (e.g., in a release script):
HUSKY=0 git push origin main
# Or set in the shell session:
export HUSKY=0
git commit -m "chore: release v1.2.0"
git push
In Husky v4, skipping hooks in CI required HUSKY_SKIP_HOOKS=1 or a conditional check inside each hook file. In Husky v8, it was HUSKY=0 set manually in CI config. In Husky v9, no CI config change is needed — the auto-skip is built in. If you are migrating from an older Husky version, you can safely remove the HUSKY=0 line from your CI YAML files.
Commit message validation with commit-msg hook
MCP server packages published to npm benefit from conventional commit messages because tools like Changesets and semantic-release parse commit history to generate changelogs and determine version bumps. A commit-msg hook with commitlint enforces the convention before commits land.
# Install commitlint
npm install -D @commitlint/cli @commitlint/config-conventional
# commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
// Conventional commit types relevant to MCP server projects:
// feat: new MCP tool or resource
// fix: bug fix in tool handler
// perf: performance improvement (e.g., batching, caching)
// refactor: code restructuring without behavior change
// test: adding or updating tests
// chore: dependency updates, build config, tooling
// docs: README or inline JSDoc
rules: {
'subject-case': [2, 'always', 'lower-case'],
'header-max-length': [2, 'always', 120],
},
};
# .husky/commit-msg — validates commit message format
npx --no-install commitlint --edit "$1"
# Examples of valid commit messages:
# feat(tools): add web_search tool with DuckDuckGo backend
# fix(transport): handle stdio close event before process exit
# chore(deps): bump @modelcontextprotocol/sdk to 1.4.0
# perf(cache): add LRU cache for repeated tool schema lookups
# Examples of INVALID commit messages (commitlint rejects these):
# Added search tool ← missing type prefix
# feat: Added search tool ← capital letter after ':'
# FEAT: add search ← type must be lowercase
The commit-msg hook receives the path to the commit message file as $1. The --edit "$1" flag tells commitlint to read the message from that file rather than stdin. This works for regular commits, merge commits, and git commit --amend. It does not run on git rebase -i squash commits — those are caught when the final squashed commit message is validated on pre-push or in CI.