How to Log and Debug Your MCP Server in Production (Without Breaking stdio)
Your MCP server works in the inspector and fails in the client. Why stdout is off limits for stdio servers, what to log for every tool call, and how to see failures after you ship.
You built an MCP server. In the inspector it works. In Claude Code, Cursor or Claude Desktop the tools never show up, or a call returns nothing, or the connection drops with a message like "failed to parse JSON". There is no stack trace, because you cannot see your server's output: the client is running it.
MCP servers are hard to debug for a structural reason. Here is how to fix that.
The rule that causes half of all "it just fails" reports
A stdio MCP server talks to its client over standard input and output. The protocol messages are JSON lines written to stdout.
That means anything else written to stdout corrupts the stream. A stray console.log("server started"), a library printing a banner, a deprecation warning, a debug line you forgot: the client sees invalid JSON and either errors out or silently drops your server.
The fix is simple and absolute:
- stdout is for the protocol only.
- Logs go to stderr (
console.error), or to a file, or over the network.
In Node, console.log writes to stdout and console.error writes to stderr, so in a stdio server, replace every console.log with console.error, and check your dependencies for ones that print.
What to log for every tool call
When something goes wrong in production you will want to answer: which tool, with what outcome, how long did it take, and what failed? A minimal useful record per call:
- the tool name
- the outcome (
okorerror) - the duration in milliseconds
- the error message and stack, if it failed
- a session or trace ID, so you can follow a multi-step run
Be careful with arguments and results. They often contain user data. Log a summary or a hash, not the raw content, unless you have decided it is safe.
server.registerTool("lookup_order", { description: "Look up an order" }, async (args) => {
const started = Date.now();
try {
const result = await lookupOrder(args.orderId);
logger.info("tool call", {
"mcp.tool.name": "lookup_order",
"mcp.outcome": "ok",
"mcp.duration_ms": Date.now() - started,
});
return result;
} catch (err) {
logger.error(err instanceof Error ? err.message : "tool failed", {
"mcp.tool.name": "lookup_order",
"mcp.outcome": "error",
"mcp.duration_ms": Date.now() - started,
});
throw err;
}
});Ship the logs somewhere you can read them
Printing to stderr helps on your machine. Once your server runs on someone else's laptop, or on a server, you need the logs to leave the process. Pick any logger that can send to a backend. With the FlareLog SDK:
import { flarelog } from "@flarelog/sdk";
const logger = flarelog({
apiKey: process.env.FLARELOG_API_KEY,
logToStderr: true, // keep stdout clean for the MCP protocol
});Logs shipped to FlareLog never touch stdout. The logToStderr option is a safety net for the case where the API key is missing: without it, the SDK falls back to printing to the console, and that includes stdout, which would corrupt your protocol stream. (The option is in the latest @flarelog/sdk; see the MCP server guide.)
Flush before you exit
A stdio server is started and stopped by the client, often abruptly. Logs still buffered when the process exits are lost, and those are usually the ones about the crash.
process.on("SIGINT", async () => {
await logger.flush();
process.exit(0);
});Two behaviours that make logs look strange
- Models retry with slightly different arguments. After a failed call an assistant often tries again with adjusted parameters, so your logs fill with near-identical failures that no human would produce. That is normal; group by tool and error message.
- Two kinds of failure exist. A protocol-level JSON-RPC error and a normal result flagged
isErrorlook different to the client. The model can read the text of anisErrorresult and act on it, so write those messages for the model, not for yourself.
A debugging checklist
- Nothing on stdout except protocol messages. Run the server by hand and look at what it prints.
- Every tool call logs name, outcome and duration.
- Logs ship off the machine, and you flush on exit.
- You can search by tool name and see the failures.
- The client's own log is checked too; for example Cursor writes MCP errors to a log file you can read.
If you want your assistant to read your server's logs and fix the bug, connect it over MCP. The same tool that logs your server can be the one your editor queries.
Start free: 10,000 logs a month.
Never miss an invisible crash again
FlareLog catches the errors Cloudflare can't log. Set up the Tail Worker in 5 minutes and see every crash, timeout, and cost spike in real time.
Start free →