odule exports a factory that applies a namespace prefix to every tool definition. This prevents name collisions and creates clear ownership boundaries.
// src/bundles/types.ts
import { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
export interface ToolHandler<TInput = unknown> {
definition: Tool;
execute: (input: TInput, context: RequestContext) => Promise<CallToolResult>;
}
export interface DomainBundle {
prefix: string;
tools: ToolHandler[];
}
export function assembleBundle(prefix: string, handlers: ToolHandler[]): DomainBundle {
return {
prefix,
tools: handlers.map((h) => ({
...h,
definition: {
...h.definition,
name: `${prefix}__${h.definition.name}`,
},
})),
};
}
Why this works: The double-underscore prefix is a widely adopted MCP convention. It is human-readable, easily parsable by client agents, and guarantees that catalog__search and admin__search never collide. The factory pattern ensures that namespace application is deterministic and centralized.
Step 2: Implement Context Injection
Flat servers often rely on module-level variables or global singletons for authentication, database connections, or feature flags. This causes state leakage across concurrent requests. The fix is a typed RequestContext that is instantiated per invocation and injected into every handler.
// src/context.ts
export interface AuthScope {
principalId: string | null;
roles: string[];
tokenExpiry: number;
}
export interface RequestContext {
requestId: string;
auth: AuthScope;
createdAt: number;
refreshAuth: () => Promise<void>;
}
export function initializeContext(): RequestContext {
return {
requestId: crypto.randomUUID(),
auth: { principalId: null, roles: [], tokenExpiry: 0 },
createdAt: Date.now(),
refreshAuth: async () => {
const session = await authProvider.validateSession();
this.auth = {
principalId: session?.userId ?? null,
roles: session?.permissions ?? [],
tokenExpiry: session?.expiresAt ?? 0,
};
},
};
}
Handlers now receive context as a second argument. This eliminates closure dependencies and enforces type-safe permission checks.
// src/bundles/inventory.ts
import { assembleBundle } from "./types.js";
export const inventoryBundle = assembleBundle("inventory", [
{
definition: {
name: "fetch_stock",
description: "Retrieve current stock levels for a product SKU",
inputSchema: {
type: "object",
properties: { sku: { type: "string", description: "Product identifier" } },
required: ["sku"],
},
},
execute: async ({ sku }: { sku: string }, ctx: RequestContext) => {
if (!ctx.auth.roles.includes("inventory:read")) {
return { content: [{ type: "text", text: "Insufficient permissions" }], isError: true };
}
const record = await db.stock.findBySku(sku);
return { content: [{ type: "text", text: JSON.stringify(record) }] };
},
},
]);
Why this works: Context is ephemeral and request-scoped. The refreshAuth method ensures credentials are validated at the start of each call, preventing stale token usage. Type safety guarantees that handlers cannot accidentally mutate shared state.
Step 3: Build an Event Publication Layer
MCP supports server-initiated notifications. Instead of polling for state changes, handlers can emit structured events after mutations. This requires a lightweight publisher that serializes domain facts and pushes them through the MCP transport.
// src/events.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
export type DomainEventType =
| "inventory.updated"
| "orders.submitted"
| "users.suspended";
export interface DomainEvent<T = unknown> {
type: DomainEventType;
payload: T;
timestamp: number;
}
export function createPublisher(server: Server) {
return {
async dispatch<T>(type: DomainEventType, payload: T): Promise<void> {
const event: DomainEvent<T> = {
type,
payload,
timestamp: Date.now(),
};
await server.notification({
method: "notifications/message",
params: {
level: "info",
logger: type,
data: JSON.stringify(event),
},
});
},
};
}
Usage inside a mutation handler:
execute: async ({ sku, quantity }: { sku: string; quantity: number }, ctx: RequestContext) => {
const updated = await db.stock.adjust(sku, quantity);
await publisher.dispatch("inventory.updated", { sku, newQuantity: updated.count });
return { content: [{ type: "text", text: JSON.stringify(updated) }] };
},
Why this works: Notifications are fire-and-forget from the server's perspective, but they provide real-time feedback to connected agents. The publisher abstracts the MCP notification schema, keeping handlers focused on business logic.
Step 4: Compose the Server
The bootstrap file ties bundles, context, and events together. It flattens all tool definitions, builds an O(1) lookup map, and wires the request handler.
// src/server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { initializeContext } from "./context.js";
import { createPublisher } from "./events.js";
import { inventoryBundle } from "./bundles/inventory.js";
import { ordersBundle } from "./bundles/orders.js";
const server = new Server(
{ name: "production-mcp", version: "2.1.0" },
{ capabilities: { tools: {}, logging: {} } }
);
const publisher = createPublisher(server);
const bundles = [inventoryBundle, ordersBundle];
const toolRegistry = new Map<string, ToolHandler["execute"]>();
const allDefinitions: Tool[] = [];
for (const bundle of bundles) {
for (const tool of bundle.tools) {
toolRegistry.set(tool.definition.name, tool.execute);
allDefinitions.push(tool.definition);
}
}
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: allDefinitions,
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const ctx = initializeContext();
await ctx.refreshAuth();
const handler = toolRegistry.get(request.params.name);
if (!handler) {
return {
content: [{ type: "text", text: `Unregistered tool: ${request.params.name}` }],
isError: true,
};
}
return handler(request.params.arguments ?? {}, ctx);
});
const transport = new StdioServerTransport();
await server.connect(transport);
Why this works: The toolRegistry map provides constant-time routing. Adding a new domain requires zero changes to this file. Context initialization and auth refresh happen per-request, guaranteeing isolation. The architecture is explicitly additive: new capabilities scale linearly without increasing coupling.
Pitfall Guide
1. Context Mutation Across Requests
Explanation: Developers sometimes attach mutable objects to the context and reuse them across handlers, expecting them to persist. MCP handlers are stateless by design; context should never carry request-scoped mutations that leak to subsequent calls.
Fix: Treat RequestContext as immutable after initialization. Use dedicated state managers or database transactions for cross-handler persistence.
2. Synchronous Event Emission Blocking the Handler
Explanation: Calling publisher.dispatch() synchronously inside a handler can delay the response if the transport experiences backpressure.
Fix: Fire-and-forget notifications should be decoupled from the response path. Use void publisher.dispatch(...) or queue events in a microtask to ensure the handler returns immediately.
3. Namespace Collisions in Multi-Team Environments
Explanation: Without strict prefix enforcement, teams may accidentally register data__export in two different modules.
Fix: Enforce prefix uniqueness at build time. Add a validation step in the bundle assembly that throws if a prefix is reused. Document naming conventions in the team's architecture decision record.
Explanation: MCP clients already validate against inputSchema. Duplicating validation logic in handlers increases latency and creates maintenance drift.
Fix: Rely on the SDK's schema validation. Only implement runtime checks for business rules that cannot be expressed in JSON Schema (e.g., cross-field dependencies, external state checks).
5. Ignoring Error Boundaries in Domain Handlers
Explanation: Uncaught exceptions in a handler crash the MCP process or return malformed JSON-RPC responses.
Fix: Wrap handler execution in a try/catch that maps errors to the MCP isError format. Log stack traces server-side but return sanitized messages to clients.
6. Treating MCP Notifications as Guaranteed Delivery
Explanation: Notifications are best-effort. Network drops or client disconnections mean events may be lost without retry logic.
Fix: Design handlers to be idempotent. If an event is critical, persist it to a durable queue and emit the notification as a side effect. Clients should implement reconciliation on reconnect.
7. Premature Abstraction for Single-Developer Projects
Explanation: Applying domain bundles to a three-tool server introduces unnecessary indirection.
Fix: Use flat registration until tool count exceeds six or team size exceeds one. Refactor to bounded contexts when merge conflicts or state leakage become observable.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| < 5 tools, single developer | Flat registration | Lower boilerplate, faster iteration | Minimal infrastructure overhead |
| 6–15 tools, growing team | Domain bundles + context injection | Prevents merge conflicts, enforces state isolation | Moderate initial setup, high long-term ROI |
| > 15 tools, multi-team ownership | Full DDD mapping + event publisher | Enables parallel development, real-time agent feedback | Higher cognitive load, requires strict conventions |
| High-frequency mutations | Async event queue + MCP notification | Guarantees delivery, prevents handler blocking | Infrastructure cost for queue, improved reliability |
Configuration Template
// src/config.ts
export const MCP_CONFIG = {
server: {
name: "enterprise-mcp",
version: "1.0.0",
capabilities: { tools: true, logging: true, notifications: true },
},
transport: "stdio",
context: {
authRefreshInterval: 300000, // 5 minutes
requestIdHeader: "x-mcp-request-id",
},
events: {
maxRetries: 3,
fallbackLogger: "console",
},
};
Quick Start Guide
- Install the SDK:
npm install @modelcontextprotocol/sdk
- Create a
bundles/ directory and define your first domain module using assembleBundle()
- Implement
initializeContext() with your authentication provider
- Wire the server bootstrap using the
toolRegistry Map pattern
- Connect via
StdioServerTransport and test with an MCP client like Claude Desktop or a custom agent