tgmcp
A Model Context Protocol (MCP) server for Telegram, built on the gotd client. It lets an MCP client (Claude Desktop, Claude Code, etc.) discover which channels have unread messages, read those messages, and mark them as read.
It authenticates as a user account (not a bot), so it sees the same channels and unread state as the logged-in user.
Tools
How it works
To avoid FLOOD_WAIT, the server does not re-fetch the dialog list on every
tool call. Instead, mirroring tdlib's strategy:
- The dialog list is loaded once (batched at 100 per request) and served from an in-memory cache.
- Per-dialog unread counts are kept live from the Telegram update stream via
gotd's
updates.Manager, which recovers gaps withgetDifference. - The dialog cache, the update state (
pts/qts/date/seq), and channel access hashes are persisted to bbolt (<session>/updates.bolt), so a restart reconciles incrementally instead of re-listing every dialog.
Setup
-
Get
APP_IDandAPP_HASHfrom https://my.telegram.org/apps. -
Configure credentials, either via environment variables or a
.envfile:cp .env.example .env # edit .env -
Build:
go build -o tgmcp . -
Log in once. This shows a QR code to scan from your Telegram app (Settings → Devices → Link Desktop Device) and prompts for the 2FA password if you have one set. It stores a reusable session under
./session/:./tgmcp auth
Configuration
Running
The server speaks MCP over HTTP (streamable transport):
./tgmcp serve
It loads the session created by tgmcp auth and never prompts; if the session
is missing or expired it exits and asks you to run tgmcp auth again.
Claude Code / Claude Desktop config
Point your MCP client at the HTTP endpoint (adjust the address to MCP_ADDR):
{
"mcpServers": {
"telegram": {
"type": "http",
"url": "http://127.0.0.1:8080"
}
}
}
Notes
- Logs are written as JSON to stderr, so journald (or any supervisor)
captures them. Set
LOG_LEVEL=debugto see every MTProto call and tool invocation. - Unread detection compares each message ID against the dialog's
read_inbox_max_id; messages newer than that boundary are returned. - After a long disconnect, a too-long difference is resynced automatically: a
single channel via
messages.getPeerDialogs, or the whole list via a full re-bootstrap. Deleting<session>/updates.boltforces a clean re-bootstrap on the next start.