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 = '






