Skip to main content

13 - Testing, Best Practices and Production

In the previous twelve articles of this series, we built a complete MCP ecosystem: servers with tools, resources, SQLite persistence…

What do you think about this article?

At a Glance

In the previous twelve articles of this series, we built a complete MCP ecosystem: servers with tools, resources, SQLite persistence…

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/testing package: 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 beforeEach with inMemory: true to 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 published
  • getPublishedEvents(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:

  1. Create the target server in-memory: the server that will be called by the caller
  2. Configure the transport: create an InMemoryTransport pair and connect the target to the ClientManager
  3. Create the caller server with the ClientManager: the server making the cross-server call
  4. Create the TestHarness for the caller: allows invoking the caller's tools as if it were an MCP client
  5. 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

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