project6 min read
Telegram MCP
An MCP server that drives Telegram as your own user account over MTProto (GramJS). 44 tools to read, search, send, and manage messages, files, chats, and contacts from any MCP client.
An AI assistant is only as useful as what it can reach. I wanted one that could reach my actual Telegram: search five years of DMs, pull the address someone sent me last winter, forward a thread, schedule a reply for the morning. Doing that means logging in as my real account, over Telegram's MTProto protocol, and handing the controls to a model.
So I built Telegram MCP. It exposes 44 tools over the Model Context Protocol and drives my account as me. That reach is also the entire risk, so most of the code is guardrails. Here is how it works.
How a request flows, and why the client stays logged in
Telegram MCP is built on GramJS, a TypeScript implementation of MTProto, Telegram's own transport protocol. A tool call travels a short path and comes back as plain JSON:
request · stdio / JSON-RPC
MCP client ───────────────────▶ server.ts ──▶ tool handler
▲ │ getClient()
│ ▼
│ compact, safe JSON GramJS · MTProto → Telegram
│ │ raw result
└──── serialize + sanitize ◀───────────────────┘
You register an app at my.telegram.org to get an api_id and api_hash, then log in once:
npm run login # phone number, the code Telegram texts you, then 2FA
That login does exactly one thing: it produces a session string and writes it to .telegram-session with 0600 permissions. The server reads that file on startup and builds one authenticated client on the first tool call, then reuses it for the whole process. Keeping a single client alive keeps Telegram's entity cache warm, so resolving a username or id stays cheap after the first lookup.
A tool is either a read or a write, and the type system enforces it
The server exposes 44 tools across seven domains: account, users, messages, files, chats, groups, and contacts. Every one is tagged as either a read or a write, and that tag is load-bearing.
MCP lets a tool declare a readOnlyHint and a destructiveHint. A client like Claude Desktop can then auto-approve the 17 reads (searching, listing, fetching) while stopping to ask before any of the 27 writes. delete_message and delete_scheduled_message go further and carry the destructive hint.
The failure I wanted to design out is a tool that does a little of both, because then the client can no longer trust the hint. So there is one helper, annotate, with two overloaded signatures:
annotate("get_messages", "read")
annotate("delete_message", "write", { destructive: true })
The read overload takes no options object at all. Passing { destructive: true } to a read is a compile error, not a code-review comment. A tool is purely one or the other, and the build refuses to let that erode.
The bug that ate the JSON-RPC stream
MCP over stdio means the server and client talk JSON-RPC over stdout. Every byte on stdout has to be a valid protocol message. Nothing else can go there.
GramJS did not get that memo. It prints a version banner to stdout, and it prints it from inside the client constructor. The first time I connected, initialization failed with a useless error, because a Telegram banner had landed in the middle of the JSON-RPC handshake and corrupted the stream.
Turning the log level down after construction does not help. By the time your setLogLevel call runs, the banner is already out the door. The fix is to hand GramJS a silenced logger at construction time:
import { Logger, LogLevel } from "telegram/extensions/Logger.js";
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
baseLogger: new Logger(LogLevel.NONE),
});
No amount of reading the MCP spec warns you about this. The protocol is clean; the dependency underneath it is chatty. On a stdio transport, a library that logs to stdout is not a nuisance, it is a wire fault.
The prompts live in YAML, not in the TypeScript
The description a model reads before it picks a tool is the single most important string in the server. It is what makes the model choose search_messages over get_messages. It is copy, and it changes on a different rhythm than the code.
So none of it lives in the TypeScript. Every tool's description and every field's help text sits in a per-tool YAML file, loaded at runtime by promptoro, a small library I wrote for exactly this. The handler holds the logic; the YAML holds the words.
const tool = prompts.get("send_message");
server.registerTool(tool.name, {
description: tool.description,
inputSchema: z.object({
text: z.string().describe(tool.fields.text.description),
}),
}, handler);
Rewording a tool description to make the model behave better is now a text edit, not a source change. That mattered more here than on a normal server, because tuning these 44 descriptions was most of the work of making the assistant reliable.
The guardrails
A server that runs a model against a real account is one place where small oversights turn into real damage. Most of the code is defense:
- Never a raw GramJS object. Those objects are circular, reference the live client, and carry big-integer ids that
JSON.stringifyrefuses to touch. Every result is mapped through a serialization layer that pulls only the fields worth returning, stringifies each id, and hands back compact JSON. - Every returned string is cleaned. Message bodies, names, and chat titles are stripped of control characters, zero-width characters, and bidirectional overrides before they leave the server, closing off invisible-text and terminal-escape tricks hidden in a message.
- The session file is treated as a credential. It is written
0600, never logged, never printed, and gitignored. It never touches your MCP config.
One honest limit on that second point: cleaning the text defends against the invisible payloads, not the visible ones. It cannot stop a plainly worded "ignore your previous instructions" sitting in someone's DM. That is what the read/write gating is for, along with treating every message body as untrusted input. The character filter closes the invisible door, not the visible one.
When not to use it
If a task wants to loop, this is the wrong tool. It drives a real account, and Telegram's anti-spam does not care that an LLM wrote the message. Bulk sends, rapid joins, and scripted contact adds are how an account gets rate-limited or banned. The tools are shaped for a person asking for one thing at a time.
And if you cannot keep the session file safe, do not create one. There is no sandbox between the assistant and your account. The read/write hints let a careful client gate the writes, but the session string itself is total access. It is stronger than your password, so guard it like one.
Run it against your own account
Telegram MCP is on GitHub: github.com/naim30/telegram-mcp. Clone it, register an app at my.telegram.org, run npm run login once, and point Claude Desktop or any MCP client at the built server.
Then ask it to find that address someone sent you last winter.
- # typescript
- # mcp
- # telegram
- # gramjs
- # mtproto
- # llm
- # open-source
- # nodejs