Design Overview
Status: Brainstorming / Architecture Phase Last updated: 2026-05-14
The intent and shape of the Telegram MCP Server. This document is the authoritative design reference until ../architecture/ and ../components/ get populated as code lands.
Project overview
A Telegram bot that acts as an MCP (Model Context Protocol) server, exposing structured tools to an external AI agent. The agent handles all decision logic; this system handles I/O, auth, memory, and async job execution.
Architecture
Components
A. Telegram Adapter (I/O Layer)
- Built on Telegram Bot API (webhook preferred over polling)
- Normalizes all incoming Telegram events into MCP-compatible events:
- Text messages
- Images / documents
- Inline button callbacks
- Handles outbound: text, media, inline keyboards
B. MCP Tool Server (Core)
- What the AI agent “sees” — exposes callable tools via MCP protocol
- Stateless except for routing and input validation
- Tool categories — see data-and-contracts.md
C. Agent Bridge Layer
- External project hook — agent logic lives outside this repo
- MCP server receives tool calls → executes Telegram operations → returns results
- Clean decoupling: this system never embeds agent logic
D. Async Job System
- Required for: image generation, long-running tasks, external API calls
- Orchestrator: Inngest (Inngest Cloud) — durable execution, retries, scheduling
- No Redis / no self-managed queue: Inngest holds queue state and invokes our functions via HTTP
- Function endpoints exposed by this service; Inngest calls them when work is ready
- Durable steps (
step.run) used inside long jobs (e.g. image gen pipeline) for granular retries - Per-user concurrency: Inngest concurrency key set to
userId→ one job at a time per user - Completion notification: worker emits a
job.completedInngest event → MCP server consumes it and pushes a notification to the agent over the MCP transport - Flow:
Agent → create_job (MCP) → emit Inngest event → Inngest invokes worker fn (HTTP) → step.run pipeline → emit job.completed → MCP push → agent
E. User / Auth & Role System
- Identity: Telegram
user_id - Admin manually approves new users
- Roles:
admin|user|blocked - Middleware: every incoming message checked against DB before processing
F. Memory System (per-user, long-term)
- Stores: conversation summaries, structured facts, task history
- Postgres for structured memory
- Optional: vector DB later for semantic recall
Hosting & Operational Shape
- Single long-running Node.js service on a rented Ubuntu VPS (public IP, dedicated subdomain of an existing domain).
- nginx in front as reverse proxy; TLS via Let’s Encrypt certs provisioned and renewed by certbot (certbot’s auto-installed systemd timer handles renewal).
- systemd unit runs the Node service; logs to journald.
- Three public HTTPS routes on the same service:
POST /telegram/webhook— Telegram bot updatesPOST /api/inngest— Inngest function invocations (signed by Inngest signing key)GET/POST /mcp— MCP streamable HTTP endpoint for the agent
- Agent runs on a separate machine and connects remotely → MCP transport is streamable HTTP (one endpoint, supports server→client notifications for job-completion push).
/mcpauth: static bearer token from env (MCP_AUTH_TOKEN), checked againstAuthorization: Bearer …. TLS + bearer is the security boundary; rotate the token by redeploying.- Postgres on the same VPS (Phase 1) or managed Postgres (later) — TBD when scale becomes relevant.
Repo Layout
telegram-mcp/
├── src/
│ ├── index.ts # entrypoint; boots HTTP server
│ ├── config.ts # typed env loader (Zod)
│ ├── http/
│ │ ├── server.ts # Fastify app + route registration
│ │ ├── telegramWebhook.ts # POST /telegram/webhook
│ │ ├── inngestHandler.ts # POST /api/inngest (serve())
│ │ └── mcpHandler.ts # /mcp streamable HTTP transport
│ ├── telegram/
│ │ ├── client.ts # telegraf bot instance
│ │ ├── normalize.ts # Telegram update → unified TelegramEvent
│ │ └── outbound.ts # impls behind send_message / send_image / …
│ ├── mcp/
│ │ ├── server.ts # MCP server + tool registration
│ │ ├── auth.ts # bearer token middleware
│ │ ├── sessions.ts # sessionId → active push callback map
│ │ ├── notifications.ts # consumes job.completed → push to session
│ │ └── tools/
│ │ ├── messaging.ts
│ │ ├── memory.ts
│ │ ├── jobs.ts
│ │ └── admin.ts
│ ├── auth/
│ │ ├── users.ts # status + role checks
│ │ └── middleware.ts
│ ├── memory/
│ │ └── store.ts # KV + chat history queries
│ ├── jobs/
│ │ ├── events.ts # event names + Zod schemas
│ │ ├── inngest.ts # Inngest client
│ │ └── functions/
│ │ └── imageGenerate.ts # reference Inngest function
│ ├── db/
│ │ ├── client.ts # pg pool
│ │ └── migrations/ # node-pg-migrate files
│ └── lib/
│ ├── log.ts # pino logger
│ └── errors.ts # typed McpError, TelegramError, etc.
├── test/
│ ├── unit/
│ └── integration/
├── ops/
│ ├── nginx-bot.conf # nginx server block (TLS via certbot)
│ ├── telegram-mcp.service # systemd unit
│ └── deploy.md # runbook
├── package.json
├── tsconfig.json
└── .env.exampleSystem Flows
Incoming Message
Telegram → Webhook → Adapter → Auth middleware → MCP event → AgentAgent Response
Agent → MCP tool call → Telegram API → UserAsync Job
Agent → create_job (MCP)
→ MCP server sends Inngest event
→ Inngest invokes worker function (HTTP, with userId concurrency key)
→ step.run pipeline (durable retries)
→ worker emits `job.completed` event
→ MCP server pushes notification to agent
→ Agent decides next action (e.g. send_image to Telegram)Error Handling & Retry Policy
- Telegram API (telegraf): typed error mapping —
400 chat not found→NOT_FOUND, no retry.403 bot was blocked by the user→ mark userblockedin DB, returnUSER_BLOCKED_BOT.429 too many requests→ respectretry_after, retry once in-tool, then fail withRATE_LIMITED.- 5xx → retry once, then
UPSTREAM_FAILURE.
- MCP tool errors are JSON-RPC errors with a stable
codefield. No stack traces inmessage. Internal context goes to logs keyed byrequestId. - Inngest function failures rely on Inngest’s built-in retry. Wrap each external call in
step.runso transient failures only re-do the failed step, not the whole function. UseNonRetriableErrorfor permanent failures (e.g. invalid input — Inngest stops retrying). - Webhook delivery: Telegram retries failed webhooks; Inngest retries failed function invocations. We do not maintain our own inbound queue.
- DB: pg pool reconnects automatically. No application-level retry on logical queries — surface as
UPSTREAM_FAILURE. - Crash recovery: jobs whose status is
runningfor >30 min with no Inngest update are flagged in audit log; manual reconciliation for v1.
Security Model
- TLS: nginx terminates (certbot-issued Let’s Encrypt cert); Node service listens on
127.0.0.1:PORT, not exposed publicly. - Telegram webhook: registered with
secret_token; handler validatesX-Telegram-Bot-Api-Secret-Tokenon every request. Mismatch → 401, log only. - Inngest webhook: Inngest SDK verifies the signature using
INNGEST_SIGNING_KEY. Reject silently if invalid. - MCP
/mcp: bearer token viaAuthorization: Bearer <MCP_AUTH_TOKEN>. Constant-time compare. 401 on mismatch, log the attempt. - Input validation: every tool input parsed with Zod before side effects; reject with
INVALID_INPUTand structured error. - Authorization: user-scoped tools (
update_memory,send_messageto a chat, etc.) verify the operatinguserIdagainst active+approved users. Admin tools additionally requirerole='admin'. Failures audit-logged. - Rate limiting:
- Outbound to Telegram: rely on telegraf’s built-in throttler (Bottleneck).
- Inbound from Telegram: Telegram’s own per-chat limits dominate.
- MCP tools: optional simple per-user token bucket (Phase 3+) — flagged as not-yet-needed since the agent is trusted.
- Secrets at rest: only in
/etc/telegram-mcp/env(mode 600). Never committed. - systemd hardening: dedicated service user,
NoNewPrivileges=true,ProtectSystem=strict,ProtectHome=true,PrivateTmp=true,ReadWritePaths=limited to what the service genuinely needs. - Telegram bot privacy mode: leave enabled (default) so the bot only sees messages explicitly directed at it.
Observability
- Logging: structured JSON via
pino. Every inbound (Telegram update, Inngest invocation, MCP request) gets arequestId; the ID propagates into any logs emitted while handling it. One summary log per request:{requestId, route, userId?, durationMs, outcome}. - Health:
GET /healthz→{ ok: true, db: 'ok'|'down', uptimeSec }. Used by external uptime checks (nginx itself does not probe). - Inngest dashboard is the queue UI — run history, step graphs, payload inspection, retry timing. No metrics tool needed for the queue side.
- Metrics: not in v1. If/when added:
prom-clientexporter on a separate internal-only port; never exposed via nginx. - Tracing: not in v1;
requestIdcorrelation is enough until proven otherwise. - Error reporting: pino logs to journald;
journalctl -u telegram-mcp -p erris the v1 alert pipeline. Sentry can be added later without rework.
Key Design Rules
- MCP layer is stateless — only validation, routing, tool execution. No agent logic.
- Normalize at the boundary — convert Telegram → unified schema immediately on ingress.
- Everything is events + tools — keeps agent integration clean and swappable.
- Async from day one — Telegram + AI always requires async handling; don’t bolt it on later.