signal-mcp-server
by Transmitt0r
README.md
# signal-mcp-server
MCP (Model Context Protocol) server for Signal messenger. Wraps a running
[signal-cli](https://github.com/AsamK/signal-cli) daemon's JSON-RPC API as MCP tools.
[](https://github.com/Transmitt0r/signal-mcp-server/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/@transmitt0r/signal-mcp-server)
[](https://opensource.org/licenses/MIT)
> ⚠️ **Known issue:** message ingestion can silently drop inbound messages
> under a specific stuck-receiver condition. Read
> [KNOWN_ISSUES.md](KNOWN_ISSUES.md) before deploying this in production.
## Prerequisites
- [signal-cli](https://github.com/AsamK/signal-cli) daemon running in HTTP mode
**with `--receive-mode=manual`** (see "Message ingestion" below for why this
matters):
`signal-cli --account +491****6789 daemon --http 127.0.0.1:8080 --receive-mode=manual`
- Node.js 22+
## Usage
### Stdio (for Hermes MCP, Claude Desktop, etc.)
```bash
SIGNAL_HTTP_URL=http://127.0.0.1:8080 npx @transmitt0r/signal-mcp-server
```
Configure in Hermes:
```yaml
mcp_servers:
signal:
command: "npx"
args: ["-y", "@transmitt0r/signal-mcp-server"]
env:
SIGNAL_HTTP_URL: "http://127.0.0.1:8080"
SIGNAL_ACCOUNT: "+491****6789"
timeout: 30
```
### HTTP transport
The HTTP transport can send/read Signal messages, so it requires a bearer
token and refuses to start without one. By default it binds to
`127.0.0.1` only; set `SIGNAL_MCP_HTTP_HOST` to change that (e.g. to expose
it inside a Docker network).
```bash
SIGNAL_MCP_HTTP_TOKEN=$(openssl rand -hex 32) signal-mcp-server --http 3100
```
Then call tools via JSON-RPC:
```bash
curl -X POST http://localhost:3100/ \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <token>' \
-d '{"jsonrpc":"2.0","method":"tools/list","id":"1"}'
```
### Docker
```bash
# Pull from GitHub Container Registry
docker run -e SIGNAL_HTTP_URL=http://host.docker.internal:8080 \
ghcr.io/transmitt0r/signal-mcp-server:latest
# Or build locally
docker build -t signal-mcp-server .
docker run -e SIGNAL_HTTP_URL=http://host.docker.internal:8080 \
signal-mcp-server
```
## Tools
| Tool | Description |
|------|-------------|
| `signal_list_contacts` | List all Signal contacts |
| `signal_list_groups` | List all Signal groups |
| `signal_list_conversations` | List recent conversations |
| `signal_read_messages` | Read recent messages (optionally filtered by sender) |
| `signal_send_message` | Send a message to a recipient or group |
| `signal_send_reaction` | React to a message with an emoji |
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `SIGNAL_HTTP_URL` | `http://127.0.0.1:8080` | signal-cli HTTP endpoint |
| `SIGNAL_ACCOUNT` | `""` | Phone number for display (optional) |
| `SIGNAL_MCP_MAX_MSGS` | `500` | Default number of messages returned per query when no explicit limit is given |
| `SIGNAL_MCP_MAX_STORED` | `100000` | Maximum number of messages retained in the database; oldest are pruned once exceeded |
| `SIGNAL_MCP_STATE_DIR` | `$XDG_STATE_HOME/signal-mcp-server` (falls back to `~/.local/state/signal-mcp-server`) | Directory holding the persisted message database (`messages.db`) |
| `SIGNAL_MCP_NO_PERSIST` | unset | Set to `1`/`true` to disable disk persistence entirely (buffer becomes memory-only, matching pre-persistence behavior) |
| `SIGNAL_MCP_HTTP_TOKEN` | none (required for `--http`) | Bearer token clients must send as `Authorization: Bearer <token>` when using the HTTP transport |
| `SIGNAL_MCP_HTTP_HOST` | `127.0.0.1` | Interface the HTTP transport binds to |
## Message ingestion: why `--receive-mode=manual` is required
Earlier versions of this server ingested messages via signal-cli's SSE
stream (`/api/v1/events`), with an optional file-tailer for durability. Both
approaches shared a structural problem, rooted in how signal-cli's **default**
receive mode works:
By default (`--receive-mode=on-start`), signal-cli's daemon runs a permanent
background thread from the moment it starts that continuously drains
Signal's server-side message queue and deletes each envelope from it —
**regardless of whether anything is listening**. Once a message is drained
this way, the only copies that exist are whatever a connected handler
happened to catch at that instant:
- The SSE endpoint (`/api/v1/events`) registers as a "weak" listener: it only
receives messages that arrive while its HTTP connection happens to be
open. Disconnects (deploys, restarts, network blips) mean those messages
are gone for good — signal-cli does not queue or replay them.
- A file-tailer reading signal-cli's redirected stdout (`-o json` +
`StandardOutput=append:...`) was intended to close this gap by acting as
an always-on "strong" listener tied to the daemon process itself. In
practice, on the systemd setup this was validated against, per-message
envelope logging landed on **stderr**, not the redirected stdout stream —
so the file only ever contained daemon startup/shutdown lines, and the
"durable" path silently ingested nothing. This is exactly the kind of
environment-specific log-routing assumption that's fragile to depend on.
> ⚠️ **This is not fully solved yet.** The poller can get stuck reporting
> "already being received" after an abort, silently dropping real inbound
> messages until it self-heals or the daemon is restarted. See
> [KNOWN_ISSUES.md](KNOWN_ISSUES.md) before relying on this in production.
**The actual fix**: run the signal-cli daemon with `--receive-mode=manual`.
This disables the automatic background drain thread entirely, so incoming
messages simply stay queued **on Signal's own servers** — not "buffered
somewhere on this box" — until this MCP server explicitly pulls them via the
JSON-RPC `receive` method (`POST /api/v1/rpc`, `{"method":"receive"}`),
which signal-cli's changelog documents as being available specifically "for
polling new messages." This server runs that call in a long-poll loop
(~55s per call) as its sole ingestion path. If the MCP server (or the whole
box) is down for hours, nothing is lost — the next successful `receive` call
just returns everything that queued up in the meantime.
**Constraint to be aware of**: signal-cli only allows one active receiver
per account at a time. If the daemon is still running in the default
`on-start` mode (or something else is calling `receive`/holding an SSE
connection concurrently), this server's poll calls will fail with `"Receive
command cannot be used if messages are already being received"` — logged
distinctly so it's easy to diagnose. There is no way to run this server's
poller *alongside* an SSE consumer or the default on-start thread; pick one.
Example systemd unit:
```ini
[Service]
ExecStart=/usr/local/bin/signal-cli -o json --account +1XXXXXXXXXX daemon --http 127.0.0.1:8081 --receive-mode=manual
Restart=on-failure
RestartSec=5
```
## Architecture
The server connects to a running signal-cli daemon via its JSON-RPC HTTP API. It
uses two main components:
- **Receive poller** (sole ingestion path): long-polls the JSON-RPC `receive`
method (`POST /api/v1/rpc`, ~55s timeout per call) in a loop for as long as
the process runs. Requires the daemon to be started with
`--receive-mode=manual` (see "Message ingestion" above) — otherwise
signal-cli's own background receive thread holds the account's single
receiver slot and every poll call fails with "already being received".
Because messages queue safely server-side until pulled, there's no
reconnect-latency or backoff tuning to get right: a poll that returns
empty just means nothing arrived in that window, and the very next poll
picks up anything that landed while this process was down entirely.
- **Message buffer**: Backed by a local SQLite database
(`$SIGNAL_MCP_STATE_DIR/messages.db`, via `better-sqlite3`) rather than an
in-memory ring buffer. Every message received is durably written as it
arrives; a `UNIQUE(source, ts)` constraint handles deduplication, and
indexes on `source` and `ts` keep sender/time-range lookups fast even as
history grows. `signal_read_messages` and `signal_list_conversations` query
this store directly.
This means a restart of the MCP server, or of the signal-cli daemon it
depends on, no longer loses message history — the whole point of keeping
history around for tools that only see it "since last restart" otherwise.
Set `SIGNAL_MCP_NO_PERSIST=1` to opt out and run purely in-memory (data is
lost on restart, as in earlier versions).
The MCP server registers six tools (`signal_list_contacts`, `signal_list_groups`,
`signal_list_conversations`, `signal_read_messages`, `signal_send_message`,
`signal_send_reaction`) and exposes them via stdio (default) or Streamable HTTP.
## Development
```bash
# Install dependencies
pnpm install
# Build (TypeScript compilation)
pnpm build
# Run unit tests (vitest)
pnpm test:unit
# Run all tests (unit + integration; integration requires a local Docker
# daemon — it uses testcontainers to spin up signal-cli)
pnpm test
# Lint (tsc — also covered by build)
pnpm build
```
The TypeScript source lives under `src/`. The main logic is in `src/index.ts`,
shared types and helpers are in `src/lib.ts`, and unit tests in
`src/tests/unit.test.ts`.This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessSyncing