Also at Deasil Works · txn2 · Plexara
Profiles GitHub · X · LinkedIn
Theme Light · Auto · Dark
Professional notes by Craig Johnston
long-form, short-form, working drafts · since 2008
VOL. XIX · MMXXVI
106 NOTES IN PRINT
FOLIO C 2026-05-31 · 6 MIN · SHORT-FORM

Logging and the Notification Family

Structured server logs filtered by severity, and the list_changed notifications that keep a client's view of a server from going stale

Diagram · folio c
sequenceDiagram
  autonumber
  participant C as Client
  participant S as Server
  C->>S: logging/setLevel { level: "info" }
  S-->>C: {}
  S--)C: notifications/message (info)
  S--)C: notifications/message (warning, error)
  Note over S: server adds a tool at runtime
  S--)C: notifications/tools/list_changed
  C->>S: tools/list
  S-->>C: updated tools

A server has two ways to tell a client something happened without being asked. One is a log message: structured, severity-tagged output the client can filter and display. The other is a list-changed notification: a signal that the server’s tools, resources, or prompts are no longer what the client last saw. Both are server-to-client notifications, and this post reads both, the logging exchange and the family of list_changed messages that keep a client in sync.

This is part of MCP on the Wire, a series that takes the Model Context Protocol apart message by message, in Go. It comes out of building and running MCP servers in production, including the open-source txn2/mcp-data-platform, an Apache-2.0 platform in Go that connects AI assistants to Trino, DataHub, and S3 through one MCP endpoint, enriching every result with semantic context (ownership, lineage, PII, data quality) behind OAuth 2.1 auth, personas, and an audit trail. Everything here is read straight off the wire against spec revision 2025-11-25, with the official Go SDK at v1.6.1.

§Structured Logs, Filtered by the Client

A server that wants to emit logs declares the logging capability. The client then controls verbosity: it sends logging/setLevel with a minimum severity, and the server sends only messages at that level or higher. The client sets info, and the server’s logs arrive as notifications/message:

write: logging/setLevel { "level": "info" }
read:  notifications/message { "level": "info",    "logger": "worker", "data": "starting work" }
read:  notifications/message { "level": "warning", "logger": "worker", "data": "retrying once" }
read:  notifications/message { "level": "error",   "logger": "worker", "data": "downstream timeout" }

The tool that produced these logged four times, including a debug line, and only three notifications crossed the wire. The debug message was below the info floor the client set, so the server dropped it. That is the whole contract: the client picks the floor, the server filters to it. Each message carries a level, an optional logger name to group related output, and data, which is any JSON-serializable value, a string here but just as often an object with structured fields. Until the client calls logging/setLevel, a server sends nothing, so logging stays off by default and the client opts in.

§Eight Levels of Severity

The levels are not invented. MCP uses the eight syslog severities from RFC 5424, the same ladder Unix has used for decades, which means they need no explanation to anyone who has read a system log. Setting the floor at one level sends it and everything more severe.

emergencysystem is unusablesent
alertaction must be taken immediatelysent
criticalcritical conditions, a component failedsent
erroroperation failuressent
warningwarning conditions, deprecated usagesent
noticenormal but significant conditionsent
infogeneral informational messagessent
floor: logging/setLevel "info": below this line is dropped
debugdetailed debugging, function entry and exitdropped

Move the floor down to debug and everything flows; raise it to error and only error, critical, alert, and emergency get through. The client tunes the volume without the server changing a line of code.

§What a Log Must Not Carry

Logs flow to the client, and the client may display them, persist them, or feed them to the model. That makes a log message an exfiltration path, so the spec draws a hard line: a log message must not contain credentials or secrets, personal identifying information, or internal details that would help an attacker. It is the same instinct behind the no-secrets rule for elicitation forms: anything that crosses to the client is assumed to leak. A server should also rate-limit its logs, because a notification stream is cheap to flood and a client has to absorb whatever arrives. Put context in the data field, not secrets.

§The list_changed Family

Logging tells the client what is happening. The other notification kind tells the client that its picture of the server is out of date. A server’s tools, resources, and prompts are not frozen at startup, and when the set changes, a server that promised to announce it sends a notification. Adding a tool at runtime pushes one:

read: notifications/tools/list_changed

The notification carries no payload, the same nudge pattern as a resource update: it says the list moved, and the client calls tools/list again to see what changed. There are four notifications in this family. Three run server to client, one per server primitive, each gated by that primitive’s listChanged sub-flag: notifications/tools/list_changed, notifications/resources/list_changed, and notifications/prompts/list_changed. The fourth runs the other way, notifications/roots/list_changed from the roots post, the client telling the server its workspace changed. Together they make a server’s surface dynamic: it can grow or shrink mid-session and every connected client stays current.

§The Go Side

The two halves are a handful of calls. A server logs with req.Session.Log, and the SDK enforces the floor for you, sending nothing if the client never set a level or if the message is below it. The client opts in with SetLoggingLevel and receives messages through a LoggingMessageHandler. For dynamic changes, adding or removing a tool at runtime is just mcp.AddTool or server.RemoveTools, and the SDK pushes the list_changed notification to connected clients automatically; the client reacts with a ToolListChangedHandler and re-lists.

§The Whole Notification Family

Step back and the notifications seen across this series form a small, consistent set, every one a JSON-RPC message with a method, no id, and no reply: notifications/initialized closes the handshake, notifications/progress and notifications/cancelled manage a request, notifications/message carries logs, the four */list_changed keep lists in sync, notifications/resources/updated flags one resource, and notifications/elicitation/complete ends an out-of-band flow. Fire-and-forget, all of them. A receiver acts on a notification or ignores it, but never answers it, which is what keeps them cheap enough to use for a steady stream of logs and updates.

txn2/mcp-data-platform leans on both halves of this post. It emits structured logs and a full audit trail that maps every tool call to the user who made it, and it pushes notifications/tools/list_changed so a client’s tool inventory stays current as personas and connections change.

§What’s Next

Everything so far has assumed the connection was already trusted. Over stdio that is fair, the server is a local subprocess. Over HTTP it is not. The next post, Authorization: OAuth 2.1 for HTTP MCP Servers, covers how a remote server proves who is calling: the OAuth 2.1 flow, the protected-resource metadata a server publishes, the resource indicators that stop a token from being replayed elsewhere, and the token-passthrough ban that keeps a server from becoming a confused deputy.


The production data platform behind this series is txn2/mcp-data-platform, available hosted as Plexara.

← back to all notes