the capability we forgot to declare
The bug shipped on a Monday. Tests were green. The release tag pushed clean. The end-to-end demo we wrote next did nothing.
what we built #
dkdc-io-imessage 0.2.0 had two jobs. The first was an MCP server: tools like imessage.reply and imessage.list_messages, called by Claude Code or Codex CLI over stdio JSON-RPC. The second was --watch mode: tail ~/Library/Messages/chat.db, and on every new inbound message, push a JSON envelope at the connected client.
Codex CLI consumes that envelope by reading $CODEX_CHANNEL_DIR/inbox/. Claude Code consumes it as an MCP notification — notifications/claude/channel over the same stdio transport that carries the tool calls. One --watch process serves both runtimes by emitting on both surfaces in parallel.
Unit tests covered the chat.db tail loop. Integration tests covered the JSON envelope shape. The Codex side worked the first time we hooked it up. We tagged 0.2.0.
what we saw #
The end-to-end test was minimal: start dkdc-io-imessage --watch, register it with claude mcp add imessage --dangerously-load-development-channels server:imessage, launch a bare Claude Code session, send an iMessage from a phone, and wait.
Claude Code printed its banner. The MCP server logged the inbound message and emitted the notification on stdout. We watched the wire: {"jsonrpc":"2.0","method":"notifications/claude/channel",...} went out cleanly, framed correctly, with the right envelope. The Claude Code session showed nothing. No tool calls, no banner update, no ← imessage · <handle>: line. Forty seconds of silence, then we killed the test.
We re-ran with claude --debug=mcp. The notification arrived at the client. The client read it, parsed it, and dropped it on the floor. No error, no warning. As if the message had never been sent.
what we missed #
The Anthropic reference plugin is gh-org/anthropics/claude-plugins-official/external_plugins/imessage/server.ts. We had ported it to Rust in 0.1.x. We had read it many times. It declares its capabilities like this:
const mcp = new Server( { name: 'imessage', version: '1.0.0' }, { capabilities: { tools: {}, experimental: { 'claude/channel': {}, // ... }, }, }, );
Our initialize handler returned this:
"capabilities": { "tools": {} }
That was the bug. Claude Code’s MCP client checks the server’s declared capabilities.experimental map before it surfaces any notifications/claude/channel to the session. If the server never declared the capability, the client treats every channel notification as out-of-spec and silently discards it. There is no error path back to the server; from the server’s perspective the wire looks identical.
The Rust port carried the comment that documented experimental.claude/channel as the opt-in. The map itself was dropped.
what we shipped #
The gap surfaced on a re-read of the reference at server.ts:548. The fix is in src/crates/netsky-io/src/mcp.rs:285:
"capabilities": { "experimental": { "claude/channel": {} }, "tools": {} },
Twenty-four characters of JSON. dkdc-io-imessage 0.2.1 published four minutes after the fix landed. The end-to-end test passed on the first re-run.
what we missed about what we missed #
The shipped fix is small. The thing that made the bug expensive is not.
MCP is a young protocol. The published spec describes the capabilities field as a server-declared map, and tells clients to honor it. It does not say what a client should do when a server emits a method the server did not declare. Implementations have answered that question differently. Some clients log. Some warn. Claude Code drops silently. All three are defensible. None are uniform.
We had no test in the pipeline that would have caught this. We tested the server in isolation and the wire format in isolation. We did not test the contract — the server declares what the client expects, and the client surfaces what the server declares. The pieces both worked. The agreement between them did not exist.
The real fix is two more tests, not in the server repo:
- A capability conformance test that boots a real Claude Code (or any conformant MCP client) against a stub server and asserts that the client surfaces a notification iff the server declares the matching
experimental.*capability. Run on every commit to the wire format. - A reverse audit: enumerate every
notifications/*method the server emits, and assert each one has a correspondingcapabilities.*declaration ininitialize. A grep with teeth.
Those tests do not live in dkdc-io/imessage. They probably live in a small mcp-conformance crate, or upstream. Either way: capability declarations are an implicit contract today, and implicit contracts rot the moment the cost of declaring is one line and the cost of forgetting is a silent drop.
the smaller story #
Three observations are worth keeping:
- For experimental capabilities, Anthropic’s reference plugin is the de facto contract. The MCP spec defines the handshake but is silent on
experimental.*. Two reads of the spec wrote the same broken port. One read of the reference fixed it. - Silent drops are the worst failure mode. A loud rejection costs an hour. A silent drop costs a release cycle. If you write a client, log the discard. If you write a server, log every notification you emit and never assume the wire is the truth.
- Green tests are not a green system. Our unit and integration suites were green for every commit through 0.2.0. The system was broken end-to-end the entire time. The next thing to build is the test that would have failed.
0.2.1 shipped the fix. 0.2.2 shipped docs. The conformance test is still backlog — exactly the shape of problem this post is about.