Guide · Observability
MCP server logging
MCP server logging has one non-negotiable rule that doesn't exist for ordinary REST APIs: never log tool call arguments. Tool arguments are the inputs an AI agent provides when invoking your tools — they frequently contain the user's query, personal information, or context from the conversation that called your server. Logging them creates a compliance liability and a data breach vector. Everything else about MCP server logging follows standard structured-log principles, but the argument-logging rule must be enforced at the logger level, not by hoping every developer remembers to omit it.
TL;DR
Use structured JSON logging (one object per line to stdout). Required fields on every log line: level, ts (ISO 8601), session_id, msg. For tool calls, add tool_name, duration_ms, error_code (null on success) — but never arguments or result. Propagate session_id via async context so it appears automatically without being passed through every function call. Retain logs 30 days for debugging. Send error-level logs to your alerting system immediately. AliveMCP adds the outside-in signal logs can't provide: it tells you when the server is unreachable or the initialize handshake fails — events that produce zero log lines.
Structured log format
Structured JSON logs are parseable by every log aggregator (Loki, Datadog Logs, CloudWatch, Papertrail). Plain-text logs require regex parsing that breaks whenever the format changes. For MCP servers running in containers, write to stdout — the container runtime forwards stdout to wherever your platform sends logs.
// logger.js — a minimal structured logger
import { createWriteStream } from 'node:fs';
const out = process.stdout;
function log(level, msg, fields = {}) {
const line = JSON.stringify({
level,
ts: new Date().toISOString(),
msg,
...fields,
});
out.write(line + '\n');
}
export const logger = {
debug: (msg, fields) => log('debug', msg, fields),
info: (msg, fields) => log('info', msg, fields),
warn: (msg, fields) => log('warn', msg, fields),
error: (msg, fields) => log('error', msg, fields),
};
Or use an existing library like pino (fastest, lowest overhead) or winston (more configurable). The key configuration is: JSON format, write to stdout, no sensitive field serialization. With pino:
import pino from 'pino';
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
// redact any field path that might contain a secret
redact: {
paths: ['arguments', 'result', 'req.headers.authorization'],
censor: '[REDACTED]',
},
});
Even with redaction configured, prefer not logging arguments at all rather than redacting them. A redaction config that covers your current tools may miss a new tool added next month. Defense in depth: architect the logging so arguments never reach the logger, not just so they're redacted if they do.
Required fields per event type
MCP servers have a small set of distinct event types, each with its own required fields:
Session lifecycle
// Session established
logger.info('session.open', {
session_id: sessionId,
client_name: initResult.clientInfo?.name,
protocol_version: initResult.protocolVersion,
});
// Session closed (normal or error)
logger.info('session.close', {
session_id: sessionId,
duration_ms: Date.now() - sessionStartMs,
reason: 'client_disconnect' | 'server_error' | 'timeout',
});
Tool calls
// Tool call — note: arguments are intentionally absent
const startMs = Date.now();
try {
const result = await tool.handler(args);
logger.info('tool.call', {
session_id: sessionId,
tool_name: toolName,
duration_ms: Date.now() - startMs,
error_code: null,
// DO NOT log: args, result
});
return result;
} catch (err) {
logger.error('tool.error', {
session_id: sessionId,
tool_name: toolName,
duration_ms: Date.now() - startMs,
error_code: err.code ?? 'UNKNOWN',
error_message: err.message,
// DO NOT log: args (they may contain PII)
});
throw err;
}
Initialization probes
// Log every initialize request — these are your probe logs
logger.info('mcp.initialize', {
session_id: sessionId,
client_name: params.clientInfo?.name,
duration_ms: Date.now() - startMs,
error_code: null,
});
AliveMCP's probe client has clientInfo.name = "AliveMCP" in its initialize request. You can filter your logs by client_name = "AliveMCP" to see only probe traffic, which is useful for separating monitoring overhead from real user activity in your metrics.
Session context propagation
Every log line from a session should include the session_id so you can filter all events for a single session when debugging. Passing session_id explicitly through every function call is error-prone — use Node.js's AsyncLocalStorage to propagate it automatically:
import { AsyncLocalStorage } from 'node:async_hooks';
const sessionContext = new AsyncLocalStorage();
// Wrap the session handler:
function handleSession(sessionId, handler) {
return sessionContext.run({ sessionId }, handler);
}
// In the logger, read from context automatically:
function log(level, msg, fields = {}) {
const ctx = sessionContext.getStore();
const line = JSON.stringify({
level,
ts: new Date().toISOString(),
session_id: ctx?.sessionId ?? null,
msg,
...fields,
});
process.stdout.write(line + '\n');
}
// Now every log call inside a session handler automatically includes session_id:
handleSession(sessionId, async () => {
logger.info('mcp.initialize'); // session_id appears automatically
await processToolCall(toolName, args); // same session_id propagated through async chain
});
AsyncLocalStorage propagates through async/await, Promise chains, and setTimeout callbacks automatically. It's the right tool for session context in Node.js MCP servers and has negligible overhead. See MCP server tracing for how to propagate W3C trace context alongside the session ID.
Log levels and when to use each
error— unhandled exceptions, tool failures that couldn't return a structured error, session crashes. Send to your alerting system (PagerDuty, Slack) immediately. Never suppress in production.warn— recoverable problems: slow tool calls exceeding a latency threshold, retried downstream requests, sessions that were dropped due to rate limiting. Review daily, alert if sustained spike.info— session open/close, every tool call (with duration), everyinitializeprobe. The standard log level for production. Default.debug— detailed state transitions inside tool implementations, cache hit/miss, downstream API response shapes. Enable only during active debugging; never in production by default — the volume overwhelms log aggregators and creates PII risk (developers tend to over-log at debug level).
Never use console.log in production MCP server code. console.log output is unstructured, has no level, no timestamp, and no session context. All logging should go through the structured logger. During local development with hot-reload, configure your logger to pretty-print to the terminal rather than switching to console.log:
// In development, pretty-print; in production, JSON
export const logger = pino({
level: process.env.LOG_LEVEL ?? 'info',
transport: process.env.NODE_ENV !== 'production'
? { target: 'pino-pretty' }
: undefined,
});
Log retention and what logs can't tell you
Retain logs for 30 days minimum. Most debugging sessions reference events from the last 7 days; post-incident reviews may need up to 30 days. Beyond 30 days, the value per stored byte drops sharply for debugging purposes (compliance requirements vary — check your jurisdiction).
The critical gap in logs-only monitoring: logs require your server to be running and reachable. When your server crashes completely, connection is refused, or the TLS certificate expires, your log output is zero lines — silence. You learn about the outage from a user complaint, not from a log alert. This is why external probe monitoring like AliveMCP exists alongside logging: it's the signal your logs can't generate. The probe runs every 60 seconds from outside your network, produces an alert on the first failed initialize handshake, and gives you MTTD of under 2 minutes regardless of whether your server is producing any logs. See MCP server observability for the four-pillar model (metrics, logs, traces, external probing).
Related questions
Should I log tool call results?
No. Tool results often contain the same PII risk as arguments, plus they may include data fetched from external APIs that you don't have rights to store. Log the outcome of the call (success vs error with error code and duration), not the content. If you need to debug a specific tool result, use a test environment with synthetic data, not production logs.
How do I correlate MCP logs with the AI agent's logs?
Use W3C traceparent propagation. The agent embeds a trace context in the MCP request (via HTTP header for HTTP/SSE transport, or via _meta.traceparent for stdio). Extract it at the server and include trace_id in your log lines. Then you can join the server's logs with the agent's logs by trace ID in your log aggregator. See MCP server tracing for implementation details.
What's the right log aggregator for a small MCP server team?
Grafana Loki is the most cost-effective self-hosted option — it indexes only labels (not the full log body), so storage is cheap for high-volume JSON logs. For managed options: Logtail (Better Stack) has a generous free tier and excellent query UI; Axiom is competitive for log volume pricing. Datadog Logs is powerful but expensive at scale. Pick based on what you already use for metrics — co-locating logs and metrics in the same platform makes correlation queries much easier.
How do I handle logging in a stdio MCP server?
stdio transport uses stdin/stdout for the JSON-RPC protocol. Writing log lines to stdout corrupts the protocol stream. Write logs to stderr instead: process.stderr.write(line + '\n'). When running the stdio server via a subprocess (e.g., from Claude Desktop), configure the MCP client to capture stderr separately, or pipe stderr to a log file: node server.js 2>>/var/log/mcp-server.log. For HTTP/SSE transport, stdout is fine since it's not used for the protocol.
Further reading
- MCP server tracing — distributed traces and W3C traceparent propagation
- MCP server debugging — local and production debug techniques
- MCP server observability — logs, metrics, traces, and external probing
- MCP server security monitoring — auth anomalies and supply chain health
- MCP server environment variables — what to log at startup vs what to never log
- MCP server error rate — distinguishing client errors from server errors
- AliveMCP — the outside-in probe that catches failures logs can't see