Debugging MCP Servers with File Logging and Stdio Workarounds
Debugging MCP Servers with File Logging and Stdio Workarounds
Debugging MCP servers is harder than debugging most software, and the stdio transport is the reason. When your server communicates over stdin/stdout, you cannot add print statements. Any output that reaches stdout that is not valid JSON-RPC corrupts the stream and kills the connection silently.
The official MCP debugging documentation is direct about this: "Local MCP servers should not log messages to stdout, as this will interfere with protocol operation." This rules out the most common debugging instinct developers have.
The Stdio Problem in Detail
MCP servers using the stdio transport have a strict contract: stdout is exclusively for JSON-RPC messages. Every byte that reaches stdout must be part of a valid protocol message. This means:
- Swift's
print()is off limits (writes to stdout by default) - Python's
print()is off limits - Node.js
console.log()is off limits - Any framework logging that defaults to stdout is off limits
The failure mode is insidious. One forgotten debug print and your server silently breaks. The client stops receiving valid responses. No error message. No stack trace. The connection just stops working - and the only diagnostic is that your Claude Code session shows the tool as unavailable.
The #1 cause of MCP -32000 errors in production is writing to stdout instead of stderr.
Solution 1: File Logging
The most reliable workaround is writing all debug output to a file in /tmp and tailing it in a separate terminal.
In Swift:
import Foundation
let logFilePath = "/tmp/mcp-server-debug.log"
func debugLog(_ message: String, function: String = #function, line: Int = #line) {
let timestamp = ISO8601DateFormatter().string(from: Date())
let entry = "[\(timestamp)] [\(function):\(line)] \(message)\n"
guard let data = entry.data(using: .utf8) else { return }
if FileManager.default.fileExists(atPath: logFilePath) {
if let handle = FileHandle(forWritingAtPath: logFilePath) {
handle.seekToEndOfFile()
handle.write(data)
handle.closeFile()
}
} else {
try? data.write(to: URL(fileURLWithPath: logFilePath), options: .atomic)
}
}
// Usage
debugLog("Received request: \(method)")
debugLog("Tool execution started: \(toolName) with params: \(params)")
debugLog("Tool execution completed in \(elapsed)ms")
In Python (FastMCP):
from mcp.server.fastmcp import FastMCP
import logging
# FastMCP's get_logger() writes to stderr automatically
logger = logging.getLogger(__name__)
# Or write to file directly
logging.basicConfig(
filename="/tmp/mcp-server-debug.log",
level=logging.DEBUG,
format="%(asctime)s [%(funcName)s:%(lineno)d] %(message)s"
)
mcp = FastMCP("my-server")
@mcp.tool()
def my_tool(query: str) -> str:
logging.debug(f"my_tool called with query={query!r}")
result = do_work(query)
logging.debug(f"my_tool returning {len(result)} chars")
return result
In TypeScript:
import * as fs from "fs";
const LOG_FILE = "/tmp/mcp-server-debug.log";
function log(level: string, message: string, data?: unknown): void {
const entry = JSON.stringify({
timestamp: new Date().toISOString(),
level,
message,
...(data !== undefined && { data })
}) + "\n";
fs.appendFileSync(LOG_FILE, entry);
}
// Usage
log("info", "server started", { version: "1.0.0" });
log("debug", "tool called", { tool: "query_db", params: { query } });
log("error", "query failed", { error: err.message, query });
Then in a separate terminal: tail -f /tmp/mcp-server-debug.log
Solution 2: Stderr Is Your Friend
Stderr is not part of the MCP protocol. You can write freely to stderr without corrupting the JSON-RPC stream. Most MCP clients capture stderr separately from stdout, and many will surface stderr output in their debug logs.
In Swift:
// Use FileHandle.standardError instead of print()
func stderrLog(_ message: String) {
let output = message + "\n"
if let data = output.data(using: .utf8) {
FileHandle.standardError.write(data)
}
}
stderrLog("DEBUG: processing request \(requestId)")
In Python:
import sys
def log(message: str) -> None:
print(message, file=sys.stderr, flush=True)
log(f"Processing tool call: {tool_name}")
In Node.js:
const log = (msg) => process.stderr.write(msg + "\n");
log(`Tool called: ${toolName}`);
Stderr output flows freely. Claude Code and other MCP clients route it separately from the protocol stream. The flush=True in Python is important - without it, stderr can buffer and you will not see messages in real time.
What to Log
Log more than you think you need. MCP server bugs are hard to reproduce without complete context:
- Every incoming request - full JSON-RPC method name and params
- Every outgoing response - including the response ID so you can match request to response
- Tool execution start and end with timing information
- All errors with full context - not just the message but the state that triggered it
- Initialization sequence - what the server reports as its capabilities during the handshake
The initialization sequence deserves special attention. Many MCP connection failures are handshake failures - the client and server do not successfully negotiate capabilities during the initialize/initialized exchange. Logging both sides of that exchange makes these failures diagnosable.
The Stdio Proxy Approach
For complex debugging, a stdio proxy intercepts and logs all communication between the MCP client and server without modifying either side. The proxy sits in between, writes all traffic to a log file, and passes it through unchanged.
Both Bash and PowerShell versions are available as open source tools. This is the right approach when you need to debug the raw protocol messages rather than your server's internal logic - useful for diagnosing cases where the server appears healthy but the client is not receiving responses correctly.
Set up file logging before you need it. The debugging experience is significantly better when the infrastructure is in place before the hard-to-reproduce bug appears.
Fazm is an open source macOS AI agent. Open source on GitHub.