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/andservers/ - 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
McpSuiteServerinterface and thecreateMcpServer()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'sworkspace: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 installand a singlepnpm buildfor the entire project -
Safe refactoring: modifying an interface in
@mcp-suite/coreimmediately 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/coreare 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.tsand.d.ts.map, enabling "Go to Definition" across the entire monorepo directly in the IDE -
Strict mode:
"strict": trueintsconfig.base.jsonfor 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
.dbfile 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-sqlite3uses 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