mctl-telegram
MCTL Assistant is an unofficial third-party connector using the Telegram
API, not affiliated with Telegram: a remote MCP server that gives AI
assistants access to a user's own Telegram account. It reads dialogs and
messages, searches, and sends only when the user has explicitly allowed
it. It runs in production at
https://tg.mctl.ai/mcp. Claude is the client we test against —
claude.ai, the desktop and mobile apps, and Claude Code — and ChatGPT can
add it by URL. It is a standard remote MCP server with OAuth, so other
clients such as Cursor, VS Code and Windsurf may work, but they are not
yet tested against tg.mctl.ai. Agent platforms can also connect on behalf
of their users.
Written in Go on gotd/td (MTProto) and
mcp-go, with its own OAuth 2.1
authorization server that signs users in with Telegram.
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. Status: v0.x; tool schemas may change before v1.0.
What it does
- For a user: connect the account once from a browser, then ask an assistant to summarize unread chats, find a message, or draft and send a reply. Accounts start read-only; sending is a separate opt-in, and every call lands in an audit log the user can read and verify.
- For an agent platform: a standard OAuth 2.1 server with Dynamic Client Registration and Client ID Metadata Documents. With CIMD a platform describes its own redirect URIs, so one approved host covers all of its users; the operator still has to allow that host once. Users who will not leave their Telegram session on a server can run it on their own machine with Local Bridge and keep the same endpoint.
Architecture
flowchart LR
C["MCP client<br/>Claude, ChatGPT,<br/>agent platform"]
subgraph S["mctl-telegram (one Go service)"]
AS["OAuth 2.1 server<br/>DCR, CIMD, PKCE,<br/>refresh rotation"]
M["MCP endpoint /mcp<br/>37 tools"]
G["Send gate<br/>scope + server flag<br/>+ owner opt-in"]
P["MTProto session pool<br/>one client per user"]
R["Local Bridge relay"]
A[("Postgres<br/>sealed sessions,<br/>hash-chained audit")]
end
TO["Telegram OIDC<br/>oauth.telegram.org"]
T["Telegram<br/>MTProto"]
D["mctl-telegram-local<br/>on the user's machine"]
C -- "OAuth sign-in" --> AS
AS -- "identity" --> TO
C -- "tool calls (Bearer)" --> M
M --> G
G --> P
P --> T
M --> R
R -- "websocket" --> D
D --> T
M --> A
P --> A
- The client discovers the authorization server from
/.well-known/oauth-protected-resource(RFC 9728), registers, and sends the user to sign in with Telegram. The service issues its own tokens. - A tool call is checked against the token's scopes, run on the user's MTProto client from the pool, and recorded in the user's audit chain. Writes also pass the send gate.
- For a Local Bridge account the call goes down a websocket to the user's own daemon instead, and the server only relays.
Design decisions
The reasoning behind the architecture is recorded as architecture decision records, each with the code that carries it and what it costs:
Engineering
- About 68,000 lines of Go, and more test code than that (about 82,000 lines in 295 test files).
- CI runs the whole module under
go test -raceagainst a real Postgres, plus tests on macOS and Windows,govulncheck, CodeQL and OpenSSF Scorecard. Actions are pinned by commit SHA. - A release cannot change the tool surface without an approved product-update entry (ADR 0007).
- Sticky routing for more than one replica is proven by an acceptance workflow on kind, against ingress-nginx and Istio.
- Service-level objectives with burn-rate alerts (docs/slo.md) and a synthetic canary that checks OAuth discovery and a read-only tool call every ten minutes.
Security and privacy model
mctl-telegram holds a server-side Telegram MTProto session on your behalf. The server can technically read your Telegram data while processing your requests, because it is the one making MTProto calls to Telegram. It stores only the session, sealed with a per-user key, and logs no message text and no phone numbers; but you are trusting both the operator of this deployment and the integrity of this code. Local Bridge exists for users who will not.
See SECURITY.md for the full threat model, cryptographic invariants, send-gate design, OAuth rules and reporting channel.
Do not expose this server publicly without OAuth, HTTPS, and a trusted deployment boundary.
Connecting a client
Hosted endpoint (tg.mctl.ai)
- Claude: Customize → Connectors
→ Add custom connector, name
MCTL Assistant, URLhttps://tg.mctl.ai/mcp. Sign in with Telegram when the browser opens. - ChatGPT: on the web, Plugins → Personal
→ Add → Add custom MCP server: name
MCTL Assistant, Server URLhttps://tg.mctl.ai/mcp, authentication OAuth, then Create as a plugin. MCTL is not listed in the public plugin directory; adding it as your own plugin is the supported path. - Cursor, VS Code, Windsurf and others (not yet tested): use
https://tg.mctl.ai/mcp; the client discovers OAuth from the well-known metadata. None of these has a verified sign-in against tg.mctl.ai, so treat this as a starting point. Acursor://redirect is refused today (onlyhttpsand loopback redirects are accepted), so Cursor sign-in may fail. Per-host status and evidence: docs/platforms/compatibility.md. - Agent platforms: register with Dynamic Client Registration from a callback host on the allowlist, or publish a Client ID Metadata Document on your own domain and ask for your host to be added. The integration guide is at tg.mctl.ai/platforms; see SECURITY.md for the exact redirect rules.
The first connection asks which permissions to grant. "Read only" is the
default; sending can be turned on later on the account page
(https://tg.mctl.ai/telegram/connect/manage) or with the set_send_consent tool. If an error comes
back, docs/troubleshooting.md explains the error
families clients actually hit.
Your own deployment
- Confirm discovery is reachable:
curl https://<your-host>/.well-known/oauth-protected-resource, then follow theauthorization_serversURL it names and fetch its/.well-known/oauth-authorization-server. Inlocal-jwtmode that is your own host; in theshared-hmacmodes it ishttps://api.mctl.ai. - Add
https://<your-host>/mcpas a custom connector in your client, with OAuth. - Run
initializeand one read-only tool call (MCP Inspector works) before relying on it. Real sends stay off untilALLOW_SEND=true, the token has the send scope, and the account owner opts in.
MCP tools
The authoritative list is docs/tool-descriptors.json (37 tools, regenerated by go test ./internal/mcp -update-descriptors): 10 read tools, 9 message write tools (prepare_send_message, send_message, send_media, edit_message, delete_messages, forward_messages, set_reaction, prepare_pin_message, pin_message), 5 own-account tools (set_send_consent, set_my_notification_preferences, revoke_local_bridge_device, disconnect_telegram_account, delete_telegram_account), 9 admin tools and 4 broadcast-operator tools. The table below describes the ones most clients use:
MCP Apps (prototype, flag-gated)
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.
Quick start (local dev)
# 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"}}}'
Self-hosted deployment
Prerequisites
- 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.
Configuration
Copy .env.example to .env and fill in the values:
cp .env.example .env
$EDITOR .env
Key variables:
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.
JWT signing key
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 64
Set it as OAUTH_JWT_SIGNING_KEY in your environment. In local development any non-empty string works.
Token lifetimes and refresh
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.
Docker
# Build
docker build -t mctl-telegram .
# Run (pass env from a file)
docker run --rm -p 8080:8080 --env-file .env mctl-telegram
Image published to ghcr.io/mctlhq/mctl-telegram:<semver> (no v prefix).
Docker Compose
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 -d
Services 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.
For alert-driven operations, see docs/runbook.md; for errors as clients see them, docs/troubleshooting.md.
Local Bridge daemon (cmd/local, beta)
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.
Operations: Canary account
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.
Setting up the canary account
-
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.
Contributing
Contributions are welcome. See CONTRIBUTING.md for dev setup, code style, and the PR process. Larger design changes should add or supersede an ADR.
Security
See SECURITY.md for the send-gate invariants, session encryption, and the reporting channel.