Skip to content

Commit 57db925

Browse files
joestumpjoestump-agent
authored andcommitted
docs: document experimental MCP channels
Covers the claude/channel capability, the --channels per-session opt-in, payload validation and escaping, delivery semantics in-process and against crush serve, two-way channels, deterministic channel_reply routing, and a Signal MCP example. Assisted-by: Claude Fable 5
1 parent 4564542 commit 57db925

1 file changed

Lines changed: 127 additions & 0 deletions

File tree

README.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
- **Session-Based:** maintain multiple work sessions and contexts per project
1919
- **LSP-Enhanced:** Crush uses LSPs for additional context, just like you do
2020
- **Extensible:** add capabilities via MCPs (`http`, `stdio`, and `sse`)
21+
- **Channel-Ready:** MCP servers can push real-time events into your session via [Claude Channels](https://code.claude.com/docs/en/channels-reference) — CI failures, webhooks, and more, acting on them without you typing a thing
2122
- **Works Everywhere:** first-class support in every terminal on macOS, Linux, Windows (PowerShell and WSL), Android, FreeBSD, OpenBSD, and NetBSD
2223
- **Industrial Grade:** built on the Charm ecosystem, powering 25k+ applications, from leading open source projects to business-critical infrastructure
2324

@@ -367,11 +368,137 @@ which do expand.
367368
"headers": {
368369
"API-Key": "$(echo $API_KEY)"
369370
}
371+
},
372+
"signal": {
373+
"type": "stdio",
374+
"command": "uv",
375+
"args": [
376+
"run",
377+
"--directory",
378+
"/path/to/signal-mcp",
379+
"python",
380+
"signal_mcp/main.py",
381+
"--user-id",
382+
"+15551234567",
383+
"--channel"
384+
]
385+
}
386+
}
387+
}
388+
```
389+
390+
#### Channels (experimental)
391+
392+
An MCP server can also act as a **channel**: instead of only exposing tools it
393+
calls, it *pushes* events straight into your session so Crush reacts to things
394+
happening outside the terminal — a webhook, a CI failure, a chat message. See
395+
the [channels reference](https://code.claude.com/docs/en/channels-reference)
396+
for the protocol.
397+
398+
A server becomes a channel by declaring the `claude/channel` capability in its
399+
`initialize` result (`capabilities.experimental["claude/channel"] = {}`) and
400+
emitting `notifications/claude/channel` events with a `content` body and an
401+
optional `meta` map. Crush injects each event into the active session as a
402+
`<channel>` element:
403+
404+
```text
405+
<channel source="webhook" severity="high" run_id="1234">
406+
build failed on main: https://ci.example.com/run/1234
407+
</channel>
408+
```
409+
410+
Listing a channel server in `mcp` is **not** enough to enable it — pushing is
411+
gated behind an explicit per-session opt-in, so a server present in config
412+
stays silent until you ask for it:
413+
414+
```bash
415+
# Enable one or more configured MCP servers as channels for this session.
416+
crush --channels server:webhook
417+
crush --channels server:webhook --channels server:signal
418+
```
419+
420+
The `source` attribute is always the (trusted) server name. Payloads are
421+
untrusted, server-initiated input: Crush validates their structure, caps the
422+
body and attribute sizes, restricts `meta` keys to identifiers
423+
(`[A-Za-z0-9_]`), escapes all content so a payload cannot break out of the
424+
`<channel>` element or forge attributes, and drops malformed payloads. A
425+
server that has not been opted in via `--channels`, or that never declared the
426+
capability, cannot inject anything.
427+
428+
Channel delivery works in the default in-process `crush` and against a shared
429+
`crush serve` backend. In-process, an event routes into the session you have
430+
open, or starts one if none is open, so it is never dropped. Against a
431+
`crush serve` backend the server routes each event exactly once — into the
432+
session an attached client is viewing (the most recently updated one when
433+
clients are viewing different sessions), otherwise into the workspace's most
434+
recent session, creating one only when none exists. That holds even with no
435+
clients connected, so a headless server still processes channel pushes;
436+
attached clients see the injected turn arrive through the normal event
437+
stream. Servers that are live channels are marked `channel` in the MCP list
438+
so you can confirm the opt-in took effect.
439+
440+
**Two-way channels.** A channel can also be interactive. Because a channel is a
441+
regular MCP server, any tool it exposes (a `reply` tool, say) is available to
442+
the agent through the normal MCP tool path — nothing channel-specific is
443+
required. Declare `tools` in the server's capabilities, register the tool, and
444+
use the server's `instructions` string (injected into the system prompt) to
445+
tell Crush when to call it and which `<channel>` attribute to pass back (like a
446+
`chat_id`).
447+
448+
For example, [Signal MCP](https://github.com/joestump/signal-mcp) lets Crush
449+
send and receive Signal messages through a
450+
[signal-cli](https://github.com/AsamK/signal-cli) daemon:
451+
452+
1. **Start the daemon:** `signal-cli -a +15551234567 daemon --tcp 127.0.0.1:7583 --receive-mode on-start --no-receive-stdout`
453+
2. **Add to `crush.json`:** Use the example in the [MCPs](#mcps) section above.
454+
3. **Launch with channel:** `crush --channels server:signal`
455+
456+
Incoming messages arrive as `<channel>` tags; use the `send_message_to_user` tool to reply.
457+
458+
#### Channel reply routing
459+
460+
By default a channel-originated turn only produces terminal output, so a
461+
person messaging you on Signal never sees the answer unless the model decides
462+
to call a send tool itself. Adding a `channel_reply` block to a channel
463+
server's MCP config makes the routing deterministic: when a turn that
464+
originated from that channel finishes without the model having replied through
465+
the channel, Crush sends the final assistant response back through the
466+
configured tool — to the sender for direct messages, or to the group for group
467+
messages.
468+
469+
```json
470+
{
471+
"mcp": {
472+
"signal": {
473+
"type": "stdio",
474+
"command": "uv",
475+
"args": ["run", "signal_mcp/main.py", "--operator", "+15551234567", "--channel"],
476+
"channel_reply": {
477+
"user": { "tool": "send_message_to_user", "target_param": "user_id" },
478+
"group": { "tool": "send_message_to_group", "target_param": "group_id" },
479+
"suppress_tools": ["send"]
480+
}
370481
}
371482
}
372483
}
373484
```
374485

486+
How it routes:
487+
488+
- **Group pushes** (meta carries `group`) go through the `group` route;
489+
**direct pushes** (meta carries `sender`) go through the `user` route.
490+
`target_meta` overrides which meta attribute supplies the target;
491+
`message_param` (default `message`) names the tool argument that receives
492+
the reply text.
493+
- If the model already called a route tool — or any tool listed in
494+
`suppress_tools` — during the turn, the automatic reply is skipped, so
495+
richer model-driven replies aren't duplicated.
496+
- Local (non-channel) turns and channels without a `channel_reply` block are
497+
unaffected.
498+
499+
The same shape works for any messaging channel (Discord, Slack, …): point the
500+
routes at that server's send tools and the matching meta attributes.
501+
375502
### Hooks
376503

377504
Crush has preliminary support for hooks. For details, see

0 commit comments

Comments
 (0)