Skip to main content
Glama
Transmitt0r

signal-mcp-server

by Transmitt0r

signal-mcp-server

MCP (Model Context Protocol) server for Signal messenger. Wraps a running signal-cli daemon's JSON-RPC API as MCP tools.

CI npm version License: MIT

⚠️ Known issue: message ingestion can silently drop inbound messages under a specific stuck-receiver condition. Read KNOWN_ISSUES.md before deploying this in production.

Prerequisites

  • 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+

Related MCP server: signal-mcp-server

Usage

Stdio (for Hermes MCP, Claude Desktop, etc.)

SIGNAL_HTTP_URL=http://127.0.0.1:8080 npx @transmitt0r/signal-mcp-server

Configure in Hermes:

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).

SIGNAL_MCP_HTTP_TOKEN=$(openssl rand -hex 32) signal-mcp-server --http 3100

Then call tools via JSON-RPC:

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

# 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 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:

[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

# 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.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Transmitt0r/signal-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server