# How to unit test MCP tool handlers without starting a server

> Test tool logic with direct calls, the contract through the SDK's in-memory client, and the command line with one spawned stdio process.

Source: https://voxgig.com/howto/unit-test-mcp-tool-handlers

- Audience: api-producer
- Level: intermediate
- Languages: typescript, javascript, python
- Verified: 2026-10-21
- Published: 2026-10-19

## Short answer

Call the handler as a function for its logic, then connect the official SDK's `Client` to your `McpServer` over `InMemoryTransport.createLinkedPair()` and send `tools/list` and `tools/call` the way a host would. The in-memory run checks the argument schema and the output schema that a direct call never sees. Keep one test that spawns the real command over stdio, because that is the only one that sees a missing environment variable.

---
## You will need

Node 22 or later with `@modelcontextprotocol/server`, `@modelcontextprotocol/client`, and `zod`,
plus Python 3.10 or later with the `mcp` package for the Python half. Verified 2026-10-21 against
Node 22.22.0, `@modelcontextprotocol/server` 2.3.1, `@modelcontextprotocol/client` 2.3.1, `zod`
4.6.7, Python 3.13.16, and `mcp` 2.3.0. The sample's `npm ci` installs the Python packages into
`vendor/` from a pinned `requirements.txt` through a `postinstall` script, so one install serves
both halves. Nothing on this page opens a port or reaches the network. The end-to-end test spawns
`node server.mjs` and talks to it over the
[stdio transport](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio),
which is how a desktop host runs a local server.

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [A spawned stdio process end to end](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio) | One test per server, proving the command line, the environment, and the entry point | A process spawn per test, a failure that reads `Connection closed` whatever went wrong, and a cleanup step | Every test that is about a tool's logic or its schema |
| [Direct handler calls](https://nodejs.org/api/test.html) | The logic of one tool: branches, edge cases, error text | Blind to the contract, so a schema that disagrees with the handler passes every one of them | The bug is in a name, a schema, or a result shape |
| [MCP Inspector by hand](https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector) | Looking at what a client sees while you shape a tool | A person at a keyboard, so the check runs again only when someone remembers to run it | Any check you want run on every commit |
| [The official SDK in-memory transport pair](https://ts.sdk.modelcontextprotocol.io/v2/testing) | The contract: `tools/list`, argument validation, output validation, in one process | The SDK loaded into the test process, and no view of the command line or the environment it runs in | The failure you are chasing happens before the server object exists |

Direct calls are the cheapest and prove the least, because they never touch the schema. The
in-memory pair proves the contract at the cost of loading the SDK, and it still runs in the test's
own process with the test's own environment. The spawned process proves the command line and pays a
process start per test for it. The Inspector is for looking, and the three files below are for
repeating.

## Call the handler as a function

The handler takes a store and the arguments and returns a tool result. Nothing in it imports the
SDK, so the first test file loads nothing but the handler.

```js title="tools.mjs"
export function readMeter(store, { id }) {
  const meter = store.find((m) => m.id === id)
  if (!meter) {
    return { isError: true, content: [{ type: 'text', text: `No meter ${id}. Call meters_search to find one by serial.` }] }
  }
  return { content: [{ type: 'text', text: `${meter.id} serial ${meter.serial}, ${meter.kwh} kWh` }], structuredContent: meter }
}
```

```bash
node --test handlers.test.mjs
```

```text output
1..3
# tests 3
# suites 0
# pass 3
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 162.179188
```

Three tests and no protocol. This is where the branches live: the unknown id, the empty search,
the limit. The file proves what the handler does with the arguments it was given. It says nothing
about which arguments a client is allowed to send, or what the client is promised back.

## Drive the server through the in-memory pair

`InMemoryTransport.createLinkedPair()` returns two transports that hand each other message
objects. One goes to the server and one to the SDK's `Client`, which is the same class a host uses
over stdio or HTTP.

```js title="client.mjs"
export async function connect(server, name = 'test-client') {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  await server.connect(serverTransport)
  const client = new Client({ name, version: '1.0.0' })
  await client.connect(clientTransport)
  return client
}
```

The tests then speak the protocol. The one below sends an argument of the wrong type and hands the
server a store that throws on any access, so it also proves the handler never ran.

```js title="server.test.mjs"
test('an argument of the wrong type is refused before the handler runs', () =>
  withClient(async (client) => {
    const result = await client.callTool({ name: 'meter_read', arguments: { id: 8 } })
    assert.equal(result.isError, true)
    assert.match(result.content[0].text, /^Input validation error: Invalid arguments for tool meter_read: id: /)
  }, {
    // A store that reports any access. The handler never touches it, because
    // the SDK rejects the arguments first.
    meters: new Proxy([], { get: () => { throw new Error('the handler ran') } }),
  }))
```

```bash
node --test server.test.mjs
```

```text output
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 330.807194
```

Four tests cover what the direct calls cannot. They check the tool names in `tools/list`, the
`required` list and the output schema the SDK derived from `zod`, a default the schema fills in,
and the refusal in the test shown. The server also validates what a handler returns against its `outputSchema`, which the
pitfall section leans on. The SDK's [testing page](https://ts.sdk.modelcontextprotocol.io/v2/testing)
wires the same client through `createMcpHandler` and a fetch function instead. Either way the
server object is the real one and the transport is the stand-in.

## Spawn the real process once

The entry point reads its configuration from the environment and refuses to start without it. No
in-process test can reach that line, because no in-process test runs the file.

```js title="server.mjs"
const fixture = process.env.METER_FIXTURE
if (!fixture) {
  console.error('meter-mcp: set METER_FIXTURE to the path of the meters file')
  process.exit(2)
}
const meters = JSON.parse(readFileSync(fixture, 'utf8'))
serveStdio(() => createServer({ meters }))
```

The test spawns it the way a host does, through `StdioClientTransport`, and passes the variable by
name. By default the client forwards only the variables that
[`getDefaultEnvironment`](https://ts.sdk.modelcontextprotocol.io/v2/api/@modelcontextprotocol/client/client/stdio)
deems safe to inherit, which on Linux and macOS are six: `HOME`, `LOGNAME`, `PATH`, `SHELL`,
`TERM`, and `USER`.[^1] Anything the server needs beyond those has to be passed explicitly.

```js title="stdio.test.mjs"
const spawnServer = async (env) => {
  const client = new Client({ name: 'e2e-client', version: '1.0.0' })
  await client.connect(new StdioClientTransport({
    command: process.execPath,
    args: ['server.mjs'],
    cwd: import.meta.dirname,
    env: { ...getDefaultEnvironment(), ...env },
  }))
  return client
}
```

```bash
node --test stdio.test.mjs
```

```text output
TAP version 13
# Subtest: the packaged command starts, lists both tools, and answers a call
ok 1 - the packaged command starts, lists both tools, and answers a call
  ---
  duration_ms: 213.894752
  type: 'test'
  ...
# meter-mcp: set METER_FIXTURE to the path of the meters file
# Subtest: without METER_FIXTURE the process exits and the client never connects
ok 2 - without METER_FIXTURE the process exits and the client never connects
  ---
  duration_ms: 179.289212
  type: 'test'
  ...
1..2
# tests 2
# suites 0
# pass 2
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 636.176679
```

The second test spawns the server without the variable and asserts that connecting fails. The
server's complaint arrives as a TAP comment, because the test runner relays the child's stderr,
while the client itself reports only `Connection closed`.[^2] Compare the `duration_ms` lines with
the two earlier files: the spawn is the expensive part, which is the reason to have one such test
rather than one per tool.

## Do the same in Python

The Python SDK's `Client` accepts a server object and connects in-process. Its
[testing page](https://py.sdk.modelcontextprotocol.io/get-started/testing/) asks for
`raise_exceptions=True` so that a failure outside a tool body reaches the test with its real
message rather than as a generic internal error.

```py title="test_server.py"
async def main() -> None:
    checks = 0
    async with Client(mcp, raise_exceptions=True) as client:
        listed = await client.list_tools()
        names = [tool.name for tool in listed.tools]
        schema = listed.tools[0].input_schema
        assert names == ["meter_read"], names
        assert schema["required"] == ["id"], schema
```

```bash
PYTHONPATH=vendor python3 test_server.py
```

```text output
tools/list: ['meter_read'], id is a required string
tools/call meter_read mtr_8f2: {'id': 'mtr_8f2', 'serial': 'SN-40199', 'kwh': 4180}
unknown id, is_error=True: Error executing tool meter_read: No meter mtr_000. Call meters_search to find one by serial.
id sent as a number, is_error=True: Error executing tool meter_read: 1 validation error for meter_readArguments
4 checks passed
```

The schema came from the type hints: `id: str` became a required string, and the `TypedDict` the
function returns became the output schema. The last check is the same wrong-type refusal the
TypeScript server gave, worded by `pydantic` rather than by `zod`. The SDK also logs each rejection
to stderr, so those lines are absent from that stdout.

## Check it worked

Run the three Node files together and look for `# pass 9`.

```bash
node --test handlers.test.mjs server.test.mjs stdio.test.mjs
```

```text output
1..9
# tests 9
# suites 0
# pass 9
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 583.696391
```

Each of the three files fails for its own reason. Breaking the error text fails the first, renaming
a tool fails the second, and deleting `meters.json` fails only the third.

## When it goes wrong

The handler passes its tests, but a client gets an error from the same code. The store changed
shape, a CSV import left `kwh` as a string, and the direct tests never compare the result with the
output schema. The pitfall script runs the same handler both ways, then spawns the server without
its variable.

```bash
node pitfall.mjs
```

```text output
direct handler call
  text: mtr_8f2 serial SN-40199, 4180 kWh
  structuredContent.kwh: "4180"
the same handler through the in-memory client
  isError true: Output validation error: Invalid structured content for tool meter_read: kwh: Invalid input: expected number, received string
  id sent as a number, isError true: Input validation error: Invalid arguments for tool meter_read: id: Invalid input: expected string, received number
spawned without METER_FIXTURE
  connect failed: Connection closed
  the server said: meter-mcp: set METER_FIXTURE to the path of the meters file
```

The direct call returns `"4180"`, and a test that reads the text is satisfied. Through the server
the same result becomes `isError: true` with an `Output validation error`, which is what the model
would read: a type drift in your data turns into a tool failure. The in-memory test is the one that
catches it, so give it a fixture shaped like production data rather than a hand-typed object.

The last three lines are the failure no in-process test sees. The client reports
`Connection closed` without a reason; the reason is on the child's stderr, which the pitfall script
captures with `stderr: 'pipe'`. Do the same in the end-to-end test when a `Connection closed`
leaves you guessing.

A third failure is a call that hangs for a minute and then times out. The server wrote to its
stdout without a newline, so the stray text joined the next message, and the client dropped the
response with it. Only the spawned test sees the hang, because the in-memory pair has no stdout in
its path, which is one more reason it stays.

The transport specification reserves stdout for protocol messages, so log to stderr, which it
leaves free for logging. A `console.log` in a handler goes unnoticed in both kinds of test,
because the SDK's client discards any stdout line that is not JSON.

## When not to do this

Do not let the in-memory suite grow at the expense of the spawned test. The pair checks the
contract inside your process, with your environment, so a missing variable, a misnamed entry
point, or a stdout write that eats a response passes it every time. Keep the one test that runs
the command a host runs.

Do not test the SDK's validation for its own sake. One wrong-type call proves the schema is wired
in. A matrix of every type against every argument tests `zod`, which has a test suite of its own.

Do not point the in-memory tests at a live API. The server object is real, so a handler that
fetches will fetch. Inject the HTTP client or the base URL and script the answers, as the mock API
page does.

Do not make the Inspector the test suite. It suits looking at a result while you shape a tool, and
its command line exits non-zero on a failing call, so a script around it can gate
a build.[^3] A test file in the repository runs on every commit without anyone deciding to run it,
which the Inspector cannot offer.

## Related how-tos

- [Run an MCP server against a mock API while you build tools](/howto/run-an-mcp-server-against-a-mock-api)

- [Choose between stdio and Streamable HTTP for an MCP server](/howto/choose-an-mcp-transport)

- [Upgrade an MCP server to a newer protocol revision](/howto/upgrade-an-mcp-server-to-a-newer-revision)

## Last verified

Verified 2026-10-21 against Node 22.22.0, `@modelcontextprotocol/server` 2.3.1,
`@modelcontextprotocol/client` 2.3.1, `zod` 4.6.7, Python 3.13.16, and `mcp` 2.3.0. Every output
block is what the command preceding it printed to stdout. The `duration_ms` figures are from one
run on one machine and are quoted for the ratio between files. The Inspector was not run; its exit
codes are read from its documentation.

[^1]: The list is the SDK's idea of what is safe to inherit, and it is short on purpose. A server
spawned by a desktop host should not see the host's secrets by accident. The same function drops
any value that begins with `()`, the shape a shell function took when bash exported it into the
environment. That shape was the opening the 2014 bash vulnerability used, and Red Hat's
[article on it](https://access.redhat.com/articles/1200223) still prints the test string. The
[reference page](https://ts.sdk.modelcontextprotocol.io/v2/api/@modelcontextprotocol/client/client/stdio)
documents the function and its purpose. The six names, the eighteen it uses on Windows instead, and
the check for `()` are in the code.

[^2]: The `TAP version 13` header is the Test Anything Protocol, which the
[project's history page](https://testanything.org/history.html) dates to 1988 and traces to Perl's
test harness. Node 22 prints it from `node --test` whenever stdout is not a terminal. The
[test runner documentation](https://nodejs.org/api/test.html#test-reporters) records that Node 23
changed that default to the `spec` reporter, so the same command on a later Node prints prose where
this page shows TAP. The lines `ok 1` and `1..2` are the format's whole grammar, and it has outlived
several generations of test frameworks by saying so little.

[^3]: The Inspector's [command line](https://modelcontextprotocol.io/docs/2026-07-28/tools/inspector/cli)
exits `5` both for a tool that answered `isError: true` and for a tool that does not exist. A typo
in a tool name and a failing tool therefore look the same to a shell's `&&`. The one-line JSON envelope
it writes to stderr tells them apart, `tool_not_found` against `tool_is_error`, for a caller
willing to parse stderr to learn which of two things went wrong.