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:
- Share a common framework β Auth, logging, error handling, MCP server boilerplate
- Maintain consistency β All servers follow the same patterns
- Test changes across all servers β One CI pipeline validates everything
- Deploy atomically β Coordinate releases when servers depend on each other
- 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.