Mcp

Testing MCP Servers: Unit and Integration

Complete guide to testing MCP servers with unit tests, integration tests, mocked transports, and CI pipeline setup.

Testing MCP Servers: Unit and Integration

Testing MCP (Model Context Protocol) servers presents a unique set of challenges that traditional API testing does not cover. Your MCP server exposes tools and resources to AI clients over a JSON-RPC transport, and every piece of that pipeline — from input validation to streaming responses to transport negotiation — needs to be verified. This article walks through a complete testing strategy for MCP servers built in Node.js, covering unit tests for individual tools, integration tests with real clients, transport mocking, CI pipeline configuration, and load testing.

Prerequisites

Before diving in, you should have a working MCP server built with the @modelcontextprotocol/sdk package. You should also be comfortable with Node.js testing fundamentals — Mocha as a test runner and Chai for assertions. Familiarity with the MCP specification (JSON-RPC 2.0 over stdio or HTTP+SSE) is assumed.

You will need the following packages installed:

npm install --save-dev mocha chai sinon nock supertest
npm install --save-dev @modelcontextprotocol/sdk

Your package.json test script should look like this:

{
  "scripts": {
    "test": "mocha --recursive --timeout 10000 test/**/*.test.js"
  }
}

Testing Challenges Unique to MCP Servers

MCP servers are not REST APIs. They operate over JSON-RPC 2.0, which means requests and responses follow a strict envelope format with id, method, params, and result fields. This creates several testing challenges that do not exist in typical HTTP API testing.

Transport coupling. Your server might run over stdio (communicating via stdin/stdout pipes), HTTP with Server-Sent Events, or a custom transport. Each transport has its own initialization handshake, lifecycle, and failure modes. A tool that works perfectly in a unit test can fail when the transport layer drops a message or buffers output incorrectly.

Stateful sessions. MCP connections have a lifecycle: initialize, negotiate capabilities, call tools, and shut down. Tests need to manage this lifecycle correctly or they will get cryptic "server not initialized" errors.

Schema validation on both sides. MCP enforces JSON Schema validation on tool inputs. Your tests need to verify that valid inputs pass, invalid inputs return structured errors, and edge cases (empty strings, null values, oversized payloads) are handled gracefully.

Non-deterministic AI context. If your tools interact with LLM outputs or accept freeform text that an AI model generated, your test inputs need to account for the unpredictable formatting that real AI clients produce.

Unit Testing Individual Tools and Resources

The most effective way to test MCP tools is to extract the handler logic from the server registration and test it in isolation. This avoids dealing with transport setup entirely.

Consider this MCP server with a search_documents tool:

// src/tools/searchDocuments.js
var database = require("../db");

function searchDocuments(params) {
  var query = params.query;
  var limit = params.limit || 10;
  var offset = params.offset || 0;

  if (!query || typeof query !== "string") {
    throw new Error("query parameter is required and must be a string");
  }

  if (limit < 1 || limit > 100) {
    throw new Error("limit must be between 1 and 100");
  }

  return database.search(query, limit, offset).then(function (results) {
    return {
      content: [
        {
          type: "text",
          text: JSON.stringify(results, null, 2)
        }
      ]
    };
  });
}

module.exports = { searchDocuments: searchDocuments };

Now you can unit test this handler directly:

// test/tools/searchDocuments.test.js
var expect = require("chai").expect;
var sinon = require("sinon");
var database = require("../../src/db");
var searchDocuments = require("../../src/tools/searchDocuments").searchDocuments;

describe("searchDocuments tool", function () {
  var searchStub;

  beforeEach(function () {
    searchStub = sinon.stub(database, "search");
  });

  afterEach(function () {
    sinon.restore();
  });

  it("should return formatted results for a valid query", function () {
    var mockResults = [
      { id: 1, title: "Node.js Best Practices", score: 0.95 },
      { id: 2, title: "Testing Strategies", score: 0.87 }
    ];
    searchStub.resolves(mockResults);

    return searchDocuments({ query: "nodejs testing" }).then(function (result) {
      expect(result.content).to.have.length(1);
      expect(result.content[0].type).to.equal("text");

      var parsed = JSON.parse(result.content[0].text);
      expect(parsed).to.have.length(2);
      expect(parsed[0].title).to.equal("Node.js Best Practices");
      expect(searchStub.calledWith("nodejs testing", 10, 0)).to.be.true;
    });
  });

  it("should apply custom limit and offset", function () {
    searchStub.resolves([]);

    return searchDocuments({ query: "api", limit: 5, offset: 20 }).then(function () {
      expect(searchStub.calledWith("api", 5, 20)).to.be.true;
    });
  });

  it("should throw when query is missing", function () {
    expect(function () {
      searchDocuments({});
    }).to.throw("query parameter is required and must be a string");
  });

  it("should throw when query is not a string", function () {
    expect(function () {
      searchDocuments({ query: 123 });
    }).to.throw("query parameter is required and must be a string");
  });

  it("should throw when limit is out of range", function () {
    expect(function () {
      searchDocuments({ query: "test", limit: 0 });
    }).to.throw("limit must be between 1 and 100");

    expect(function () {
      searchDocuments({ query: "test", limit: 101 });
    }).to.throw("limit must be between 1 and 100");
  });

  it("should handle database errors gracefully", function () {
    searchStub.rejects(new Error("Connection refused"));

    return searchDocuments({ query: "test" }).then(
      function () {
        throw new Error("Should have rejected");
      },
      function (err) {
        expect(err.message).to.equal("Connection refused");
      }
    );
  });
});

Running these tests is fast — under 50ms for the entire suite — because no transport or server initialization is involved.

$ npm test -- test/tools/searchDocuments.test.js

  searchDocuments tool
    ✓ should return formatted results for a valid query (3ms)
    ✓ should apply custom limit and offset (1ms)
    ✓ should throw when query is missing (1ms)
    ✓ should throw when query is not a string (0ms)
    ✓ should throw when limit is out of range (1ms)
    ✓ should handle database errors gracefully (2ms)

  6 passing (48ms)

Mocking the MCP Transport Layer

When you need to test the full MCP request/response cycle without spinning up a real client, you can mock the transport layer. The SDK provides in-memory transports that are perfect for this.

// test/integration/server.test.js
var expect = require("chai").expect;
var McpServer = require("@modelcontextprotocol/sdk/server/mcp").McpServer;
var InMemoryTransport = require("@modelcontextprotocol/sdk/inMemory").InMemoryTransport;
var Client = require("@modelcontextprotocol/sdk/client/index").Client;

describe("MCP Server with mocked transport", function () {
  var server;
  var client;
  var serverTransport;
  var clientTransport;

  beforeEach(function () {
    server = new McpServer({
      name: "test-server",
      version: "1.0.0"
    });

    server.tool(
      "echo",
      "Echoes back the input text",
      {
        text: { type: "string", description: "Text to echo" }
      },
      function (params) {
        return {
          content: [{ type: "text", text: params.text }]
        };
      }
    );

    server.tool(
      "add_numbers",
      "Adds two numbers together",
      {
        a: { type: "number", description: "First number" },
        b: { type: "number", description: "Second number" }
      },
      function (params) {
        return {
          content: [{ type: "text", text: String(params.a + params.b) }]
        };
      }
    );

    var transportPair = InMemoryTransport.createLinkedPair();
    serverTransport = transportPair[0];
    clientTransport = transportPair[1];

    client = new Client({
      name: "test-client",
      version: "1.0.0"
    });

    return Promise.all([
      server.connect(serverTransport),
      client.connect(clientTransport)
    ]);
  });

  afterEach(function () {
    return Promise.all([
      client.close(),
      server.close()
    ]);
  });

  it("should list available tools", function () {
    return client.listTools().then(function (result) {
      var toolNames = result.tools.map(function (t) { return t.name; });
      expect(toolNames).to.include("echo");
      expect(toolNames).to.include("add_numbers");
    });
  });

  it("should execute the echo tool", function () {
    return client.callTool({
      name: "echo",
      arguments: { text: "hello world" }
    }).then(function (result) {
      expect(result.content[0].text).to.equal("hello world");
    });
  });

  it("should execute the add_numbers tool", function () {
    return client.callTool({
      name: "add_numbers",
      arguments: { a: 17, b: 25 }
    }).then(function (result) {
      expect(result.content[0].text).to.equal("42");
    });
  });

  it("should return an error for unknown tools", function () {
    return client.callTool({
      name: "nonexistent_tool",
      arguments: {}
    }).then(function (result) {
      expect(result.isError).to.be.true;
    });
  });
});

This approach tests the full JSON-RPC serialization and deserialization cycle, capability negotiation, and tool dispatch — all without touching the network. The InMemoryTransport.createLinkedPair() method creates a pair of transports that are wired together in memory, so messages from the client arrive at the server and vice versa.

Integration Testing with a Real MCP Client

For true integration tests, you want to spin up your MCP server as a subprocess and connect to it the same way a real AI client would — over stdio. This validates the entire pipeline including process spawning, stdio buffering, and signal handling.

// test/integration/stdio.test.js
var expect = require("chai").expect;
var Client = require("@modelcontextprotocol/sdk/client/index").Client;
var StdioClientTransport = require("@modelcontextprotocol/sdk/client/stdio").StdioClientTransport;
var path = require("path");

describe("MCP Server stdio integration", function () {
  var client;
  var transport;

  this.timeout(15000);

  beforeEach(function () {
    transport = new StdioClientTransport({
      command: "node",
      args: [path.resolve(__dirname, "../../src/index.js")],
      env: Object.assign({}, process.env, {
        NODE_ENV: "test",
        DB_HOST: "localhost",
        DB_PORT: "5432"
      })
    });

    client = new Client({
      name: "integration-test-client",
      version: "1.0.0"
    });

    return client.connect(transport);
  });

  afterEach(function () {
    return client.close();
  });

  it("should complete the initialization handshake", function () {
    return client.listTools().then(function (result) {
      expect(result.tools).to.be.an("array");
      expect(result.tools.length).to.be.greaterThan(0);
    });
  });

  it("should handle rapid sequential tool calls", function () {
    var calls = [];
    for (var i = 0; i < 20; i++) {
      calls.push(
        client.callTool({
          name: "echo",
          arguments: { text: "message " + i }
        })
      );
    }

    return Promise.all(calls).then(function (results) {
      expect(results).to.have.length(20);
      results.forEach(function (result, index) {
        expect(result.content[0].text).to.equal("message " + index);
      });
    });
  });

  it("should survive tool execution errors without crashing", function () {
    return client.callTool({
      name: "search_documents",
      arguments: { query: "" }
    }).then(function (result) {
      expect(result.isError).to.be.true;
      // Server should still be responsive after the error
      return client.callTool({
        name: "echo",
        arguments: { text: "still alive" }
      });
    }).then(function (result) {
      expect(result.content[0].text).to.equal("still alive");
    });
  });
});

One important detail: the this.timeout(15000) on the describe block is necessary because subprocess spawning and stdio negotiation can take a few seconds, especially on Windows where process creation is slower.

Testing Tool Input Validation and Error Responses

MCP tools should return structured errors, not crash the server. Test every validation boundary explicitly.

// test/tools/validation.test.js
var expect = require("chai").expect;
var sinon = require("sinon");

describe("Tool input validation", function () {
  var createUser = require("../../src/tools/createUser").createUser;
  var db;

  beforeEach(function () {
    db = require("../../src/db");
    sinon.stub(db, "insertUser").resolves({ id: 1 });
  });

  afterEach(function () {
    sinon.restore();
  });

  var invalidInputs = [
    { desc: "missing email", input: { name: "Alice" }, error: "email is required" },
    { desc: "invalid email format", input: { name: "Alice", email: "not-an-email" }, error: "invalid email format" },
    { desc: "name too long", input: { name: "A".repeat(256), email: "[email protected]" }, error: "name must be 255 characters or fewer" },
    { desc: "empty name", input: { name: "", email: "[email protected]" }, error: "name cannot be empty" },
    { desc: "null input", input: null, error: "parameters are required" },
    { desc: "sql injection attempt", input: { name: "'; DROP TABLE users; --", email: "[email protected]" }, error: null }
  ];

  invalidInputs.forEach(function (testCase) {
    it("should handle " + testCase.desc, function () {
      if (testCase.error) {
        expect(function () {
          createUser(testCase.input);
        }).to.throw(testCase.error);
      } else {
        // Should not throw — input is sanitized, not rejected
        return createUser(testCase.input).then(function (result) {
          expect(result.content).to.exist;
        });
      }
    });
  });
});

The key insight here is that validation errors should be thrown as exceptions (which the MCP SDK translates into JSON-RPC error responses), while potentially dangerous but technically valid input should be sanitized and processed normally.

Testing Streaming and Long-Running Tools

Some MCP tools perform long-running operations like file processing or multi-step workflows. These tools might use progress notifications to keep the client informed. Testing them requires patience and proper timeout handling.

// test/tools/longRunning.test.js
var expect = require("chai").expect;
var sinon = require("sinon");
var processFile = require("../../src/tools/processFile").processFile;

describe("Long-running tool tests", function () {
  this.timeout(30000);

  it("should process a large file and return results", function () {
    var params = {
      filePath: "/tmp/test-data.csv",
      format: "csv"
    };

    var clock = sinon.useFakeTimers();
    var resultPromise = processFile(params);

    // Advance time to simulate the processing delay
    clock.tick(5000);

    return resultPromise.then(function (result) {
      expect(result.content[0].type).to.equal("text");
      var parsed = JSON.parse(result.content[0].text);
      expect(parsed.rowsProcessed).to.be.greaterThan(0);
      expect(parsed.durationMs).to.be.a("number");
      clock.restore();
    });
  });

  it("should respect timeout limits", function () {
    var params = {
      filePath: "/tmp/huge-file.csv",
      format: "csv",
      timeout: 1000
    };

    return processFile(params).then(
      function () {
        throw new Error("Should have timed out");
      },
      function (err) {
        expect(err.message).to.match(/timed out after 1000ms/);
      }
    );
  });
});

Snapshot Testing MCP Responses

Snapshot testing catches unintentional changes to response structure. This is especially valuable for MCP servers where clients depend on specific response formats.

// test/snapshots/toolResponses.test.js
var expect = require("chai").expect;
var fs = require("fs");
var path = require("path");

var SNAPSHOT_DIR = path.resolve(__dirname, "__snapshots__");

function loadOrCreateSnapshot(name, actual) {
  var snapshotPath = path.join(SNAPSHOT_DIR, name + ".json");

  if (!fs.existsSync(SNAPSHOT_DIR)) {
    fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
  }

  if (process.env.UPDATE_SNAPSHOTS || !fs.existsSync(snapshotPath)) {
    fs.writeFileSync(snapshotPath, JSON.stringify(actual, null, 2));
    return actual;
  }

  return JSON.parse(fs.readFileSync(snapshotPath, "utf8"));
}

describe("Response snapshot tests", function () {
  var server = require("../../src/server");

  it("should match list_tools snapshot", function () {
    return server.handleRequest({
      jsonrpc: "2.0",
      id: 1,
      method: "tools/list",
      params: {}
    }).then(function (response) {
      // Normalize dynamic fields
      var normalized = JSON.parse(JSON.stringify(response));
      delete normalized.id;

      var expected = loadOrCreateSnapshot("list_tools", normalized);
      expect(normalized).to.deep.equal(expected);
    });
  });

  it("should match echo tool response snapshot", function () {
    return server.handleRequest({
      jsonrpc: "2.0",
      id: 2,
      method: "tools/call",
      params: { name: "echo", arguments: { text: "snapshot test" } }
    }).then(function (response) {
      var normalized = JSON.parse(JSON.stringify(response));
      delete normalized.id;

      var expected = loadOrCreateSnapshot("echo_response", normalized);
      expect(normalized).to.deep.equal(expected);
    });
  });
});

To update snapshots when you intentionally change response formats, run:

UPDATE_SNAPSHOTS=1 npm test -- test/snapshots/

Setting Up CI Pipelines for MCP Server Tests

A proper CI pipeline for an MCP server needs to handle the test database, run both unit and integration tests, and verify that the server can actually start and accept connections.

Here is a GitHub Actions workflow:

# .github/workflows/test-mcp-server.yml
name: MCP Server Tests

on:
  push:
    branches: [main, master]
  pull_request:
    branches: [main, master]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]

    steps:
      - uses: actions/checkout@v4

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm

      - run: npm ci

      - name: Run unit tests
        run: npm test -- test/tools/ test/snapshots/
        env:
          NODE_ENV: test

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests

    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: mcp_test
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm

      - run: npm ci

      - name: Run database migrations
        run: node scripts/migrate.js
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/mcp_test

      - name: Run integration tests
        run: npm test -- test/integration/
        timeout-minutes: 5
        env:
          NODE_ENV: test
          DATABASE_URL: postgresql://test:test@localhost:5432/mcp_test

      - name: Verify server starts cleanly
        run: |
          node src/index.js &
          SERVER_PID=$!
          sleep 3
          kill -0 $SERVER_PID 2>/dev/null && echo "Server is running" || (echo "Server failed to start" && exit 1)
          kill $SERVER_PID
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/mcp_test

The pipeline runs unit tests first across multiple Node.js versions (they are fast and catch most issues), then runs integration tests with a real PostgreSQL database. The final step verifies that the server process starts without crashing, which catches import errors and missing environment variables that tests might not cover.

Load Testing MCP Endpoints

MCP servers that handle multiple concurrent AI clients need load testing. Here is a simple load test using Node.js that spawns multiple concurrent clients:

// test/load/concurrent-clients.js
var Client = require("@modelcontextprotocol/sdk/client/index").Client;
var StdioClientTransport = require("@modelcontextprotocol/sdk/client/stdio").StdioClientTransport;
var path = require("path");

var NUM_CLIENTS = 10;
var CALLS_PER_CLIENT = 50;
var SERVER_PATH = path.resolve(__dirname, "../../src/index.js");

function createClient(clientId) {
  var transport = new StdioClientTransport({
    command: "node",
    args: [SERVER_PATH]
  });

  var client = new Client({
    name: "load-test-client-" + clientId,
    version: "1.0.0"
  });

  return client.connect(transport).then(function () {
    return { client: client, id: clientId };
  });
}

function runCalls(clientInfo) {
  var calls = [];
  var startTime = Date.now();

  for (var i = 0; i < CALLS_PER_CLIENT; i++) {
    calls.push(
      clientInfo.client.callTool({
        name: "echo",
        arguments: { text: "client " + clientInfo.id + " call " + i }
      })
    );
  }

  return Promise.all(calls).then(function (results) {
    var elapsed = Date.now() - startTime;
    var errors = results.filter(function (r) { return r.isError; }).length;

    return {
      clientId: clientInfo.id,
      totalCalls: CALLS_PER_CLIENT,
      errors: errors,
      durationMs: elapsed,
      avgLatencyMs: Math.round(elapsed / CALLS_PER_CLIENT)
    };
  });
}

console.log("Starting load test: " + NUM_CLIENTS + " clients x " + CALLS_PER_CLIENT + " calls each");
console.log("Total calls: " + (NUM_CLIENTS * CALLS_PER_CLIENT));
console.log("---");

var startTime = Date.now();

var clientPromises = [];
for (var c = 0; c < NUM_CLIENTS; c++) {
  clientPromises.push(createClient(c));
}

Promise.all(clientPromises)
  .then(function (clients) {
    return Promise.all(clients.map(runCalls));
  })
  .then(function (results) {
    var totalDuration = Date.now() - startTime;
    var totalErrors = results.reduce(function (sum, r) { return sum + r.errors; }, 0);
    var avgLatency = Math.round(
      results.reduce(function (sum, r) { return sum + r.avgLatencyMs; }, 0) / results.length
    );

    console.log("\nResults:");
    console.log("  Total duration:   " + totalDuration + "ms");
    console.log("  Total errors:     " + totalErrors);
    console.log("  Avg latency:      " + avgLatency + "ms per call");
    console.log("  Throughput:       " + Math.round((NUM_CLIENTS * CALLS_PER_CLIENT) / (totalDuration / 1000)) + " calls/sec");

    results.forEach(function (r) {
      console.log("  Client " + r.clientId + ": " + r.durationMs + "ms, " + r.errors + " errors, " + r.avgLatencyMs + "ms avg");
    });

    process.exit(totalErrors > 0 ? 1 : 0);
  })
  .catch(function (err) {
    console.error("Load test failed:", err);
    process.exit(1);
  });

Running it produces output like this:

$ node test/load/concurrent-clients.js

Starting load test: 10 clients x 50 calls each
Total calls: 500
---

Results:
  Total duration:   4823ms
  Total errors:     0
  Avg latency:      8ms per call
  Throughput:       103 calls/sec
  Client 0: 412ms, 0 errors, 8ms avg
  Client 1: 389ms, 0 errors, 7ms avg
  Client 2: 445ms, 0 errors, 8ms avg
  Client 3: 398ms, 0 errors, 7ms avg
  Client 4: 467ms, 0 errors, 9ms avg
  Client 5: 401ms, 0 errors, 8ms avg
  Client 6: 423ms, 0 errors, 8ms avg
  Client 7: 378ms, 0 errors, 7ms avg
  Client 8: 451ms, 0 errors, 9ms avg
  Client 9: 413ms, 0 errors, 8ms avg

If your server needs to handle high concurrency, consider running this test as part of your CI pipeline with a threshold — fail the build if throughput drops below a minimum or if error rates exceed zero.

Complete Working Example

Here is the complete project structure and a full test suite you can drop into an existing MCP server project:

project/
  src/
    index.js          # Server entry point
    tools/
      echo.js
      searchDocuments.js
      createUser.js
  test/
    tools/
      echo.test.js
      searchDocuments.test.js
      validation.test.js
    integration/
      server.test.js
      stdio.test.js
    snapshots/
      toolResponses.test.js
      __snapshots__/
    load/
      concurrent-clients.js
    helpers/
      setup.js

The test helper sets up shared configuration:

// test/helpers/setup.js
var sinon = require("sinon");

beforeEach(function () {
  // Reset environment for each test
  process.env.NODE_ENV = "test";
});

afterEach(function () {
  sinon.restore();
});

// Global error handler for unhandled rejections during tests
process.on("unhandledRejection", function (err) {
  console.error("Unhandled rejection in test:", err);
  process.exit(1);
});

Register it in your .mocharc.yml:

# .mocharc.yml
require:
  - test/helpers/setup.js
recursive: true
timeout: 10000
spec: test/**/*.test.js

Here is the complete echo tool and its test as a minimal but complete example:

// src/tools/echo.js
function echo(params) {
  if (!params || typeof params.text !== "string") {
    throw new Error("text parameter is required and must be a string");
  }

  if (params.text.length > 10000) {
    throw new Error("text must be 10000 characters or fewer");
  }

  var response = params.text;
  if (params.uppercase) {
    response = response.toUpperCase();
  }

  return {
    content: [
      {
        type: "text",
        text: response
      }
    ]
  };
}

module.exports = { echo: echo };
// test/tools/echo.test.js
var expect = require("chai").expect;
var echo = require("../../src/tools/echo").echo;

describe("echo tool", function () {
  it("should echo back the input text", function () {
    var result = echo({ text: "hello" });
    expect(result.content[0].text).to.equal("hello");
  });

  it("should support uppercase mode", function () {
    var result = echo({ text: "hello", uppercase: true });
    expect(result.content[0].text).to.equal("HELLO");
  });

  it("should handle empty string", function () {
    var result = echo({ text: "" });
    expect(result.content[0].text).to.equal("");
  });

  it("should handle unicode text", function () {
    var result = echo({ text: "Hello world" });
    expect(result.content[0].text).to.equal("Hello world");
  });

  it("should reject missing text", function () {
    expect(function () { echo({}); }).to.throw("text parameter is required");
    expect(function () { echo(null); }).to.throw("text parameter is required");
  });

  it("should reject oversized text", function () {
    var longText = "x".repeat(10001);
    expect(function () { echo({ text: longText }); }).to.throw("10000 characters or fewer");
  });

  it("should handle text with special JSON characters", function () {
    var result = echo({ text: '{"key": "value with \\"quotes\\""}' });
    expect(result.content[0].text).to.contain("quotes");
  });
});

Common Issues and Troubleshooting

1. "Server not initialized" errors during tests

Error: McpError: Server not initialized
    at Client._assertCapability (node_modules/@modelcontextprotocol/sdk/dist/client/index.js:87:13)

This happens when you call client.callTool() before the client.connect() promise resolves. Always await (or .then()) the connect call before making any tool calls. In Mocha, return the promise from beforeEach:

beforeEach(function () {
  return client.connect(transport); // Return the promise!
});

2. Tests hang indefinitely and hit the timeout

Error: Timeout of 10000ms exceeded. For async tests and hooks, ensure "done()" is called;
  if returning a Promise, ensure it resolves.

This usually happens when the MCP server subprocess does not exit cleanly. Make sure your afterEach hook closes both the client and the server. If using stdio transport, the child process should terminate when stdin closes. Add a safety kill:

afterEach(function () {
  return client.close().catch(function () {
    // Force kill the subprocess if graceful close fails
    if (transport._process) {
      transport._process.kill("SIGKILL");
    }
  });
});

3. Snapshot mismatches after adding new tools

AssertionError: expected { tools: [ ... ] } to deeply equal { tools: [ ... ] }
  + expected - actual

  -  { "name": "new_tool", "description": "...", "inputSchema": { ... } }

This is expected when you add new tools. Run UPDATE_SNAPSHOTS=1 npm test to regenerate the snapshot files, then review the diff in version control to confirm the change is intentional.

4. EPIPE errors during stdio integration tests

Error: write EPIPE
    at WriteWrap.onWriteComplete [as oncomplete] (node:internal/stream_base_commons:94:16)

This happens when the test writes to the server's stdin after the server process has already exited. Common causes: the server crashed during initialization (missing environment variable, failed database connection), or a previous test did not properly clean up. Add error event handlers to the transport:

transport.on("error", function (err) {
  if (err.code !== "EPIPE") {
    console.error("Transport error:", err);
  }
});

5. Port conflicts in CI when multiple test files run concurrently

Error: listen EADDRINUSE: address already in use :::3000

If your MCP server also exposes an HTTP endpoint (for SSE transport), tests that spawn the server will conflict. Use dynamic port allocation:

var transport = new StdioClientTransport({
  command: "node",
  args: [SERVER_PATH],
  env: Object.assign({}, process.env, {
    PORT: "0" // Let the OS assign a random port
  })
});

Best Practices

  • Extract tool logic from server registration. Keep your tool handler functions in separate modules that accept plain objects and return plain objects. This makes them trivially testable without any MCP infrastructure.

  • Test the transport layer separately from tool logic. Unit tests should verify business logic with sinon stubs. Integration tests should verify the transport works. Do not mix these concerns in the same test file.

  • Use in-memory transports for most integration tests. Reserve stdio integration tests for CI pipelines. In-memory transport tests run in under a second; stdio tests require subprocess spawning and take 3-5 seconds each.

  • Always test the error path. Every tool should be tested with invalid inputs, missing parameters, null values, and backend failures. MCP clients (AI models) will send unexpected inputs — your server must not crash.

  • Snapshot test your tool list. The tools/list response is a contract with every client that connects to your server. Snapshot testing catches accidental changes to tool names, descriptions, or parameter schemas.

  • Run load tests with realistic concurrency. If your server will handle 5 concurrent AI clients in production, test with 10. Measure both throughput and error rates under load.

  • Isolate test databases. Integration tests should use a separate database (or schema) that is created and destroyed for each test run. Never run tests against a production or development database.

  • Set aggressive timeouts in CI. MCP tests that hang can block your entire pipeline. Set timeout-minutes: 5 on the CI step and this.timeout(10000) in Mocha. If a test needs more than 10 seconds, something is wrong.

  • Test capability negotiation. If your server declares specific capabilities (like tools or resources), verify that the client receives them correctly during initialization. A missing capability declaration means the client will not even try to use that feature.

References

Powered by Contentful