From cc92ce4d906287d5a32490355a77caeb1b76f581 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Fri, 3 Jul 2026 10:47:26 +0100 Subject: [PATCH] Add streamable HTTP transport, document shared-container setup Previously, MCP_TRANSPORT supported only stdio and the legacy SSE transport, and the README's docker guidance had every MCP client spawn its own container. With several clients (or coding agents spawning subagent sessions) this multiplies Telethon sessions, which Telegram throttles, and a client that exits uncleanly leaves its container running: on one machine this accumulated 92 running containers for 29 live clients. After this change, MCP_TRANSPORT=http serves streamable HTTP on MCP_HOST:MCP_PORT at /mcp, which Claude Code (--transport http) and Codex (--url) speak natively. The transport selection is extracted into _serve() with tests, docker-compose.yml runs the shared HTTP server on localhost, and the README documents the shared-container setup and client registration. The mcp pin in requirements.txt is raised to >=1.8.0 (first release with streamable HTTP), matching pyproject.toml. --- README.md | 88 ++++++++++++++++++++++++++++++++++++++---- docker-compose.yml | 12 ++++-- requirements.txt | 2 +- telegram_mcp/runner.py | 34 ++++++++++------ tests/test_runner.py | 61 +++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1aaf1d0e..3a258e22 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,48 @@ from GitHub explicitly: uvx --from "git+https://github.com/chigwell/telegram-mcp.git@" telegram-mcp-generate-session ``` +### Transports + +The server speaks three MCP transports, selected with `MCP_TRANSPORT`: + +| Value | Transport | Use case | +| ------- | -------------------------- | --------------------------------------------------------------- | +| `stdio` | stdio (default) | One dedicated server process per MCP client | +| `http` | streamable HTTP | One shared server for many clients (Claude Code, Codex, Cursor) | +| `sse` | SSE (legacy HTTP) | Clients that only support the deprecated SSE transport | + +For `http` and `sse`, the server binds `MCP_HOST`:`MCP_PORT` (default +`127.0.0.1:8765`); the streamable HTTP endpoint is `/mcp`, the SSE endpoint is +`/sse`. + +Prefer `http` when more than one MCP client (or many coding-agent sessions) +will use the server: a single long-lived process holds one Telegram +connection, instead of every client spawning its own Telethon session — +Telegram throttles and may flag accounts that open many parallel sessions. + +Register the shared server with clients: + +```bash +# Claude Code +claude mcp add --transport http telegram http://127.0.0.1:8765/mcp + +# Codex +codex mcp add telegram --url http://127.0.0.1:8765/mcp +``` + +For stdio-only clients, bridge with [mcp-remote](https://www.npmjs.com/package/mcp-remote): + +```json +{ + "mcpServers": { + "telegram-mcp": { + "command": "npx", + "args": ["-y", "mcp-remote", "http://127.0.0.1:8765/mcp"] + } + } +} +``` + ## Multi-Account Setup Use suffixed session variables to configure multiple Telegram accounts: @@ -355,22 +397,52 @@ Build the image: docker build -t telegram-mcp:latest . ``` -Run with Compose: +### Shared server (recommended) + +Run one long-lived container serving streamable HTTP, and point every MCP +client at it (see [Transports](#transports) for client registration): ```bash -docker compose up --build +docker run -d --name telegram-mcp --restart unless-stopped \ + --env-file .env \ + -e MCP_TRANSPORT=http \ + -e MCP_HOST=0.0.0.0 \ + -p 127.0.0.1:8765:8765 \ + telegram-mcp:latest ``` -Run directly: +`MCP_HOST=0.0.0.0` binds inside the container so the published port works; +`-p 127.0.0.1:8765:8765` keeps the server reachable only from the local +machine — the endpoint is unauthenticated, so never publish it on a public +interface. + +The bundled Compose file runs the same setup: ```bash -docker run -it --rm \ - -e TELEGRAM_API_ID="YOUR_API_ID" \ - -e TELEGRAM_API_HASH="YOUR_API_HASH" \ - -e TELEGRAM_SESSION_STRING="YOUR_SESSION_STRING" \ - telegram-mcp:latest +docker compose up --build -d +``` + +### One container per client (stdio) + +Alternatively, an MCP client can spawn a dedicated container itself: + +```json +{ + "mcpServers": { + "telegram-mcp": { + "command": "docker", + "args": ["run", "-i", "--rm", "--env-file", "/full/path/to/.env", "telegram-mcp:latest"] + } + } +} ``` +This is fine for a single client, but with several clients (or coding agents +that spawn subagent sessions) each one starts its own container and its own +Telegram session, which Telegram throttles; a client that exits uncleanly can +also leave its container running. Prefer the shared server above in those +setups. + For multiple accounts, pass variables such as `TELEGRAM_SESSION_STRING_WORK` and `TELEGRAM_SESSION_STRING_PERSONAL`. ## Development diff --git a/docker-compose.yml b/docker-compose.yml index 8588fb34..9a4d55a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,15 @@ services: # Load environment variables from a .env file in the same directory env_file: - .env - # Keep stdin open and allocate a pseudo-TTY, crucial for stdio MCP servers - stdin_open: true - tty: true + # Serve streamable HTTP so one long-lived container is shared by all MCP + # clients (see the Transports section in README.md). MCP_HOST must be + # 0.0.0.0 inside the container for the published port to work. + environment: + MCP_TRANSPORT: http + MCP_HOST: 0.0.0.0 + # The endpoint is unauthenticated — publish on localhost only. + ports: + - "127.0.0.1:8765:8765" # Optional: Uncomment the following lines to mount a local directory # for persisting the Telegram session file if NOT using TELEGRAM_SESSION_STRING. # Replace './telegram_sessions' with your desired host path. diff --git a/requirements.txt b/requirements.txt index f92cae81..3d6c5ce6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ dotenv>=0.9.9 httpx>=0.28.1 -mcp[cli]>=1.4.1 +mcp[cli]>=1.8.0 nest-asyncio>=1.6.0 python-dotenv>=1.1.0 python-json-logger>=3.3.0 diff --git a/telegram_mcp/runner.py b/telegram_mcp/runner.py index 6349f032..5c1157c6 100644 --- a/telegram_mcp/runner.py +++ b/telegram_mcp/runner.py @@ -26,6 +26,28 @@ async def _connect_authorized_client(label, client) -> None: ) +async def _serve(transport: str) -> None: + """Run the MCP server on the selected transport. + + HTTP transports let one long-lived process hold a single shared Telegram + connection while multiple local MCP clients connect over HTTP, instead of + each client spawning its own Telethon session (which Telegram + throttles/flags). "http" is streamable HTTP — the current MCP transport + that Claude Code (`--transport http`) and Codex (`--url`) speak natively; + "sse" is kept for clients that only support the legacy SSE transport. + """ + if transport in ("http", "sse"): + mcp.settings.host = os.getenv("MCP_HOST", "127.0.0.1") + mcp.settings.port = int(os.getenv("MCP_PORT", "8765")) + if transport == "http": + await mcp.run_streamable_http_async() + else: + await mcp.run_sse_async() + else: + # Use the asynchronous entrypoint instead of mcp.run() + await mcp.run_stdio_async() + + async def _main() -> None: try: labels = ", ".join(clients.keys()) @@ -55,17 +77,7 @@ async def _warm_caches() -> None: f"Telegram client(s) started ({labels}). Running MCP server ({transport})...", file=sys.stderr, ) - # SSE transport: one long-lived process holds a single shared Telegram - # connection, while multiple local MCP clients (Claude Code via mcp-remote, - # Claude Desktop) connect over HTTP. This avoids spawning one Telethon - # session per client, which Telegram throttles/flags. - if transport == "sse": - mcp.settings.host = os.getenv("MCP_HOST", "127.0.0.1") - mcp.settings.port = int(os.getenv("MCP_PORT", "8765")) - await mcp.run_sse_async() - else: - # Use the asynchronous entrypoint instead of mcp.run() - await mcp.run_stdio_async() + await _serve(transport) except Exception as e: print(f"Error starting client: {e}", file=sys.stderr) if isinstance(e, sqlite3.OperationalError) and "database is locked" in str(e): diff --git a/tests/test_runner.py b/tests/test_runner.py index 484c6e71..d965146c 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -38,3 +38,64 @@ async def test_connect_authorized_client_rejects_unauthorized_session(): assert client.connected is True assert client.started is False + + +class _FakeSettings: + def __init__(self): + self.host = None + self.port = None + + +class _FakeMcp: + def __init__(self): + self.settings = _FakeSettings() + self.ran = None + + async def run_stdio_async(self): + self.ran = "stdio" + + async def run_sse_async(self): + self.ran = "sse" + + async def run_streamable_http_async(self): + self.ran = "http" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["stdio", "unknown"]) +async def test_serve_defaults_to_stdio(monkeypatch, transport): + fake = _FakeMcp() + monkeypatch.setattr(runner, "mcp", fake) + + await runner._serve(transport) + + assert fake.ran == "stdio" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("transport", ["http", "sse"]) +async def test_serve_http_transports_bind_host_and_port(monkeypatch, transport): + fake = _FakeMcp() + monkeypatch.setattr(runner, "mcp", fake) + monkeypatch.setenv("MCP_HOST", "0.0.0.0") + monkeypatch.setenv("MCP_PORT", "9000") + + await runner._serve(transport) + + assert fake.ran == transport + assert fake.settings.host == "0.0.0.0" + assert fake.settings.port == 9000 + + +@pytest.mark.asyncio +async def test_serve_http_uses_default_host_and_port(monkeypatch): + fake = _FakeMcp() + monkeypatch.setattr(runner, "mcp", fake) + monkeypatch.delenv("MCP_HOST", raising=False) + monkeypatch.delenv("MCP_PORT", raising=False) + + await runner._serve("http") + + assert fake.ran == "http" + assert fake.settings.host == "127.0.0.1" + assert fake.settings.port == 8765