diff --git a/README.md b/README.md index bd31f0e..27ad4e4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,46 @@ account-to-account messages. --- +## MCP server — Claude Code & AI agent integration + +`tg-ringer` includes a **stdio MCP server** (`tg-ringer-mcp`) — wire it into +Claude Code, the Claude desktop app, Cursor, Windsurf, or any MCP-compatible agent. + +```bash +pip install 'tg-ringer[mcp]' +``` + +**Claude Code config** (SSH to remote host — no open ports, SSH key = auth): + +```json +// ~/.claude/settings.json +{ + "mcpServers": { + "tg-ringer": { + "command": "ssh", + "args": ["your-server", "tg-ringer-mcp"] + } + } +} +``` + +**5 MCP tools:** + +| Tool | Does | +|------|------| +| `tg_ring` | Ring a user (urgent interrupt) | +| `tg_message` | Send a DM (quiet alert) | +| `tg_whoami` | Show logged-in userbot | +| `tg_status` | Check anti-spam via @SpamBot | +| `tg_ask` | Ask a question, wait for your Telegram reply, return it to Claude | + +`tg_ask` is the standout: Claude sends you a question on Telegram, blocks until +you reply, then continues with your answer — **human-in-the-loop via Telegram**. + +Full MCP docs → **https://jdp5949.github.io/tg-ringer/#mcp-server--claude-code--ai-agent-integration** + +--- + ## When to use it | You want… | Use this? | @@ -19,6 +59,7 @@ account-to-account messages. | Phone to **ring** on a critical event (build failed, server down, prod alert) | ✅ yes | | A free alternative to paid call APIs, and you already live in Telegram | ✅ yes | | Account-to-account DM from a script (faster than Bot API on a warm connection) | ✅ yes | +| Claude Code / AI agent to send you Telegram alerts or ask for input | ✅ yes | | Spoken/TTS audio in the call | ❌ no — ring only (see [limitations](#limitations)) | | Reach someone with **no internet** (real cellular call) | ❌ no — Telegram is VoIP; use Twilio/PSTN | | Mass messaging / spam | ❌ absolutely not — instant ban | diff --git a/docs/index.md b/docs/index.md index 244922c..b9bd44b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,6 +42,7 @@ Telegram account you already have, with zero monthly cost. | 🔁 **Auto-resolve numbers** | A `+phone` is imported as a temp contact so you can reach it. | | ⏱️ **Control ring length** | `--seconds` / `RING_SECONDS`. | | 🧩 **CLI + Python library** | Use from shell scripts or import `tg_ringer`. | +| 🤖 **MCP server** | Claude Code / AI agent integration — 5 Telegram tools over stdio. | | 🆓 **Free** | No paid telephony, no per-call cost. | --- @@ -220,6 +221,135 @@ variables (handy for CI), which take precedence: --- +## MCP server — Telegram tools for Claude Code and AI agents + +`tg-ringer` ships a **stdio MCP server** (`tg-ringer-mcp`) that exposes Telegram +actions as tools any MCP-compatible AI agent can call: Claude Code, the Claude +desktop app, Cursor, Windsurf, or any custom agent using the MCP SDK. + +### 5 MCP tools + +| Tool | What it does | +|------|-------------| +| `tg_ring` | Ring a Telegram user (phone rings, no audio). Best for urgent interrupts. | +| `tg_message` | Send a direct message (quiet alert with detail). | +| `tg_whoami` | Show which userbot account is logged in. | +| `tg_status` | Check anti-spam status via `@SpamBot`. | +| `tg_ask` | Send a question, wait for your Telegram reply, return it to the agent. | + +The killer tool is **`tg_ask`** — it lets an AI agent pause mid-task, message you +on Telegram, and continue only once you reply. Human-in-the-loop over Telegram. + +### Install the MCP server + +```bash +# on the machine that has the Telegram session (e.g. a remote server) +pip install 'tg-ringer[mcp]' +tg-ringer login # if not already configured +``` + +### Connect Claude Code (via SSH) + +The recommended setup: MCP server runs on a remote host (`mini4-india` or any +SSH target), Claude Code connects over stdio through SSH. No open ports, no token — +SSH key is the auth. + +```json +// ~/.claude/settings.json (or project .claude/settings.json) +{ + "mcpServers": { + "tg-ringer": { + "command": "ssh", + "args": ["mini4-india", "tg-ringer-mcp"] + } + } +} +``` + +If the `tg-ringer-mcp` binary isn't on the remote `PATH`, use the full path: + +```json +"args": ["mini4-india", "/home/ubuntu/tg-ringer-venv/bin/tg-ringer-mcp"] +``` + +### Connect Claude Code (local) + +If the session lives on your laptop, skip SSH entirely: + +```json +{ + "mcpServers": { + "tg-ringer": { + "command": "tg-ringer-mcp" + } + } +} +``` + +### Connect the Claude desktop app + +Same JSON, placed in the Claude app's MCP config file +(`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): + +```json +{ + "mcpServers": { + "tg-ringer": { + "command": "ssh", + "args": ["mini4-india", "tg-ringer-mcp"] + } + } +} +``` + +### Connect Cursor / Windsurf / any MCP client + +Any editor or agent that supports MCP servers over stdio works the same way — +point it at `tg-ringer-mcp` (local) or `ssh tg-ringer-mcp` (remote). + +### Using the tools + +Once connected, tell Claude (or any agent) in plain English: + +``` +After you finish the migration, ping me on Telegram. +``` + +Claude will call `tg_message` automatically. Or more explicitly: + +``` +Ring me on Telegram if the tests fail. +``` +→ Claude calls `tg_ring` on failure. + +### tg_ask — human-in-the-loop via Telegram + +The most powerful tool: Claude pauses, messages you, and waits for your reply +before continuing. + +``` +Before you delete those files, ask me via Telegram which ones to keep. +``` + +Flow: +1. Claude calls `tg_ask("Which files should I keep? Reply with filenames.")` +2. You get a Telegram DM: `🤖 Claude asks: Which files should I keep?` +3. You reply in Telegram: `keep src/core.py and tests/` +4. Claude receives your reply and continues with that input. + +Example in a script: + +```python +# Any agent SDK that supports MCP tool calls +result = await client.call_tool("tg_ask", { + "question": "Prod deploy ready. Confirm? (yes/no)", + "timeout": 300 # wait up to 5 minutes +}) +# result = "yes" ← your Telegram reply +``` + +--- + ## When to use it ✅ Phone should **ring** on a critical event diff --git a/pyproject.toml b/pyproject.toml index e456e27..fb40f8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "tg-ringer" -version = "0.3.0" +version = "0.4.0" description = "Ring (call) and message any Telegram user from your own account — urgent alerts via a real Telegram call." readme = "README.md" requires-python = ">=3.9" @@ -30,11 +30,13 @@ Issues = "https://github.com/jdp5949/tg-ringer/issues" [project.scripts] tg-ringer = "tg_ringer.cli:main" +tg-ringer-mcp = "tg_ringer.mcp:main" [tool.hatch.build.targets.wheel] packages = ["tg_ringer"] [project.optional-dependencies] +mcp = ["mcp>=1.0"] dev = ["ruff", "pytest", "build", "twine"] [tool.ruff] diff --git a/tg_ringer/mcp.py b/tg_ringer/mcp.py new file mode 100644 index 0000000..717e120 --- /dev/null +++ b/tg_ringer/mcp.py @@ -0,0 +1,225 @@ +"""MCP server for tg-ringer — Telegram tools for Claude Code and AI agents. + +Exposes 5 tools over stdio (launch via `tg-ringer-mcp`): + + tg_ring — ring a Telegram user (phone rings, no audio) + tg_message — send a DM + tg_whoami — show logged-in userbot account + tg_status — check anti-spam status via @SpamBot + tg_ask — send a question, block until user replies, return reply text + +Claude Code config (~/.claude/settings.json): + + { + "mcpServers": { + "tg-ringer": { + "command": "ssh", + "args": ["", "tg-ringer-mcp"] + } + } + } + +For local use (no SSH): + { "mcpServers": { "tg-ringer": { "command": "tg-ringer-mcp" } } } +""" + +from __future__ import annotations + +import asyncio +import os +import time +from contextlib import asynccontextmanager +from pathlib import Path + +from mcp.server.fastmcp import FastMCP + +# --------------------------------------------------------------------------- +# Config helpers (mirrors cli.py so same config file is shared) +# --------------------------------------------------------------------------- + +_CONFIG_DIR = Path( + os.environ.get("TG_RINGER_HOME", Path.home() / ".config" / "tg-ringer") +) +_CONFIG_FILE = _CONFIG_DIR / "config" + + +def _load_config() -> None: + if not _CONFIG_FILE.exists(): + return + for line in _CONFIG_FILE.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) + + +def _creds() -> tuple[int, str]: + _load_config() + api_id = os.environ.get("TG_API_ID") + api_hash = os.environ.get("TG_API_HASH") + if not api_id or not api_hash: + raise RuntimeError("Not configured — run `tg-ringer init` on the server first") + return int(api_id), api_hash + + +def _session() -> str: + sess = os.environ.get("TG_SESSION") + if sess: + return sess + _CONFIG_DIR.mkdir(parents=True, exist_ok=True) + return str(_CONFIG_DIR / "userbot") + + +def _default_target() -> str | None: + return os.environ.get("TG_TARGET") + + +def _resolve_target(arg: str | None) -> str: + t = arg or _default_target() + if not t: + raise ValueError("No target — pass `target` or set TG_TARGET in config") + return t + + +# --------------------------------------------------------------------------- +# Singleton TgCaller (kept connected for the lifetime of the MCP process) +# --------------------------------------------------------------------------- + +_caller: object | None = None # TgCaller; avoid top-level import of telethon + + +@asynccontextmanager +async def _lifespan(_server: FastMCP): + global _caller + from tg_ringer.client import TgCaller + + api_id, api_hash = _creds() + caller = TgCaller(api_id, api_hash, _session()) + await caller.__aenter__() # type: ignore[attr-defined] + _caller = caller + try: + yield + finally: + _caller = None + await caller.__aexit__(None, None, None) # type: ignore[attr-defined] + + +mcp = FastMCP("tg-ringer", lifespan=_lifespan) + + +def _tg(): + """Return the live TgCaller, raising clearly if not ready.""" + if _caller is None: + raise RuntimeError("tg-ringer MCP not initialized — check session on server") + return _caller # type: ignore[return-value] + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +async def tg_ring(seconds: int = 20, target: str | None = None) -> str: + """Ring a Telegram user so their phone rings, then hang up. Ring IS the alert. + + Args: + seconds: How long to let it ring before hanging up (default 20). + target: @username, numeric id, or +E164 phone. Defaults to TG_TARGET env var. + """ + t = _resolve_target(target) + cid = await _tg().ring(t, seconds=seconds) + return f"Rang {t} for {seconds}s (call id {cid})" + + +@mcp.tool() +async def tg_message(text: str, target: str | None = None) -> str: + """Send a Telegram direct message from the userbot account. + + Args: + text: Message body to send. + target: @username, numeric id, or +E164 phone. Defaults to TG_TARGET env var. + """ + t = _resolve_target(target) + mid = await _tg().message(t, text) + return f"Sent to {t} (msg id {mid})" + + +@mcp.tool() +async def tg_whoami() -> str: + """Return the logged-in userbot Telegram account (name, id, username).""" + me = await _tg().whoami() + uname = f"@{me.username}" if me.username else "(no username)" + return f"{me.first_name} (id {me.id}, {uname})" + + +@mcp.tool() +async def tg_status() -> str: + """Check this userbot account's anti-spam status via @SpamBot. + + Useful to diagnose PeerFloodError or call failures. + """ + return await _tg().spam_status() + + +@mcp.tool() +async def tg_ask( + question: str, + timeout: int = 120, + target: str | None = None, +) -> str: + """Send a question to the Telegram user and wait for their reply. + + Use when you need human input to continue work: + 1. User receives the question as a Telegram DM. + 2. User types their reply in Telegram. + 3. This tool returns the reply text so you can proceed. + + Args: + question: The question or prompt to send. + timeout: Seconds to wait for a reply before giving up (default 120). + target: @username, numeric id, or +E164. Defaults to TG_TARGET env var. + + Returns: + The user's reply text. + + Raises: + TimeoutError: If no reply arrives within `timeout` seconds. + """ + t = _resolve_target(target) + tg = _tg() + + # Resolve target entity (needed for message filtering) + entity = await tg.resolve(t) + + # Send the question + prompt = f"\U0001f916 Claude asks:\n\n{question}" + sent = await tg.client.send_message(entity, prompt) + + # Poll for a reply using min_id so we only see messages after our question. + # This avoids date-comparison timezone issues and the same-second miss. + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + async for msg in tg.client.iter_messages(entity, limit=50, min_id=sent.id): + if not msg.out: + return msg.raw_text or "(empty reply)" + await asyncio.sleep(3) + + raise TimeoutError( + f"No reply from {t} within {timeout}s. " + "Check Telegram and retry, or increase timeout." + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main()