Summary
ChannelRegistry.handleChanged reconciles before it responds, while handleRegister responds before it reconciles. Reconciling sends a server-bound channels/open or channels/close per descriptor. A server that announces a channel from inside a request it is currently serving therefore cannot be answered: its own loop is busy, so the reconcile request it must answer is never read, and the announcement times out.
handleRegister's comment already names the hazard:
// Respond before reconciliation — the server blocks on this response and
// can't process channels/open until it arrives.
src/mcpl/channel-registry.ts:569-572 (response at :572, reconcile at :577)
handleChanged does the opposite: reconcile at :656, responder?.respond(...) at :658. Identical in 0.13.0, 0.14.0 and main.
Why it bites
MCPL servers commonly announce a channel from inside a tools/call handler (an agent asking to refresh or subscribe). A server that serves one request at a time — the natural shape for a stdio child — then deadlocks:
- server:
tools/call refresh_channels → channels/changed
- host:
handleChanged registers the descriptor, calls reconcileChannels, which awaits channels/open
- server: cannot read that request; it is inside step 1
- server: announcement times out (mcpl-core default 30s), the tool answers, and only then is the host's
channels/open served
Observed in zulip-mcp as antra-tess/zulip_mcp#20: a stream the bot joined after startup stayed -32023 Unknown channel for channels/open until the process was restarted, because a restart goes through channels/register (which answers first) while refresh_channels goes through channels/changed (which does not). Mentions from the stream were delivered the whole time, since the message path is off the request loop.
Suggested fix
Respond first, reconcile after, as handleRegister does:
responder?.respond({ results: addedResults });
if (accepted.length > 0) await this.reconcileChannels(serverId, accepted);
The itemized verdicts are known before reconciling, so nothing in the response depends on it. That also removes the case where a reconcile-time channels/open races a server that has not yet committed the descriptor.
Workaround in the server (not a fix)
zulip-mcp now records the descriptor before announcing, bounds the wait, and treats a host-initiated channels/open / channels/close as the confirmation the announcement itself can never get: antra-tess/zulip_mcp#21. That keeps the channel usable, but every such server has to carry the same workaround until this is fixed here.
Not verified
Read from 0.13.0, 0.14.0 and main sources plus a harness that mimics handleChanged; no connectome-host process was run.
🤖 Generated with Claude Code
Summary
ChannelRegistry.handleChangedreconciles before it responds, whilehandleRegisterresponds before it reconciles. Reconciling sends a server-boundchannels/openorchannels/closeper descriptor. A server that announces a channel from inside a request it is currently serving therefore cannot be answered: its own loop is busy, so the reconcile request it must answer is never read, and the announcement times out.handleRegister's comment already names the hazard:src/mcpl/channel-registry.ts:569-572(response at:572, reconcile at:577)handleChangeddoes the opposite: reconcile at:656,responder?.respond(...)at:658. Identical in 0.13.0, 0.14.0 and main.Why it bites
MCPL servers commonly announce a channel from inside a
tools/callhandler (an agent asking to refresh or subscribe). A server that serves one request at a time — the natural shape for a stdio child — then deadlocks:tools/call refresh_channels→channels/changedhandleChangedregisters the descriptor, callsreconcileChannels, which awaitschannels/openchannels/openservedObserved in zulip-mcp as antra-tess/zulip_mcp#20: a stream the bot joined after startup stayed
-32023 Unknown channelforchannels/openuntil the process was restarted, because a restart goes throughchannels/register(which answers first) whilerefresh_channelsgoes throughchannels/changed(which does not). Mentions from the stream were delivered the whole time, since the message path is off the request loop.Suggested fix
Respond first, reconcile after, as
handleRegisterdoes:The itemized verdicts are known before reconciling, so nothing in the response depends on it. That also removes the case where a reconcile-time
channels/openraces a server that has not yet committed the descriptor.Workaround in the server (not a fix)
zulip-mcp now records the descriptor before announcing, bounds the wait, and treats a host-initiated
channels/open/channels/closeas the confirmation the announcement itself can never get: antra-tess/zulip_mcp#21. That keeps the channel usable, but every such server has to carry the same workaround until this is fixed here.Not verified
Read from 0.13.0, 0.14.0 and main sources plus a harness that mimics
handleChanged; no connectome-host process was run.🤖 Generated with Claude Code