No description
Find a file
Kate d546e19cb4 sendReceipt: recipient is singular, not a list
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
2026-07-27 02:02:49 +02:00
tests sendReceipt: recipient is singular, not a list 2026-07-27 02:02:49 +02:00
.gitignore Initial signal-mcp: MCP bridge for signal-cli's JSON-RPC interface 2026-07-26 22:43:03 +02:00
flake.lock Initial signal-mcp: MCP bridge for signal-cli's JSON-RPC interface 2026-07-26 22:43:03 +02:00
flake.nix Add signal-notify: public information-free 'doorbell' endpoint 2026-07-27 00:17:59 +02:00
README.md Add signal-notify: public information-free 'doorbell' endpoint 2026-07-27 00:17:59 +02:00
signal-mcp Initial signal-mcp: MCP bridge for signal-cli's JSON-RPC interface 2026-07-26 22:43:03 +02:00
signal_mcp.py sendReceipt: recipient is singular, not a list 2026-07-27 02:02:49 +02:00
signal_notify.py Add signal-notify: public information-free 'doorbell' endpoint 2026-07-27 00:17:59 +02:00

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

  1. Get signal-cli. On NixOS the ./signal-mcp launcher pulls it in automatically via nix shell nixpkgs#signal-cli when it's not on PATH. Otherwise install it any way you like and (if needed) point SIGNAL_CLI_BIN at the binary.

  2. 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 with signal-cli -a +316... register / verify.

  3. Configure the MCP server in the agent's MCP config (for Claude Code: .mcp.json in the project, or claude 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 without since once 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_ACCOUNT doesn't match a registered account. It must be byte-identical to what signal-cli listAccounts prints — 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.json but "not registered", especially with "uuid": null: a leftover stub from an aborted link/register attempt. Remove it with signal-cli -a <number> deleteLocalAccountData and 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_message returns 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.