signal-cli's SendReceiptCommand declares recipient as a plain string argument (unlike send/sendTyping/sendReaction, which take lists), so the list form failed to bind. targetTimestamp stays a list (nargs='+'). The source also has no --username arg here, so the u:-prefixed recipient normalization is the only username route — which we already do. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016LYrdTwfR49dKdLyi3vnfW |
||
|---|---|---|
| tests | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| signal-mcp | ||
| signal_mcp.py | ||
| signal_notify.py | ||
signal-mcp
A small MCP (Model Context Protocol) server that gives a Claude agent access to the Signal messenger, by wrapping signal-cli's built-in JSON-RPC interface.
Pure Python stdlib — no dependencies. By default it spawns
signal-cli jsonRpc as a subprocess and bridges MCP tool calls to it; it can
also connect to an already-running signal-cli daemon over a UNIX or TCP
socket.
Tools exposed
| Tool | Purpose |
|---|---|
send_message |
Send text (with optional attachments / quote-reply) to a number, username, or group |
receive_messages |
Drain incoming messages buffered since the last call (optionally block waiting) |
list_groups |
List groups and their ids |
list_contacts |
List known contacts |
send_reaction |
Emoji-react to a message |
send_read_receipt |
Mark a received message as read |
send_typing |
Typing indicator |
signal_rpc |
Escape hatch: any signal-cli JSON-RPC method (updateGroup, block, …) |
Incoming messages arrive asynchronously from signal-cli and are buffered
in-process (bounded, default 1000 envelopes); receive_messages drains the
buffer, so an agent can poll on its own cadence — or pass wait_seconds to
long-poll.
Setup
-
Get signal-cli. On NixOS the
./signal-mcplauncher pulls it in automatically vianix shell nixpkgs#signal-cliwhen it's not on PATH. Otherwise install it any way you like and (if needed) pointSIGNAL_CLI_BINat the binary. -
Register or link an account (one-time, interactive — not done through this server). The usual choice is linking as a secondary device to the Signal app on your phone:
nix shell nixpkgs#signal-cli --command signal-cli link -n "signal-mcp"This prints a
sgnl://linkdevice?...URI — turn it into a QR code (e.g.qrencode -t ansiutf8 'URI') and scan it from the phone under Settings → Linked devices. Alternatively register a dedicated number withsignal-cli -a +316... register/verify. -
Configure the MCP server in the agent's MCP config (for Claude Code:
.mcp.jsonin the project, orclaude mcp add):{ "mcpServers": { "signal": { "command": "/home/deprekated/Projects/home/signal-mcp/signal-mcp", "env": { "SIGNAL_ACCOUNT": "+31612345678" } } } }
Configuration (environment variables)
| Variable | Meaning |
|---|---|
SIGNAL_ACCOUNT |
Account number (E.164). Optional if only one account is registered. |
SIGNAL_CLI_BIN |
Path to signal-cli (default signal-cli on PATH). |
SIGNAL_CLI_SOCKET |
UNIX socket of a running signal-cli daemon --socket — connect instead of spawning. |
SIGNAL_CLI_TCP |
host:port of a running signal-cli daemon --tcp — connect instead of spawning. |
SIGNAL_INBOX_SIZE |
Max buffered incoming envelopes (default 1000). |
SIGNAL_CONNECT_TIMEOUT |
Seconds to keep retrying the daemon socket connection at startup (default 120) — covers the daemon's JVM startup and restarts. |
Daemon mode is useful when several processes should share one Signal connection, or to avoid JVM startup cost per session:
signal-cli -a +31612345678 daemon --socket /run/user/1000/signal-cli.sock
then set SIGNAL_CLI_SOCKET=/run/user/1000/signal-cli.sock (with
SIGNAL_ACCOUNT still set if the daemon serves multiple accounts).
The doorbell (signal-notify)
signal_notify.py (flake package signal-notify) is an optional companion
for daemon-mode setups: a deliberately information-free HTTP endpoint that a
client can hit — without authentication — to learn that a new message
arrived, and nothing else. No sender, content, or counts are exposed; the
only observable is arrival timing, which any blocking endpoint reveals by
nature. Receipts, typing indicators, and own-device sync traffic don't ring
it — only actual incoming messages (including edits).
It connects to the signal-cli daemon socket as a second client and serves:
GET /doorbell— SSE stream;event: message(empty data) per arrival, heartbeat comments every ~20s. Reports only messages arriving after connect.GET /doorbell/wait?since=<cursor>&timeout=<s>— long-poll; blocks until a message newer than the cursor arrives (or timeout, default/max 300s) and returns{"new": bool, "cursor": <int>}. Call withoutsinceonce to bootstrap the cursor, then pass it back on each call.
Configure with SIGNAL_CLI_SOCKET/SIGNAL_CLI_TCP (what to watch) and
SIGNAL_NOTIFY_LISTEN (default 127.0.0.1:7386), then expose the listener
through whatever frontend you like. Intended flow for a polling agent: block
on the doorbell cheaply, and only do the authenticated MCP round-trip
(receive_messages) when it rings.
Troubleshooting
NotRegisteredException/ "Failed to load new manager" on every tool call (daemon mode):SIGNAL_ACCOUNTdoesn't match a registered account. It must be byte-identical to whatsignal-cli listAccountsprints — canonical E.164, so+31628331774, not+310628331774(no domestic trunk zero). The env is read at proxy start; restart after fixing.- An account listed in
accounts.jsonbut "not registered", especially with"uuid": null: a leftover stub from an aborted link/register attempt. Remove it withsignal-cli -a <number> deleteLocalAccountDataand link again (a completed link fills in the uuid and canonical number itself).
Notes
- signal-cli is a JVM app: subprocess mode has a few seconds of startup latency per MCP session, then requests are fast.
- Message timestamps are the identifiers Signal uses for
reactions, quotes, and read receipts —
send_messagereturns the sent timestamp, and received envelopes carry the sender's. - Only one process can use an account's local data at a time; don't run two subprocess-mode servers on the same account simultaneously (use daemon mode for that).
Development
python3 tests/test_mcp.py drives the server through a full MCP session
against a fake signal-cli (tests/fake-signal-cli) — no Signal account
needed. The server itself is a single file, signal_mcp.py.