Why MCP logging as a bus
BlenderMCP needs a way to push messages from the server to subscribed clients. We picked the MCP notifications/message log channel. This page is the why — and what we gave up.
What we needed
Section titled “What we needed”- Server-initiated pushes to specific clients (not just request/response)
- Per-client addressing
- Priority ordering
- Subscribers in two flavors: persistent (Blender peers) and ephemeral (LLM sessions)
- Authentication that maps each pushed message to “this user’s bus”
Plus a soft requirement: no extra moving parts. The whole point of MCP is one well-defined protocol surface — adding a parallel channel for “the real traffic” felt like a smell.
Option 1 — Custom TCP socket (the original design)
Section titled “Option 1 — Custom TCP socket (the original design)”The pre-bus implementation used a custom JSON-over-TCP socket on localhost:9876. A Blender addon spun up a socket server; the MCP server connected as a client.
Worked: simple, low-latency, easy to reason about.
Didn’t work:
- Single Blender per server. No multiplexing.
- The Blender side had to bind a port, which clashed when multiple Blender installs were running.
- No authentication. Anyone on the host could connect.
- Required a separate protocol spec; clients written in other languages needed a fresh client library.
- The Blender process was the server, which meant LLM clients reached out to “Blender’s MCP socket” rather than “the bus” — inverted from where identity actually sits.
We could have layered multiplexing/auth on top, but at that point we’d be reinventing what MCP already provides.
Option 2 — WebSocket
Section titled “Option 2 — WebSocket”A WebSocket alongside the MCP HTTP endpoint, with our own framing for bus messages.
Worked on paper: persistent connection, server-initiated pushes, standard browser support.
Didn’t work:
- A second wire format to spec, document, and version.
- A second authentication path (the JWT validation lives in FastAPI middleware for HTTP requests; we’d need to re-implement it for WebSocket upgrades).
- Forwarding handler complexity doubles — we’d need a registry of WebSocket connections alongside MCP sessions.
- Library users wanting to write a client need to learn MCP and our WebSocket framing.
The killer was the “second auth path.” We’d already done the work to make /mcp JWT-gated. Doubling it for WebSockets meant two places to keep in sync.
Option 3 — MCP sampling
Section titled “Option 3 — MCP sampling”MCP has a sampling/createMessage request the server can send to clients. We considered using it as a generic “server pushes work to client” mechanism.
Worked on paper: the request/response semantics fit “dispatch a job and get a result.”
Didn’t work:
sampling/createMessageis specifically a request for the client to run an LLM completion. Repurposing it for arbitrary work is a protocol abuse — clients implementing the spec correctly will reject anything that isn’t an LLM-shaped request.- Only one outstanding sampling request per session — no concurrent jobs.
- No native fan-out (one server → many clients).
- The server-to-client direction in MCP is bidirectional only because of sampling; once you’re not using it for sampling, you’re outside the spec.
Option 4 — MCP logging (what we picked)
Section titled “Option 4 — MCP logging (what we picked)”Every MCP client can subscribe to log notifications via logging/setLevel. The server emits notifications/message records the client receives. The spec says these are for logs, but the structure (a level, a logger, a free-form data field) is general enough to carry anything.
The transport works:
- One notification per addressed client. The server’s
BusForwardingHandlerlooks up the per-targetsessionand pushes there. - Eight built-in priority levels (RFC 5424). We map them 1:1 to job priorities.
- A dedicated logger name (
_message_bus) lets clients filter bus traffic from any other log noise on the same channel. - Authentication happens once, at the HTTP transport layer. The JWT middleware sets a
ContextVar; every bus tool reads it.
Tradeoffs we accepted:
- Subscribers receive every notification at or above their set level. A client at the default
warninglevel missesinfo/notice/debugtraffic. The addon explicitly callsset_logging_level("debug")to subscribe to everything; LLM clients have to do the same. This is the single most common silent-failure mode for new clients. - The “logs” framing is misleading at first. A new developer looking at the protocol sees “this is a log channel” and isn’t sure what to do with the
datafield. We document the convention (logger == "_message_bus",datais the routing record) but it’s not enforced by the spec. - No per-message ACK from the receiver. Notifications are fire-and-forget. We do our own ACK via the
blender_job_updatereply path — but the reply is itself a separate routed message, not a transport-level response. - No backpressure. If a client subscribes and then stops draining, the underlying MCP transport queues notifications. We rely on clients doing the right thing.
Why it wins anyway
Section titled “Why it wins anyway”The framing-as-logs concern is real but small. The “every recipient gets every notification” concern is solved by the per-target log records — each one is stamped with its target_uuid, and clients drop anything that isn’t for them. (The server fans out one record per target, not one record broadcast to all.)
The authentication story is what closed the deal. JWT on the HTTP transport, ContextVar after middleware, every tool reads it the same way. No second auth path. No protocol invention.
And the practical proof: a vanilla fastmcp.Client with a message_handler that filters logger == "_message_bus" is a working bus subscriber in about 30 lines of code. That’s the “no extra moving parts” property paying off.
Related
Section titled “Related”- Architecture — the full round-trip
- Priority levels — the
set_logging_levelgotcha - Bus tools — the five tools that pump the bus