Go remote MCP server exposing user-authorized Telegram account access (via gotd/td MTProto) as MCP tools — dialogs, messages, preview-gated sends, pin controls, audit logs, and account/admin controls — for ChatGPT Apps, Claude.ai, and any MCP-compatible client.
Status: Apps SDK readiness track (v0.x). Fourteen MCP tools, OAuth-protected, preview-only sending by default, reviewer/demo login mode, and production-facing docs/metadata intended for ChatGPT Apps review. Telegram session is per-user and persisted encrypted. APIs and tool schemas may change before v1.0.
mctl-telegram is an independent project, not an official Telegram app or Telegram API partner. It operates only on the Telegram account that the user explicitly connects and controls; users remain responsible for complying with Telegram's terms.
mctl-telegram holds a server-side Telegram MTProto session on your behalf. This means the server can technically read your Telegram data while processing your requests — it is the one making MTProto calls to Telegram. We minimize what is stored (encrypted session blob only) and what is logged (no message text, no phone numbers), but you are trusting both the operator of this deployment and the integrity of this code.
See SECURITY.md for the full threat model, cryptographic invariants, send-gate design, and reporting channel.
Do not expose this server publicly without OAuth, HTTPS, and a trusted deployment boundary.
| Path | Purpose |
|---|---|
/healthz, /readyz |
Probes — ok 200. |
/.well-known/oauth-protected-resource |
RFC 9728 metadata declaring the authorization server for this deployment. |
/mcp |
MCP Streamable HTTP endpoint. Auth: Authorization: Bearer <JWT>. 401 responses include WWW-Authenticate pointing to /.well-known/oauth-protected-resource/mcp (RFC 9728 discovery). |
| Tool | MCP annotations | Notes |
|---|---|---|
list_dialogs |
readOnly=true, destructive=false, openWorld=true |
Reads Telegram dialogs (audit row is internal observability). Inputs: limit (≤200, default 50), optional query. |
get_unread_messages |
readOnly=true, destructive=false, openWorld=true |
Reads unread Telegram messages (audit row is internal observability). Inputs: optional peer, limit (≤200). When peer is omitted, DMs and chats/groups (including megagroup/supergroups) fill limit before broadcast channels. |
get_messages |
readOnly=true, destructive=false, openWorld=true |
Reads recent message history for a specific peer (audit row is internal observability). |
search_messages |
readOnly=true, destructive=false, openWorld=true |
Searches Telegram messages by text query, newest-first. Inputs: query, optional peer, limit (default 20, max 100), min_date/max_date (inclusive UTC bounds, RFC 3339 or plain YYYY-MM-DD) to bound the search to a time window. |
send_message |
readOnly=false, destructive=true, openWorld=true |
Inputs: peer, text. Preview-only by default: sends for real only when the gate is fully open (server ALLOW_SEND=true, identity has telegram:messages:send scope, per-account send_enabled=true). Otherwise returns sent=false with dry_reason; no message is delivered. |
send_media |
readOnly=false, destructive=true, openWorld=true |
Inputs: peer, media_type (photo/video/document/animation), exactly one of file_url/file_base64, optional caption/file_name (required for document+file_base64). Same preview-only gate as send_message; a denied call never fetches file_url or decodes file_base64. animation is always sent as a distinct type from video, never relabeled. file_url goes through an SSRF-guarded fetcher (HTTPS-only; loopback/link-local/private-range addresses refused, including on redirect hops). Both sources are capped by MEDIA_UPLOAD_MAX_BYTES (default 20 MiB). |
prepare_pin_message |
readOnly=false, destructive=false, openWorld=false |
Creates a local one-shot confirmation record for a later pin_message call. |
pin_message |
readOnly=false, destructive=true, openWorld=true |
Pins or unpins a Telegram message after a matching confirmation id. |
get_my_audit_log |
readOnly=true, destructive=false, openWorld=false |
Returns the authenticated user's own audit rows. |
get_my_send_status |
readOnly=true, destructive=false, openWorld=false |
Reports whether send_message would deliver for real, without sending. Returns can_send, the blocking reason, and the three gate conditions separately (server_allow_send, has_send_scope, send_enabled) plus connected. The verdict comes from the same gate send_message consults, so the two cannot disagree. The per-peer rate limit is not evaluated (it depends on a recipient and checking it would spend that recipient's budget). |
disconnect_telegram_account |
readOnly=false, destructive=true, openWorld=false |
Soft-revokes your session and tears down the in-memory MTProto client. |
delete_telegram_account |
readOnly=false, destructive=true, openWorld=false |
Hard-deletes the encrypted session blob and per-account metadata from this server. |
list_telegram_identities |
readOnly=true, destructive=false, openWorld=false |
Admin-only: lists signed-in Telegram identities and access state, with audit metadata. |
set_telegram_access |
readOnly=false, destructive=true, openWorld=false |
Admin-only: grants or revokes the local client access tier for a Telegram user. |
set_account_send |
readOnly=false, destructive=true, openWorld=false |
Admin-only: enables or disables the per-account real-send gate. |
get_user_audit_log |
readOnly=true, destructive=false, openWorld=false |
Admin-only: reads another Telegram user's audit rows, with audit metadata. |
revoke_telegram_session |
readOnly=false, destructive=true, openWorld=false |
Admin-only: revokes a user's active MTProto session on this server. |
prepare_broadcast |
readOnly=false, destructive=false, openWorld=false |
Broadcast operators only (admin:broadcast = platform admin AND BROADCAST_OPERATORS). Previews a broadcast to opted-in clients and records it as a prepared campaign; sends nothing. Returns the eligible/skipped counts and an approval_url. No tool can approve: a human operator approves on /telegram/connect/broadcasts, signed in with Telegram in a browser. See docs/runbook.md. |
list_broadcasts |
readOnly=true, destructive=false, openWorld=false |
Broadcast operators only: lists campaigns, optionally by state. |
get_broadcast |
readOnly=true, destructive=false, openWorld=false |
Broadcast operators only: one campaign plus its aggregate delivery report (no recipients named). |
cancel_broadcast |
readOnly=false, destructive=true, openWorld=false |
Broadcast operators only: cancels a campaign that has not finished; unsent messages are skipped. |
Set MCP_APPS_ENABLED=true to expose a flag-gated MCP Apps (SEP-1865) research/triage App, served inline from internal/mcpui as a ui:// resource; off by default, so the surface above is unchanged unless you opt in. See docs/reports/mcp-apps-spike.md for the full design, threat model and host-compatibility evidence.
# 1. Build & run the server (auth bypassed for local dev)
ADDR=127.0.0.1:8080 \
AUTH_MODE=local-dev AUTH_REQUIRED=false \
OPERATOR_GITHUB_LOGIN=your-github-handle \
DATABASE_URL='file:./mctl-telegram.db?_pragma=journal_mode(WAL)' \
go run ./cmd/server
# 2. First-time login (register an app at https://my.telegram.org first)
TG_API_ID=12345 TG_API_HASH=hexhexhex... \
DATABASE_URL='file:./mctl-telegram.db?_pragma=journal_mode(WAL)' \
OPERATOR_GITHUB_LOGIN=your-github-handle \
go run ./cmd/login --phone +1...
# 3. Smoke test via MCP inspector or curl
curl -s -X POST localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"local","version":"0"}}}'- Telegram API credentials: register an app at https://my.telegram.org to get
TG_API_IDandTG_API_HASH. - A Telegram bot for the OIDC login flow: create one via BotFather and note its numeric id and token.
- A Postgres database (or SQLite for single-node deployments).
- An HTTPS endpoint reachable by your MCP clients.
Copy .env.example to .env and fill in the values:
cp .env.example .env
$EDITOR .envKey variables:
| Variable | Description |
|---|---|
AUTH_MODE |
local-jwt — server signs its own tokens (recommended for self-hosting) |
AUTH_REQUIRED |
true in production; false only for local dev |
OAUTH_JWT_SIGNING_KEY |
HS256 signing key for access tokens; see below |
TELEGRAM_OIDC_CLIENT_ID |
Login bot's numeric Telegram id (from BotFather; not secret) |
TELEGRAM_OIDC_CLIENT_SECRET |
OIDC client secret for the login bot |
TELEGRAM_LOGIN_BOT_TOKEN |
Bot token — used only for the new-client welcome digest |
TELEGRAM_LOGIN_BOT_USERNAME |
Optional. Login bot @username for the t.me start link on the connect success and manage pages (issue-679). Not the 0.16.0 widget variable of the same name, which was removed (see CHANGELOG 0.16.0) |
BOT_RECEIVER_ENABLED |
Optional, default off. true long-polls getUpdates for the login bot. Dev/local bots only: production updates arrive through mctl-agent's webhook, and polling a webhook-owned token only returns 409. Never enable it against such a token, and never call deleteWebhook to make it work |
BOT_START_BRIDGE_TOKEN |
Optional. Bearer token (min 32 chars) for POST /internal/bot-start-observations, through which mctl-agent forwards a client's /start (issue-679). Unset: the route is not mounted. Set but shorter than 32: startup fails. See the runbook |
TG_API_ID |
Telegram API id from my.telegram.org |
TG_API_HASH |
Telegram API hash from my.telegram.org |
ENCRYPTION_KEY |
32-byte hex key for encrypting session blobs at rest |
DATABASE_URL |
postgres://... or file:./mctl-telegram.db?_pragma=journal_mode(WAL) |
ALLOW_SEND |
false by default; set true only after validating the send gate |
PUBLIC_BASE_URL |
External HTTPS base URL, e.g. https://tg.example.com |
ALLOWED_ORIGINS |
optional; comma-separated Origin allowlist for /mcp (DNS-rebinding protection). No-Origin requests always pass; defaults to the PUBLIC_BASE_URL origin |
OAUTH_ACCESS_TOKEN_TTL |
optional, default 1h |
OAUTH_REFRESH_TOKEN_TTL |
optional, default 720h (30 days) |
OAUTH_PREREGISTERED_CLIENTS |
optional; JSON array of {"client_id","redirect_uris"} seeded as static clients with byte-exact redirect matching. For a counterpart that cannot use dynamic registration; carries no secret. See SECURITY.md and docs/cloudflare-portal-compat.md |
OAUTH_DCR_REDIRECT_URIS |
optional; comma-separated, byte-exact redirect URI allowlist for POST /oauth/register. A registration whose redirect_uris are all on it is accepted without the implicit-host allowlist; mixing in any other URI is refused. For an MCP gateway that registers itself (the Cloudflare MCP portal in automatic mode). Unset changes nothing. See docs/cloudflare-portal-compat.md |
OAUTH_JWT_SECRETis a deprecated alias ofOAUTH_JWT_SIGNING_KEY. It is still accepted as a fallback but logs a warning at startup. UseOAUTH_JWT_SIGNING_KEYfor new deployments.
In local-jwt mode mctl-telegram signs its own access tokens (HS256). The signing key must persist across restarts and must be dedicated to this service — if it changes, every previously issued token fails verification.
Generate a key:
# 64 random bytes, base64-encoded — store this in a secret manager or .env
openssl rand -base64 64Set it as OAUTH_JWT_SIGNING_KEY in your environment. In local development any non-empty string works.
Access tokens are intentionally short-lived (OAUTH_ACCESS_TOKEN_TTL, default 1h). Clients renew them silently with the OAuth 2.1 refresh_token grant: the /oauth/token endpoint accepts grant_type=refresh_token and returns a new access token plus a rotated refresh token, with no Telegram sign-in interaction. Refresh tokens are opaque, stored SHA-256-hashed, and rotated on every use; replaying an already-rotated token revokes the whole token family.
A refresh token (and its whole rotation family) can be revoked on demand with POST /oauth/revoke (RFC 7009; advertised as revocation_endpoint in /.well-known/oauth-authorization-server). Access tokens are not individually revocable within their TTL — see SECURITY.md for that trade-off.
# Build
docker build -t mctl-telegram .
# Run (pass env from a file)
docker run --rm -p 8080:8080 --env-file .env mctl-telegramImage published to ghcr.io/mctlhq/mctl-telegram:<semver> (no v prefix).
A docker-compose.yml is included for a self-contained local deployment with Postgres:
cp .env.example .env
$EDITOR .env # fill in TG_API_ID, TG_API_HASH, keys
docker compose up -dServices started: app (mctl-telegram on port 8080) and db (Postgres 16).
For Beta-tier service-level objectives, error-budget policy, and burn-rate alert definitions, see docs/slo.md.
If a tool call or the OAuth flow returned an error string and you need to know what it means and what to do next, see docs/troubleshooting.md — a client-facing page for the error families clients actually hit, distinct from the alert-driven docs/runbook.md.
- Start mctl-telegram and confirm the well-known is reachable:
curl https://<your-host>/.well-known/oauth-protected-resource
- In Claude.ai → Settings → Connectors → Add custom connector:
- Remote MCP URL:
https://<your-host>/mcp - Authentication: OAuth (the connector discovers the authorization server from the well-known metadata).
- Remote MCP URL:
- Complete the Telegram login flow in the browser; the issued access token is used automatically on every MCP request.
Note: mctl-telegram does not require an
OPENAI_API_KEYserver-side. ChatGPT connects to your MCP endpoint using OAuth bearer tokens issued by this service.
- In ChatGPT, open Settings → Apps and select your draft app (enable Developer Mode first if required).
- Set the MCP server URL to the public endpoint:
https://<your-host>/mcp(OAuth auth). - Ensure these public pages are reachable over HTTPS:
- Landing:
https://<your-host>/ - Docs:
https://<your-host>/docs - Security:
https://<your-host>/security - Privacy:
https://<your-host>/privacy
- Landing:
- Verify OAuth discovery:
- Fetch
https://<your-host>/.well-known/oauth-protected-resource(always served on your host). - Follow the
authorization_serversURL it advertises and confirm that server's/.well-known/oauth-authorization-serverresolves. In the defaultlocal-jwtmode this is your own host; inshared-hmac/shared-hmac-legacymode discovery points athttps://api.mctl.ai, so do not expect that document on<your-host>.
- Fetch
- Run a live handshake (
initialize) and at least one read-only tool call with MCP Inspector or ChatGPT Developer Mode before submitting.
If you are using the shared hosted deployment, configure:
- Landing page:
https://tg.mctl.ai/ - MCP connector URL:
https://tg.mctl.ai/mcp
Submission notes:
- Keep real sends gated (
ALLOW_SEND=falseon tg.mctl.ai blocks all real sends until opt-in gates are enabled). - If you enable real sends later, keep the per-account
send_enabledgate and confirmation flow documented in/security. - Prepare the dashboard submission package with the privacy policy URL, MCP/tool information, screenshots, and test prompts/responses.
Local Bridge keeps the MTProto session on the user's own machine; tg.mctl.ai
becomes a relay that forwards MCP tool calls down a websocket to a local
daemon. Released binaries for macOS, Linux and Windows are attached to each
release.
The public guide is split so onboarding is not buried under operator
procedures. docs/local-bridge.md is the overview; the rest lives under
docs/local-bridge/. The site serves the same files at
/docs/local-bridge (quick start,
owner controls, how it works, support, legacy). internal/web/ holds
mirrors for go:embed; TestLocalBridgeMarkdownMatchesDocs fails the
build if they drift.
Turning the mode on for a new Telegram id is self-service
(mctl-telegram-local activate). Migrating an existing hosted account,
or flipping back to hosted, still uses the operator tool
set_account_mode. internal/bridge/DESIGN.md carries the
implementation status, the known correctness gaps, and one rejected
approach that should not be revived.
The synthetic canary probe (cmd/canary) verifies the live service end-to-end. It requires a dedicated Telegram test account — not the operator's personal account — to avoid false-positive FLOOD_WAIT interference.
-
Create a fresh Telegram account for the canary. Note its numeric user id.
-
Complete the browser-based setup flow by visiting
GET /telegram/connectwhile signed in as the canary. This links the session to an authenticated identity in the database. -
Issue a read-only bearer token with the
set_telegram_accessadmin MCP tool:set_telegram_access(tg_user_id="<canary-account-id>", scopes="telegram:dialogs:read,telegram:messages:read")The token must carry exactly
telegram:dialogs:read,telegram:messages:readand must not includetelegram:messages:send. -
Pass
tg_user_idandbearer_tokento the canary probe via environment variables or a Kubernetes Secret. -
Schedule the canary against your deployment. tg.mctl.ai runs it every ten minutes; whatever interval you pick, size the alert windows against it — a window that spans a single run turns one flap into a page.
The canary pushes three Prometheus metric families to a Pushgateway:
mctl_telegram_canary_success— 1 if all probes passed, 0 if any failed.mctl_telegram_canary_duration_seconds— wall-clock time of the run.mctl_telegram_canary_step_failure_total{step=}— per-step failure counters.
The alert rules that actually fire live in mctl-gitops
(infra-components/observability/vm-rules/mctl-telegram-canary.yaml), alongside
the CronJob itself (services/labs/mctl-telegram/values.yaml). This repository
keeps no copy. The one it used to keep was still being edited in August while
its pinned image tag stayed at a May release and its schedule at an interval
production had already left behind — maintained in appearance, stale in fact.
Contributions are welcome. See CONTRIBUTING.md for dev setup, code style, and the PR process.
See SECURITY.md — covers the send-gate invariants, session encryption, and the reporting channel.