Introduction: From Writing Code to Production
In the previous twelve articles of this series, we built a complete MCP ecosystem: servers with tools, resources, SQLite persistence, inter-server communication via EventBus and ClientManager. But a professional MCP server is not complete without a rigorous testing strategy and proper production preparation.
Testing an MCP server presents specific challenges: tools communicate through the JSON-RPC protocol,
events travel through asynchronous buses, and cross-server communication involves in-memory transports.
The @mcp-suite/testing package from the
Tech-MCP project solves
these complexities by providing dedicated utilities such as TestHarness,
InMemoryTransport, and MockEventBus.
What You Will Learn in This Article
- The MCP test pyramid: unit, integration, and wiring tests
- How to test the store in isolation with an in-memory database
- The
@mcp-suite/testingpackage: TestHarness, InMemoryTransport, MockEventBus - End-to-end integration testing of tools with
createTestHarness() - Verifying published events with
MockEventBus - Wiring tests for cross-server communication
- Security best practices: input validation, prepared statements, rate limiting
- Performance: SQLite WAL mode, indexes, batch transactions
- Production deployment: structured logging, graceful shutdown, Docker
The MCP Test Pyramid
A professional MCP server requires three levels of testing, organized in a pyramid that balances execution speed and functional coverage. At the base we find the fastest and most numerous tests, at the top the most complex but less frequent ones:
/\
/ \
/ W \ Wiring Tests
/ I R \ (cross-server with InMemoryTransport)
/ I I \
/ N N G \
/────────────\
/ INTEGRATION \ Tool Tests
/ (test harness) \ (tool → store → result)
/──────────────────\
/ \
/ UNIT TESTS \ Store Tests
/ (store in-memory) \ (pure logic, no MCP)
/──────────────────────────\
The Three MCP Test Levels
| Level | What It Tests | Speed | Setup Required |
|---|---|---|---|
| Unit | Store methods (persistence logic) | Very fast | In-memory store only |
| Integration | End-to-end tool via MCP protocol | Fast | TestHarness + InMemoryTransport |
| Wiring | Real cross-server communication | Medium | Multiple servers + ClientManager |
Store Unit Tests
The first level of the pyramid tests persistence logic in isolation, without involving
the MCP protocol. The store is instantiated with an in-memory database (inMemory: true),
ensuring each test starts from a clean state and execution is extremely fast.
Store unit tests verify all CRUD operations (Create, Read, Update, Delete), integrity constraints (UNIQUE constraints), filters, searches, and JSON data serialization/deserialization.
import { describe, it, expect, beforeEach } from "vitest";
import { NotesStore } from "../../src/services/notes-store.js";
describe("NotesStore", () => {
let store: NotesStore;
beforeEach(() => {
// Each test gets a fresh in-memory database
store = new NotesStore({ inMemory: true });
});
it("should add and retrieve a note", () => {
const note = store.addNote({
title: "Test",
content: "Test content",
tags: ["tag1", "tag2"],
});
expect(note.id).toBe(1);
expect(note.title).toBe("Test");
expect(note.tags).toEqual(["tag1", "tag2"]);
const retrieved = store.getNote(1);
expect(retrieved).toEqual(note);
});
it("should return undefined for non-existent note", () => {
const note = store.getNote(999);
expect(note).toBeUndefined();
});
it("should list notes ordered by updatedAt desc", () => {
store.addNote({ title: "First", content: "A" });
store.addNote({ title: "Second", content: "B" });
store.addNote({ title: "Third", content: "C" });
const notes = store.listNotes();
expect(notes).toHaveLength(3);
expect(notes[0].title).toBe("Third"); // Most recent first
});
it("should handle UNIQUE constraint on title", () => {
store.addNote({ title: "Unique", content: "A" });
expect(() => store.addNote({ title: "Unique", content: "B" }))
.toThrow();
});
it("should search notes by content", () => {
store.addNote({ title: "JS", content: "Arrow functions and closures" });
store.addNote({ title: "TS", content: "Generic types and interfaces" });
const results = store.searchNotes("functions");
expect(results).toHaveLength(1);
expect(results[0].title).toBe("JS");
});
it("should delete a note and return true", () => {
store.addNote({ title: "To delete", content: "X" });
expect(store.deleteNote(1)).toBe(true);
expect(store.getNote(1)).toBeUndefined();
});
});
Best Practices for Store Unit Tests
- Use
beforeEachwithinMemory: trueto ensure total isolation between tests - Test the complete CRUD cycle: create, read, update, delete
- Verify edge cases: non-existent records, violated constraints, empty filters
- Test JSON serialization/deserialization (tag arrays, nested objects)
- Do not test SQL queries directly: test the observable behavior of the store
Tool Integration Tests with TestHarness
The second level of the pyramid tests tools end-to-end through the MCP protocol.
The @mcp-suite/testing package provides the createTestHarness() function
that creates an in-memory client-server pair, allowing you to invoke tools exactly as a real
MCP client would.
How createTestHarness() Works
The function creates an InMemoryTransport pair, connects the server and client, and returns
a TestHarness object with the client ready to invoke tools:
export async function createTestHarness(server: McpServer): Promise<TestHarness> {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "1.0.0" });
await server.connect(serverTransport); // Server FIRST
await client.connect(clientTransport); // Client AFTER
return {
client,
close: async () => {
await client.close();
await server.close();
},
};
}
The connection order is critical: the server must connect before the client. This is because the server must be ready to handle the capability negotiation that the client initiates immediately after connecting.
Complete Tool Test
Here is a complete example that tests the add-note tool, verifying the MCP response format,
result content, and error handling:
import { describe, it, expect, afterEach } from "vitest";
import { createTestHarness, MockEventBus, type TestHarness } from "@mcp-suite/testing";
import { createNotesServer } from "../../src/server.js";
describe("add-note tool", () => {
let harness: TestHarness;
afterEach(async () => {
if (harness) await harness.close();
});
it("should add a note and return it as JSON", async () => {
const { server } = createNotesServer({ storeOptions: { inMemory: true } });
harness = await createTestHarness(server);
const result = await harness.client.callTool({
name: "add-note",
arguments: {
title: "Test Note",
content: "Hello World",
tags: ["test"],
},
});
// Verify MCP response format
const content = result.content as Array<{ type: string; text: string }>;
expect(content[0].type).toBe("text");
// Verify content
const note = JSON.parse(content[0].text);
expect(note.title).toBe("Test Note");
expect(note.content).toBe("Hello World");
expect(note.tags).toEqual(["test"]);
expect(note.id).toBeDefined();
});
it("should return error for duplicate title", async () => {
const { server } = createNotesServer({ storeOptions: { inMemory: true } });
harness = await createTestHarness(server);
await harness.client.callTool({
name: "add-note",
arguments: { title: "Duplicate", content: "First" },
});
const result = await harness.client.callTool({
name: "add-note",
arguments: { title: "Duplicate", content: "Second" },
});
expect(result.isError).toBe(true);
});
});
MockEventBus: Verifying Event Publishing
The MockEventBus is a test implementation of the EventBus interface that
records all published events without propagating them. It provides two key methods for assertions:
wasPublished(eventName): checks whether a specific event was publishedgetPublishedEvents(eventName): returns the array of all published events with that name, including their payloads
it("should publish event when note is added", async () => {
const eventBus = new MockEventBus();
const { server } = createNotesServer({
eventBus,
storeOptions: { inMemory: true },
});
harness = await createTestHarness(server);
await harness.client.callTool({
name: "add-note",
arguments: { title: "Event", content: "Test" },
});
// Verify that the event was published
expect(eventBus.wasPublished("notes:created")).toBe(true);
// Verify the event payload
const events = eventBus.getPublishedEvents("notes:created");
expect(events[0].payload).toMatchObject({ title: "Event" });
});
Test Pattern with MockEventBus
The MockEventBus allows you to verify that tools publish the correct events without needing to configure a real bus or subscribe handlers. This approach isolates the tool test from collaboration logic, following the single responsibility principle even in tests.
Cross-Server Wiring Tests
The third level of the pyramid tests real communication between MCP servers.
Wiring tests verify that one server can invoke tools on another server through the
McpClientManager, simulating a complete production scenario.
import { describe, it, expect, afterEach } from "vitest";
import { createTestHarness, type TestHarness } from "@mcp-suite/testing";
import { McpClientManager } from "@mcp-suite/client-manager";
import { createInsightEngineServer } from "../../src/server.js";
import { createAgileMetricsServer } from "../../../agile-metrics/src/server.js";
describe("insight-engine -> agile-metrics wiring", () => {
let callerHarness: TestHarness;
let clientManager: McpClientManager;
afterEach(async () => {
if (callerHarness) await callerHarness.close();
if (clientManager) await clientManager.disconnectAll();
});
it("should fetch velocity from agile-metrics", async () => {
// 1. Create the target server (the one being called)
const targetSuite = createAgileMetricsServer({
storeOptions: { inMemory: true },
});
// 2. Set up in-memory connection via ClientManager
clientManager = new McpClientManager();
const [ct, st] = McpClientManager.createInMemoryPair();
await targetSuite.server.connect(st);
await clientManager.connectInMemoryWithTransport("agile-metrics", ct);
// 3. Create the caller server (the one making the call)
const callerSuite = createInsightEngineServer({
clientManager,
storeOptions: { inMemory: true },
});
callerHarness = await createTestHarness(callerSuite.server);
// 4. Invoke the tool that makes a cross-server call
const result = await callerHarness.client.callTool({
name: "health-dashboard",
arguments: {},
});
// 5. Verify the result
const content = result.content as Array<{ type: string; text: string }>;
const dashboard = JSON.parse(content[0].text);
expect(dashboard.dataSources["agile-metrics"]).toBe("available");
});
});
Wiring Test Structure
Every wiring test follows a precise 5-phase flow:
- Create the target server in-memory: the server that will be called by the caller
- Configure the transport: create an
InMemoryTransportpair and connect the target to the ClientManager - Create the caller server with the ClientManager: the server making the cross-server call
- Create the TestHarness for the caller: allows invoking the caller's tools as if it were an MCP client
- Verify the result: ensure data from the target server was retrieved correctly
Test File Organization
In the Tech-MCP project, test files follow a standardized structure for each server:
servers/my-server/
tests/
services/
my-store.test.ts # Store unit tests
tools/
add-item.test.ts # Tool integration test
get-stats.test.ts # Tool integration test
get-stats-wiring.test.ts # Cross-server wiring test
server.test.ts # Factory test (optional)
The Vitest configuration is minimal and shared through the workspace:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["tests/**/*.test.ts"],
},
});
Security Best Practices
Bringing an MCP server to production requires security attention at multiple levels. Every tool is a potential entry point for malicious input, and every exposed HTTP endpoint needs adequate protection.
Input Validation with Zod
The MCP SDK automatically handles argument validation through the Zod schemas declared in the tool definition. Using precise constraints is the first line of defense:
import { z } from "zod";
server.tool(
"create-project",
"Create a new project",
{
name: z.string().min(1).max(100),
budget: z.number().positive().optional(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).max(10).optional(),
startDate: z.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Format: YYYY-MM-DD")
.optional(),
},
async (args) => {
// args is already validated and typed by Zod
// ...
},
);
Prepared Statements and SQL Injection
The store must never execute SQL built from concatenated strings. The better-sqlite3
library uses prepared statements by default, eliminating SQL injection risk:
// CORRECT: prepared statement with positional parameters
const stmt = this.db.prepare("SELECT * FROM items WHERE title = ?");
const item = stmt.get(title);
// WRONG: SQL injection vulnerable
const item = this.db.exec(`SELECT * FROM items WHERE title = '${title}'`);
HTTP Authentication
For MCP servers exposed over the network via HTTP transport, protect the endpoint with Bearer token authentication:
import express from "express";
const app = express();
const API_KEY = process.env.MCP_API_KEY;
app.use("/mcp", (req, res, next) => {
if (!API_KEY) return next(); // Dev mode: no authentication
const auth = req.headers.authorization;
if (auth !== `Bearer ${API_KEY}`) {
return res.status(401).json({ error: "Unauthorized" });
}
next();
});
Principle of Least Privilege
- Expose only the operations that are strictly necessary
- Read-only tools must not be able to write
- Separate dangerous tools (
delete,purge) from safe tools (list,get) - In production, consider separate tools for destructive operations
- Do not expose stack traces in errors: only human-readable messages for the AI
Error Handling in Tools
Every tool must catch exceptions and return structured errors in the MCP format.
The isError: true flag signals to the AI that the operation failed, allowing
the language model to decide whether to retry or inform the user:
server.tool(
"my-tool",
"Tool description",
{ param: z.string() },
async ({ param }) => {
try {
const result = store.doSomething(param);
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
};
} catch (error) {
return {
content: [{
type: "text",
text: `Error: ${error instanceof Error ? error.message : String(error)}`,
}],
isError: true,
};
}
},
);
Error Handling Rules
- Never throw unhandled exceptions: the tool MUST always return a result
isError: true: signals to the AI model that the operation failed- Readable message: the AI reads the error message to decide the next action
- No stack traces in production: do not expose server internal details
SQLite Performance
SQLite database performance is critical for a responsive MCP server. The Tech-MCP project adopts three main strategies to optimize performance.
WAL Mode (Write-Ahead Logging)
WAL mode significantly improves concurrent read performance, allowing multiple readers to access the database simultaneously without blocking writes:
constructor(options?: { inMemory?: boolean }) {
this.db = new Database(
options?.inMemory ? ":memory:" : "data/my-server.db"
);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
this.migrate();
}
Transactions for Batch Operations
Transactions improve performance by orders of magnitude for multiple insertions and guarantee atomicity (all or nothing):
addBulkItems(items: NewItem[]): Item[] {
const insert = this.db.prepare(
"INSERT INTO items (title, content) VALUES (?, ?)"
);
const addMany = this.db.transaction((items: NewItem[]) => {
return items.map(item => {
const result = insert.run(item.title, item.content);
return { id: Number(result.lastInsertRowid), ...item };
});
});
return addMany(items);
}
Strategic Indexes
Add indexes on columns frequently used in filters and sorting:
CREATE INDEX IF NOT EXISTS idx_items_created ON items(createdAt);
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
Structured Logging for Production
MCP over STDIO uses stdout for JSON-RPC messages. Logging MUST use
stderr to avoid corrupting the communication protocol:
// CORRECT: log to stderr (does not interfere with MCP)
console.error("[INFO] Server started on port 3000");
console.error("[ERROR] Database connection failed:", error.message);
// WRONG: stdout corrupts the JSON-RPC protocol
console.log("Server started"); // BREAKS MCP!
JSON Structured Logger
For production environments, a structured logger that produces JSON output on stderr facilitates integration with monitoring systems like ELK Stack or Datadog:
type LogLevel = "debug" | "info" | "warn" | "error";
function createLogger(name: string, level: LogLevel = "info") {
const levels: Record<LogLevel, number> = {
debug: 0, info: 1, warn: 2, error: 3,
};
const minLevel = levels[level];
return {
debug: (msg: string, data?: unknown) => {
if (minLevel <= 0) console.error(JSON.stringify({
level: "debug", server: name, msg, data,
ts: new Date().toISOString()
}));
},
info: (msg: string, data?: unknown) => {
if (minLevel <= 1) console.error(JSON.stringify({
level: "info", server: name, msg, data,
ts: new Date().toISOString()
}));
},
warn: (msg: string, data?: unknown) => {
if (minLevel <= 2) console.error(JSON.stringify({
level: "warn", server: name, msg, data,
ts: new Date().toISOString()
}));
},
error: (msg: string, data?: unknown) => {
if (minLevel <= 3) console.error(JSON.stringify({
level: "error", server: name, msg, data,
ts: new Date().toISOString()
}));
},
};
}
const logger = createLogger("my-server");
logger.info("Tool invoked", { tool: "add-item", args: { title: "Test" } });
Graceful Shutdown
In production, the server must handle termination signals cleanly, closing connections and releasing resources before exiting:
// index.ts - Server entry point
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMyServer } from "./server.js";
const { server } = createMyServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[INFO] Server started");
// Handle SIGINT signal (Ctrl+C)
process.on("SIGINT", async () => {
console.error("[INFO] Shutting down...");
await server.close();
process.exit(0);
});
// Handle SIGTERM signal (container orchestration)
process.on("SIGTERM", async () => {
console.error("[INFO] Terminating...");
await server.close();
process.exit(0);
});
Docker Deployment
For containerized deployment, here is an optimized Dockerfile for an MCP server with multi-stage build and minimal final image:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy only dependency files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/ ./packages/
COPY servers/my-server/package.json ./servers/my-server/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source code
COPY servers/my-server/ ./servers/my-server/
COPY tsconfig.json ./
# Build
RUN pnpm --filter @mcp-suite/server-my-server build
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy only compiled files and production dependencies
COPY --from=builder /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
COPY --from=builder /app/packages/ ./packages/
COPY --from=builder /app/servers/my-server/dist/ ./servers/my-server/dist/
COPY --from=builder /app/servers/my-server/package.json ./servers/my-server/
RUN pnpm install --frozen-lockfile --prod
# Directory for SQLite databases
RUN mkdir -p /app/data
VOLUME ["/app/data"]
# Environment variables
ENV NODE_ENV=production
# Entry point
CMD ["node", "servers/my-server/dist/index.js"]
Claude Desktop Configuration for Production
To use an MCP server with Claude Desktop, add the configuration to the
claude_desktop_config.json file:
Local STDIO Server
{
"mcpServers": {
"my-server": {
"command": "node",
"args": ["/path/to/server/dist/index.js"],
"env": {
"NODE_ENV": "production"
}
}
}
}
Remote HTTP Server
{
"mcpServers": {
"my-server": {
"url": "http://localhost:3000/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}
}
Pre-Production Checklist
Before deploying an MCP server, systematically verify every point in this checklist:
Complete Deployment Checklist
| Area | Requirement | Verification |
|---|---|---|
| Functionality | Complete Zod validation on all tools | Schemas with min(), max(), enum() |
| Functionality | Error handling with try/catch and isError: true |
No unhandled exceptions |
| Functionality | Test coverage for happy path and error cases | Unit + integration tests pass |
| Security | No console.log on stdout |
Only console.error for logging |
| Security | Prepared statements for all SQL queries | No SQL string concatenation |
| Security | Authentication on exposed HTTP endpoints | Bearer token or API key |
| Performance | WAL mode enabled on SQLite | pragma("journal_mode = WAL") |
| Performance | Indexes on filtered columns | CREATE INDEX for WHERE/ORDER BY columns |
| Deployment | Graceful shutdown with SIGINT and SIGTERM | Server closes connections correctly |
| Architecture | Server factory with optional parameters | eventBus, clientManager, storeOptions |
Monorepo with Workspaces
For projects with multiple MCP servers, a monorepo with pnpm workspaces and Turborepo simplifies dependency management, compilation, and testing:
# pnpm-workspace.yaml
packages:
- "packages/*"
- "servers/*"
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
}
}
}
The "dependsOn": ["^build"] directive tells Turborepo to build dependencies first,
then the current package. The build system automatically resolves the order and parallelizes
independent compilations, significantly reducing build times.
Evolutionary Path: From Zero to Production
In this series, we have traversed a complete path through four levels of increasing complexity:
Level 1 (Basic)
─────────────────────────────────
In-memory tools + STDIO
Single client + Zod validation
|
v
Level 2 (Intermediate)
─────────────────────────────────
Resources + Prompts
HTTP transport + SQLite store
Versioned migrations
|
v
Level 3 (Advanced)
─────────────────────────────────
EventBus (fire-and-forget)
ClientManager (cross-server)
Collaboration handlers
|
v
Level 4 (Production)
─────────────────────────────────
Complete test pyramid
Structured logging
Security + Performance
Monorepo + CI/CD + Docker
Conclusions
Testing and production preparation are fundamental phases in developing professional MCP servers. The test pyramid -- unit, integration, and wiring -- ensures coverage at every level of the architecture, from persistence logic to cross-server communication.
The @mcp-suite/testing package with TestHarness, InMemoryTransport,
and MockEventBus enormously simplifies test setup, allowing you to verify tool behavior
through the real MCP protocol without external dependencies.
Security best practices (Zod validation, prepared statements, authentication), performance (WAL mode, indexes, transactions), and deployment (structured logging, graceful shutdown, Docker) complete the path toward a production-ready MCP server.
In the next and final article of the series, we will present a complete overview of the Tech-MCP project: the catalog of all 31 servers, project statistics, lessons learned, and how to contribute.
The complete code for all examples is available in the Tech-MCP GitHub repository.
Commenti
Caricamento commenti...
Sign in to comment