Skip to content

Telegram

Read a Telegram group you are a member of — not an admin of — from an agent, and optionally reply in it. That requirement is the whole design of this server, and it is why the credential is a login rather than a token.

telegram-ro and telegram-rw are strad’s own MCP server — source in servers/telegram/, mounted at /telegram inside the bundle image.

The Telegram Bot API cannot answer the requirement, at any setting:

  • a bot receives only messages sent after it joined, and has no method to fetch earlier history;
  • a bot in a group runs with privacy mode on, so it sees only the messages that address it;
  • a bot that is a group admin sees everything — but a member cannot make a bot an admin of someone else’s group.

So this server speaks MTProto, the protocol Telegram’s own apps speak, with a user session: it is the account, doing what the account can do. There is no bot-token fallback, because a bot token cannot do the job.

ToolTierWhat it does
get_mereadThe account this server acts as
list_chatsreadChats the account belongs to, filterable by kind and title
get_chatreadResolve an id / @username / t.me link to the chat it names
get_chat_historyreadA chat’s messages, oldest first, paged backwards via offset_id
search_messagesreadFull-text search inside one chat, server-side
send_messagewritePost to a chat as the account itself

Start at list_chats: its id is what every other tool takes as chat, and it is also what teaches the session about a chat it has never opened — resolving a group by numeric id needs an access hash, and a dialog list is where one comes from.

Messages come back oldest first, with the sender, the timestamp, the reply-to id, and a media field naming the KIND of any attachment (photo, document, poll, …). File contents are never returned. next_offset_id pages backwards in time; its absence means the chat has been read back to its first message.

search_messages requires a chat. Telegram’s account-wide search is deliberately not exposed — a query that sweeps every private conversation the account has is a much larger read than the one that was asked for.

Slugtools: allow
telegram-roget_me, list_chats, get_chat, get_chat_history, search_messages
telegram-rw(no policy — all six, send_message included)

Both point at the same mount — one path:, one instance, one MTProto connection built once from one set of credentials and memoised for the life of the process. That is why the two slugs cannot be two accounts as written: the mount has no idea which slug a request arrived under, since the only per-slug thing on the wire is the path. Both slugs’ variables are already on the container under per-slug names (TELEGRAM_RO__TELEGRAM_API_HASH, TELEGRAM_RW__TELEGRAM_API_HASH) — that part is unconditional. What makes them two accounts is giving each its own path:, /telegram-ro and /telegram-rw, matching the slug exactly: the bundle host then builds one instance per slug, each reading its own names off one container. As long as they share a path they share an account, which is the arrangement today and the correct one while there is one account to share.

Both point at the same container, running at full capability. Read-only is a gateway decision, enforced on tools/list and on tools/call: a withheld tool is refused with -32600 before it reaches the mount. test/telegram.policy.test.ts proves it against a real gateway with a call-counting upstream — a refused send_message never arrives.

Hand out telegram-ro. A message sent through telegram-rw is indistinguishable from one the person typed — same account, same name, no marker that software sent it — and Telegram allows the sender to delete a message for 48 hours and nothing after that.

Three variables: TELEGRAM_API_ID, TELEGRAM_API_HASH and TELEGRAM_STRING_SESSION. The first two come from my.telegram.org; the third is minted once by an interactive login as the account itself (the recipe is in servers/telegram/README.md).

The session string is the account. Anything that account can read or send, a holder of that string can read or send: every private conversation, under that person’s own name. It does not expire and it has no scope to narrow. It is revoked from a Telegram client under Settings → Devices, and that is the only way to take it back.

That cost is not incidental — it is what the requirement buys. Reading a group you do not administer means holding a credential that is the whole account, so treat it like one: it belongs in the secret store, never in a config file, a log line or a PR.

The image ships no credentials; strad injects them at deploy time. Until it does, the mount is up and degraded: tools/list answers with the full surface, so an agent and the console can see what this deployment would offer, and every call fails with an error naming the three missing variables. One server’s missing config never costs the other twenty mounts their boot.

In infra/strad.staging.yaml both slugs currently ship enabled: false, because an unresolved ${REF} fails the render on purpose. Seeding the three staging secrets and deleting the two enabled: lines is the whole of turning them on.

  • No account-wide search, by design (above).
  • No media download. Attachments are named, not fetched.
  • get_chat_history does not mark anything read, so an agent reading a group does not clear its unread badge in the person’s own client.
  • Telegram rate-limits per account. A FLOOD_WAIT answer names the number of seconds to wait; the server passes that through rather than retrying, because the account is shared with a human who is using it.
  • The MTProto library is archived upstream. See limitations.