← Blog

MCP Patterns: Push Notifications in a Protocol Without Push

Anthropic’s Model Context Protocol [1] (MCP) connects AI models to external tools. It handles request-response well. What it doesn’t handle — yet — is push. There’s no mechanism to notify the model or the user when something happens in the background. The protocol is young and evolving quickly; push semantics may come in a future revision.

We ran into this building Biff, our team communication tool, built with FastMCP [2]. When a message arrives, both the human and the AI need to know about it. MCP’s notifications/message isn’t currently integrated into Claude Code [3]‘s display layer — the protocol prioritizes request-response for safety and predictability, which is a reasonable design choice for a young protocol. For real-time tools like Biff, we needed notifications to work now rather than wait for the protocol to evolve. So we built six patterns. These aren’t criticisms of MCP — they’re solutions we needed for a real-time communication tool running within a protocol designed for synchronous request-response. We’re sharing them for anyone building MCP tools with similar needs.

The Display Problem: Two-Channel Display

MCP tool output in Claude Code gets truncated behind an expansion prompt when it exceeds a few lines. A /who table with three teammates is hidden. A message confirmation (“Sent to @kai”) is one line.

The fix: split output into two channels via a PostToolUse hook.

Silent commands (confirmations) put the full result in updatedMCPToolOutput — shown in the compact tool panel. The model stays quiet.

Data commands (tables, inbox) put a one-line summary in the panel (“3 online”) and the full data in additionalContext. The model emits it verbatim into the conversation.

Same hook, two code paths, per-command classification. Data shows up immediately. Confirmations produce no noise.

The Formatting Problem: Prior-Context Priming

The two-channel pattern requires the model to emit data verbatim — no markdown tables, no code fences, no reformatting. We wrote skill prompts saying exactly this. The model understood them (it could repeat them back) but didn’t follow them.

Five iterations later, the answer: timing. Formatting guidance delivered in the same turn as a tool call is treated as a suggestion. The same guidance delivered in a prior turn — before any tool calls — is followed much more consistently.

MCP’s instructions field, set during the initialize handshake, is injected into the system context before the first tool call. We put formatting rules there. The skill prompt reinforces them. Both are needed — instructions handles the first command, the skill prompt handles subsequent ones.

The delivery mechanism was the variable, not the prompt wording.

The Notification Problem: Dynamic Description Notify

Messages arrive while the user is working. Neither the model nor the human has any way to know.

Three mechanisms, working together:

  1. Tool description mutation — When unread count changes, rewrite the tool’s description to include it: “Check messages (2 unread: @kai about auth, @eric about lunch).” Fire tools/list_changed so Claude Code re-reads the tool list. Now the model sees the unread messages in tool discovery.

  2. Background notification dispatch — Inside a tool handler, use the request context to send list_changed. From a background poller (no request context), use a stored session reference captured from the last tool call. Two code paths because MCP handlers and background tasks have different access to the session.

  3. Status line file — A background poller writes unread state to a JSON file. The status line script reads it and displays biff(2). This is the human channel — the model can’t read the status line, and the human can’t easily read tool descriptions.

Two audiences, two channels, both necessary.

The Broadcast Problem: Wall

Direct messages go to an inbox and wait for /read. Broadcasts are different — they need ambient visibility without requiring anyone to poll. BSD wall(1) wrote to every logged-in terminal. We needed the same semantics over MCP.

/wall posts a message with a duration (default one hour, max three days). The message propagates through the same Dynamic Description Notify pattern: tool description mutation plus tools/list_changed. Every teammate’s tool list updates to show [WALL] deploying auth service — back in 30m. The status line picks it up too, with priority ordering: active talk sessions outrank wall messages, which outrank idle state.

No inbox, no /read, no acknowledgment. The wall expires on its own. /wall clear removes it early. The information is ambient — it’s there when you look, gone when it’s stale.

The Identity Problem: Process Tree Walk

The MCP server writes a state file. The status line command reads it. Both are descendants of the same Claude Code process. They need to agree on a filename — but Claude Code doesn’t expose a session ID as an environment variable.

Our first approach: os.getppid(). Both processes had the same parent PID. It worked — until Claude Code introduced an intermediate child process between its main process and MCP servers. The direct-parent assumption broke: the MCP server’s getppid() returned the intermediate process, while the status line’s getppid() returned something else entirely. File not found, status bar shows nothing.

The fix: walk the process tree. A single ps -eo pid=,ppid=,comm= call parses the full process table, then walks upward from the current PID to find the topmost ancestor whose basename is claude. Both MCP server and status line converge on the same root PID regardless of how many intermediate processes exist. The result is cached for the process lifetime (the ancestor never changes) and bounded to 10 levels for safety. Falls back to getppid() if no claude ancestor is found.

No configuration, no environment variables, no registration. The process hierarchy is still the identity — we just read more of it now.

The Composition Problem: Stash and Wrap

Claude Code’s statusLine setting accepts one command. No composition API. A tool that wants to display status must own the entire setting, but the user might already have something there.

Stash the current value to a file before replacing it. At runtime, the replacement command executes the stashed original as a subprocess, captures its output, and appends the tool’s segment. On uninstall, restore the original from the stash.

Non-destructive substitution. The stash file doubles as installation state.

The Pattern Language

These six patterns form a connected system: Two-Channel Display creates the need for Prior-Context Priming. Dynamic Description Notify creates the need for Process Tree Walk. Wall extends Dynamic Description Notify from per-user notifications to team-wide broadcasts. Stash and Wrap installs the human channel that Dynamic Description Notify writes to.

Each pattern solves one problem. Together, they solve a problem MCP doesn’t: real-time, bidirectional awareness between a background process, an AI model, and a human — all within a protocol designed for synchronous request-response. Steve Yegge has written about MCP’s potential as a universal integration standard [4], which suggests these workarounds may become unnecessary as the protocol matures — and that would be a good outcome.

All six are documented in punt-kit as reusable patterns with problem statements, forces, consequences, and known uses.

References

  1. Anthropic. “Model Context Protocol.” 2024–present. modelcontextprotocol.io
  2. Jlowin et al. “FastMCP.” 2024–present. gofastmcp.com
  3. Anthropic. “Claude Code.” 2024–present. docs.anthropic.com
  4. Yegge, S. “MCP Is the New HTTP.” Sourcegraph Blog, 2025. sourcegraph.com