An MCP server is usually invisible. It may be a subprocess the user never sees, or a remote process running in somebody else’s infrastructure, and the person trying to work out why a tool call returned nonsense is sitting in the host application rather than tailing your log file. MCP’s answer is to make diagnostics a first-class protocol message: the server pushes log records to the client over the same JSON-RPC channel it uses for everything else, and the client decides how verbose that stream should be. It is a small piece of the protocol — one request, one notification, one enum — but it is the piece that decides whether your server is debuggable in production or a black box. This article covers setLevel, the notification shape, severity discipline, the classic stdout-corruption bug, volume and privacy, and what the client owes you.

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.

MCP logging — logging/setLevel + notifications/message + client sinks + level policyserver-produced logs to the hostMCP serverproduces log lineslogging/setLevelclient-set thresholdnotifications/messageserver → clientLevel enumdebug / info / warning / error / etcSink at clientUI console / store / forwardStructured payloadlevel + logger + dataVolume disciplineavoid floodCorrelationwith tools + resourcesPII scrubbingserver-sideRetention (client)policy per hostOps — governance + privacy + auditconsumestructuresamplecorrelatescrubretainretainoperateoperate
MCP logging pipeline: server → notifications → client sinks.
Advertisement

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.

LevelUse it when
debugStep-by-step detail useful only while diagnosing
infoNormal, noteworthy events: a cache warmed, a job started
noticeNormal but significant — a config reload, a failover
warningSomething is wrong but the operation continues
errorAn operation failed; the server is still healthy
criticalA component is broken, not just one request
alertA human needs to act now
emergencyThe 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.

Advertisement

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.

What must never reach a log line

A log record leaves your process and lands somewhere you do not control: a host UI, a client-side file, possibly a vendor’s telemetry pipeline, possibly a model context window. Treat every notification as published output. Credentials are the obvious case — API keys, tokens, connection strings, the Authorization header you were about to dump while debugging a request — and they leak most often not from careless code but from generic helpers that serialise a whole request or config object.

Beyond secrets, the same restraint applies to personal data, to the contents of records a tool touched, and to full prompts and model outputs, which are the most tempting and most sensitive thing a server sees. Log the shape instead: an identifier, a row count, a token count, a duration, an error class. Scrub at the point of emission with an allowlist of fields rather than a denylist of patterns, because a denylist only blocks the leaks you already thought of.

Correlating server logs with client-side traces

A log record is far less useful when you cannot tie it to the call that caused it. MCP does not mandate a trace or request identifier on log notifications, so correlation is something you build rather than something you receive. Two handles are available. The logger field namespaces the source, so a hierarchical name such as db.query or fetcher.http lets a client filter by subsystem without parsing payloads.

Everything else goes in data, by convention you define. Put the JSON-RPC request id of the in-flight call in every record emitted while handling it, plus a session identifier and, if you speak to a traced backend, your trace and span ids. That gives whoever is debugging a join key: filter server log records by request id to reconstruct one tool call, or carry the trace id outward so the server’s view lines up with spans recorded downstream. Emit the same key names everywhere; inconsistent field naming defeats the whole exercise.

What the client owes the log stream

The obligations run both ways. A client that declares it can receive logs must accept every notification without erroring, including unknown levels and payload shapes it has never seen — notifications carry no response, so there is nothing to reject them with, and a parser that throws on an unexpected data object turns a diagnostic into an outage. Silently discarding a record is always a legal choice; crashing is not.

Beyond that, the client is the policy layer. It sets and re-sets the level as the user opens or closes a debug view, decides where records go — a console pane, a rolling in-memory buffer, a file, an upstream telemetry sink — and enforces retention, because the host is where these records come to rest. It should also treat server-supplied strings as untrusted display data rather than markup, and bound its buffer so a chatty or hostile server cannot exhaust memory.

MCP logging turns server diagnostics into protocol messages: the client sets a threshold with logging/setLevel, and the server emits notifications/message records carrying a severity, an optional logger name, and an arbitrary JSON payload. Use the eight RFC 5424 levels honestly, prefer structured data over pre-rendered sentences, and remember the rule that breaks the most first servers: under stdio, stdout belongs to the protocol — log to stderr or to the protocol, never to stdout. Nothing throttles this stream, so sample, deduplicate, and aggregate before you emit; and treat every record as published output, keeping secrets, personal data, and full prompts out of it. Correlation is yours to build, via the logger name and identifiers you place in data. On the other side, a client must swallow anything it is sent without erroring, then decide what to render, store, and discard.