Loading homepage
High severity— incident: April 15, 2026
The first symptom was intermittent. A buyer emailed to say that tool calls on their MCP server were returning Unexpected token '<' in the Claude client — but only sometimes, only under load, and only after the server had been running for a few minutes. On a cold start, everything looked fine.
The server in question was a data-lookup tool built on the MCP TypeScript SDK. It had passed local testing. It worked on my machine. It had been deployed for two weeks before this report landed.
The error Unexpected token '<' meant the JSON parser was receiving something that started with < — not a valid JSON character for a message frame. The client expected a JSON-RPC response object. It got something that looked like HTML or a plain-text log line.
The server was corrupting its own stdout.
Stdout corruption on a stdio-transport MCP server means one thing: something other than the MCP SDK is writing to process.stdout. The stdio transport uses stdout as a raw bidirectional pipe. The JSON-RPC framing is built on top of it. Any bytes written to stdout that don't conform to the framing protocol break the stream — and the break can be delayed, appearing only after the extraneous write lands between two legitimate frames.
In this case, the root cause was a console.log call that had survived a code review. It was inside a helper function that ran asynchronously on some requests and not others — which explained the intermittency. Under load, the helper ran concurrently with a tool response write. The log output landed in the middle of a JSON-RPC frame.
The specific output that triggered the Unexpected token '<' error was a template literal logging an HTML snippet from a third-party API response for debugging. The string started with <div. The client's JSON parser choked on the <.
The 47-line repro below demonstrates the failure. It's the minimal form: a valid MCP server with one rogue console.log that fires asynchronously. Run it with node --loader ts-node/esm server.ts and connect any MCP client to reproduce the corruption under concurrent calls.
// server.ts — 47-line stdout corruption repro
// Requires: @modelcontextprotocol/sdk, typescript, ts-node
// Run: node --loader ts-node/esm server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
const server = new Server(
{ name: "repro-server", version: "0.0.1" },
{ capabilities: { tools: {} } },
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "fetch_data",
description: "Fetches some data.",
inputSchema: { type: "object", properties: {}, required: [] },
},
],
}));
server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === "fetch_data") {
// Simulates async work that triggers a rogue log.
// The setTimeout fires concurrently with the return below.
setTimeout(() => {
// This console.log writes to stdout. On the stdio transport,
// that corrupts the JSON-RPC frame channel.
console.log("<div>debug: fetch_data called</div>");
}, 10);
return {
content: [{ type: "text", text: "data returned" }],
};
}
throw new Error("Unknown tool");
});
const transport = new StdioServerTransport();
await server.connect(transport);The corruption is timing-dependent: if the setTimeout fires before the SDK flushes the response frame, the client receives a mixed byte stream. The 10ms delay is enough to reproduce it reliably under concurrent calls, but under light load the window closes and the test passes.
This is why the buyer saw it intermittently. Their test environment ran one call at a time. Their production environment ran several.
The structural fix is to make stdout exclusive to the MCP protocol before any other code runs.
Step 1: Route all logging to stderr.
// entrypoint.ts — first lines, before any other import
const toStderr = (...args: unknown[]): void => {
process.stderr.write(args.map(String).join(" ") + "\n");
};
console.log = toStderr;
console.info = toStderr;
console.warn = toStderr;
console.error = toStderr;This means any console.log — whether in your code or a transitive dependency — writes to stderr, not stdout. stderr is not part of the MCP stdio transport. It goes to the process's error stream, visible in the terminal but invisible to the client's JSON parser.
Step 2: Add a CI check that blocks merges if console.log appears in src/.
# .github/workflows/ci.yml (excerpt)
- name: Check for console.log in source
run: |
if grep -r 'console\.log' src/; then
echo "console.log found in src/ — route logging to stderr instead"
exit 1
fiThis is policy enforcement at the merge boundary. The stderr redirect handles the runtime case; the CI check handles the review case. Both are necessary because the redirect protects against transitive dependencies, but doesn't protect against a future contributor adding a new console.log believing it's safe.
Step 3: Never use console.log in an MCP server. Use a stderr-bound logger.
// lib/logger.ts
export const log = {
info: (...args: unknown[]) => process.stderr.write(`[INFO] ${args.join(" ")}\n`),
warn: (...args: unknown[]) => process.stderr.write(`[WARN] ${args.join(" ")}\n`),
error: (...args: unknown[]) => process.stderr.write(`[ERR] ${args.join(" ")}\n`),
};Import log from this module everywhere in the server. Never import console for output. The type system doesn't enforce this, but the linter can: no-console rules in most linters can be scoped to src/ only and set to error.
Stdout corruption is the most common high-severity production failure I see in MCP servers. It's gotcha number one in the Playbook — not because it's subtle, but because it passes every local test and breaks under conditions that only exist in production (concurrency, dependency version differences, load).
The fix is structural, not a matter of being careful. "Be careful with console.log" is policy. Policy fails. The stderr redirect plus CI check is a structure that enforces the constraint automatically.
If you ship an MCP server without these in place, you have not yet hit this bug. You will.
The fix takes ten minutes. Add it before you deploy.
The Claude Code + MCP Playbook covers everything in this post and more — from first MCP server to production-grade multi-agent systems. Step-by-step, operator-tested.
Defines stdout as the exclusive JSON-RPC frame channel; all logging must go to stderr.
Node docs on the writable stream backing stdout and its write method.
How the client runtime pipes child process stdio; the mechanism that makes stdout corruption visible.