Index Agentica

Test your MCP server with in-process tests and Inspector CI checks

Automated tests for a TypeScript MCP server: call tools in-process through the SDK's handler.fetch, assert on structuredContent and isError, snapshot the tool list so schema changes fail review, test the bearer-token gate, spawn the stdio build, and add MCP Inspector CLI checks to CI.

Type
Guide
Author
Agentica Author
Published
Last verified
Difficulty
intermediate
Time
40 min

Prerequisites

Clicking through the MCP Inspector is how most people check an MCP server. That doesn't catch regressions. A renamed tool, a tightened schema or a broken auth gate goes unnoticed until a client fails. This guide sets up tests that run on every commit:

  1. In-process client-to-server tests with the MCP TypeScript SDK v2, with no port and no child process.
  2. A snapshot of the tool contract, so that changes to the schema or description show up in review.
  3. Tests for the bearer-token gate.
  4. One test that spawns the real stdio entry point.
  5. MCP Inspector CLI checks for CI, using its exit codes.

What was actually run, on 2026-10-21: every file below, on Node.js 22.23.2, with @modelcontextprotocol/server 2.3.1, @modelcontextprotocol/client 2.3.1, @modelcontextprotocol/node 2.1.1, zod 4.6.7 and @modelcontextprotocol/inspector 2.11.0. The result was 8 of 8 tests passing. I also changed one tool description to confirm that the snapshot test fails. The GitHub Actions workflow at the end was not run.

If you are still at "does it start", run the basic Inspector calls in Publish an MCP server first. This guide picks up from there.

The setup

mkdir mcp-tests && cd mcp-tests && npm init -y && npm pkg set type=module
npm install @modelcontextprotocol/server @modelcontextprotocol/client @modelcontextprotocol/node zod

The server under test exports a factory rather than a running server. The SDK's createMcpHandler(createServer) turns that factory into a web-standard HTTP handler, so the same code can be served over HTTP in production and called directly in tests. The tool is the SDK testing doc's apply-discount example, with an output schema and a deliberate error path:

// src/server.mjs
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";

export function createServer() {
  const server = new McpServer({ name: "pricing", version: "1.0.0" });
  server.registerTool(
    "apply-discount",
    {
      description: "Apply a percentage discount to a price",
      inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }),
      outputSchema: z.object({ total: z.number() }),
      annotations: { readOnlyHint: true },
    },
    async ({ price, percent }) => {
      if (price < 0) return { content: [{ type: "text", text: "price must be >= 0" }], isError: true };
      const total = price * (1 - percent / 100);
      return { content: [{ type: "text", text: `$${total}` }], structuredContent: { total } };
    },
  );
  return server;
}

There are two thin entry points, one for each transport:

// src/stdio.mjs
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import { createServer } from "./server.mjs";
await createServer().connect(new StdioServerTransport());
// src/http.mjs  (Streamable HTTP on 127.0.0.1:3000/mcp)
import http from "node:http";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { createServer } from "./server.mjs";
const node = toNodeHandler(createMcpHandler(createServer));
http.createServer((req, res) => (req.url === "/mcp" ? node(req, res) : res.writeHead(404).end()))
  .listen(3000, "127.0.0.1", () => console.error("http://127.0.0.1:3000/mcp"));

1. In-process tests through handler.fetch

The SDK's testing page recommends a specific approach. Build the handler, then give the client's StreamableHTTPClientTransport a custom fetch that calls handler.fetch directly. The client and server then go through the real protocol, including initialization, version negotiation and JSON-RPC, but no socket is ever opened. Tests stay fast and don't compete for ports.

// test/server.test.mjs  (run with: node --test)
import { test, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { readFile, writeFile } from "node:fs/promises";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { createMcpHandler } from "@modelcontextprotocol/server";
import { createServer } from "../src/server.mjs";

let handler, client;

beforeEach(async () => {
  handler = createMcpHandler(createServer);
  const transport = new StreamableHTTPClientTransport(new URL("http://test.local/mcp"), {
    fetch: (url, init) => handler.fetch(new Request(url, init)), // in-process: no port, no socket
  });
  client = new Client({ name: "test-harness", version: "1.0.0" }, { versionNegotiation: { mode: "auto" } });
  await client.connect(transport);
});

afterEach(async () => {
  await client.close();  // client first,
  await handler.close(); // then the handler
});

test("happy path returns structured content", async () => {
  const r = await client.callTool({ name: "apply-discount", arguments: { price: 80, percent: 25 } });
  assert.deepEqual(r.structuredContent, { total: 60 });
});

test("handler failure is a result with isError, not a throw", async () => {
  const r = await client.callTool({ name: "apply-discount", arguments: { price: -5, percent: 25 } });
  assert.equal(r.isError, true);
  assert.match(r.content[0].text, /price must be >= 0/);
});

test("schema-invalid arguments are rejected", async () => {
  const r = await client.callTool({ name: "apply-discount", arguments: { price: 80, percent: 150 } });
  assert.equal(r.isError, true);
});

There are three behaviours to design your assertions around. All three come from the SDK's testing page, and all three were confirmed by the run:

There is also InMemoryTransport.createLinkedPair() in @modelcontextprotocol/client, which is the older way to wire up an in-memory pair. The testing page says it "connects 2025-era instances only". It names handler.fetch as the in-process route for 2026-07-28 coverage, which is why this guide uses handler.fetch.

2. Snapshot the tool contract

Clients and models depend on tool names, descriptions and JSON Schemas. A model chooses tools by reading their descriptions, so an innocent-looking edit there changes agent behaviour. This test, which was added to the same file, stores tools/list output as JSON in the repo and fails when it changes:

test("tool contract matches the committed snapshot", async () => {
  const { tools } = await client.listTools();
  const current = JSON.stringify(tools, null, 2);
  const path = new URL("./tools.snapshot.json", import.meta.url);
  if (process.env.UPDATE_SNAPSHOT) await writeFile(path, current);
  assert.equal(current, await readFile(path, "utf8"), "tool names, descriptions or schemas changed");
});

Create the snapshot with UPDATE_SNAPSHOT=1 node --test, commit test/tools.snapshot.json, and from then on run plain node --test. The snapshot holds the JSON Schema that the SDK generates from the zod schema. For this server, that includes "minimum": 0, "maximum": 100 on percent and "additionalProperties": false on the output, so tightening a bound shows up in the diff. I checked this by shortening the tool's description: the test failed with the message above. When a change is intentional, re-run with UPDATE_SNAPSHOT=1 and review the JSON diff the way you would review an API change.

3. Test the auth gate

If the server sits behind OAuth (see Add OAuth authorization to your own MCP server), the in-process approach still works. Put the SDK's web-standard requireBearerAuth gate in front of handler.fetch, exactly as the authorization doc does, and use a stub verifier so no identity provider is needed:

// test/auth.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
import { createMcpHandler, OAuthError, OAuthErrorCode, requireBearerAuth } from "@modelcontextprotocol/server";
import { createServer } from "../src/server.mjs";

const url = new URL("http://test.local/mcp");
const verifier = {
  async verifyAccessToken(token) {
    if (token !== "good-token") throw new OAuthError(OAuthErrorCode.InvalidToken, "unknown token");
    return { token, clientId: "test", scopes: ["mcp"], expiresAt: Math.floor(Date.now() / 1000) + 60, resource: url };
  },
};
const gate = requireBearerAuth({ verifier, requiredScopes: ["mcp"], expectedResource: url });
const handler = createMcpHandler(createServer);
const app = async (request) => {
  const auth = await gate(request);
  return auth instanceof Response ? auth : handler.fetch(request, { authInfo: auth });
};

test("no token: raw request gets 401 with a WWW-Authenticate challenge", async () => {
  const res = await app(new Request(url, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }));
  assert.equal(res.status, 401);
  assert.match(res.headers.get("www-authenticate"), /^Bearer /);
});

test("no token: the SDK client fails to connect", async () => {
  const client = new Client({ name: "t", version: "1" });
  const transport = new StreamableHTTPClientTransport(url, { fetch: (u, i) => app(new Request(u, i)) });
  await assert.rejects(client.connect(transport));
});

test("valid token: tools work", async () => {
  const client = new Client({ name: "t", version: "1" }, { versionNegotiation: { mode: "auto" } });
  const transport = new StreamableHTTPClientTransport(url, {
    fetch: (u, i) => app(new Request(u, i)),
    requestInit: { headers: { authorization: "Bearer good-token" } },
  });
  await client.connect(transport);
  const r = await client.callTool({ name: "apply-discount", arguments: { price: 100, percent: 10 } });
  assert.deepEqual(r.structuredContent, { total: 90 });
  await client.close();
});

In the run, the rejected connect threw an SdkHttpError whose message carried the server's invalid_token body. Assert that it rejects, not on the exact error class, because the class is the part most likely to change between SDK releases. Add cases that match your real verifier too: an expired token, a token for a different resource, and a token that is missing a scope (which should get 403). The OAuth guide's test section does this with locally minted JWTs.

4. Spawn the stdio build once

For stdio there is no in-process shortcut. The SDK's testing page says to spawn the real process with StdioClientTransport. One test is enough to prove that the entry point boots, writes nothing but JSON-RPC to stdout, and answers:

// test/stdio.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";

test("stdio entry point starts and answers", async () => {
  const client = new Client({ name: "t", version: "1" });
  await client.connect(new StdioClientTransport({ command: "node", args: ["src/stdio.mjs"] }));
  const { tools } = await client.listTools();
  assert.deepEqual(tools.map((t) => t.name), ["apply-discount"]);
  await client.close(); // also shuts the child process down
});

If you publish a build, point args at the built file (the docs use dist/server.js), so that the test covers what users actually run.

Output of node --test for all three files:

ok 1 - no token: raw request gets 401 with a WWW-Authenticate challenge
ok 2 - no token: the SDK client fails to connect
ok 3 - valid token: tools work
ok 4 - happy path returns structured content
ok 5 - handler failure is a result with isError, not a throw
ok 6 - schema-invalid arguments are rejected
ok 7 - tool contract matches the committed snapshot
ok 8 - stdio entry point starts and answers
# pass 8
# fail 0

5. Inspector CLI checks for CI

The SDK tests check your code. The MCP Inspector CLI checks the server from the outside, as an independent client, and it works for servers written in any language. That includes Python servers built on FastMCP or the MCP Python SDK. Its exit codes are what make it useful in CI. The CLI README documents these:

SituationExit code
Call succeeded0
tools/call returned isError: true5
tools/list --strict found an error-severity schema portability problem6
--verify (skills) found a failing skill7

Runs against this server (Inspector 2.11.0, which declares Node.js 22.19 or later in its npm engines field):

I="npx -y @modelcontextprotocol/inspector@2.11.0 --cli"

# stdio: list, call, expect a structured result
$I node src/stdio.mjs --method tools/list
$I node src/stdio.mjs --method tools/call --tool-name apply-discount \
  --tool-arg price=80 --tool-arg percent=25 --format json
# -> {"result":{"content":[{"type":"text","text":"$60"}],"structuredContent":{"total":60}}}

# the error path really is an error: exit code 5
$I node src/stdio.mjs --method tools/call --tool-name apply-discount --tool-arg price=-1 --tool-arg percent=25; echo $?
# -> 5

# schemas that strict clients will accept: exit 0 here, 6 if not
$I node src/stdio.mjs --method tools/list --strict

# HTTP: a connect-only probe that reports what was negotiated
node src/http.mjs &
$I http://127.0.0.1:3000/mcp --method initialize --format json
# -> {"result":{"serverInfo":{"name":"pricing","version":"1.0.0"},"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true}}}}

Some notes on these commands:

A CI job (not run)

This workflow wasn't executed for this guide. It only chains the commands that were run above:

# .github/workflows/mcp-tests.yml
name: mcp-tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: node --test
      - run: npx -y @modelcontextprotocol/inspector@2.11.0 --cli node src/stdio.mjs --method tools/list --strict
      - name: error path must report isError (exit 5)
        run: |
          set +e
          npx -y @modelcontextprotocol/inspector@2.11.0 --cli node src/stdio.mjs \
            --method tools/call --tool-name apply-discount --tool-arg price=-1 --tool-arg percent=25
          test $? -eq 5

What to test, in order

  1. Every tool: one happy path and one failure. Assert on structuredContent and on isError, not on thrown errors.
  2. Schema edges. Make sure out-of-range and missing arguments come back as isError results, not crashes.
  3. The tool contract snapshot. Review the diff whenever it changes.
  4. The auth gate. No token should give 401 with a Bearer challenge, and a good token should work. Add expired, wrong-audience and missing-scope cases for your real verifier.
  5. Each shipped transport, once. That means spawning the stdio build, and probing HTTP with Inspector --method initialize.
  6. Strict schema portability with Inspector --strict.

These tests cover correctness. They don't cover whether a model uses your tools well, or whether a hostile prompt can misuse them. For the first, see Evaluate an AI agent. For the second, see LLM red-teaming tools compared. If you put the server behind a gateway, run the Inspector checks against the gateway endpoint too. The MCP gateways comparison explains why tool names and schemas can change there.

Troubleshooting

Directory entries in this guide

Related

Sources

Machine-readable