How-to › Expose your API to agents

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.

Audience
API producer
Level
intermediate
Topic
Build an MCP server
Languages
TypeScript, JavaScript and Python
Verified
Tags

Your tool tests pass, but the server fails inside the host, because the tests call the handler functions directly. They never build a tools/list response, never run an argument through the schema, and never start the process the host starts. The first tools/call from a real client is refused over an argument type, and the entry point exits over a missing variable.

What you get

You will end up with three test files that fail for different reasons: direct calls for the logic, an in-memory client for the contract, one spawned process for the command line. This is for you if handler tests are green but a host rejects the server.

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, which is how a desktop host runs a local server.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
A spawned stdio process end to endOne test per server, proving the command line, the environment, and the entry pointA process spawn per test, a failure that reads Connection closed whatever went wrong, and a cleanup stepEvery test that is about a tool’s logic or its schema
Direct handler callsThe logic of one tool: branches, edge cases, error textBlind to the contract, so a schema that disagrees with the handler passes every one of themThe bug is in a name, a schema, or a result shape
MCP Inspector by handLooking at what a client sees while you shape a toolA person at a keyboard, so the check runs again only when someone remembers to run itAny check you want run on every commit
The official SDK in-memory transport pairThe contract: tools/list, argument validation, output validation, in one processThe SDK loaded into the test process, and no view of the command line or the environment it runs inThe 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.

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 }
}
node --test handlers.test.mjs
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.

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.

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') } }),
  }))
node --test server.test.mjs
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 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.

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 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.

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
}
node --test stdio.test.mjs
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 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.

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
PYTHONPATH=vendor python3 test_server.py
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.

node --test handlers.test.mjs server.test.mjs stdio.test.mjs
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.

node pitfall.mjs
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.

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.

Footnotes

  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 still prints the test string. The reference page documents the function and its purpose. The six names, the eighteen it uses on Windows instead, and the check for () are in the code. ↩︎ Back to text

  2. The TAP version 13 header is the Test Anything Protocol, which the project’s history page 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 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. ↩︎ Back to text

  3. The Inspector’s command line 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. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.