Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 80 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,48 @@ from GitHub explicitly:
uvx --from "git+https://github.com/chigwell/telegram-mcp.git@<pinned-release-tag-or-commit>" 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:
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 23 additions & 11 deletions telegram_mcp/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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):
Expand Down
61 changes: 61 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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