Guide · Code Quality & Release Tooling

MCP Server Turborepo — monorepo pipeline, outputs cache, shared configs

Three Turborepo behaviours surprise MCP server monorepo authors: Turborepo's cache only captures files declared in outputs — if your MCP server TypeScript build writes dist/*.js but also generates dist/*.d.ts type declaration files, and you only list "dist/**/*.js" in outputs, the .d.ts files are invisible to the cache; a cache hit will restore .js files but leave .d.ts missing, causing downstream TypeScript packages that import your MCP server as a library to fail with "Cannot find module" errors; Turborepo's remote cache is opt-in and requires a storage backend — without remote cache enabled, the cache only persists on the local machine (no sharing between CI runs or team members), so CI times improve only through local task memoization within a single run; and each MCP server package must declare its own peer dependency on @modelcontextprotocol/sdk — listing it only in the workspace root does not make it available to individual packages.

TL;DR

Add turbo to the workspace root devDependencies. Create turbo.json with a tasks (v2) or pipeline (v1) block defining build, lint, typecheck, and test. In build, set "outputs": ["dist/**", "!dist/**/*.map"] — include .d.ts files, exclude source maps. Set "dependsOn": ["^build"] so packages build only after their workspace dependencies finish building. Enable remote cache in GitHub Actions via TURBO_TOKEN and TURBO_TEAM.

Monorepo structure for a collection of MCP server packages

A typical MCP server monorepo groups related tools into domain packages (e.g., @myorg/mcp-server-search, @myorg/mcp-server-storage) and shares common tooling config via internal packages in a packages/ directory. Turborepo orchestrates build order, lint, and test across all packages in parallel.

my-mcp-collection/
├── turbo.json               ← Turborepo pipeline config
├── package.json             ← workspace root (not published to npm)
├── packages/
│   ├── eslint-config/       ← shared ESLint config (internal package)
│   │   ├── package.json
│   │   └── eslint.config.js
│   ├── tsconfig/            ← shared TypeScript config (internal package)
│   │   ├── package.json
│   │   ├── base.json
│   │   └── library.json
├── servers/
│   ├── mcp-server-search/   ← @myorg/mcp-server-search (published to npm)
│   │   ├── package.json
│   │   ├── tsconfig.json
│   │   ├── eslint.config.js
│   │   └── src/
│   │       └── index.ts
│   ├── mcp-server-storage/  ← @myorg/mcp-server-storage (published to npm)
│   │   ├── package.json
│   │   └── src/
│   │       └── index.ts
└── apps/
    └── docs/                ← documentation site (not published to npm)
// package.json (workspace root)
{
  "name": "my-mcp-collection",
  "private": true,           // workspace root is never published
  "workspaces": [
    "packages/*",
    "servers/*",
    "apps/*"
  ],
  "devDependencies": {
    "turbo": "^2.0.0"
  },
  "scripts": {
    "build":     "turbo build",
    "lint":      "turbo lint",
    "typecheck": "turbo typecheck",
    "test":      "turbo test",
    "dev":       "turbo dev --parallel"
  }
}

turbo.json pipeline with correct outputs for TypeScript MCP servers

The outputs array in each task definition is the list of files that Turborepo snapshots into the cache. For TypeScript packages, this must include both the compiled JavaScript and the type declaration files (.d.ts). The dependsOn: ["^build"] syntax means "this task depends on the build task in all upstream workspace dependencies" — the caret (^) is the cross-package dependency marker.

// turbo.json (Turborepo v2 syntax — "tasks" instead of "pipeline")
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      // ^build: build all workspace dependencies first
      // This ensures @myorg/mcp-server-search builds after @myorg/shared-types
      "dependsOn": ["^build"],
      "outputs": [
        "dist/**",          // JavaScript files (.js, .cjs, .mjs)
        "!dist/**/*.map"    // EXCLUDE source maps (large, not needed for downstream)
        // NOTE: dist/**/*.d.ts is included by "dist/**" glob above
        // Never exclude .d.ts — TypeScript consumers need them for type checking
      ],
      "inputs": [
        "src/**/*.ts",
        "package.json",
        "tsconfig.json"
      ]
    },

    "typecheck": {
      "dependsOn": ["^build"],  // needs built .d.ts files from dependencies
      "outputs": [],            // tsc --noEmit produces no output files
      "inputs": [
        "src/**/*.ts",
        "tsconfig.json"
      ]
    },

    "lint": {
      // Lint doesn't depend on build — can run in parallel with build
      "outputs": [],
      "inputs": [
        "src/**/*.ts",
        "eslint.config.js",
        ".eslintignore"
      ]
    },

    "test": {
      "dependsOn": ["build"],   // tests import from dist/, not src/
      "outputs": [
        "coverage/**"
      ],
      "inputs": [
        "src/**/*.ts",
        "tests/**/*.ts",
        "vitest.config.ts"
      ]
    },

    "dev": {
      "dependsOn": ["^build"],
      "persistent": true,       // long-running task (watch mode)
      "cache": false            // never cache dev/watch tasks
    }
  }
}

The inputs array is optional but strongly recommended — it narrows the set of files that Turborepo hashes to determine cache hits. Without inputs, Turborepo hashes every file in the package directory, including README.md, .prettierrc, and other files that don't affect the build output. Adding inputs means editing documentation doesn't invalidate the build cache for a package.

Shared TypeScript and ESLint configs as workspace packages

Internal packages with shared configs eliminate duplication across MCP server packages. Each internal package is a JSON or JS file published to the workspace only (never to npm). Other packages in the monorepo reference them as devDependencies using the workspace protocol.

// packages/tsconfig/package.json
{
  "name": "@myorg/tsconfig",
  "version": "0.0.1",
  "private": true,
  "files": ["*.json"]   // only the JSON files — no src/, no dist/
}

// packages/tsconfig/base.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "node16",
    "moduleResolution": "node16",
    "lib": ["ES2022"],
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}

// packages/tsconfig/library.json — for packages published to npm
{
  "extends": "./base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src",
    "composite": true        // required for --build mode (project references)
  },
  "exclude": ["dist", "node_modules", "**/*.test.ts"]
}
// servers/mcp-server-search/tsconfig.json — extends shared config
{
  "extends": "@myorg/tsconfig/library.json",
  "compilerOptions": {
    // Only override what differs from the base:
    // (nothing in this case — the shared config is sufficient)
  }
}

// servers/mcp-server-search/package.json — reference shared configs
{
  "name": "@myorg/mcp-server-search",
  "version": "1.0.0",
  "devDependencies": {
    "@myorg/tsconfig": "workspace:*",
    "@myorg/eslint-config": "workspace:*",
    // Each MCP server package must declare its own SDK dependency:
    "@modelcontextprotocol/sdk": "^1.4.0"
    // DO NOT rely on the workspace root's SDK dependency —
    // npm workspaces don't hoist peer deps to child packages automatically
  },
  "scripts": {
    "build":     "tsc --build",
    "typecheck": "tsc --noEmit",
    "lint":      "eslint src/",
    "test":      "vitest run"
  }
}

Remote cache setup in GitHub Actions

Turborepo's local cache only persists within a single CI run (or locally on one developer's machine). Remote cache stores task artifacts in a shared store so CI runs on different branches and different developers share the same cache. Vercel provides hosted remote cache (free tier available); you can also self-host with Turborepo's open-source remote cache server.

# .github/workflows/ci.yml — Turborepo with remote cache
name: CI
on: [push, pull_request]

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

      - name: Build, lint, and test
        run: turbo build lint typecheck test
        env:
          # Remote cache credentials from Vercel (or your self-hosted server)
          TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
          TURBO_TEAM:  ${{ secrets.TURBO_TEAM }}
          # With these set, Turborepo reads/writes cache to the remote store
          # First run: cache miss (builds everything, uploads artifacts)
          # Subsequent runs on same code: cache hit (downloads artifacts, skips builds)

      # Turborepo prints cache hit stats at the end:
      # Tasks: 12 successful, 12 total
      # Cached: 8 cached, 12 total  ← 8 tasks skipped (cache hits)
      # Time: 14.2s >>> FULL TURBO  ← "FULL TURBO" = 100% cache hit

To generate a TURBO_TOKEN, log in to vercel.com, go to Settings → Tokens, and create a token. The TURBO_TEAM is your Vercel team slug (found in the URL of your Vercel dashboard). Add both as GitHub repository secrets under Settings → Secrets → Actions. Without remote cache, Turborepo still parallelizes tasks within a single run but starts fresh on every CI run — useful for correctness, but slower than with cache sharing across runs.