Skip to main content
Glama

mcp-stub

A scripted mock MCP server for testing AI agents. Decide what each tool call returns, make the 2nd call fail and the 3rd one hang, then check which tools your agent called and with what arguments.

npm CI license

import { mockMcp } from "mcp-stub";

const server = await mockMcp({
  tools: {
    search_issues: [
      { returns: [{ id: 1, title: "Bug" }] }, // 1st call
      { throws: "rate_limited" },            // 2nd call
      { delayMs: 30_000 },                   // 3rd call hangs
    ],
  },
});

await runMyAgent({ mcpUrl: server.url }); // point your agent at the mock

server.assertCalled("search_issues", { times: 3 });
server.assertCalled("search_issues", { with: { query: /bug/i } });
await server.close();

Real MCP servers can't fail on demand, so the error paths of an agent (retries, timeouts, a tool that returns nonsense) usually go untested. mcp-stub makes those failures happen on the call you choose.

  • Scripted responses per call. Each call takes the next step; the last step repeats.

  • Failures on demand: tool errors, JSON-RPC errors, delays, hangs, malformed results and dropped connections.

  • Assertions on which tools were called, how often, in what order and with what arguments.

  • Streamable HTTP or in memory. Test any agent that takes an MCP URL, in any language, or connect an SDK client in the same process.

  • One dependency: the official @modelcontextprotocol/sdk.

It mocks the server so you can test the agent. To check that a real server follows the spec, use @modelcontextprotocol/conformance instead.

Install

npm install --save-dev mcp-stub

Needs Node.js 20 or later. Works with any test runner: node:test, Vitest, Jest and others.

Related MCP server: mockserver-mcp

Steps

A tool's script is a list of steps. Call 1 gets the first step, call 2 the second, and so on. Once the list runs out, the last step repeats.

Step

What the agent gets

{ returns: value }

A result. A string becomes one text block; anything else is sent as JSON text.

{ content: [...] }

A result with these content blocks (text, image, audio, resource links), plus optional structuredContent.

{ toolError: "message" }

A failed tool run: isError: true with the message, the way MCP servers report errors the model should see.

{ throws: "message" }

A JSON-RPC error instead of a result. Use { throws: { code, message, data } } to set the code.

{ delayMs: 2000, ... }

The step's answer, after a wait. Alone, an empty result after the wait. A cancelled call stops waiting.

{ hang: true }

No answer at all, until the client cancels or the server closes.

{ result: anything }

This exact JSON-RPC result, unchecked, to test how the agent handles a malformed reply.

{ disconnect: true }

A dropped connection mid-call. Over HTTP the session is gone, so the client has to initialize again.

(call) => step

A step worked out from the call, for answers that depend on the arguments. Can be async.

Add repeat: n to use a step for n calls in a row:

const server = await mockMcp({
  tools: {
    fetch_page: [
      { throws: { code: -32000, message: "upstream timeout" }, repeat: 2 }, // calls 1 and 2 fail
      { returns: "<html>…</html>" },                                       // then it recovers
    ],
  },
});

A tool can be a single step, a list of steps, or a full definition with a description and schemas the agent sees in tools/list:

const server = await mockMcp({
  tools: {
    get_weather: {
      description: "Current weather for a city",
      inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
      outputSchema: { type: "object", properties: { tempC: { type: "number" } } },
      annotations: { readOnlyHint: true },
      responses: (call) =>
        call.arguments.city === "Paris" ? { returns: { tempC: 21 } } : { toolError: `Unknown city: ${call.arguments.city}` },
    },
    delete_repo: { toolError: "permission denied" },
  },
});

With an outputSchema, object returns values are also sent as structuredContent. Arguments aren't checked against inputSchema, so you can see exactly what the agent sent, valid or not. A call to a tool that doesn't exist gets the spec's -32602 error and is still recorded.

Connecting

Over HTTP. server.url is a Streamable HTTP endpoint such as http://127.0.0.1:52811/mcp. Pass it to anything that speaks MCP: your own agent, the Claude Agent SDK, the OpenAI Agents SDK, LangChain's MCP adapters, or a Python agent started from your test. Each client gets its own session, and they share the scripts.

In memory. Skip the network and connect an SDK client in the same process:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";

const server = await mockMcp({ http: false, tools: { ping: { returns: "pong" } } });
const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(await server.connectInMemory());

Checking what the agent did

server.assertCalled("search_issues");                                  // at least once
server.assertCalled("search_issues", { times: 2 });                    // exactly twice
server.assertCalled("search_issues", { with: { repo: "acme/api" } });  // with matching arguments
server.assertNotCalled("delete_repo");
server.assertCalledInOrder("search_issues", "get_issue", "add_comment");

with matches deeply and partially: extra keys in the real arguments are fine, arrays must match item by item, and a RegExp tests a string. A failed assertion throws an AssertionError listing the calls that did happen:

AssertionError: Expected "search_issues" to be called 3 times, but it was called 2 times.
Calls:
  #0 {"query":"bug","limit":10}
  #1 {"query":"crash"}

For anything else, read the record yourself:

server.calls;                 // every tool call, in order
server.callsTo("get_issue");  // the calls to one tool
server.messages;              // every JSON-RPC message: initialize, tools/list, pings, cancellations…

Each call has tool, arguments, index (0 for the first call to that tool), outcome, startedAt, endedAt, sessionId and meta. The outcome is one of result, tool-error, protocol-error, unknown-tool, cancelled, disconnected or pending, so you can check that the agent really cancelled the call that hung:

await runMyAgent({ mcpUrl: server.url, toolTimeoutMs: 1000 });
assert.equal(server.callsTo("search_issues")[2].outcome, "cancelled");

When the agent runs in the background, wait for it:

const calls = await server.waitForCalls(3, { tool: "search_issues", timeoutMs: 10_000 });

Changing tools during a test

await server.setTool("new_tool", { returns: "hi" }); // add or replace, then notify clients
await server.removeTool("search_issues");             // remove, then notify clients
server.reset();                                       // clear the record and restart every script

setTool and removeTool send notifications/tools/list_changed, so you can test that the agent picks up new tools. A replaced tool starts its new script from the first step.

API

mockMcp(options?): Promise<MockMcpServer>

Option

Default

tools

{}

The tools, by name: a step, a list of steps, or a definition.

port

a free port

Port to listen on.

host

"127.0.0.1"

Host to listen on.

path

"/mcp"

Path of the MCP endpoint.

http

true

Set to false to connect in memory only.

serverInfo

{ name: "mcp-stub", version: "0.1.0" }

Name and version sent on initialize.

instructions

none

Server instructions sent on initialize.

MockMcpServer

Member

url

The Streamable HTTP endpoint.

calls, callsTo(tool)

The recorded tool calls.

messages

Every JSON-RPC request and notification received.

assertCalled(tool, { times?, with? })

Throws unless the tool was called (that many times, with those arguments). Returns the matching calls.

assertNotCalled(tool)

Throws if the tool was called.

assertCalledInOrder(...tools)

Throws unless the tools were called in this order. Other calls may come in between.

waitForCalls(count, { tool?, timeoutMs? })

Resolves once there are at least count calls.

connectInMemory()

Returns a transport for an SDK Client.

setTool(name, script), removeTool(name)

Change the tools and notify clients.

reset()

Clear the record and restart every script.

close()

Close every connection and stop the server. Also works with await using.

matches(actual, expected) is exported too, the same partial matcher with uses.

License

MIT

Related MCP Connectors

Related MCP Servers

  • A
    license
    B
    quality
    D
    maintenance
    A mock MCP server for testing MCP client implementations and development workflows. Supports tools, prompts, and resources across multiple transport protocols (stdio, HTTP, SSE).
    1
    MIT
  • A
    license
    A
    quality
    C
    maintenance
    An MCP server for interacting with MockServer, enabling AI assistants to create mock HTTP expectations, verify requests, clear state, and manage MockServer instances programmatically.
    6
    22 npm
    1
    MIT
  • A
    license
    Not graded
    quality
    D
    maintenance
    A lightweight mock MCP server for local testing and resilience experiments, providing predictable tool responses with simulated latency and errors.
    1
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    This MCP server provides a stateful, resettable, verifiable API runtime that gates every tool call, enabling agents to run long workflows against provider-shaped environments without live provider write access. It records decisions, side effects, and outcome evidence for replayable, verifiable benchmark runs.
    Apache 2.0