Skip to Content
Telegram MCPPlan & PhasesDesign Overview

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.completed Inngest 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 updates
    • POST /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).
  • /mcp auth: static bearer token from env (MCP_AUTH_TOKEN), checked against Authorization: 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.example

System Flows

Incoming Message

Telegram → Webhook → Adapter → Auth middleware → MCP event → Agent

Agent Response

Agent → MCP tool call → Telegram API → User

Async 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 foundNOT_FOUND, no retry.
    • 403 bot was blocked by the user → mark user blocked in DB, return USER_BLOCKED_BOT.
    • 429 too many requests → respect retry_after, retry once in-tool, then fail with RATE_LIMITED.
    • 5xx → retry once, then UPSTREAM_FAILURE.
  • MCP tool errors are JSON-RPC errors with a stable code field. No stack traces in message. Internal context goes to logs keyed by requestId.
  • Inngest function failures rely on Inngest’s built-in retry. Wrap each external call in step.run so transient failures only re-do the failed step, not the whole function. Use NonRetriableError for 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 running for >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 validates X-Telegram-Bot-Api-Secret-Token on 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 via Authorization: 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_INPUT and structured error.
  • Authorization: user-scoped tools (update_memory, send_message to a chat, etc.) verify the operating userId against active+approved users. Admin tools additionally require role='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 a requestId; 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-client exporter on a separate internal-only port; never exposed via nginx.
  • Tracing: not in v1; requestId correlation is enough until proven otherwise.
  • Error reporting: pino logs to journald; journalctl -u telegram-mcp -p err is the v1 alert pipeline. Sentry can be added later without rework.

Key Design Rules

  1. MCP layer is stateless — only validation, routing, tool execution. No agent logic.
  2. Normalize at the boundary — convert Telegram → unified schema immediately on ingress.
  3. Everything is events + tools — keeps agent integration clean and swappable.
  4. Async from day one — Telegram + AI always requires async handling; don’t bolt it on later.