Skip to main content
Glama
README.md
# tgread

A **read-only** Telegram reader — a CLI first, and an MCP server second.
Fifteen tools for reading your channels, groups and DMs, and not one that can
write to them.

```sh
./install.sh                              # pinned venv + launcher + MCP registration
tgread login                              # api_id/api_hash, phone, code, 2FA
tgread tools                              # the catalogue
tgread read_chat --chat @durov --limit 5  # …and every tool runs from a shell
```

## The CLI comes first

Every tool is a subcommand; the MCP server is a second front end onto the same
handlers. That ordering is the point. A tool you can only reach through an MCP
client is a tool you cannot test against a real account, and "the code
type-checks" is not evidence that a Telegram result unpacks the way you assumed.

```sh
tgread tools                                     # catalogue, with required flags
tgread read_chat --help                          # one tool's arguments
tgread read_chat --chat @durov --limit 5         # JSON on stdout
tgread search_messages --query "postgres" | jq '.messages[].url'
```

Flags are generated from each tool's `inputSchema` — the same object advertised
over MCP — so the two surfaces cannot drift. Adding a property to a schema adds
a flag; there is no second place to update. Types come from the schema too:
`--limit 5` is coerced to an integer, `--ids 1,2,3` to an array, `--admins-only`
is a bare boolean, and `--kind banana` is rejected against the enum.

| exit | meaning |
|---|---|
| 0 | success; a JSON document on stdout, diagnostics on stderr |
| 1 | the tool ran and failed — not logged in, chat not visible, no such thread |
| 2 | usage error — unknown flag, bad type, missing required argument |
| 3 | **a read tool attempted a write.** Should be unreachable; it means a bug |
| 4 | an unexpected error escaped a tool — also a bug, never a stack trace |

CLI output carries no `UNTRUSTED CONTENT` banner. That envelope exists to frame
the payload for a model; a human or a `jq` pipeline wants the document itself.

`TGREAD_TOOLS` restricts what an *agent* sees, not what you can run — the person
at the terminal is not the threat it exists for. `tgread tools` marks anything
currently hidden from agents.

## As an MCP server

**You never run `tgread serve` yourself.** Claude Code spawns it as a child
process and speaks JSON-RPC over its stdin/stdout, dispatching to the same
handlers the CLI calls; `install.sh` registers it so that happens automatically:

```sh
claude mcp add --scope user tgread -- ~/.local/bin/tgread serve
```

Restart Claude Code (or `/mcp` → reconnect) and the tools appear. To check:

```sh
claude mcp list        # tgread: … - ✔ Connected
tgread status          # account, session validity, tool surface
```

Those two answer different questions, and the difference bites people:
**`Connected` only means the process started and completed the MCP handshake.**
It says nothing about whether you are logged in — connection to Telegram is
lazy, on the first tool call. `tgread status` is the one that talks to Telegram.

```mermaid
sequenceDiagram
    participant C as Claude Code
    participant S as tgread serve
    participant T as Telegram
    C->>S: spawn (stdio)
    C->>S: initialize / tools/list
    S-->>C: 4 read tools
    Note over S,T: no connection yet — login is not needed to start
    C->>S: tools/call read_chat
    S->>T: connect, reuse stored auth_key
    T-->>S: history
    S-->>C: UNTRUSTED envelope + JSON
```

Any other MCP client works too — it is a plain stdio server:

```json
{ "mcpServers": { "tgread": { "command": "/home/you/.local/bin/tgread",
                              "args": ["serve"] } } }
```

To drive it by hand while debugging, pipe JSON-RPC at it (`TGREAD_LOG=DEBUG`
puts diagnostics on stderr; stdout stays pure protocol):

```sh
printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | tgread serve
```

## Why not one of the existing ones

There are good community servers — [chigwell/telegram-mcp](https://github.com/chigwell/telegram-mcp)
has 1.5k stars, 30 contributors and real release hygiene. The reason to write
this one is not distrust of that code. It is that **the honest gain from
self-writing is tool surface and reviewability, not dependency count**, and
those are the two things that matter when the process holds a Telegram session
and feeds an agent attacker-controlled text.

| | community server | tgread |
|---|---|---|
| MTProto | Telethon | Telethon — *same*, and rightly so |
| resolved packages | 44 | **5** (`telethon`, `pyaes`, `pyasn1`, `rsa`, itself) |
| MCP layer | `mcp` SDK → starlette, uvicorn, pydantic, `pyjwt[crypto]`, opentelemetry | ~200 lines of stdio JSON-RPC in this repo |
| read tools | ~15 | **15** |
| runnable without an MCP client | no | **yes — every tool is a subcommand** |
| write tools | send, edit, delete, forward, react, join, admin | **none** |
| write enforcement | by convention | at the transport chokepoint |
| code to review before trusting it | ~3,000 lines across 30 contributors | one file you can read in a sitting |

Writing MTProto by hand would be reckless — Telethon *is* the crypto, the DC
migration and the reconnect logic. So it stays. Everything above it is ours.

## The threat model

Reading channels means **text an attacker chose enters an agent that holds a
shell** — and, usually, whatever else you have connected: mail, notes, cloud
credentials. That is the dominant risk here, and it is not fixed by who wrote
the server. Choosing a server with no write surface is one of the few
mitigations that does not depend on the model behaving well.

```mermaid
flowchart TD
    A["hostile channel post<br/>'ignore previous instructions…'"] --> B["tgread read_chat"]
    B --> C["UNTRUSTED envelope<br/>wrapped around every payload"]
    C --> D["agent context"]
    D --> E{"agent tries to act on it"}
    E -->|"send / delete / join"| F["no such tool exists<br/>tools/call → isError"]
    E -->|"raw TL request"| G["guard at _call → WriteBlocked"]
    E -->|"summarise for the user"| H["fine — this is the intended path"]
    style F fill:#1f6f43,color:#fff
    style G fill:#1f6f43,color:#fff
```

Three layers, in increasing order of how much they'd survive a bug:

1. **No write tool is advertised.** An injected instruction has nothing to call.
2. **Every payload is wrapped** in an `UNTRUSTED CONTENT` banner naming it as
   data, not instructions — including chat *titles* and bios, which are
   attacker-controlled too.
3. **The transport guard.** Telethon funnels every outbound TL request through
   `TelegramClient._call` (68 internal call sites reach it via `await self(req)`,
   and `__call__` is a one-line delegate). `ReadOnlyClient` overrides it. A bug
   in this file still cannot mutate the account.

The guard **fails closed**: a request is refused unless its TL class name starts
with `Get`/`Search`/`Resolve`/`Check`/`Find`, or is on a nine-entry
infrastructure allowlist. Three read-*shaped* requests are denied by name
because they have effects other people can observe — `GetMessagesViews`
(bumps the public view counter), `GetBotCallbackAnswer` (presses an inline
button), `GetInlineBotResults` (queries a bot as you). Nested requests are
walked, so a write cannot ride inside an allowed `InvokeWithLayer` wrapper.

```mermaid
flowchart LR
    R["TL request"] --> W["walk nested .query"]
    W --> D{"in EXPLICIT_DENY?"}
    D -->|yes| X["WriteBlocked"]
    D -->|no| I{"in INFRA_ALLOW?"}
    I -->|yes| P["to the wire"]
    I -->|no| V{"starts with Get/Search/<br/>Resolve/Check/Find?"}
    V -->|yes| P
    V -->|"no — incl. every<br/>name we've never seen"| X
    style X fill:#8b2020,color:#fff
    style P fill:#1f6f43,color:#fff
```

`tgread check` runs this offline: 26 write requests blocked, 17 reads allowed,
unknown names fail closed, nesting checked. No network, no session, no
credentials.

## Tools

The surface is as wide as MTProto's read paths allow — narrowing it is the
*deployment's* job, not the server's (see below). Every tool takes a chat as a
`@username`, a `t.me` link, or a numeric id from `list_chats`.

**Identity and discovery**

| Tool | What |
|---|---|
| `whoami` | which account this server is signed in as |
| `list_chats` | dialogs, filterable by name and by kind |
| `search_public_chats` | Telegram's public directory — finds channels never joined, split into already-known and strangers |
| `list_folders` | chat folders (dialog filters) and their sizes |
| `list_contacts` | saved contacts |

**Reading**

| Tool | What |
|---|---|
| `read_chat` | history, oldest-first, paged by message id or date |
| `get_messages` | specific ids, or a window *around* one — what a search hit was replying to |
| `read_thread` | replies under a post: a channel item's comment section |
| `list_pinned` | pinned messages — usually a chat's rules and announcements |

**Search**

| Tool | What |
|---|---|
| `search_messages` | full text, in one chat or across everything the account sees |
| `list_media` | history filtered by attachment kind, via Telegram's server-side index |

**People and metadata**

| Tool | What |
|---|---|
| `chat_info` | kind, member count, description, verified/scam flags |
| `list_members` | participants with roles, searchable, `admins_only` |
| `get_reactions` | who reacted to a message, and with what |
| `common_chats` | groups and channels shared with a given user |

Three things these deliberately do **not** do, despite being adjacent to tools
that would:

- **No media download.** `list_media` returns captions and metadata; fetching
  attachments would mean writing attacker-chosen bytes to the agent's disk.
- **Reading never marks as read.** `messages.ReadHistory` is a write and the
  guard blocks it, so the unread badges on your phone stay exactly as they were.
- **Nothing joins, reacts or presses.** `search_public_chats` finds channels
  without joining them; `get_reactions` reads reactions without adding one;
  `GetBotCallbackAnswer` — which would press an inline button — is denied by
  name even though it is spelled like a read.

## Restricting the tool surface

The server implements everything; the deployment decides what an agent sees.
Those are separate concerns, so they are separate settings — a tool you might
want next month should not have to be a tool your agent can reach today.

Set `TGREAD_TOOLS` in `config.env` (or the environment) to a comma-separated
allowlist. **Unset means all fifteen**, which is the right default: the cost of
a read tool is context, not risk, and the risky operations were never
implemented.

```sh
TGREAD_TOOLS=list_chats,read_chat                    # just follow some channels
TGREAD_TOOLS=list_chats,read_chat,search_messages,get_messages   # + research
```

Restricted tools are **not advertised** in `tools/list` and are refused again
on the call path. That ordering matters: a denied-but-advertised tool still
costs context and is still something a model can be argued into attempting,
whereas a tool that was never listed does not exist as far as the model is
concerned. Same reason there are no write tools at all — absence beats denial.

An unknown name is fatal at startup, not a warning. A typo that silently fell
back to "all tools" would widen the surface at exactly the moment someone was
trying to narrow it.

There is **no YAML config**, deliberately: a YAML parser means PyYAML, and
5 dependencies is the point of this repo. `config.env` is `KEY=VALUE`.

Three places can narrow what an agent reaches, weakest last:

| Layer | Where | Format |
|---|---|---|
| **Not advertised** | `TGREAD_TOOLS` here | `KEY=VALUE` |
| Advertised, denied | Claude Code `settings.json` → `permissions.deny: ["mcp__tgread__search_messages"]` | JSON |
| Per-subagent | `.claude/agents/*.md` frontmatter `tools:` | YAML |

## Operational notes

- **Use a secondary account.** Userbots (any MTProto client that is not the
  official app) are ToS-ban-able. This risk is identical for every server here.
- **The session file is a bearer token for the whole account.** Changing your
  Telegram password does *not* invalidate it. Only `tgread logout` — which
  revokes server-side before deleting locally — or Settings → Devices does.
  Treat it like an SSH private key.
- **`pip install telegram-mcp` is not this, and not chigwell's either.** That
  PyPI name belongs to an unrelated project; passing `TELEGRAM_API_ID` /
  `TELEGRAM_API_HASH` to it would hand your credentials to third-party code.
  Nothing here is published to PyPI on purpose.
- **State lives in one directory** — `$TGREAD_STATE_DIR`, default
  `~/.local/state/tgread`, mode 0700, holding `config.env` (0600) and
  `tgread.session` (0600). One directory to chmod, to back up, to destroy.
  `tgread status` flags it if the permissions drift.
- **`install.sh` uses `uv sync --frozen`** — it installs exactly the versions in
  the committed `uv.lock` and fails rather than re-resolving. A resolver that
  quietly picks up a fresh upstream release is how a compromised package reaches
  a process holding your session.

## Layout

| Path | What |
|---|---|
| `tgread.py` | the whole server: guard, tools, JSON-RPC loop, CLI |
| `bin/tgread` | launcher — execs the pinned venv's interpreter |
| `pyproject.toml`, `uv.lock` | the pin |
| `install.sh` | venv, symlink, `claude mcp add --scope user` |
| `test-tgread.sh` | 45 offline tests — guard, catalogue, protocol, CLI, hygiene |
| `test-tgread-live.sh` | every tool against the real account; skips cleanly with no session |

## Commands

```
tgread tools                        the catalogue, and what agents see of it
tgread <tool> [--flag value ...]    run one tool, JSON on stdout
tgread <tool> --help                that tool's arguments

tgread login      interactive: API credentials, phone, login code, 2FA
tgread status     who am I, is the session valid, are permissions sane
tgread logout     revoke server-side, then delete locally
tgread check      offline self-test of the read-only guard (no network)
tgread serve      speak MCP over stdio — what Claude Code runs
```

Two test suites, split by what they can actually prove:

```sh
./test-tgread.sh        # 45 tests, no network/session/credentials needed
./test-tgread-live.sh   # every tool against your account; skips without a session
```

The offline suite covers the guard, the catalogue, the protocol and the CLI —
the properties that must hold *before* this is pointed at an account. The live
suite covers the half it structurally cannot: that a TL result unpacks the way
the code assumes. It is read-only like everything else, so it is safe to re-run.

It earned its keep on the first run, catching `get_reactions` crashing with
`BroadcastForbiddenError` — broadcast channels expose per-emoji totals but not
who reacted. Telegram declining a read is a normal answer, so every tool now
funnels through one seam that turns any RPC refusal into a readable error.
`WriteBlocked` is deliberately not caught there: that one really is a bug.

`login` and `logout` use a plain client, not the guarded one: the guard exists
to constrain the *agent*, not the human at the terminal establishing or
revoking the session. Everything the MCP server touches goes through
`ReadOnlyClient`.

TDQS

A4/5.0

Scored across 15 tools

Disambiguation5/5

Each tool targets a distinct read operation or resource: chat metadata, message lists, specific message fetches, threads, media, members, reactions, contacts, folders, and shared chats. Even the message-related tools (read_chat, get_messages, read_thread) are clearly separated by scope and purpose.

Naming Consistency4/5

The vast majority of tools follow a clear verb_noun pattern (list_*, get_*, read_*, search_*), with three exceptions: chat_info, whoami, and common_chats. These deviations are minor and still readable, but they prevent a perfect consistency score.

Tool Count5/5

At 15 tools, the server sits at the upper edge of the well-scoped range, and every tool earns its place for a read-only Telegram interface. The count is substantial but not bloated, with each tool covering a distinct aspect of the domain.

Completeness5/5

The read surface is remarkably comprehensive: chat enumeration, message retrieval, search, threads, pinned messages, media, members, reactions, contacts, folders, public directory search, and account info are all present. The only excluded capability, media downloading, is explicitly documented as out of scope, so there are no hidden gaps.

Maintenance

ActivitySlowing
ResponsivenessNo issues