Unit tests can tell you that a function returns the right object. They cannot, by themselves, tell you that a spawned MCP server completes its initialization handshake, advertises the tools a client expects, keeps protocol frames on stdout, or turns a real tool call into a valid response.
That process boundary is where a useful class of integration bugs hides.
This article shows a small, sequential stdio smoke test for a Node.js MCP server. The example is a cron-expression server, but the test shape applies to any MCP server launched as a child process.
The boundary to test
An MCP server using stdio has two streams with different jobs:
- stdout carries protocol messages.
- stderr carries diagnostics intended for humans.
A debug console.log() in the wrong place can corrupt stdout and make a correct handler look like a broken server. The server in this example starts with the MCP SDK's StdioServerTransport and logs its startup message with console.error, not console.log:
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// stdout is reserved for MCP protocol frames.
console.error(`[${SERVER_NAME}] v${SERVER_VERSION} running on stdio`);
}
The other part of the boundary is the request sequence. A client must initialize the connection before asking for tools or calling one:
- send
initialize; - send the
notifications/initializednotification; - request
tools/list; - call a tool through
tools/call.
A unit test that invokes a handler directly skips all four steps.
A minimal smoke-test harness
Save this as e2e-smoke.js in the repository root. It deliberately spawns the real entry point instead of importing the handler functions.
'use strict';
const { spawn } = require('node:child_process');
const child = spawn(process.execPath, ['index.js'], {
stdio: ['pipe', 'pipe', 'pipe'],
});
let buffer = '';
let nextId = 1;
const pending = new Map();
child.stdout.on('data', (chunk) => {
buffer += chunk.toString();
let newline;
while ((newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
let message;
try {
message = JSON.parse(line);
} catch (error) {
for (const { reject } of pending.values()) reject(error);
pending.clear();
continue;
}
if (message.id !== undefined && pending.has(message.id)) {
pending.get(message.id).resolve(message);
pending.delete(message.id);
}
}
});
child.stderr.on('data', (chunk) => {
process.stderr.write(`[server] ${chunk}`);
});
function request(method, params) {
const id = nextId++;
const message = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
child.stdin.write(message);
});
}
function notification(method, params) {
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function main() {
try {
const initialized = await request('initialize', {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'smoke-test', version: '1.0.0' },
});
assert(!initialized.error, 'initialize failed');
assert(initialized.result?.serverInfo?.name === 'cron-mcp-server', 'wrong server name');
notification('notifications/initialized', {});
const listed = await request('tools/list', {});
const names = listed.result?.tools?.map((tool) => tool.name) ?? [];
assert(!listed.error, 'tools/list failed');
assert(names.length === 4, `expected 4 tools, got ${names.length}`);
const parsed = await request('tools/call', {
name: 'parse_cron',
arguments: { expression: '*/5 * * * *' },
});
assert(!parsed.error, 'parse_cron failed');
const validated = await request('tools/call', {
name: 'validate_cron',
arguments: { expression: '0 0 30 2 *' },
});
const validationText = validated.result?.content?.[0]?.text ?? '';
assert(!validated.error, 'validate_cron failed');
assert(validationText.includes('never'), 'semantic warning was not returned');
const nextRuns = await request('tools/call', {
name: 'next_runs',
arguments: { expression: '0 9 * * 1-5', count: 3 },
});
assert(!nextRuns.error, 'next_runs failed');
console.log('stdio MCP smoke test: PASS');
} finally {
child.kill();
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
There are a few details worth keeping:
- The harness reads stdout line by line and parses every response as JSON.
- Requests are sent sequentially. That keeps the example small and makes each failure attributable to one step.
- The server's stderr is forwarded separately, so diagnostics do not get mistaken for protocol frames.
- The test checks a semantic result, not just an HTTP-like success.
0 0 30 2 *has five syntactically valid fields, but February 30 does not occur. The expected result is therefore a valid parse plus a warning/observation that the schedule never fires.
Run the unit and process tests separately
From the repository root:
npm ci
npm test
node e2e-smoke.js
The project also includes a checked-in end-to-end script that exercises the same boundary with initialize, tools/list, parse_cron, validate_cron, next_runs, and cron_presets.
In the source checkpoint used for this article, the unit suite completed with 19 passing tests and no failures. The real stdio script completed its handshake, found four tools, returned responses for all four calls, and reported PASS.
Those are two different signals:
- Unit tests cover transformations and edge cases in the cron engine and the expected MCP-shaped output.
- The stdio test covers process startup, transport framing, initialization order, tool registration, and the actual child-process boundary.
Keeping both is useful because either one can pass while the other fails.
What this catches that direct tests miss
Accidental stdout logging
If the server prints a banner with console.log, the first line on stdout is no longer a protocol frame. The JSON parser in the harness will fail immediately. Keep logs on stderr and make that a code-review rule.
A server that starts but advertises the wrong tools
A process can stay alive while registering no tools, using a renamed tool, or exposing a schema the client does not expect. The tools/list assertion catches a missing or renamed registration before a user tries to call it.
Confusing syntax validity with schedule usefulness
The validate_cron call is intentionally made against an impossible calendar date. The server returns valid: true for the expression's syntax, while its observations explain that the schedule is impossible. That distinction is more useful to a caller than collapsing every problem into a single boolean.
Broken request sequencing
Calling tools/list before initialization is a client bug. A direct function test cannot detect it. The harness models the real order and sends the initialized notification before discovery.
Output that is not actually serializable
The server returns text content containing JSON for its tools. Parsing the outer JSON-RPC response and then inspecting the content forces the test to exercise the wire format, not just an in-memory JavaScript object.
Keep the harness honest
A smoke test is not a protocol certification suite. This one does not prove:
- every cron grammar extension;
- concurrent request behavior;
- client-specific UX;
- long-running process stability;
- production deployment health; or
- that a schedule is operationally appropriate for a particular workload.
It also does not replace unit tests. The source tests deliberately exercise invalid expressions, named days, next-run calculations, custom dates, presets, and JSON serialization. The process test adds the missing transport seam.
If the server later gains more tools, update the expected tool list and add one meaningful call for each new behavior. If the server gains notifications or progress messages, make the harness distinguish responses from notifications rather than assuming every line has an id.
Practical takeaway
For a stdio MCP server, “the handler works” and “the server works” are different claims. Test the smallest real client flow you depend on:
- spawn the actual entry point;
- keep stdout protocol-only;
- complete initialization;
- discover the registered tools;
- make representative calls;
- assert on both wire shape and semantic content; and
- terminate the child cleanly.
This is a small amount of code, but it verifies the boundary where a server stops being a collection of functions and becomes a process another tool can actually use.
The complete server used in this example is available in the cron-mcp-server npm package.
Disclosure: This article was created with the help of AI. The examples and factual claims were checked against the source checkout and the published package metadata; the test results reported above came from an actual local run.
Top comments (1)
This is exactly the right boundary for a first smoke test. The next failure class I’d add is “the child process disappears and the harness waits forever.” Give every request a hard deadline, and reject all pending promises from the child’s
error,exit, andclosehandlers, including malformed or partial final frames.I’d also assert the negotiated protocol version/capabilities and the exact tool schemas, not only names and counts. That catches a server that starts successfully but quietly changes its contract.
A small adversarial transport matrix would make this especially strong: split/coalesced chunks, interleaved notifications, concurrent requests with out-of-order responses, cancellation, malformed input, graceful shutdown, and a noisy stderr stream to exercise backpressure. The sequential happy path proves operability; those cases expose whether the harness and server survive real process behavior.