Logs as protocol messages, not console output
Most server code writes diagnostics to a file or a terminal it controls. An MCP server frequently controls neither. Under a stdio transport it is a child process whose output streams belong to the host; under an HTTP transport it may be a shared service whose real logs live in an observability stack the user has no access to. Either way, the operator who needs the diagnostic and the process that produced it are separated by the protocol boundary.
So MCP carries diagnostics across that boundary. A server that declares the logging capability during the initialize handshake may emit log records as JSON-RPC notifications, and the host can surface them next to the request that provoked them. Keep this distinct from progress: progress notifications report how far a specific long-running operation has advanced toward completion, while logs describe what the server is doing and seeing internally, whether or not any operation is in flight.
logging/setLevel — the client owns the volume knob
The one request in this part of the protocol is logging/setLevel. The client sends a level, and from that point the server should emit records at that severity and everything more severe, suppressing the rest. A host running normally might sit at info; a developer opening a debug panel drops it to debug and the same server starts narrating itself. The level can be changed at any time during the session, so verbosity is a live control rather than a startup flag.
Two consequences are worth internalising. First, verbosity is not your decision: a server that ignores the threshold and emits everything has broken its half of the contract. Second, do not assume a particular starting level when no setLevel has been sent — that is implementation-defined, so a well-behaved client sets an explicit level early rather than inheriting whatever the server happened to choose.
The shape of a log message — level, logger, data
Records travel as notifications/message. Three fields carry the meaning. level is required and names the severity. logger is an optional string naming the component that emitted the record — the equivalent of a logger name in any conventional logging library, and the thing that lets a client group or filter by subsystem. data carries the payload, and it is deliberately unconstrained: any JSON value the server wants to send.
That freedom is the interesting design choice. There is no mandated message string, which means you are not forced to flatten structure into prose. Prefer an object — {"event":"query.slow", "ms":2140, "table":"orders"} — over a pre-rendered sentence, because a client can render an object as text but cannot reliably parse a sentence back into fields. Keep the shape stable per logger, and keep it small: this payload rides the same connection as your tool results.
Eight severities, and picking the right one
MCP borrows the syslog severities of RFC 5424 wholesale, which is a quiet mercy — everyone already knows them, and they map onto existing logging libraries without translation.
| Level | Use it when |
|---|---|
debug | Step-by-step detail useful only while diagnosing |
info | Normal, noteworthy events: a cache warmed, a job started |
notice | Normal but significant — a config reload, a failover |
warning | Something is wrong but the operation continues |
error | An operation failed; the server is still healthy |
critical | A component is broken, not just one request |
alert | A human needs to act now |
emergency | The server is unusable |
The discipline that matters is honesty about the top half. Servers drift toward logging every failed request as error and every retry as warning, and within a week the client’s error view is wallpaper nobody reads. A failed tool call that you reported properly in the response is not an error in the server; it is info at most.
The stdout trap — when stderr is still correct
Under the stdio transport, the server’s stdout is the protocol channel. Every byte on it is expected to be a framed JSON-RPC message. A single stray print(), a library that announces itself on startup, a progress bar, a deprecation banner — any of these injects garbage into the message stream and the client’s parser fails, usually with a mystifying error that names your log line as invalid JSON. It is the most common bug in first-time MCP servers, and it is why routing diagnostics through the protocol exists at all.
The rule is simple: never write anything to stdout except protocol messages. Configure your logging framework’s default handler to stderr explicitly rather than trusting the default. And stderr remains genuinely correct for the things the protocol cannot carry — output before initialize completes, startup and configuration failures, and crash-path stack traces. Hosts typically capture the server’s stderr and show it, so that material is not lost.
Volume control — sampling, dedup, and aggregation
Nothing in the protocol throttles log traffic. There is no rate-limit field and no backpressure knob for notifications; volume control is exactly two things, and one of them is yours. The client’s lever is the level it set. Your lever is discipline about what you emit at each level.
This matters more than it does in an ordinary service, because log spam here is not just noise in a file — it competes for the same connection as tool results, it can swamp whatever pane the host renders it in, and in agentic hosts that feed server output into a transcript it can crowd out the material the model actually needs. The practical patterns are the ordinary ones, applied earlier: sample high-frequency events rather than emitting all of them, collapse repeats into a count with a window (‘42 retries in the last 10s’) instead of forty-two notifications, aggregate per-item detail into one record per batch, and never log inside a tight loop at a level a normal session will actually be listening to.