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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A spawned stdio process end to end | 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 | 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 | 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 | 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.
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.
Related how-tos
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
-
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 -
The
TAP version 13header 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 fromnode --testwhenever stdout is not a terminal. The test runner documentation records that Node 23 changed that default to thespecreporter, so the same command on a later Node prints prose where this page shows TAP. The linesok 1and1..2are the format’s whole grammar, and it has outlived several generations of test frameworks by saying so little. ↩︎ Back to text -
The Inspector’s command line exits
5both for a tool that answeredisError: trueand 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_foundagainsttool_is_error, for a caller willing to parse stderr to learn which of two things went wrong. ↩︎ Back to text