Skip to main content

03 - Monorepo Architecture and Project Patterns

Organize an MCP project with Turborepo monorepo, pnpm workspaces, shared packages and architectural patterns for managing 30+ servers.

What do you think about this article?

At a Glance

Organize an MCP project with Turborepo monorepo, pnpm workspaces, shared packages and architectural patterns for managing 30+ servers.

Introduction: Organizing a Large-Scale MCP Project

When developing a single MCP server, the project structure is simple: a package.json, a few TypeScript files, and you're ready to go. But as the number of servers grows, organizational complexity explodes. How do you share common code without publishing to npm? How do you ensure all servers compile in the correct order? How do you maintain consistency in structure and patterns?

The answer to these questions is the monorepo: a single repository that contains all servers and shared packages. In this article, we analyze the monorepo architecture of the Tech-MCP project, the technological choices at its foundation (TypeScript, SQLite, STDIO/HTTP), and the 4-layer pattern that every server follows to ensure uniformity and maintainability.

What You'll Learn in This Article

  • How to structure an MCP monorepo with pnpm workspaces and Turborepo
  • The complete directory structure with packages/ and servers/
  • Why TypeScript, SQLite, and STDIO/HTTP are the optimal choices for MCP servers
  • The 4-layer architectural pattern: index.ts, server.ts, tools/, services/
  • The McpSuiteServer interface and the createMcpServer() factory
  • The build pipeline with Turborepo and the dependency graph

Why a Monorepo for MCP Servers?

A monorepo collects all project components in a single Git repository. For a suite of MCP servers, the advantages are significant:

  • Code sharing without publishing: packages in packages/ are available to all servers through pnpm's workspace: protocol, without needing to publish them to npm
  • Atomic and ordered builds: Turborepo analyzes the dependency graph and compiles packages in the correct order, parallelizing where possible
  • Unified versioning: all packages and servers evolve together, eliminating version compatibility issues
  • Superior Developer Experience: a single pnpm install and a single pnpm build for the entire project
  • Safe refactoring: modifying an interface in @mcp-suite/core immediately highlights all servers that need updating

Complete Monorepo Structure

The Tech-MCP project is organized into three main directories: the root with configuration files, packages/ for shared libraries, and servers/ for independent MCP servers.


mcp-suite/
├── package.json              # Root: global scripts, engine constraints
├── pnpm-workspace.yaml       # Defines packages/* and servers/* as workspaces
├── turbo.json                # Build pipeline with Turborepo
├── tsconfig.base.json        # Shared TypeScript configuration
│
├── packages/                 # Shared libraries (6 packages)
│   ├── core/                 #   Server factory, config, logger, errors, types
│   ├── event-bus/            #   Typed EventBus with 29 events
│   ├── database/             #   SQLite connection + migrations
│   ├── testing/              #   Test harness + MockEventBus
│   ├── cli/                  #   CLI for managing servers
│   └── client-manager/       #   MCP client pool for server-to-server communication
│
├── servers/                  # 22 independent MCP servers
│   ├── scrum-board/          #   Sprint, story, and task management
│   ├── standup-notes/        #   Daily standup notes
│   ├── time-tracking/        #   Time tracking
│   ├── agile-metrics/        #   Agile metrics (velocity, burndown)
│   ├── code-review/          #   Code analysis and review
│   ├── test-generator/       #   Automatic test generation
│   ├── cicd-monitor/         #   CI/CD pipeline monitoring
│   ├── docker-compose/       #   Docker Compose management
│   ├── db-schema-explorer/   #   Database schema exploration
│   ├── dependency-manager/   #   Project dependency management
│   ├── api-documentation/    #   API documentation
│   ├── codebase-knowledge/   #   Code knowledge base
│   ├── data-mock-generator/  #   Mock data generation
│   ├── environment-manager/  #   Environment variable management
│   ├── http-client/          #   HTTP client for API testing
│   ├── log-analyzer/         #   Application log analysis
│   ├── performance-profiler/ #   Performance profiling
│   ├── project-economics/    #   Project economics (budget, costs)
│   ├── project-scaffolding/  #   New project scaffolding
│   ├── regex-builder/        #   Regular expression builder
│   ├── retrospective-manager/#   Agile retrospective management
│   └── snippet-manager/      #   Code snippet library
│
└── docs/                     # Project documentation
    

This structure implements the principle of server independence: each server is a self-contained folder with its own package.json, tsconfig.json, and source code. At the same time, optional collaboration is enabled through the shared packages in packages/.

The 6 Shared Packages

The packages in packages/ provide the common foundation for all servers:

Shared Packages and Their Responsibilities

Package Responsibility Dependencies
@mcp-suite/core Server factory, configuration, logger, errors, shared types event-bus
@mcp-suite/event-bus Typed EventBus with 29 events and pub/sub pattern None
@mcp-suite/database SQLite connection with WAL mode and migration system core
@mcp-suite/testing Test harness and MockEventBus for unit testing core, event-bus
@mcp-suite/cli CLI for managing, starting, and monitoring servers core, event-bus, client-manager
@mcp-suite/client-manager MCP client pool for server-to-server communication core

Design Decisions: Why TypeScript, SQLite, and STDIO

The technologies underpinning the project were not chosen randomly. Each decision addresses specific requirements of a distributed MCP system.

Why TypeScript?

TypeScript is the ideal language for MCP servers for four fundamental reasons:

  • End-to-end type-safety: types defined in @mcp-suite/core are shared across all servers, ensuring compile-time consistency. An interface change immediately propagates as an error in all servers that use it
  • Native ESM: target ES2022 with "module": "Node16" for native compatibility with modern Node.js, without needing additional bundlers or transpilers
  • Declaration maps: each package generates .d.ts and .d.ts.map, enabling "Go to Definition" across the entire monorepo directly in the IDE
  • Strict mode: "strict": true in tsconfig.base.json for maximum type safety and runtime error prevention

Why SQLite (better-sqlite3)?

For the local persistence of each server, SQLite is the optimal choice:

  • Zero configuration: no database server to install or manage. The .db file is created automatically on first execution
  • File-based: each server has its own database file in ~/.mcp-suite/data/, completely independent from other servers
  • Synchronous: better-sqlite3 uses synchronous C++ bindings, ideal for fast local operations without the complexity of Promises for local I/O
  • WAL mode: Write-Ahead Logging enabled for optimal concurrent read performance, essential when multiple tools access the store simultaneously
  • Portable: the database moves simply by copying the file, facilitating backup and migration

Transports: STDIO and HTTP

MCP Suite supports two transports, selectable via the MCP_SUITE_TRANSPORT environment variable:

MCP Transport Comparison

Feature STDIO (default) HTTP (Streamable HTTP)
Use case Local use with Claude Desktop, Cursor, VS Code Remote deployments, inter-server communication
Configuration No ports, no network conflicts Dedicated port per server
Security Local communication, no exposure Stateful protocol with session UUID
Scalability One process per connection Containers, horizontal scaling
Routes stdin/stdout POST/GET/DELETE /mcp + GET /health

STDIO is the default transport, ideal for local development. HTTP becomes necessary when servers need to communicate with each other through the Client Manager or when deploying to remote servers.

Monorepo Configuration

Three files in the root directory define the monorepo structure: the pnpm workspace, the Turborepo pipeline, and the root package.json.

pnpm-workspace.yaml

This file declares which directories contain the monorepo workspaces:


# pnpm-workspace.yaml
packages:
  - "packages/*"
  - "servers/*"
    

With this configuration, pnpm treats each subfolder of packages/ and servers/ as an independent workspace. Internal dependencies are resolved through the workspace:* protocol instead of being fetched from npm.

turbo.json

Turborepo manages the build pipeline, defining compilation order and task dependencies:


{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": []
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    }
  }
}
    

The key directive is "dependsOn": ["^build"]. The ^ symbol indicates that each workspace's build task depends on the build of its internal dependencies. This ensures that @mcp-suite/core is compiled before any server that uses it.

Server package.json with Internal Dependencies

Each server declares its own dependencies, both external (npm) and internal (workspace):


{
  "name": "@mcp-suite/scrum-board",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "start": "node dist/index.js",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@mcp-suite/core": "workspace:*",
    "@mcp-suite/event-bus": "workspace:*",
    "@mcp-suite/database": "workspace:*",
    "@modelcontextprotocol/sdk": "^1.0.0",
    "better-sqlite3": "^11.0.0",
    "zod": "^3.23.0"
  },
  "devDependencies": {
    "@types/better-sqlite3": "^7.6.0",
    "typescript": "^5.7.0"
  }
}
    

The "workspace:*" syntax tells pnpm to resolve the dependency from the local workspace instead of fetching it from npm. This means changes to shared packages are immediately available to all servers after recompilation.

The Build Pipeline

When running pnpm build from the monorepo root, Turborepo analyzes the dependency graph and plans the optimal build:


pnpm build
    │
    ├── @mcp-suite/event-bus      (no internal dependencies)
    ├── @mcp-suite/core           (depends on event-bus)
    ├── @mcp-suite/database       (depends on core)
    ├── @mcp-suite/testing        (depends on core, event-bus)
    ├── @mcp-suite/client-manager (depends on core)
    ├── @mcp-suite/cli            (depends on core, event-bus, client-manager)
    │
    └── servers/* (all depend on core, event-bus, database)
        ├── scrum-board
        ├── standup-notes
        ├── time-tracking
        └── ... (other 19 servers compiled in parallel)
    

Turborepo compiles event-bus first (no dependencies), then core (depends on event-bus), then database and other packages, and finally all servers in parallel. Turborepo's built-in cache skips recompilation of unchanged packages, making incremental builds extremely fast.

The 4-Layer Server Pattern

Every MCP server in the suite follows a rigorous 4-layer architectural pattern. This pattern ensures uniformity, maintainability, and testability across all 22 servers. This is not an optional convention but an architectural contract: every new server must adhere to this structure.


servers/server-name/
├── package.json        # Dependencies and scripts
├── tsconfig.json       # Extends tsconfig.base.json
└── src/
    ├── index.ts        # Layer 1: Entry point, bootstrap
    ├── server.ts       # Layer 2: Factory, tool registration
    ├── tools/          # Layer 3: One file per tool
    │   ├── create-sprint.ts
    │   ├── get-sprint.ts
    │   └── ...
    ├── services/       # Layer 4: SQLite store, business logic
    │   └── scrum-store.ts
    └── collaboration.ts  # Cross-server event handlers (optional)
    

The 4 Layers and Their Responsibilities

Layer File Responsibility Dependencies
1. Entry Point index.ts Bootstrap: create EventBus, call factory, start transport @mcp-suite/core, @mcp-suite/event-bus
2. Factory server.ts Create McpSuiteServer, instantiate store, register tools @mcp-suite/core, services, tools
3. Tools tools/*.ts Single tool definition: Zod schema + async handler @modelcontextprotocol/sdk, services
4. Services services/*.ts SQLite persistence, migrations, domain logic @mcp-suite/database

Layer 1: Entry Point (index.ts)

The entry point is the simplest file in the entire server. It has exactly three responsibilities: create a LocalEventBus instance, call the server factory, and start the transport.


#!/usr/bin/env node

import { startServer } from '@mcp-suite/core';
import { LocalEventBus } from '@mcp-suite/event-bus';
import { createScrumBoardServer } from './server.js';

const eventBus = new LocalEventBus();
const suite = createScrumBoardServer(eventBus);
startServer(suite).catch((error) => {
  console.error('Failed to start scrum-board server:', error);
  process.exit(1);
});
    

The shebang #!/usr/bin/env node enables direct execution as a binary. The EventBus is created here and injected into the server via Dependency Injection. The startServer() function automatically selects the transport (STDIO or HTTP) based on the configuration. Fatal errors terminate the process with process.exit(1).

Layer 2: Factory (server.ts)

The factory is the heart of the server. It creates the McpSuiteServer object, instantiates persistence services, registers all tools, and configures collaboration with other servers.


import { createMcpServer, type McpSuiteServer, type EventBus } from '@mcp-suite/core';
import { ScrumStore } from './services/scrum-store.js';
import { registerCreateSprint } from './tools/create-sprint.js';
import { registerGetSprint } from './tools/get-sprint.js';
import { registerCreateStory } from './tools/create-story.js';
import { registerCreateTask } from './tools/create-task.js';
import { registerUpdateTaskStatus } from './tools/update-task-status.js';
import { registerSprintBoard } from './tools/sprint-board.js';
import { registerGetBacklog } from './tools/get-backlog.js';
import { setupCollaborationHandlers } from './collaboration.js';

export function createScrumBoardServer(eventBus?: EventBus): McpSuiteServer {
  const suite = createMcpServer({
    name: 'scrum-board',
    version: '0.1.0',
    description: 'MCP server for managing sprints, user stories, tasks',
    eventBus,
  });

  const store = new ScrumStore();

  // Register all tools
  registerCreateSprint(suite.server, store, suite.eventBus);
  registerGetSprint(suite.server, store);
  registerCreateStory(suite.server, store);
  registerCreateTask(suite.server, store);
  registerUpdateTaskStatus(suite.server, store, suite.eventBus);
  registerSprintBoard(suite.server, store);
  registerGetBacklog(suite.server, store);

  // Cross-server collaboration (only if EventBus is present)
  if (suite.eventBus) {
    setupCollaborationHandlers(suite.eventBus, store);
  }

  suite.logger.info('All scrum-board tools registered');

  return suite;
}
    

Notice how createMcpServer() returns a fully configured McpSuiteServer object. The store is created once and shared among all tools through the registration functions. The eventBus is optional: if not provided, the server operates in standalone mode.

Layer 3: Tool Registration (tools/)

Each tool is a separate file that exports a registerXxx() function. This function receives the McpServer, the store, and optionally the EventBus, and registers a single tool with its Zod validation schema and async handler.


import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import type { EventBus } from '@mcp-suite/core';
import type { ScrumStore } from '../services/scrum-store.js';

export function registerCreateSprint(
  server: McpServer,
  store: ScrumStore,
  eventBus?: EventBus,
): void {
  server.tool(
    'create-sprint',                                         // Tool name
    'Create a new sprint with a name, date range, and goals',  // LLM description
    {                                                         // Zod schema
      name: z.string().describe('Sprint name (e.g. "Sprint 12")'),
      startDate: z.string().describe('Start date ISO (YYYY-MM-DD)'),
      endDate: z.string().describe('End date ISO (YYYY-MM-DD)'),
      goals: z.array(z.string()).describe('Sprint goals'),
    },
    async ({ name, startDate, endDate, goals }) => {          // Handler
      try {
        const sprint = store.createSprint({ name, startDate, endDate, goals });

        // Fire-and-forget event publishing
        eventBus?.publish('scrum:sprint-started', {
          sprintId: String(sprint.id),
          name: sprint.name,
          startDate: sprint.startDate,
          endDate: sprint.endDate,
        });

        return {
          content: [{ type: 'text' as const, text: JSON.stringify(sprint, null, 2) }],
        };
      } catch (error) {
        return {
          content: [{
            type: 'text' as const,
            text: `Failed to create sprint: ${error instanceof Error ? error.message : String(error)}`,
          }],
          isError: true,
        };
      }
    },
  );
}
    

The registration pattern follows a precise structure: a unique tool name, an LLM-readable description to understand when to use it, a Zod schema for automatic input parameter validation, and an async handler with error handling. The EventBus is used with the optional chaining operator eventBus?.publish(...): if it doesn't exist, the call simply does nothing.

Layer 4: Services and Store (services/)

Services contain the business logic and persistence. The Store is the main class that manages CRUD operations on the SQLite database, using the migration system from @mcp-suite/database.


import type Database from 'better-sqlite3';
import { createDatabase } from '@mcp-suite/database';
import { runMigrations, type Migration } from '@mcp-suite/database';

const migrations: Migration[] = [
  {
    version: 1,
    description: 'Create sprints, stories, and tasks tables',
    up: `
      CREATE TABLE IF NOT EXISTS sprints (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        startDate TEXT NOT NULL,
        endDate TEXT NOT NULL,
        goals TEXT NOT NULL DEFAULT '[]',
        status TEXT NOT NULL DEFAULT 'planning',
        createdAt TEXT NOT NULL DEFAULT (datetime('now'))
      );
    `,
  },
];

export class ScrumStore {
  private db: Database.Database;

  constructor(options?: { inMemory?: boolean; dataDir?: string }) {
    this.db = createDatabase({
      serverName: 'scrum-board',
      inMemory: options?.inMemory,
      dataDir: options?.dataDir,
    });
    runMigrations(this.db, migrations);
  }

  createSprint(input: {
    name: string; startDate: string; endDate: string; goals: string[]
  }): Sprint {
    const stmt = this.db.prepare(
      'INSERT INTO sprints (name, startDate, endDate, goals) VALUES (?, ?, ?, ?)',
    );
    const result = stmt.run(
      input.name, input.startDate, input.endDate,
      JSON.stringify(input.goals)
    );
    return this.getSprint(Number(result.lastInsertRowid))!;
  }

  // ... other CRUD methods ...
}
    

The Store uses createDatabase() from @mcp-suite/database to create the SQLite database with WAL mode enabled. Migrations are defined as an array of Migration objects and applied automatically in the constructor. The constructor supports inMemory: true for tests, allowing unit tests to run without touching the filesystem.

The McpSuiteServer Interface

At the center of the architecture lies the McpSuiteServer interface, the contract that every server must respect. This interface is created by the createMcpServer() factory and passed to all tool registration functions.


export interface CreateServerOptions {
  name: string;
  version: string;
  description?: string;
  config?: Partial<ServerConfig>;
  eventBus?: EventBus;
}

export interface McpSuiteServer {
  server: McpServer;      // MCP server instance (official SDK)
  config: ServerConfig;   // Loaded and validated configuration
  logger: Logger;         // Structured logger on stderr
  eventBus?: EventBus;    // Optional EventBus for collaboration
}

export function createMcpServer(options: CreateServerOptions): McpSuiteServer {
  const config = loadConfig(options.name, options.config);
  const logger = new Logger(options.name, config.logLevel);

  logger.info(`Initializing ${options.name} v${options.version}`);

  const server = new McpServer({
    name: options.name,
    version: options.version,
  });

  return { server, config, logger, eventBus: options.eventBus };
}
    

The factory's internal flow is linear: it loads configuration from environment variables (loadConfig), creates the logger with the configured level, instantiates the McpServer from the official SDK, and returns the complete bundle. The eventBus field is optional: if present, tools can publish events; if absent, tools operate in standalone mode without any side effects.

Complete Architectural Diagram

The following diagram shows how all components relate to each other, from AI clients down to external dependencies:


┌────────────────────────────────────────────────────────────────────┐
│             CLIENT (Claude Desktop, Cursor, VS Code)               │
│          Communicates via STDIO or HTTP (JSON-RPC / Streamable)    │
└──────────────┬─────────────────────────────────┬───────────────────┘
               │                                 │
               v                                 v
┌──────────────────────┐           ┌──────────────────────┐
│   scrum-board        │           │   time-tracking      │
│   ┌──────────────┐   │ EventBus  │   ┌──────────────┐   │
│   │  tools/      │   │<────────>│   │  tools/      │   │
│   │  services/   │   │(optional) │   │  services/   │   │
│   │  server.ts   │   │           │   │  server.ts   │   │
│   │  index.ts    │   │           │   │  index.ts    │   │
│   └──────────────┘   │           │   └──────────────┘   │
└──────────┬───────────┘           └──────────┬───────────┘
           │                                  │
           v                                  v
┌────────────────────────────────────────────────────────────────────┐
│                    PACKAGES (shared libraries)                     │
│                                                                    │
│  ┌────────────┐  ┌────────────┐  ┌──────────┐  ┌────────────┐     │
│  │ @mcp-suite│  │ @mcp-suite│  │@mcp-suite│  │ @mcp-suite│     │
│  │ /core     │  │ /event-bus │  │/database │  │ /testing   │     │
│  └────────────┘  └────────────┘  └──────────┘  └────────────┘     │
└────────────────────────────────────────────────────────────────────┘
                                │
                                v
┌────────────────────────────────────────────────────────────────────┐
│  @modelcontextprotocol/sdk       better-sqlite3          zod      │
│  (official MCP protocol)        (native database)     (validation)│
└────────────────────────────────────────────────────────────────────┘
    

EventBus Injection Flow

A fundamental aspect of the pattern is how the EventBus is injected through the various layers. The design follows the fire-and-forget principle: if the EventBus doesn't exist, eventBus?.publish(...) calls do nothing. If it exists but no one is listening, the event is simply ignored. The tool never waits for the event's response.


index.ts                   server.ts                      tools/xxx.ts
────────                   ─────────                      ────────────

const eventBus =           function createXxxServer(      function registerXxx(
  new LocalEventBus();       eventBus?: EventBus            server, store,
                           ) {                              eventBus?: EventBus
const suite =                const suite = createMcpServer( ) {
  createXxxServer(             { eventBus }                  // usage:
    eventBus                 );                              eventBus?.publish(...)
  );                                                       }
                             registerXxx(
                               suite.server,
                               store,
                               suite.eventBus  // passed to tools
                             );
                           }
    

This Dependency Injection pattern ensures each component receives only the dependencies it needs. The EventBus is always optional, so adding or removing collaboration requires no structural changes to the server.

Advantages of the 4-Layer Pattern

  • Separation of concerns: each layer has a well-defined task, making code easy to read and maintain
  • Testability: services can be tested with in-memory databases, tools can be tested with store mocks
  • Scalability: adding a new tool only requires a new file in tools/ and registration in the factory
  • Uniformity: all 22 servers follow the same structure, making it easy to navigate and understand any server
  • Independence: each server is independently deployable, with its own dependencies and database

Conclusions

The monorepo architecture with pnpm workspaces and Turborepo, combined with the 4-layer server pattern, provides a solid foundation for a scalable and maintainable MCP suite. The technological choices (TypeScript for type-safety, SQLite for local persistence, STDIO/HTTP for transports) were guided by the specific needs of an MCP server ecosystem.

The McpSuiteServer interface and the createMcpServer() factory standardize the creation of new servers, while the layer separation (entry point, factory, tools, services) ensures each component has a clear and limited responsibility.

In the next article, we'll move from theory to practice: we'll create our first MCP server in TypeScript from scratch, following the pattern described in this article. We'll see how to set up the project, define tools with Zod, implement persistence with SQLite, and test the server with Claude Desktop.

The complete code for all examples is available in the Tech-MCP GitHub repository.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects