Back to Blog

MCP Server Monorepo Setup: Building Scalable AI Skill Architectures

Ultrion TeamJuly 22, 202614 min read

title: "MCP Server Monorepo Setup: Building Scalable AI Skill Architectures" slug: "mcp-server-monorepo-setup" description: "A complete guide to structuring MCP server monorepos for scale β€” tooling, CI/CD, testing, deployment patterns, and best practices for managing dozens of MCP servers." category: "Technical" publishedAt: "2026-07-22"

MCP Server Monorepo Setup: Building Scalable AI Skill Architectures

When you're building one MCP server, a simple repository works fine. But when you're managing 5, 10, or 50 MCP servers β€” each with different tools, dependencies, and deployment cycles β€” you need a monorepo. This guide shows you how to structure, build, test, and deploy MCP servers at scale using a monorepo architecture.

Why a Monorepo for MCP Servers?

Managing multiple MCP servers in separate repositories creates several problems:

  • Shared code duplication β€” Authentication, logging, and utilities copied across repos
  • Inconsistent versions β€” Server A uses utils v1.2, Server B uses v1.4, Server C is still on v1.0
  • Difficult refactoring β€” Changing a shared interface requires updates across multiple repos
  • Fragmented CI/CD β€” Each repo has its own pipeline, deployment process, and monitoring
  • Complex dependency management β€” Coordinating releases across repos is error-prone

A monorepo solves all of these. Companies like Google, Meta, and Stripe use monorepos for the same reasons. For MCP servers specifically, a monorepo lets you:

  1. Share a common framework β€” Auth, logging, error handling, MCP server boilerplate
  2. Maintain consistency β€” All servers follow the same patterns
  3. Test changes across all servers β€” One CI pipeline validates everything
  4. Deploy atomically β€” Coordinate releases when servers depend on each other
  5. Onboard faster β€” New server setup is a scaffold command, not a repo creation

Monorepo Structure

mcp-monorepo/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ shared/                    # Shared utilities and types
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ jwt.ts         # JWT authentication
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ api-key.ts     # API key authentication
β”‚   β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ middleware/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ rate-limit.ts  # Rate limiting
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ logging.ts     # Request logging
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ error-handling.ts
β”‚   β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ tools/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ base-tool.ts   # Base tool class
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ validation.ts  # Zod schemas
β”‚   β”‚   β”‚   β”‚   └── pagination.ts  # Pagination helpers
β”‚   β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   β”‚   └── index.ts       # Shared TypeScript types
β”‚   β”‚   β”‚   └── utils/
β”‚   β”‚   β”‚       β”œβ”€β”€ cache.ts       # Redis caching
β”‚   β”‚   β”‚       β”œβ”€β”€ retry.ts       # Retry logic
β”‚   β”‚   β”‚       └── tracing.ts     # OpenTelemetry
β”‚   β”‚   β”œβ”€β”€ package.json
β”‚   β”‚   └── tsconfig.json
β”‚   β”‚
β”‚   β”œβ”€β”€ server-template/           # Template for new servers
β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts           # Entry point
β”‚   β”‚   β”‚   β”œβ”€β”€ tools/
β”‚   β”‚   β”‚   └── resources/
β”‚   β”‚   β”œβ”€β”€ Dockerfile
β”‚   β”‚   └── package.json
β”‚   β”‚
β”‚   β”œβ”€β”€ servers/                   # Actual MCP servers
β”‚   β”‚   β”œβ”€β”€ crm/
β”‚   β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ tools/
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ contact-lookup.ts
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ deal-search.ts
β”‚   β”‚   β”‚   β”‚   β”‚   └── activity-log.ts
β”‚   β”‚   β”‚   β”‚   └── resources/
β”‚   β”‚   β”‚   β”‚       └── schemas.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ tests/
β”‚   β”‚   β”‚   β”œβ”€β”€ Dockerfile
β”‚   β”‚   β”‚   └── package.json
β”‚   β”‚   β”‚
β”‚   β”‚   β”œβ”€β”€ database/
β”‚   β”‚   β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ tools/
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ query.ts
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ schema-inspect.ts
β”‚   β”‚   β”‚   β”‚   β”‚   └── export.ts
β”‚   β”‚   β”‚   β”‚   └── connections/
β”‚   β”‚   β”‚   β”‚       β”œβ”€β”€ postgres.ts
β”‚   β”‚   β”‚   β”‚       └── redis.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ tests/
β”‚   β”‚   β”‚   └── package.json
β”‚   β”‚   β”‚
β”‚   β”‚   β”œβ”€β”€ email/
β”‚   β”‚   β”œβ”€β”€ calendar/
β”‚   β”‚   β”œβ”€β”€ file-storage/
β”‚   β”‚   └── analytics/
β”‚   β”‚
β”‚   └── cli/                       # CLI tooling
β”‚       β”œβ”€β”€ src/
β”‚       β”‚   β”œβ”€β”€ scaffold.ts        # Create new server
β”‚       β”‚   β”œβ”€β”€ deploy.ts          # Deploy server
β”‚       β”‚   └── test.ts            # Run tests
β”‚       └── package.json
β”‚
β”œβ”€β”€ deployments/                   # Kubernetes/deployment configs
β”‚   β”œβ”€β”€ base/
β”‚   β”‚   β”œβ”€β”€ deployment.yaml
β”‚   β”‚   β”œβ”€β”€ service.yaml
β”‚   β”‚   └── ingress.yaml
β”‚   └── overlays/
β”‚       β”œβ”€β”€ staging/
β”‚       └── production/
β”‚
β”œβ”€β”€ .github/
β”‚   └── workflows/
β”‚       β”œβ”€β”€ ci.yml                 # Test all packages
β”‚       └── deploy.yml             # Deploy changed servers
β”‚
β”œβ”€β”€ turbo.json                     # Turborepo config
β”œβ”€β”€ package.json                   # Root package.json
β”œβ”€β”€ tsconfig.base.json             # Shared TypeScript config
└── README.md

Tooling: Turborepo + pnpm

Root package.json

{
  "name": "mcp-monorepo",
  "private": true,
  "scripts": {
    "dev": "turbo dev",
    "build": "turbo build",
    "test": "turbo test",
    "lint": "turbo lint",
    "deploy": "turbo deploy",
    "scaffold": "pnpm --filter @mcp/cli scaffold"
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "typescript": "^5.5.0",
    "prettier": "^3.3.0"
  },
  "packageManager": "pnpm@9.5.0"
}

turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["tsconfig.base.json"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"],
      "inputs": ["src/**/*.ts", "tests/**/*.ts"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "deploy": {
      "dependsOn": ["build", "test"],
      "outputs": []
    }
  }
}

pnpm-workspace.yaml

packages:
  - "packages/*"
  - "packages/servers/*"

The Shared Package: Your Secret Weapon

The shared package is what makes the monorepo worthwhile. It contains all the cross-cutting concerns that every MCP server needs:

Base MCP Server Class

// packages/shared/src/server/base-server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { authMiddleware } from "../auth";
import { loggingMiddleware } from "../middleware";
import { rateLimiter } from "../middleware/rate-limit";
import { tracingMiddleware } from "../utils/tracing";

export class BaseMCPServer {
  protected server: McpServer;
  protected name: string;
  protected version: string;
  
  constructor(config: { name: string; version: string }) {
    this.name = config.name;
    this.version = config.version;
    
    this.server = new McpServer({
      name: config.name,
      version: config.version
    });
    
    this.setupMiddleware();
  }
  
  private setupMiddleware() {
    // Auth, logging, rate limiting, tracing β€” all servers get these
    this.server.use(authMiddleware);
    this.server.use(loggingMiddleware);
    this.server.use(rateLimiter({ requestsPerMinute: 100 }));
    this.server.use(tracingMiddleware(this.name));
  }
  
  protected registerTool(config: ToolConfig) {
    // Common tool registration with validation, error handling, metrics
    this.server.tool(
      config.name,
      config.description,
      config.schema,
      async (params, context) => {
        const startTime = Date.now();
        try {
          const result = await config.handler(params, context);
          metrics.recordToolCall(config.name, "success", Date.now() - startTime);
          return result;
        } catch (error) {
          metrics.recordToolCall(config.name, "error", Date.now() - startTime);
          throw error;
        }
      }
    );
  }
  
  async start(port: number = 3000) {
    const transport = new StreamableHTTPServerTransport({});
    await this.server.connect(transport);
    
    const app = express();
    app.get("/health", (req, res) => res.json({ status: "healthy", name: this.name }));
    app.post("/mcp", transport.handleRequest);
    
    app.listen(port);
    logger.info(`${this.name} v${this.version} listening on port ${port}`);
  }
}

Using the Base in a Specific Server

// packages/servers/crm/src/index.ts
import { BaseMCPServer } from "@mcp/shared";
import { z } from "zod";

class CRMMCPServer extends BaseMCPServer {
  constructor() {
    super({ name: "crm-server", version: "1.2.0" });
    this.registerTools();
  }
  
  private registerTools() {
    this.registerTool({
      name: "contact_lookup",
      description: "Look up a contact by email or ID",
      schema: z.object({
        email: z.string().email().optional(),
        id: z.string().optional()
      }),
      handler: async (params) => {
        const contact = await crmDb.findContact(params);
        return {
          content: [{ type: "text", text: JSON.stringify(contact) }]
        };
      }
    });
    
    this.registerTool({
      name: "deal_search",
      description: "Search deals by stage, value, or date",
      schema: z.object({
        stage: z.string().optional(),
        min_value: z.number().optional(),
        since: z.string().optional()
      }),
      handler: async (params) => {
        const deals = await crmDb.searchDeals(params);
        return {
          content: [{ type: "text", text: JSON.stringify(deals) }]
        };
      }
    });
  }
}

const server = new CRMMCPServer();
server.start(3000);

Testing Strategy

Shared Test Utilities

// packages/shared/src/testing/index.ts
import { BaseMCPServer } from "../server/base-server";

export async function testTool<T extends BaseMCPServer>(
  server: T,
  toolName: string,
  params: any
): Promise<any> {
  const tools = server.getTools();
  const tool = tools.find(t => t.name === toolName);
  
  if (!tool) throw new Error(`Tool ${toolName} not found`);
  
  const result = await tool.handler(params, { client: { id: "test" } });
  return result;
}

export function createTestServer<T extends BaseMCPServer>(
  ServerClass: new () => T
): T {
  return new ServerClass();
}

Per-Server Tests

// packages/servers/crm/tests/contact-lookup.test.ts
import { describe, test, expect } from "vitest";
import { createTestServer, testTool } from "@mcp/shared/testing";
import { CRMMCPServer } from "../src";

describe("CRM Server: contact_lookup", () => {
  const server = createTestServer(CRMMCPServer);
  
  test("finds contact by email", async () => {
    const result = await testTool(server, "contact_lookup", {
      email: "test@example.com"
    });
    
    expect(result.content[0].text).toBeDefined();
    const contact = JSON.parse(result.content[0].text);
    expect(contact.email).toBe("test@example.com");
  });
  
  test("validates email format", async () => {
    await expect(
      testTool(server, "contact_lookup", { email: "not-an-email" })
    ).rejects.toThrow();
  });
  
  test("handles missing contact gracefully", async () => {
    const result = await testTool(server, "contact_lookup", {
      email: "nonexistent@example.com"
    });
    
    const data = JSON.parse(result.content[0].text);
    expect(data.error).toBe("Contact not found");
  });
});

CI/CD Pipeline

GitHub Actions: Test Everything

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v4
        with:
          version: 9.5.0
      
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      
      - run: pnpm install --frozen-lockfile
      
      - run: pnpm build
      
      - run: pnpm test -- --coverage
      
      - run: pnpm lint
      
      # Type check all packages
      - run: pnpm -r exec tsc --noEmit

Deploy Only Changed Servers

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      servers: ${{ steps.changes.outputs.servers }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2
      
      - id: changes
        run: |
          # Detect which servers changed
          CHANGED=$(git diff --name-only HEAD^ HEAD | \
            grep "^packages/servers/" | \
            cut -d'/' -f3 | \
            sort -u | \
            jq -R . | jq -sc .)
          echo "servers=$CHANGED" >> $GITHUB_OUTPUT
  
  deploy:
    needs: detect-changes
    if: needs.detect-changes.outputs.servers != '[]'
    runs-on: ubuntu-latest
    strategy:
      matrix:
        server: ${{ fromJson(needs.detect-changes.outputs.servers) }}
    steps:
      - uses: actions/checkout@v4
      
      - run: pnpm install --frozen-lockfile
      - run: pnpm build --filter @mcp/servers/${{ matrix.server }}
      
      - name: Build Docker image
        run: |
          docker build \
            -t registry.gitlab.com/myorg/mcp-${{ matrix.server }}:latest \
            -t registry.gitlab.com/myorg/mcp-${{ matrix.server }}:${{ github.sha }} \
            -f packages/servers/${{ matrix.server }}/Dockerfile \
            .
      
      - name: Push image
        run: docker push registry.gitlab.com/myorg/mcp-${{ matrix.server }} --all-tags
      
      - name: Deploy to Kubernetes
        run: |
          helm upgrade --install mcp-${{ matrix.server }} \
            ./deployments/charts/mcp-server \
            --set imageName=mcp-${{ matrix.server }} \
            --set imageTag=${{ github.sha }} \
            --namespace mcp-servers

Scaffolding New Servers

Create new MCP servers in seconds:

// packages/cli/src/scaffold.ts
import fs from "fs";
import path from "path";

const TEMPLATE = `
import { BaseMCPServer } from "@mcp/shared";
import { z } from "zod";

class {{ClassName}}Server extends BaseMCPServer {
  constructor() {
    super({ name: "{{name}}", version: "1.0.0" });
    this.registerTools();
  }
  
  private registerTools() {
    // TODO: Register your tools here
    this.registerTool({
      name: "hello",
      description: "A sample tool",
      schema: z.object({ name: z.string() }),
      handler: async ({ name }) => ({
        content: [{ type: "text", text: \`Hello, \${name}!\` }]
      })
    });
  }
}

const server = new {{ClassName}}Server();
server.start(process.env.PORT || 3000);
`;

const args = process.argv.slice(2);
const serverName = args[0];

if (!serverName) {
  console.error("Usage: scaffold <server-name>");
  process.exit(1);
}

const className = serverName.split("-").map(w => 
  w.charAt(0).toUpperCase() + w.slice(1)
).join("");

const serverDir = path.join("packages", "servers", serverName);
fs.mkdirSync(path.join(serverDir, "src", "tools"), { recursive: true });
fs.mkdirSync(path.join(serverDir, "tests"), { recursive: true });

// Write source file
fs.writeFileSync(
  path.join(serverDir, "src", "index.ts"),
  TEMPLATE.replace(/{{ClassName}}/g, className).replace(/{{name}}/g, serverName)
);

// Write package.json
fs.writeFileSync(
  path.join(serverDir, "package.json"),
  JSON.stringify({
    name: `@mcp/servers/${serverName}`,
    version: "1.0.0",
    main: "dist/index.js",
    scripts: {
      dev: "tsx src/index.ts",
      build: "tsc",
      test: "vitest"
    },
    dependencies: {
      "@mcp/shared": "workspace:*",
      "@modelcontextprotocol/sdk": "^2026.1.0"
    }
  }, null, 2)
);

console.log(`βœ… Created ${serverName} server at ${serverDir}`);

Monitoring Across All Servers

// packages/shared/src/monitoring/fleet-dashboard.ts
export class FleetDashboard {
  async getFleetStatus(): Promise<FleetStatus> {
    const servers = await this.registry.getAllServers();
    
    const statuses = await Promise.all(
      servers.map(async (server) => ({
        name: server.name,
        version: server.version,
        status: await this.checkHealth(server.endpoint),
        uptime: await this.getUptime(server.name),
        requestsToday: await this.getRequestCount(server.name, "today"),
        errorRate: await this.getErrorRate(server.name),
        avgLatency: await this.getAvgLatency(server.name),
        monthlyCost: await this.getMonthlyCost(server.name)
      }))
    );
    
    return {
      totalServers: servers.length,
      healthyServers: statuses.filter(s => s.status === "healthy").length,
      servers: statuses,
      aggregate: {
        totalRequestsToday: statuses.reduce((sum, s) => sum + s.requestsToday, 0),
        avgErrorRate: avg(statuses.map(s => s.errorRate)),
        totalMonthlyCost: statuses.reduce((sum, s) => sum + s.monthlyCost, 0)
      }
    };
  }
}

Conclusion

A monorepo for MCP servers transforms how you build, deploy, and maintain AI tool infrastructure. The shared package eliminates duplication, the testing framework ensures quality, and the CI/CD pipeline automates deployment. Start with 2-3 servers in a monorepo, add the shared package, and grow from there. The initial setup takes a day; the ongoing productivity gains compound forever.

If you're building more than 3 MCP servers and still using separate repositories, you're losing 5-10 hours per week to context switching, duplicated code, and inconsistent patterns. Make the switch. Your future self will thank you.

Newsletter

Enjoying this article?

Get weekly insights on building and selling AI skills, MCP tools, and creator economics. Join 2,000+ AI builders and creators.

No spam. Unsubscribe anytime.

Get the Free MCP Server Handbook

50+ pages of practical guides, code examples, and production-ready templates.

  • Complete MCP protocol reference
  • 15+ production-ready templates
  • Security best practices guide

No spam. Unsubscribe anytime. We respect your privacy.

Related Articles

Ready to try AI skills?

Browse the marketplace and discover skills for your AI agents.

Browse Skills