Guide · Architecture

MCP server monorepo patterns

As your MCP server project grows — multiple servers for different domains, a shared schema package used by both the server and its tests, reusable test utilities across packages — a monorepo becomes worthwhile. The key value isn't just colocation: it's the shared schema package that defines your tool definitions once and uses them in the server, in snapshot tests, and in client-side type generation, ensuring every layer stays in sync when a tool changes.

TL;DR

Structure your monorepo with apps/ for runnable MCP servers and packages/ for shared code. Create a packages/mcp-schema that exports Zod-validated tool definitions — both the server and the tests import from it, so a schema change is a single edit propagated everywhere by TypeScript. Use pnpm workspaces for linking and Turborepo for incremental build/test pipelines. In CI, run pnpm --filter ...[HEAD~1] run test to only test packages affected by the current commit.

When a monorepo makes sense for MCP servers

SignalRecommendation
One MCP server, no shared packagesSingle-repo — monorepo overhead not justified
Two or more MCP servers sharing type definitionsMonorepo with packages/mcp-schema
MCP server + MCP client (e.g., a CLI that talks to your server)Monorepo — share types between server and client
MCP server + REST API sharing database schema / validationMonorepo — share Prisma schema or Zod validators
Multiple teams working on separate MCP serversSeparate repos — monorepo coordination cost exceeds benefit

Directory structure

mcp-platform/
├── apps/
│   ├── mcp-server-documents/     # Document search MCP server
│   │   ├── src/
│   │   │   ├── index.ts          # Server factory
│   │   │   └── cli.ts            # stdio entry point
│   │   ├── package.json
│   │   └── tsconfig.json
│   └── mcp-server-analytics/     # Analytics MCP server
│       ├── src/
│       ├── package.json
│       └── tsconfig.json
├── packages/
│   ├── mcp-schema/               # Shared tool definitions
│   │   ├── src/
│   │   │   ├── documents.ts      # Document tool schemas
│   │   │   └── analytics.ts     # Analytics tool schemas
│   │   └── package.json
│   └── test-utils/               # Shared test helpers
│       ├── src/
│       │   ├── connect.ts        # createMcpTestClient factory
│       │   └── fakes.ts          # createFakeDb(), createFakeCache()
│       └── package.json
├── pnpm-workspace.yaml
├── turbo.json
├── tsconfig.base.json
└── .eslintrc.base.js

pnpm workspace configuration

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
// packages/mcp-schema/package.json
{
  "name": "@yourscope/mcp-schema",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    "./documents": "./dist/documents.js",
    "./analytics": "./dist/analytics.js"
  },
  "dependencies": {
    "zod": "^3.22.0"
  },
  "scripts": {
    "build": "tsc",
    "type-check": "tsc --noEmit"
  }
}
// apps/mcp-server-documents/package.json
{
  "name": "@yourscope/mcp-server-documents",
  "type": "module",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.15.0",
    "@yourscope/mcp-schema": "workspace:*"
  },
  "devDependencies": {
    "@yourscope/test-utils": "workspace:*",
    "vitest": "^2.1.0"
  }
}

The workspace:* protocol tells pnpm to link the local package rather than resolving from the registry. When you npm publish the app package, pnpm replaces workspace:* with the actual version number of the published dependency.

The shared schema package

The core value of the monorepo is defining tool schemas once and using them everywhere. The schema package exports Zod schemas and the corresponding TypeScript types, plus the tool definition objects that get registered with the MCP SDK:

// packages/mcp-schema/src/documents.ts
import { z } from 'zod';

export const GetDocumentArgsSchema = z.object({
  document_id: z.string().uuid().describe('The UUID of the document to retrieve'),
  format: z.enum(['markdown', 'plain', 'html']).optional().default('markdown'),
});

export type GetDocumentArgs = z.infer<typeof GetDocumentArgsSchema>;

export const SearchDocumentsArgsSchema = z.object({
  query: z.string().min(1).max(500).describe('Full-text search query'),
  limit: z.number().int().min(1).max(50).optional().default(10),
  tags: z.array(z.string()).optional().describe('Filter by tag names'),
});

export type SearchDocumentsArgs = z.infer<typeof SearchDocumentsArgsSchema>;

// Tool definition objects — registered with MCP SDK
export const DOCUMENT_TOOLS = [
  {
    name: 'get_document' as const,
    description: 'Retrieve a document by its UUID. Returns the document content in the specified format.',
    inputSchema: {
      type: 'object' as const,
      properties: {
        document_id: { type: 'string', format: 'uuid', description: 'The UUID of the document to retrieve' },
        format: { type: 'string', enum: ['markdown', 'plain', 'html'], description: 'Output format' },
      },
      required: ['document_id'],
    },
  },
  {
    name: 'search_documents' as const,
    description: 'Full-text search across all documents. Returns up to 50 matching documents with title and excerpt.',
    inputSchema: {
      type: 'object' as const,
      properties: {
        query: { type: 'string', minLength: 1, maxLength: 500, description: 'Search query' },
        limit: { type: 'number', minimum: 1, maximum: 50, description: 'Max results' },
        tags: { type: 'array', items: { type: 'string' }, description: 'Filter by tags' },
      },
      required: ['query'],
    },
  },
] as const;

export type DocumentToolName = typeof DOCUMENT_TOOLS[number]['name'];

The MCP server imports the schemas and tool definitions:

// apps/mcp-server-documents/src/index.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
import {
  DOCUMENT_TOOLS,
  GetDocumentArgsSchema,
  SearchDocumentsArgsSchema,
} from '@yourscope/mcp-schema/documents';

export function createServer(deps: { db: DocumentDb }) {
  const server = new Server(
    { name: 'documents-mcp', version: '1.0.0' },
    { capabilities: { tools: {} } }
  );

  server.setRequestHandler(ListToolsRequestSchema, async () => ({
    tools: DOCUMENT_TOOLS, // Imported from shared schema
  }));

  server.setRequestHandler(CallToolRequestSchema, async (req) => {
    if (req.params.name === 'get_document') {
      const args = GetDocumentArgsSchema.parse(req.params.arguments);
      // ... handler logic
    }
    // ...
  });

  return server;
}

Tests also import from the schema package — when a tool definition changes, the test snapshot fails immediately, requiring an explicit version bump decision:

// packages/test-utils/src/connect.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';

export async function createMcpTestClient(serverFactory: () => Server) {
  const [serverTransport, clientTransport] = InMemoryTransport.createLinkedPair();
  const server = serverFactory();
  await server.connect(serverTransport);
  const client = new Client({ name: 'test', version: '1' }, { capabilities: {} });
  await client.connect(clientTransport);
  return { client, cleanup: () => client.close() };
}

Turborepo pipeline

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "cache": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    },
    "lint": {
      "cache": true
    }
  }
}

The "^build" syntax means "build all dependencies first." So when you run turbo build for mcp-server-documents, Turborepo first builds mcp-schema (since it's listed as a workspace dependency), then builds the app. Turborepo caches build outputs — if neither the schema package nor the app has changed, the next build is instant.

# Run all builds in dependency order
npx turbo build

# Run all tests (builds first if needed)
npx turbo test

# Run only for affected packages since last git commit
npx turbo run test --filter=...[HEAD~1]

CI workflow with change detection

name: CI

on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2 # Need parent commit for --filter

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      # Only run tests for packages changed in this commit (or their dependents)
      - name: Test changed packages
        run: pnpm --filter "...[HEAD~1]" run test

      # Always type-check everything (fast, catches cross-package type errors)
      - name: Type check all packages
        run: pnpm --filter "*" run type-check

The --filter "...[HEAD~1]" flag uses pnpm's change detection to find packages that changed in the last commit (or any packages that depend on them). This means editing packages/mcp-schema automatically triggers tests for both mcp-server-documents and mcp-server-analytics since they both depend on the schema package.

Shared TypeScript configuration

// tsconfig.base.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  }
}
// apps/mcp-server-documents/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src/**/*"],
  "references": [
    { "path": "../../packages/mcp-schema" },
    { "path": "../../packages/test-utils" }
  ]
}

TypeScript project references (references field) enable the tsc --build mode that understands the dependency graph and builds packages in the correct order with incremental compilation.

Multiple AliveMCP monitors in a monorepo

Each deployed MCP server in the monorepo gets its own AliveMCP monitor. Store the monitor IDs in each app's configuration:

# apps/mcp-server-documents/.env.production
ALIVEMCP_MONITOR_ID=mon_abc123
SERVICE_URL=https://documents.yourdomain.com

# apps/mcp-server-analytics/.env.production
ALIVEMCP_MONITOR_ID=mon_def456
SERVICE_URL=https://analytics.yourdomain.com

In the CI/CD deploy workflow, add a post-deploy step that pings AliveMCP's API to confirm the specific server's probe is green before marking the deploy successful. Separate monitors mean a failure in the analytics server doesn't obscure whether the documents server is healthy.

Related pages

FAQ

pnpm workspaces vs npm workspaces vs Yarn workspaces — which should I use?

pnpm is the recommended choice for MCP server monorepos. It uses hard links to avoid duplicating node_modules across packages (important when multiple servers share the MCP SDK, which is large), and its --filter syntax is more expressive than npm's. Yarn v4 (Berry) is a valid alternative. npm workspaces work but lack a --filter equivalent for change detection. Avoid mixing package managers within the same monorepo.

Do I need Turborepo or can I just use pnpm's built-in workspace commands?

pnpm's --filter gives you change detection, but it doesn't cache build outputs. Turborepo adds caching on top: if a package's source hasn't changed and its dependencies haven't changed, Turborepo skips the build/test entirely and replays cached outputs in under a second. For monorepos with more than three packages, the time savings are significant. If you have just two or three packages, plain pnpm workspaces are simpler and sufficient.

How do I share environment variables across packages in the monorepo?

Don't. Each package should have its own .env files. Sharing environment variables via a root .env leaks configuration from one service to another, which is a security concern and creates implicit coupling. The packages/mcp-schema package should have no environment variables — it's pure TypeScript. Each apps/* package reads only the env vars it explicitly declares.

How do I version packages in the monorepo when they have internal dependencies?

Use changesets for monorepo versioning. When you publish, changesets understands that bumping mcp-schema from 1.2 to 2.0 (a major bump for a breaking tool schema change) should also trigger a major bump for mcp-server-documents and mcp-server-analytics, since they both depend on it. Running changeset version propagates the version bump through the dependency graph automatically.

Can I deploy monorepo packages to different cloud platforms?

Yes. Each app in the monorepo deploys independently. A common pattern: apps/mcp-server-documents deploys to Railway, apps/mcp-server-analytics deploys to Fly.io. The deploy workflow runs with pnpm --filter mcp-server-documents deploy:prod, packaging only that app's dependencies. Use Turborepo's --filter in CI to deploy only apps whose packages changed in the PR.