npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

mcp-testlib

v0.1.0

Published

Testing library for MCP servers — in-memory test client, Vitest/Jest matchers, and test doubles for sampling and elicitation.

Readme

mcp-testlib

The assertion and testing layer for MCP servers. What supertest and Testing Library are to HTTP and React, mcp-testlib is to the Model Context Protocol: an in-memory test client, a matcher pack for Vitest, and test doubles for the client-side behavior your server asks for (sampling, elicitation, roots) — so you can test a server without hand-rolling a transport or digging into result.content[0].

npm version CI License: MIT

import { createTestClient } from "mcp-testlib";
import "mcp-testlib/vitest"; // registers matchers

const client = await createTestClient(myServer); // official SDK or FastMCP instance
const result = await client.callTool("search", { query: "hello" });

expect(result).toMatchToolResult({ text: expect.stringContaining("hello") });
expect(result).not.toBeToolError();

No ports, no child processes, no transport config. createTestClient works the same way whether you handed it an official-SDK server or a FastMCP instance — see FastMCP support.

New to testing MCP servers? GUIDE.md walks through it from the hand-rolled InMemoryTransport version up through sampling, elicitation, and snapshot testing.

Table of contents

Install

pnpm add -D mcp-testlib

You also need @modelcontextprotocol/sdk — mcp-testlib's test client is always built on it internally, even when the server under test is a FastMCP instance:

pnpm add -D @modelcontextprotocol/sdk

If your server is built with FastMCP rather than the official SDK directly, add that too — it's an optional peer, only required if you actually import it:

pnpm add -D fastmcp

Requires Node 22+ and an ESM project. Matchers are written against the Vitest expect API; mcp-testlib/vitest is the only runner adapter shipped today (see How mcp-testlib compares — a Jest adapter is demand-driven, not yet published).

Quick start

import { describe, it, beforeEach, afterEach, expect } from "vitest";
import { createTestClient, type TestClient } from "mcp-testlib";
import "mcp-testlib/vitest";
import { myServer } from "./server";

describe("search tool", () => {
  let client: TestClient;

  beforeEach(async () => {
    client = await createTestClient(myServer());
  });

  afterEach(async () => {
    await client.close();
  });

  it("returns matching results", async () => {
    const result = await client.callTool("search", { query: "hello" });

    expect(result).toMatchToolResult({ text: expect.stringContaining("hello") });
  });

  it("rejects an unknown tool with a protocol error", async () => {
    await expect(client.callTool("does-not-exist", {})).rejects.toMatchProtocolError({
      code: -32602,
    });
  });
});

client.close() tears down the in-memory transport; call it in afterEach like above. If your TypeScript/Node setup supports explicit resource management, you can drop the afterEach entirely:

await using client = await createTestClient(myServer());

Client surface

Every protocol method is a typed, awaitable call — no raw JSON-RPC, no result.content[0] indexing:

await client.listTools();
await client.callTool("search", { query: "hello" });

await client.listResources();
await client.readResource("greeting://world");

await client.listPrompts();
await client.getPrompt("greet", { name: "Ada" });

await client.complete({ type: "ref/prompt", name: "greet" }, { name: "name", value: "A" });

Server-initiated traffic — logging messages, progress updates, resource-updated notifications — is recorded on the client as it happens, so you can assert on it after the fact with toHaveEmittedNotification.

Matcher reference

Import mcp-testlib/vitest once (it registers matchers as a side effect) and use expect as normal.

| Matcher | Checks | Example | | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | toMatchToolResult(expected) | A tool result's content matches the given shape (text, structured content, resource links) — you never touch result.content[0]. | expect(result).toMatchToolResult({ text: "hello" }) | | toBeToolError(matcher?) | The call is an in-band tool error (isError: true), as opposed to a protocol-level failure. | expect(result).toBeToolError() | | toHaveTextContent(matcher) | Shorthand for asserting just the text content, with string/regex/expect.* matcher semantics. | expect(result).toHaveTextContent(/^42$/) | | toMatchProtocolError(expected) | A rejected call failed at the JSON-RPC layer (unknown tool, invalid params) rather than in-band. | await expect(client.callTool("nope", {})).rejects.toMatchProtocolError({ code: -32602 }) | | toExposeTool(name, shape?) | The server's tool list includes name, optionally matching a partial shape against the real Tool descriptor. | await expect(client).toExposeTool("search", { inputSchema: expect.objectContaining({ required: ["query"] }) }) | | toExposeResource(uri, shape?) | The server's resource list includes a resource at uri. | await expect(client).toExposeResource("greeting://world") | | toExposePrompt(name, shape?) | The server's prompt list includes name. | await expect(client).toExposePrompt("greet") | | toHaveEmittedNotification(method, shape?) | The client recorded a server-initiated notification (progress, logging, resource updates) matching method, and optionally a partial params shape. | expect(client).toHaveEmittedNotification("notifications/progress", { progress: 100 }) |

toExposeTool, toExposeResource, and toExposePrompt call the server to fetch its current list, so they're async — always await them. Every other matcher is synchronous. shape parameters (here and in the doubles below) are partial matches — only the keys you name are checked, extra fields on the real value are ignored — while text/expected parameters are exact matches unless you wrap them in a RegExp or an expect.* asymmetric matcher.

Every matcher's failure message prints what the server actually returned, formatted, not a raw protocol dump — that's the point.

import "mcp-testlib/vitest" also registers a snapshot serializer: toMatchSnapshot() on a tool result or on client.notifications strips _meta (progress tokens, related-task ids — the spec's carrier for exactly this kind of per-request noise) before printing, so snapshots stay stable across runs instead of failing on every request. It applies by value shape, not by which matcher you used first.

Testing sampling and elicitation

This is the thing you can't test today without hand-rolling a fake client. Pass canned or scripted responses to createTestClient, and mcp-testlib answers sampling/createMessage, elicitation/create, and roots/list requests on your server's behalf:

import { createTestClient, scripted } from "mcp-testlib";
import "mcp-testlib/vitest";

const client = await createTestClient(myServer, {
  sampling: scripted({
    role: "assistant",
    model: "mock-model",
    content: { type: "text", text: "42" },
  }),
  elicitation: scripted({ action: "accept", content: { confirmed: true } }),
  roots: [{ uri: "file:///workspace", name: "workspace" }],
});

const result = await client.callTool("askTheModel", { question: "What is 6 * 7?" });

expect(result).toHaveTextContent("42");

scripted(...) takes one or more canned responses, consumed in order each time the server makes a request; if the server asks for more than you scripted, the double throws a descriptive error naming which request went unanswered, instead of hanging.

For responses that depend on the request, pass a function instead:

const client = await createTestClient(myServer, {
  sampling: (request) => ({
    role: "assistant",
    model: "mock-model",
    content: { type: "text", text: `saw ${request.messages.length} message(s)` },
  }),
});

Sampling and elicitation are opt-in: mcp-testlib only advertises the corresponding client capability to the server if you configure a responder for it. If the server asks for one you didn't configure, the call rejects with a message naming the missing option (sampling, elicitation, or roots) instead of hanging.

Fixtures

mcp-testlib/fixtures ships a tiny reference server so you (and this README) have something to run against without writing one first:

import { createTestClient } from "mcp-testlib";
import { createFixtureServer } from "mcp-testlib/fixtures";
import "mcp-testlib/vitest";

const client = await createTestClient(createFixtureServer());

expect(client).toExposeTool("echo"); // echoes its input back
expect(client).toExposeTool("add"); // adds two numbers
expect(client).toExposeTool("fail"); // always returns an in-band tool error
expect(client).toExposeTool("progress"); // emits notifications/progress, then succeeds
expect(client).toExposeResource("greeting://world");
expect(client).toExposePrompt("greet");

Every example in this README runs against createFixtureServer() in the library's own test suite — if a doc example looks off, it'll fail CI before it reaches you.

FastMCP support

mcp-testlib supports both the official @modelcontextprotocol/sdk and FastMCP from the same entry point — and there's no separate adapter to name or import. createTestClient doesn't branch on which one you pass: a Server/McpServer and a FastMCP instance both just need a connect(transport) method, so the exact same code path handles either.

import { FastMCP } from "fastmcp";
import { createTestClient } from "mcp-testlib";
import "mcp-testlib/vitest";
import { z } from "zod";

const server = new FastMCP({ name: "my-server", version: "1.0.0" });
server.addTool({
  name: "search",
  parameters: z.object({ query: z.string() }),
  execute: async ({ query }) => `results for ${query}`,
});

const client = await createTestClient(server);
const result = await client.callTool("search", { query: "hello" });

expect(result).toMatchToolResult({ text: "results for hello" });

Everything else — matchers, doubles, recorders, teardown — behaves identically regardless of which server library you're testing.

Spec-version support policy

mcp-testlib tracks the spec revision implemented by the current major of @modelcontextprotocol/sdk (^1.x today). When the SDK ships a new major for a new spec revision, mcp-testlib ships a matching major on the next release cycle — we don't support pairing an SDK major with an mcp-testlib version that predates it, so pin both together in your lockfile. Each release's changelog states which spec revision it was validated against.

How mcp-testlib compares

| | mcp-testlib | mcp-testing-kit | @gleanwork/mcp-server-tester | MCP Inspector | | ----------------------------------------- | -------------------------------- | --------------------------- | ------------------------------ | ----------------------------------- | | Category | Unit/integration testing library | Test client | E2E / eval framework | Interactive debugging UI | | Runs inside your test runner | ✅ Vitest (Jest planned) | ✅ (bring your own asserts) | ❌ own Playwright-based runner | ❌ standalone app | | Matcher pack | ✅ dedicated matchers | ❌ | Partial (eval scoring) | ❌ | | Protocol-error vs. tool-error distinction | ✅ taught via matchers | Manual | N/A | Manual (inspect by hand) | | Sampling/elicitation test doubles | ✅ | ❌ | ❌ | Manual, interactive only | | FastMCP support | ✅ | ❌ | ❌ | ✅ (connects to any running server) | | LLM-as-judge / eval scoring | ❌ out of scope | ❌ | ✅ | ❌ | | Best for | Fast, deterministic CI tests | A minimal scripted client | End-to-end agent evals | Poking at a running server by hand |

@gleanwork/mcp-server-tester and MCP Inspector aren't really competitors — they're neighbors. Reach for mcp-server-tester when you need LLM-judged, end-to-end agent evaluations; reach for Inspector when you want to explore a running server interactively. Reach for mcp-testlib for the other 90% of testing: fast, deterministic checks that run in CI on every commit.

What this is not

mcp-testlib is a library, not a framework. It deliberately does not do:

  • LLM-as-a-judge evaluation or eval datasets — see @gleanwork/mcp-server-tester.
  • Protocol conformance/certification suites.
  • Load testing or benchmarking.
  • Its own CLI or test runner — you already have Vitest.

License

MIT