MCP Testing TypeScript Debugging

An MCP Server Can Connect While Its Stdout Is Invalid

The Prompt Shelf ·

A successful MCP connection is not a complete stdio transport test. In our local TypeScript SDK 1.27.1 experiment, a server printed an ordinary diagnostic line to stdout, then successfully initialized, listed a tool, and returned its result. The client also reported a parser error.

That combination matters when a test only asserts that connect() resolves. It can miss a malformed message on the same channel. The fix is to keep ordinary server diagnostics off stdout and test the client’s error observations as well as successful requests.

Our existing MCP testing guide explains the stderr rule. This article adds a measured counterexample to the assumption that a passing handshake proves the channel is clean. It tests a custom Node client and child server, not Claude Code or every MCP host.

The result: functional success and a channel error coexist

On October 11, 2026, we ran three separate local server processes using the already-installed SDK 1.27.1 and Node.js 25.8.0 on macOS. There were no credentials, external service calls, or production events. The fixture returned the public string pong.

The table scrolls horizontally on small screens.

Server diagnostic destinationInitializeTool list and callClient error callbacksCaptured stderr diagnostic
No diagnosticPassedPassed; pong0None
One plain-text stdout linePassedPassed; pong1None
The same line on stderrPassedPassed; pong0Present

This is one bounded reproduction, not a guarantee that a noisy server always recovers. Different clients, payloads, timing, or repeated noise can behave differently. The observed callback in the stdout case was a parsing error; production code should not assume every client.onerror callback is a JSON parser error.

The 2025-06-18 stdio transport specification, relevant to the handshake-era SDK tested here, reserves stdout for MCP messages and allows stderr diagnostics. The 2026-07-28 stdio binding retains that channel rule, but its newer discovery semantics are outside this fixture. Do not infer modern-version compatibility from the initialize test below.

Reproduce all three cases with a real client

Use a disposable local directory with @modelcontextprotocol/sdk pinned to 1.27.1. The code below uses public package imports. We ran those imports against the existing installed dependency through a local node_modules link; we did not test a fresh installation.

If you need to prepare your own isolated directory, the setup is:

mcp_package='@modelcontextprotocol/sdk'
mcp_version='1.27.1'
npm install --save-exact "${mcp_package}@${mcp_version}"
node --version
npm ls @modelcontextprotocol/sdk

Save the following as server.mjs. The only intentional defect is the stdout mode’s ordinary log line. Run it only as a test fixture.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const mode = process.argv[2];
if (mode === 'stdout') console.log('diagnostic: ready');
if (mode === 'stderr') console.error('diagnostic: ready');
const server = new McpServer({ name: 'stdio-noise-fixture', version: '1.0.0' });
server.registerTool('ping', { description: 'Return a public fixture value' }, async () => ({ content: [{ type: 'text', text: 'pong' }] }));
await server.connect(new StdioServerTransport());

Save this client beside it as client.mjs:

import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
const results = [];
for (const mode of ['clean', 'stdout', 'stderr']) {
  const errors = [];
  let diagnostics = '';
  const client = new Client({ name: 'noise-test-client', version: '1.0.0' });
  client.onerror = error => errors.push(error.message);
  const transport = new StdioClientTransport({ command: process.execPath,
    args: [fileURLToPath(new URL('./server.mjs', import.meta.url)), mode], stderr: 'pipe' });
  transport.stderr.on('data', data => { diagnostics += data.toString(); });
  try {
    await client.connect(transport, { timeout: 5000 });
    const tools = await client.listTools({}, { timeout: 5000 });
    const result = await client.callTool({ name: 'ping', arguments: {} }, undefined, { timeout: 5000 });
    assert.equal(tools.tools[0].name, 'ping');
    assert.equal(result.content[0].text, 'pong');
    assert.equal(errors.length, mode === 'stdout' ? 1 : 0);
    assert.equal(diagnostics.includes('diagnostic: ready'), mode === 'stderr');
    results.push({ mode, initialize: 'PASS', toolsList: 'PASS', toolsCall: 'pong', parserErrors: errors.length, stderrDiagnostic: diagnostics.trim() });
  } finally { await client.close(); }
}
console.log(JSON.stringify({ node: process.version, SDK: '1.27.1', results }, null, 2));

Run node client.mjs. Its JSON output should contain the three rows summarized above for the tested version. The assertions intentionally expect one error in the broken stdout case. This is a negative control demonstrating that our observer detects the defect, not an acceptance test that permits an error in a production server.

The client attaches client.onerror before connecting, and captures the child server’s stderr through stderr: 'pipe'. Its own console.log() prints the test report after the subprocess checks; it does not write into the child server’s stdout. Each request has a five-second timeout so an unexpected hang becomes a visible test failure.

Turn the reproduction into an acceptance check

For your actual server, start with the clean case and require all of the following:

  • Initialization or the version-appropriate discovery step succeeds.
  • The intended tool is listed, accepts its expected arguments, and returns the expected result.
  • No unexpected transport or protocol errors are reported during the checked interval.
  • A harmless diagnostic, if enabled, is visible on the logging channel you intended.

The last check prevents a misleading fix: deleting every log statement may hide the symptom without confirming that diagnostics still reach operators. Stderr output alone is not proof of a failure; inspect the message and process status.

The SDK 1.27.1 client transport implementation forwards stdout data into its message reader and reports read errors through the transport error callback. The experiment shows why both paths need to be observed. Our error counter applies only to this known fixture; classify real errors before deciding which ones are expected.

Check wrappers as well as the server code

Moving console.log() to console.error() in the server is only part of the audit. Check shell wrappers, startup banners, imported libraries, and development watchers that may also write to the child process’s stdout. In particular, a wrapper that merges stderr into stdout undoes the channel separation. We did not test a wrapper in the three-case experiment, so treat that as an inspection item rather than another measured result.

Do not filter arbitrary lines out of a live protocol stream to make a test pass. A text filter can discard legitimate messages or conceal a malformed producer. Fix the writer or wrapper, then repeat the clean-channel test. Keep actual secrets and customer payloads out of diagnostics; this fixture needs only a public marker.

If the process never starts, check the executable and file path first. If settings fail to reach an otherwise working child, use the separate SDK environment-inheritance reproduction. If you are implementing a server from scratch, the server-writing guide covers the broader structure. These are different failure boundaries; changing credentials is not a general remedy for a parser error.

Related Articles

Explore the collection

Browse all AI coding rules — CLAUDE.md, .cursorrules, AGENTS.md, and more.

Browse Rules