Q0T Telegram Mcp — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Q0T Telegram Mcp (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
A production-quality Telegram MCP server in Python that actually sends messages.
The reference Go implementation (@chaindead/telegram-mcp) ships with two bugs that make it silently broken:
MessagesSaveDraft instead of SendMessage. Messages are stored as unsent drafts — they never arrive at the recipient.to, but the Go struct uses the JSON tag name. The value passed by the caller is silently ignored and the dialog name is never set.This project fixes both and builds a reliable, well-tested replacement.
Tools marked ✎ change state and are omitted entirely under TG_READONLY.
| Tool | Parameters | Returns | Description |
|---|---|---|---|
tg_me | — | UserInfo | Get current Telegram account info |
tg_dialogs | unread_only (bool, default false), limit (int, default 20), offset (int, default 0) | List of dialogs — each has id, name, username (or null), unread_count, type flags, last_message, date | List dialogs (chats, groups, channels) |
tg_dialog | dialog_id (int), username (str), or name (str) — at least one required; limit (int, default 20), offset (int, default 0) | List of messages | Get messages from a dialog |
tg_send ✎ | dialog selector — at least one required; text (str); reply_to (int, optional); parse_mode (markdown default, html, or none) | {"ok": true, "message_id": n} | Send a message — calls SendMessage, never SaveDraft |
tg_send_file ✎ | dialog selector — at least one required; file_paths (list[str], one or many); caption (str, optional) | {"ok": true, "message_ids": [n, …]} | Send one or more files to a dialog; several files arrive as an album (details) |
tg_forward ✎ | message_id (int); one source from_* selector; one destination to_* selector | {"ok": true} | Forward a message from one dialog to another |
tg_read ✎ | dialog_id (int), username (str), or name (str) — at least one required | {"ok": true} | Mark all messages in a dialog as read |
tg_click ✎ | dialog selector; message_id (int); text (str, button label); row/col (int, optional) | {answer, alert, message, url?} | Press an inline keyboard button on a bot message; returns the callback answer and the (usually edited) message |
tg_react ✎ | dialog selector; message_id (int); emoji (str); big (bool, default false); remove (bool, default false) | {"ok": true} | React to a message with an emoji (or remove the reaction). Verify the result by reading reactions from tg_dialog. |
tg_edit ✎ | dialog selector; message_id (int); text (str); parse_mode (markdown default, html, none) | {"ok": true} | Edit one of your own sent messages |
tg_delete ✎ | dialog selector; message_ids (list[int]); revoke (bool, default true) | {"ok": true, "deleted": n} | Delete one or more messages |
tg_search | query (str); dialog selector (optional — omit for a global search); limit (int, default 20) | List of messages (each with chat_id/chat_name) | Search messages by text, per-dialog or globally |
tg_download | dialog selector; message_id (int); dest_dir (str, optional) | {path, media, size} | Download a message's media (photo/voice/document/…) to disk |
tg_folders | resolve_names (bool, default true) | List of folders — each has id, title, kind, emoji, include/pinned/exclude (lists of {dialog_id, name}), and the eight filter flags | List chat folders, including All chats as id 0 |
tg_folder_create ✎ | title (str); emoji (str, optional); chats (list[str], optional); contacts/non_contacts/groups/broadcasts/bots/exclude_muted/exclude_read/exclude_archived (bool, default false) | {"ok": true, "folder_id": n, "folder": {…}, "skipped": [...]} | Create a folder (chat references) |
tg_folder_update ✎ | folder selector — folder_id (int) or title (str); new_title (str, optional); emoji (str, optional); the eight flags (bool, optional) | {"ok": true, "folder": {…}} | Rename a folder or change its auto-filters; anything omitted is left as it was |
tg_folder_delete ✎ | folder selector | {"ok": true, "deleted_id": n, "deleted_title": "…"} | Delete a folder; the chats in it are untouched |
tg_folder_add_chats ✎ | chats (list[str]); folder selector | {"ok": true, "added": [...], "skipped": [...], "folder": {…}} | Add chats to a folder |
tg_folder_remove_chats ✎ | chats (list[str]); folder selector | {"ok": true, "removed": [...], "skipped": [...], "folder": {…}} | Remove chats from a folder, pinned list included |
tg_send_file always takes a list, so one file is ["/path/one.png"] and many are just a longer list. Files sent together arrive as an album, and caption captions that album as a whole rather than any single file:
{"file_paths": ["/tmp/1.png", "/tmp/2.png", "/tmp/3.png"],
"username": "alice",
"caption": "three screenshots"}{"ok": true, "message_ids": [101, 102, 103]}Two Telegram rules shape the rest of the behaviour:
another; everything else travels as documents. A mixed list is therefore split by kind — by file extension — and sent as one album per kind, visual first, with the caption on the first. ["a.png", "b.pdf", "c.jpg"] becomes an album of a.png+c.jpg and an album of b.pdf, and message_ids covers both.
Paths are checked before anything is sent: if any path is missing, the call fails naming every bad one and nothing is delivered. ~ is expanded.
If a later album fails after earlier ones succeeded, the result carries both — the error and the IDs that did go through:
{"error": "PartialSendError: FloodWaitError: …", "message_ids": [101, 102]}Retrying the whole list in that case would post the first album twice; resend only the part that is missing.
Messages returned by tg_dialog carry a reactions field: a list of {kind, count, chosen, emoji, custom_emoji_id}, or null when nobody has reacted. chosen is true for the reaction you left, which is what makes a tg_react call verifiable rather than merely "returned ok".
kind | Meaning |
|---|---|
emoji | Standard unicode reaction — emoji holds it |
custom | Custom emoji reaction — custom_emoji_id holds the document id |
paid | Paid (star) reaction |
other | Unrecognised reaction type |
Telegram normalises the emoji it stores, so what comes back is not always byte-identical to what you sent: reacting with ❤️ (U+2764 U+FE0F) reads back as ❤ (U+2764, variation selector dropped). Removal still works with either form, but comparing sent == reactions[i].emoji directly will surprise you — normalise before comparing.
tg_click can pressMessages returned by tg_dialog carry a buttons field: rows of {text, kind, url}, or null when the message has no keyboard. The kind tells you what the button actually is:
kind | Meaning | tg_click behaviour |
|---|---|---|
callback | Inline button that calls the bot back | Pressed; returns the callback answer and the re-fetched message |
url | Inline button that opens a link | Returns url; no callback is sent |
webview | Inline button that opens a Mini App | Returns url; no callback is sent |
reply | Bottom reply-keyboard button, not an inline one | Raises an error — it is not pressable |
other | Unrecognised button type | Raises an error |
Only callback buttons can be pressed. The other kinds are labelled honestly so you do not aim tg_click at a target that cannot respond — an unpressable button raises a clear error instead of silently doing nothing.
The same three selectors are available for every message tool (tg_dialog, tg_send, tg_send_file, tg_read, tg_click, tg_react, tg_edit, tg_delete, tg_download; and optionally tg_search). Supply at least one (except tg_search, where omitting them means a global search):
| Selector | Type | Notes |
|---|---|---|
dialog_id | int | Numeric ID from tg_dialogs. Most reliable — always refers to exactly one entity. |
username | str | Telegram @handle. Leading @ is accepted but not required. |
name | str | Display name (e.g. "Alice"). Least reliable — may match the wrong contact when names are not unique. |
When more than one selector is supplied, priority is: dialog_id > username > name.
A @handle is understood in the name slot too, so putting it in the "wrong" field is not a failure. Resolution of name tries, in order: an exact display-name match; then — when the value starts with @ — a handle lookup against Telegram; then a case-insensitive match against the username of your dialogs. Display name keeps priority, so nothing that resolved before 0.7.0 resolves differently now.
tg_folder_create, tg_folder_add_chats, and tg_folder_remove_chats take chats as a list of strings rather than the usual three selectors — the selector convention does not extend to a list. Each entry is read as:
| Form | Example | Meaning |
|---|---|---|
| Digits, optionally negative | -1001234567890 | Numeric dialog_id from tg_dialogs — most reliable |
Leading @ | @durov | Telegram handle |
me or self | me | Saved Messages |
| Anything else | Team standup | Display name; may be ambiguous |
Entries that do not resolve are returned in skipped with a reason, and the rest are still applied. A folder itself is addressed by folder_id (from tg_folders) or title; when two folders share a title the call fails naming both IDs rather than guessing.
contacts, non_contacts, groups,broadcasts, bots. The exclude_* flags only narrow a folder. Removing the last chat from a folder with no such flag is refused — delete the folder instead.
letters do not. Over the limit Telegram answers MESSAGE_TOO_LONG, which is why the length is checked here before anything is sent.
accepted by the request and silently stored as nothing, so emoji comes back null.
All chats (id: 0) is a view, not a folder: it cannot be edited or deleted.kind: "shared"; this server lists thembut does not edit them.
tg_folder_update never touches membership, and copies the stored filter rather thanrebuilding it, so folder settings this server does not model (tab colour, title animation) survive an edit made from here.
dialog_id vs nameUse name only as a fallback. Two real problems arise when you rely on it:
get_entity() cannot resolve a human display name like "Алёна 😍" directly. Under the hood this server scans your cached dialog list for an exact match, which is slower and can fail if the dialog is not yet cached.Recommended workflow:
tg_dialogs to get the list. Each entry includes both id and username (when the contact has a public handle).dialog_id (preferred) or username to tg_send, tg_dialog, and tg_read.name only when neither id nor username is available (e.g. phone-only contacts without a handle).Obtain API credentials from https://my.telegram.org/apps.
| Variable | Required for | Notes |
|---|---|---|
TG_APP_ID | always | From https://my.telegram.org/apps |
TG_API_HASH | auth only | Not needed to run the server — see below |
TG_SESSION_PATH | optional | Choosing the session path |
TG_READONLY | optional | Read-only mode |
TG_TRANSPORT | optional | stdio (default), http, or sse — Several clients on one machine |
TG_HOST / TG_PORT | optional | Bind address in http/sse mode; defaults to 127.0.0.1:8765 |
TG_API_HASH is not needed by the serverapi_hash is the secret half of your app credentials, and Telegram only ever receives it during login — auth.SendCodeRequest, auth.ImportBotAuthorizationRequest, and the QR login token request. The connection a running server actually opens sends InitConnectionRequest, which carries api_id and no hash at all.
This server never logs in: it connects to a session that auth produced earlier, possibly on a different machine. So from 0.7.0 onwards the server (and check) start fine without TG_API_HASH, and a self-documenting placeholder is substituted to satisfy Telethon's non-empty-value check. auth still requires it and fails with a clear message if it is absent.
Practical upshot: a deployment that only serves a session no longer has to ship the secret to wherever the MCP client config lives. Setting it anyway changes nothing — existing 0.6.x configurations keep working untouched.
No repository clone is needed. Use uvx to run directly from PyPI.
API credentials (TG_APP_ID and TG_API_HASH) can be supplied either as environment variables or as CLI flags — flags take priority.
# Via environment variables
TG_APP_ID=your_app_id TG_API_HASH=your_api_hash uvx q0t-telegram-mcp auth --phone +1234567890
# Via CLI flags (no env vars needed)
uvx q0t-telegram-mcp auth --phone +1234567890 --app-id your_app_id --api-hash your_api_hash# Via environment variables
TG_APP_ID=your_app_id TG_API_HASH=your_api_hash uvx q0t-telegram-mcp auth --phone +1234567890 --password your_2fa_password
# Via CLI flags
uvx q0t-telegram-mcp auth --phone +1234567890 --app-id your_app_id --api-hash your_api_hash --password your_2fa_passwordYou will be prompted to enter the OTP sent to your Telegram app (or SMS). After successful authentication, a session file is saved locally and subsequent connections reuse it automatically.
check)A session can be revoked at any time from an official Telegram client (Settings → Devices). Nothing local changes when that happens, so without asking you would only find out when the server starts and the first tool call fails. check asks up front:
TG_APP_ID=your_app_id uvx q0t-telegram-mcp check
TG_APP_ID=your_app_id uvx q0t-telegram-mcp check --jsonIt takes configuration from the same places as every other command, and accepts the same override flags: --app-id, --api-hash, --session. TG_API_HASH is not required.
| Exit code | Meaning | What to do |
|---|---|---|
0 | Session is authorized | Nothing |
1 | Telegram says the session is not authorized | Re-run auth |
2 | Could not determine — no session file, or Telegram unreachable | Check the path and the network |
Codes 1 and 2 are deliberately distinct: "this session is dead" and "I could not find out" call for opposite reactions, so a script must be able to tell them apart.
Human output names the session file and, when authorized, who is logged in. --json emits:
{"status": "authorized", "authorized": true, "session_path": "/home/you/tg/alice.session",
"user": {"id": 777, "username": "alice", "first_name": "Alice", "last_name": null, "phone": "1555…"},
"error": null}Two things worth knowing:
device and refreshes its "last seen" timestamp. It is not a purely local operation.
2 rather thansilently creating an empty session, and the check runs against a throwaway copy — so it is safe to run while a server is holding the real session file open.
By default the session is stored at .telegram-mcp/session.session relative to whichever directory auth was run from. The MCP server must be started from the same directory so it can find the session.
For Claude Code users, add .telegram-mcp/ to .gitignore to avoid committing credentials:
.telegram-mcp/TG_SESSION_PATH)Set TG_SESSION_PATH — or pass --session to auth — to put the session file wherever you like. The flag wins over the environment variable, as with the credential flags.
TG_APP_ID=your_app_id TG_API_HASH=your_api_hash uvx q0t-telegram-mcp auth \
--phone +1234567890 --session ~/telegram-sessions/alice.sessionThe value names the session file. Telethon appends the .session extension when it is missing, so alice and alice.session address the same file. A blank value counts as "not set" and falls back to the default above.
This is what makes several accounts at once possible. The default path is relative to the working directory, so two servers started from the same directory would fight over one session file; give each its own path and they coexist:
{
"mcpServers": {
"telegram_alice": {
"command": "uvx",
"args": ["q0t-telegram-mcp"],
"env": {
"TG_APP_ID": "your_app_id",
"TG_API_HASH": "your_api_hash",
"TG_SESSION_PATH": "/home/you/telegram-sessions/alice.session"
}
},
"telegram_bob": {
"command": "uvx",
"args": ["q0t-telegram-mcp"],
"env": {
"TG_APP_ID": "your_app_id",
"TG_API_HASH": "your_api_hash",
"TG_SESSION_PATH": "/home/you/telegram-sessions/bob.session"
}
}
}
}Both entries may share one TG_APP_ID/TG_API_HASH: the app credentials identify the application, the session identifies the account.
Add the following to your project's .mcp.json:
{
"mcpServers": {
"telegram": {
"command": "uvx",
"args": ["q0t-telegram-mcp"],
"env": {
"TG_APP_ID": "your_app_id",
"TG_API_HASH": "your_api_hash"
}
}
}
}Claude Code starts the server from your project root, so run auth from the same directory before using the tools.
TG_API_HASH may be omitted from this block — the server does not need it (see above). Only auth does.
If two agent runs can overlap, you need HTTP mode. Not as an optimisation — the default stdio setup corrupts your login when they do.
MCP clients spawn a stdio server per run. Two runs at once means two processes of this package against one Telethon session file, and both die:
sqlite3.OperationalError: database is locked
telethon/sessions/sqlite.py c.execute('delete from sessions')Telethon writes to the session when it starts — it does not merely read it.
Giving each process its own copy of the session file removes the lock error and replaces it with a far worse one. The copies share an auth_key, and Telegram invalidates a key it sees used from two places at once: AUTH_KEY_DUPLICATED. The account is then genuinely logged out and has to sign in again. Telethon documents this as a protection that cannot be turned off — if you want several clients, you make several sessions, not several copies of one.
| Workaround | Lock error | Account survives |
|---|---|---|
| Copy the session file per process | fixed | ❌ AUTH_KEY_DUPLICATED |
StringSession in the environment | fixed | ❌ same key, same outcome — and the secret spreads |
| A separate login per process | fixed | ✅ but that is N accounts, not N clients |
| One process, clients over HTTP | fixed | ✅ |
Start one long-lived owner of the session:
TG_APP_ID=your_app_id uvx q0t-telegram-mcp --transport http --port 8765Then point every client at it instead of letting them spawn their own:
{
"mcpServers": {
"telegram": {
"url": "http://127.0.0.1:8765/mcp"
}
}
}The exact key differs per client — url, httpUrl, or codex mcp add --url; all of them speak streamable HTTP.
| Variable | Flag | Default | Meaning |
|---|---|---|---|
TG_TRANSPORT | --transport | stdio | stdio, http (or streamable-http), sse |
TG_HOST | --host | 127.0.0.1 | Address to bind in http/sse mode |
TG_PORT | --port | 8765 | Port to bind in http/sse mode |
Flags win over environment variables. stdio remains the default, so existing configs that invoke the package with no arguments are unaffected.
GET /health reports whether the process is up and whether its Telegram connection is, without opening an MCP session:
{"status": "ok", "name": "q0t-telegram-mcp", "version": "0.11.1", "telegram": "connected", "active_sessions": 1}telegram | HTTP | Meaning |
|---|---|---|
"connected" | 200 | Requests reach Telegram. |
"reconnecting" | 503 | The link dropped (network loss, sleep) and Telethon is retrying in the background. |
"disconnected" | 503 | The client is down and not retrying; the watchdog (or the next tool call) reconnects it. |
"unauthorized" | 503 | The session itself is gone (revoked, logged out, key duplicated). The process exits with status 78 within a minute; run auth again. |
null | 200 | No client held yet (only under sse, before the first session). |
status is "ok" with a 200, "unauthorized" with "telegram": "unauthorized", and "degraded" with any other 503, so a supervisor can key off either field. Before 0.11.1 it always said ok, even with a dead client.
A client that cannot be used ends the process. A watchdog checks the held client every 60 s. If Telethon has given up on it, the watchdog reconnects it. If Telegram says the session is gone, the process exits with status `78`. A restart then stops at startup with Not authorized until someone runs auth again. If the client is down and five reconnects in a row fail, the process exits with status `75`, and a restart is the expected fix. A plain network outage does not count toward that limit, because Telethon keeps retrying it in the background. Run the server under something that restarts it on a non-zero exit.
Since 0.10.0 the Telegram connection is opened when the process starts, not when the first MCP session arrives, and is held until the server stops. A server with no authorized session therefore fails to start rather than booting and answering /health while every tool call fails. Run q0t-telegram-mcp check before starting if you are unsure the session is still good.
Network loss is survived, however long. Since 0.11.1 Telethon retries forever (every 5 s) instead of giving up after five attempts, so a laptop that sleeps with the lid closed comes back on its own after wake. If the client has given up anyway, the next tool call reconnects it first (waiting up to 30 s) instead of failing with Cannot send requests while disconnected. A tool call made while the network is still gone waits for it rather than failing at once.
The same change makes active_sessions a hold count rather than a client count: in http mode it reads 1 for as long as the server runs, however many clients are attached. Do not use it to count clients — under sse it still tracks connections, so the number means different things per transport.
The default bind is 127.0.0.1 deliberately. This server has no authentication of its own — whoever can reach the port can read and send messages as the account behind the session. The listen address is the only access control there is.
--host 0.0.0.0 is permitted, because you may be binding inside a container whose network is already isolated, but it prints a warning at startup and you are then responsible for whatever can reach that port. Do not put it on a public interface without something in front of it.
TG_READONLY)Set TG_READONLY to give a model access to your correspondence without letting it act as you:
{
"mcpServers": {
"telegram": {
"command": "uvx",
"args": ["q0t-telegram-mcp"],
"env": {
"TG_APP_ID": "your_app_id",
"TG_READONLY": "1"
}
}
}
}Accepted as "on": 1, true, yes (any case). Anything else — including unset — leaves the full tool set registered, which is the pre-0.7.0 behaviour.
Writing tools are not registered at all in this mode, rather than registered and refusing. A tool the client never receives cannot be called, argued with, or worked around; a tool that exists and returns an error is a capability the model can see and keep reaching for.
| Available in read-only | Hidden in read-only |
|---|---|
tg_me, tg_dialogs, tg_dialog, tg_search, tg_download | tg_send, tg_send_file, tg_forward, tg_edit, tg_delete, tg_react, tg_read, tg_click |
Two entries on the hidden side are worth explaining, because they sound passive:
can trigger anything the bot does, including payments or destructive actions.
tg_download stays on the read side: it writes to local disk but changes nothing on the account.
uv build
UV_PUBLISH_TOKEN="$(awk '/^password/{print $3; exit}' ~/.pypirc)" \
uv publish dist/q0t_telegram_mcp-<version>.tar.gz \
dist/q0t_telegram_mcp-<version>-py3-none-any.whluv publish does not read ~/.pypirc (unlike twine), so the token has to be passed explicitly. Name the files of the version being released: dist/ accumulates older builds, and re-uploading an existing version aborts the run.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.