From a02c69e38ea976fd6e9a56ac20307935d0aee11e Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 10:12:18 +0700 Subject: [PATCH 1/9] telegram mcp server for thclaws --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 2 + mcp/telegram-mcp/LICENSE | 201 ++++++++++++ mcp/telegram-mcp/NOTICE.md | 6 + mcp/telegram-mcp/README.md | 116 +++++++ mcp/telegram-mcp/pyproject.toml | 54 ++++ mcp/telegram-mcp/src/telegram_mcp/__init__.py | 5 + mcp/telegram-mcp/src/telegram_mcp/__main__.py | 6 + mcp/telegram-mcp/src/telegram_mcp/server.py | 304 ++++++++++++++++++ mcp/telegram-mcp/tests/test_server.py | 181 +++++++++++ plan/telegram_mcp_plan.md | 211 ++++++++++++ 11 files changed, 1086 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 mcp/telegram-mcp/LICENSE create mode 100644 mcp/telegram-mcp/NOTICE.md create mode 100644 mcp/telegram-mcp/README.md create mode 100644 mcp/telegram-mcp/pyproject.toml create mode 100644 mcp/telegram-mcp/src/telegram_mcp/__init__.py create mode 100644 mcp/telegram-mcp/src/telegram_mcp/__main__.py create mode 100644 mcp/telegram-mcp/src/telegram_mcp/server.py create mode 100644 mcp/telegram-mcp/tests/test_server.py create mode 100644 plan/telegram_mcp_plan.md diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8f37bd8c4acb3a4213bc121773ae11137717d43a GIT binary patch literal 6148 zcmeHKJ5Iwu5S;}VmS|E^?iEtdFOfMxE&w4C6gGB4Zz^uUm2!gIfP?VnLzKuEi3SPu zMw)%znR#CO6&{a>q}B6wDY6ie8m=g>x=c;;>JvMQNCixKkM?1?zVEwXQaK+`ZY6`X zawkt;@DI7JZ<Z zUpR850#tws92C&+LxwAsz|qk@9awAx0P1hN8Ln-X0A>vUOW^2;2rM}j=+qP=Mshmh zIqFK_=;(Awb~5f0C#RTDlAVrtwsJ|ukt-FT0;dX``*xxI{{}uY|DTe$qXJamUn!8P zX5FmtO3_;gW!*AiAhFUAfVyzfxtr#0^#g|8QS+9{Vfup0*5jQ%JKLV-?jSBpR F0w2%~DbN4_ literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd3571e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/mcp/telegram-mcp/src/telegram_mcp/__pycache__ +*.pyc diff --git a/mcp/telegram-mcp/LICENSE b/mcp/telegram-mcp/LICENSE new file mode 100644 index 0000000..3299bb8 --- /dev/null +++ b/mcp/telegram-mcp/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for describing the origin of the Work and + reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or support. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party works. + + Copyright 2026 ThaiGPT Co., Ltd. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied. See the License for the specific language governing permissions and + limitations under the License. diff --git a/mcp/telegram-mcp/NOTICE.md b/mcp/telegram-mcp/NOTICE.md new file mode 100644 index 0000000..ef29304 --- /dev/null +++ b/mcp/telegram-mcp/NOTICE.md @@ -0,0 +1,6 @@ +# thClaws Telegram MCP + +This is an original MCP server implementation for the thClaws marketplace. + +It uses the Telegram Bot API via `httpx` and the Python MCP SDK. No Telegram +source code or third-party Telegram client implementation is vendored here. diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md new file mode 100644 index 0000000..b36f70d --- /dev/null +++ b/mcp/telegram-mcp/README.md @@ -0,0 +1,116 @@ +# thClaws Telegram MCP + +Telegram Bot API MCP server for thClaws agents. It lets an agent send messages, photos, documents, and inspect recent bot updates through an allowlisted Telegram bot. + +This is a tool surface, not a thClaws UI transport. Use it for notifications, escalation, and sending artifacts after work completes. + +## Tools + +| Tool | Args | Returns | +|---|---|---| +| `telegram_send_message` | `chat_id`, `text`, optional `parse_mode` | Message id + chat summary | +| `telegram_send_photo` | `chat_id`, `file_path`, optional `caption` | Message id + chat summary | +| `telegram_send_document` | `chat_id`, `file_path`, optional `caption` | Message id + chat summary | +| `telegram_get_updates` | optional `limit`, optional `offset` | Compact JSON for updates from allowlisted chats | + +## Install + +### Local stdio + +```bash +git clone https://github.com/thClaws/marketplace.git +cd marketplace/mcp/telegram-mcp +pip install -e . +``` + +Then add the server to `.thclaws/mcp.json` in your project, or to `~/.config/thclaws/mcp.json` for user scope: + +```json +{ + "mcpServers": { + "telegram": { + "command": "thclaws-telegram", + "env": { + "TELEGRAM_BOT_TOKEN": "replace-with-your-bot-token", + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789" + } + } + } +} +``` + +You can also run it with Python directly: + +```json +{ + "mcpServers": { + "telegram": { + "command": "python", + "args": ["-m", "telegram_mcp"], + "env": { + "TELEGRAM_BOT_TOKEN": "replace-with-your-bot-token", + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789" + } + } + } +} +``` + +## Configuration + +| Env var | Purpose | Default | +|---|---|---| +| `TELEGRAM_BOT_TOKEN` | Required Telegram bot token from BotFather | unset | +| `TELEGRAM_ALLOWED_CHAT_IDS` | Required comma-separated allowlist of chat IDs | unset | +| `TELEGRAM_API_BASE` | Optional Telegram-compatible API base URL | `https://api.telegram.org` | +| `MCP_TRANSPORT` | `stdio` for local install, `sse` for HTTP/SSE hosting | `stdio` | +| `MCP_PORT` | Port for SSE transport | `8000` | + +## Finding Your Chat ID + +1. Create a bot with Telegram BotFather and copy its token. +2. Send a message to the bot from the chat you want to allow. +3. Query Telegram directly once and inspect the returned `chat.id`: + + ```bash + curl "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/getUpdates" + ``` + +4. Add only the needed IDs to `TELEGRAM_ALLOWED_CHAT_IDS`. + +For group chats, Telegram chat IDs are usually negative. Keep the minus sign in the allowlist. + +## Security Notes + +- The bot token is read only from `TELEGRAM_BOT_TOKEN`. +- No secrets should be committed into `mcp.json`, README examples, screenshots, or tests. +- Every tool is constrained by `TELEGRAM_ALLOWED_CHAT_IDS`. +- File upload tools only accept local file paths. URL-based uploads are intentionally not supported in this first version. +- `telegram_get_updates` filters output to allowlisted chats before returning data to the agent. +- Telegram messages may leave your local environment and be stored by Telegram. Do not send secrets unless your operational policy allows it. +- Do not use this MCP server to publish another person's private information without their explicit permission. + +## Example Uses + +Ask thClaws: + +```text +When the tests finish, send me a Telegram message with the result. +``` + +Or: + +```text +Generate the report, then send the PDF to chat 123456789 over Telegram. +``` + +## Limitations + +- This does not make Telegram a chat UI for thClaws. +- It does not implement a long-running approval workflow or wait-for-reply tool yet. +- It does not accept remote file URLs for uploads. +- It does not manage Telegram webhooks. + +## License + +Apache-2.0 - see [LICENSE](./LICENSE). diff --git a/mcp/telegram-mcp/pyproject.toml b/mcp/telegram-mcp/pyproject.toml new file mode 100644 index 0000000..0b79904 --- /dev/null +++ b/mcp/telegram-mcp/pyproject.toml @@ -0,0 +1,54 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "thclaws-telegram-mcp" +version = "0.1.0" +description = "Telegram Bot API MCP server for thClaws agents" +readme = "README.md" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [ + { name = "ThaiGPT Co., Ltd. (thClaws project)" }, +] +keywords = ["mcp", "model-context-protocol", "telegram", "thclaws"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Communications :: Chat", + "Topic :: Software Development :: Libraries", +] +dependencies = [ + "mcp>=1.2.0", + "httpx>=0.27.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", +] + +[project.urls] +Homepage = "https://github.com/thClaws/marketplace/tree/main/mcp/telegram-mcp" +Repository = "https://github.com/thClaws/marketplace" +Issues = "https://github.com/thClaws/marketplace/issues" + +[project.scripts] +thclaws-telegram = "telegram_mcp.server:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/telegram_mcp"] + +[tool.hatch.build.targets.sdist] +include = [ + "src/telegram_mcp", + "README.md", + "LICENSE", + "NOTICE.md", + "pyproject.toml", +] diff --git a/mcp/telegram-mcp/src/telegram_mcp/__init__.py b/mcp/telegram-mcp/src/telegram_mcp/__init__.py new file mode 100644 index 0000000..edceead --- /dev/null +++ b/mcp/telegram-mcp/src/telegram_mcp/__init__.py @@ -0,0 +1,5 @@ +"""Telegram MCP server package for thClaws.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/mcp/telegram-mcp/src/telegram_mcp/__main__.py b/mcp/telegram-mcp/src/telegram_mcp/__main__.py new file mode 100644 index 0000000..880d55e --- /dev/null +++ b/mcp/telegram-mcp/src/telegram_mcp/__main__.py @@ -0,0 +1,6 @@ +"""Run `python -m telegram_mcp`.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/mcp/telegram-mcp/src/telegram_mcp/server.py b/mcp/telegram-mcp/src/telegram_mcp/server.py new file mode 100644 index 0000000..2603eb8 --- /dev/null +++ b/mcp/telegram-mcp/src/telegram_mcp/server.py @@ -0,0 +1,304 @@ +"""thClaws Telegram MCP server. + +This server exposes Telegram Bot API actions as MCP tools so an agent +can notify users, send artifacts, or inspect recent bot updates without +turning Telegram into the primary thClaws UI. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import httpx +from mcp.server.fastmcp import FastMCP + +DEFAULT_API_BASE = "https://api.telegram.org" + + +class TelegramConfigError(RuntimeError): + """Raised when required Telegram MCP environment is missing.""" + + +class TelegramApiError(RuntimeError): + """Raised when Telegram returns a non-ok API response.""" + + +def _token() -> str: + token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip() + if not token: + raise TelegramConfigError("TELEGRAM_BOT_TOKEN is required") + return token + + +def _api_base() -> str: + return os.environ.get("TELEGRAM_API_BASE", DEFAULT_API_BASE).rstrip("/") + + +def _allowed_chat_ids() -> set[str]: + raw = os.environ.get("TELEGRAM_ALLOWED_CHAT_IDS", "") + allowed = {item.strip() for item in raw.split(",") if item.strip()} + if not allowed: + raise TelegramConfigError( + "TELEGRAM_ALLOWED_CHAT_IDS is required; set a comma-separated " + "allowlist of chat IDs this MCP server may access" + ) + return allowed + + +def _normalize_chat_id(chat_id: str | int) -> str: + normalized = str(chat_id).strip() + if not normalized: + raise TelegramConfigError("chat_id is required") + return normalized + + +def _check_chat_allowed(chat_id: str | int) -> str: + normalized = _normalize_chat_id(chat_id) + if normalized not in _allowed_chat_ids(): + raise TelegramConfigError( + f"chat_id {normalized!r} is not allowed by TELEGRAM_ALLOWED_CHAT_IDS" + ) + return normalized + + +def _api_url(method: str) -> str: + return f"{_api_base()}/bot{_token()}/{method}" + + +def _telegram_result(data: dict[str, Any], method: str) -> dict[str, Any]: + if data.get("ok") is not True: + description = data.get("description") or "unknown Telegram API error" + error_code = data.get("error_code") + if error_code is None: + raise TelegramApiError(f"{method}: {description}") + raise TelegramApiError(f"{method}: [{error_code}] {description}") + result = data.get("result") + if isinstance(result, dict): + return result + return {"result": result} + + +async def _post_json(method: str, payload: dict[str, Any]) -> dict[str, Any]: + async with httpx.AsyncClient() as client: + resp = await client.post(_api_url(method), json=payload, timeout=20.0) + resp.raise_for_status() + return _telegram_result(resp.json(), method) + + +async def _post_file( + method: str, + file_field: str, + chat_id: str, + file_path: str, + caption: str | None, +) -> dict[str, Any]: + path = Path(file_path).expanduser() + if str(file_path).startswith(("http://", "https://")): + raise TelegramConfigError("file_path must be a local path, not a URL") + if not path.is_file(): + raise TelegramConfigError(f"file_path does not exist or is not a file: {file_path}") + + data: dict[str, str] = {"chat_id": chat_id} + if caption: + data["caption"] = caption + + with path.open("rb") as handle: + files = {file_field: (path.name, handle)} + async with httpx.AsyncClient() as client: + resp = await client.post( + _api_url(method), + data=data, + files=files, + timeout=60.0, + ) + resp.raise_for_status() + return _telegram_result(resp.json(), method) + + +def _message_summary(result: dict[str, Any]) -> str: + chat = result.get("chat") if isinstance(result.get("chat"), dict) else {} + return json.dumps( + { + "message_id": result.get("message_id"), + "chat_id": chat.get("id"), + "chat_type": chat.get("type"), + "date": result.get("date"), + }, + sort_keys=True, + ) + + +def _chat_id_from_update(update: dict[str, Any]) -> str | None: + for key in ("message", "edited_message", "channel_post", "edited_channel_post"): + msg = update.get(key) + if isinstance(msg, dict): + chat = msg.get("chat") + if isinstance(chat, dict) and chat.get("id") is not None: + return str(chat["id"]) + callback = update.get("callback_query") + if isinstance(callback, dict): + msg = callback.get("message") + if isinstance(msg, dict): + chat = msg.get("chat") + if isinstance(chat, dict) and chat.get("id") is not None: + return str(chat["id"]) + return None + + +def _compact_update(update: dict[str, Any]) -> dict[str, Any]: + compact: dict[str, Any] = {"update_id": update.get("update_id")} + for key in ("message", "edited_message", "channel_post", "edited_channel_post"): + msg = update.get(key) + if not isinstance(msg, dict): + continue + chat = msg.get("chat") if isinstance(msg.get("chat"), dict) else {} + sender = msg.get("from") if isinstance(msg.get("from"), dict) else {} + compact.update( + { + "kind": key, + "message_id": msg.get("message_id"), + "date": msg.get("date"), + "chat_id": chat.get("id"), + "chat_type": chat.get("type"), + "from_username": sender.get("username"), + "text": msg.get("text") or msg.get("caption"), + } + ) + return compact + + callback = update.get("callback_query") + if isinstance(callback, dict): + compact.update( + { + "kind": "callback_query", + "callback_query_id": callback.get("id"), + "from_username": (callback.get("from") or {}).get("username") + if isinstance(callback.get("from"), dict) + else None, + "data": callback.get("data"), + } + ) + return compact + + +mcp = FastMCP( + "thclaws-telegram", + instructions=( + "Provides Telegram Bot API tools for agent-initiated communication. " + "Use these tools for notifications, sending artifacts, and reading " + "recent bot updates. This is not a Telegram UI transport for thClaws." + ), +) + + +@mcp.tool() +async def telegram_send_message( + chat_id: str, + text: str, + parse_mode: str | None = None, +) -> str: + """Send a text message to an allowlisted Telegram chat. + + Args: + chat_id: Telegram chat ID. Must be listed in TELEGRAM_ALLOWED_CHAT_IDS. + text: Message body to send. + parse_mode: Optional Telegram parse mode, e.g. "MarkdownV2" or "HTML". + """ + allowed_chat_id = _check_chat_allowed(chat_id) + payload: dict[str, Any] = {"chat_id": allowed_chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + result = await _post_json("sendMessage", payload) + return f"Telegram message sent: {_message_summary(result)}" + + +@mcp.tool() +async def telegram_send_photo( + chat_id: str, + file_path: str, + caption: str | None = None, +) -> str: + """Send a local image file to an allowlisted Telegram chat. + + Args: + chat_id: Telegram chat ID. Must be listed in TELEGRAM_ALLOWED_CHAT_IDS. + file_path: Local path to the image file. URLs are intentionally not supported. + caption: Optional caption. + """ + allowed_chat_id = _check_chat_allowed(chat_id) + result = await _post_file("sendPhoto", "photo", allowed_chat_id, file_path, caption) + return f"Telegram photo sent: {_message_summary(result)}" + + +@mcp.tool() +async def telegram_send_document( + chat_id: str, + file_path: str, + caption: str | None = None, +) -> str: + """Send a local document file to an allowlisted Telegram chat. + + Args: + chat_id: Telegram chat ID. Must be listed in TELEGRAM_ALLOWED_CHAT_IDS. + file_path: Local path to the document file. URLs are intentionally not supported. + caption: Optional caption. + """ + allowed_chat_id = _check_chat_allowed(chat_id) + result = await _post_file( + "sendDocument", + "document", + allowed_chat_id, + file_path, + caption, + ) + return f"Telegram document sent: {_message_summary(result)}" + + +@mcp.tool() +async def telegram_get_updates(limit: int = 10, offset: int | None = None) -> str: + """Read recent Telegram bot updates for allowlisted chats. + + Args: + limit: Number of updates to request, clamped to Telegram's 1-100 range. + offset: Optional Telegram update offset for pagination. + """ + allowed = _allowed_chat_ids() + requested_limit = max(1, min(int(limit), 100)) + payload: dict[str, Any] = {"limit": requested_limit, "timeout": 0} + if offset is not None: + payload["offset"] = int(offset) + + result = await _post_json("getUpdates", payload) + updates = result.get("result") if "result" in result else result + if not isinstance(updates, list): + updates = [] + + compact = [ + _compact_update(update) + for update in updates + if isinstance(update, dict) + and (chat_id := _chat_id_from_update(update)) is not None + and chat_id in allowed + ] + if not compact: + return "No updates for allowlisted Telegram chats." + return json.dumps(compact, ensure_ascii=False, sort_keys=True) + + +def main() -> None: + """Entry point for the `thclaws-telegram` console script.""" + transport = os.environ.get("MCP_TRANSPORT", "stdio") + if transport == "sse": + port = int(os.environ.get("MCP_PORT", "8000")) + mcp.settings.host = "0.0.0.0" + mcp.settings.port = port + mcp.run(transport="sse") + else: + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/mcp/telegram-mcp/tests/test_server.py b/mcp/telegram-mcp/tests/test_server.py new file mode 100644 index 0000000..1b95818 --- /dev/null +++ b/mcp/telegram-mcp/tests/test_server.py @@ -0,0 +1,181 @@ +"""Offline tests for thclaws-telegram MCP. + +The tests patch httpx so they never call the real Telegram Bot API. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from telegram_mcp.server import ( + TelegramApiError, + TelegramConfigError, + _api_url, + _check_chat_allowed, + _telegram_result, + telegram_get_updates, + telegram_send_document, + telegram_send_message, + telegram_send_photo, +) + + +@pytest.fixture(autouse=True) +def telegram_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test-token") + monkeypatch.setenv("TELEGRAM_ALLOWED_CHAT_IDS", "123,-456") + + +def _fake_response(data: dict) -> AsyncMock: + response = AsyncMock() + response.raise_for_status = lambda: None + response.json = lambda: data + return response + + +def test_missing_bot_token_fails_clearly(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TELEGRAM_BOT_TOKEN") + with pytest.raises(TelegramConfigError, match="TELEGRAM_BOT_TOKEN"): + _api_url("sendMessage") + + +def test_allowed_chat_id_passes() -> None: + assert _check_chat_allowed("123") == "123" + assert _check_chat_allowed(-456) == "-456" + + +def test_disallowed_chat_id_fails() -> None: + with pytest.raises(TelegramConfigError, match="not allowed"): + _check_chat_allowed("999") + + +def test_missing_allowlist_fails(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TELEGRAM_ALLOWED_CHAT_IDS") + with pytest.raises(TelegramConfigError, match="TELEGRAM_ALLOWED_CHAT_IDS"): + _check_chat_allowed("123") + + +@pytest.mark.asyncio +async def test_send_message_builds_telegram_request() -> None: + fake_post = AsyncMock( + return_value=_fake_response( + { + "ok": True, + "result": { + "message_id": 42, + "date": 1710000000, + "chat": {"id": 123, "type": "private"}, + }, + } + ) + ) + + with patch("telegram_mcp.server.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = fake_post + out = await telegram_send_message("123", "hello", parse_mode="HTML") + + assert "Telegram message sent" in out + assert '"message_id": 42' in out + fake_post.assert_awaited_once() + _, kwargs = fake_post.await_args + assert kwargs["json"] == { + "chat_id": "123", + "text": "hello", + "parse_mode": "HTML", + } + assert kwargs["timeout"] == 20.0 + + +@pytest.mark.asyncio +async def test_get_updates_filters_to_allowed_chats() -> None: + fake_post = AsyncMock( + return_value=_fake_response( + { + "ok": True, + "result": [ + { + "update_id": 1, + "message": { + "message_id": 10, + "date": 1710000000, + "chat": {"id": 123, "type": "private"}, + "from": {"username": "allowed"}, + "text": "ping", + }, + }, + { + "update_id": 2, + "message": { + "message_id": 11, + "date": 1710000001, + "chat": {"id": 999, "type": "private"}, + "from": {"username": "blocked"}, + "text": "secret", + }, + }, + ], + } + ) + ) + + with patch("telegram_mcp.server.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = fake_post + out = await telegram_get_updates(limit=200, offset=3) + + data = json.loads(out) + assert len(data) == 1 + assert data[0]["chat_id"] == 123 + assert data[0]["text"] == "ping" + _, kwargs = fake_post.await_args + assert kwargs["json"] == {"limit": 100, "timeout": 0, "offset": 3} + + +@pytest.mark.asyncio +async def test_file_upload_rejects_missing_file() -> None: + with pytest.raises(TelegramConfigError, match="does not exist"): + await telegram_send_photo("123", "/tmp/not-a-real-telegram-file.png") + + +@pytest.mark.asyncio +async def test_file_upload_rejects_url() -> None: + with pytest.raises(TelegramConfigError, match="local path"): + await telegram_send_document("123", "https://example.com/report.pdf") + + +@pytest.mark.asyncio +async def test_send_document_uploads_local_file(tmp_path: Path) -> None: + doc = tmp_path / "report.txt" + doc.write_text("hello", encoding="utf-8") + fake_post = AsyncMock( + return_value=_fake_response( + { + "ok": True, + "result": { + "message_id": 77, + "date": 1710000000, + "chat": {"id": 123, "type": "private"}, + }, + } + ) + ) + + with patch("telegram_mcp.server.httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.post = fake_post + out = await telegram_send_document("123", str(doc), caption="report") + + assert "Telegram document sent" in out + _, kwargs = fake_post.await_args + assert kwargs["data"] == {"chat_id": "123", "caption": "report"} + assert "document" in kwargs["files"] + + +def test_telegram_api_error_surfaces() -> None: + with pytest.raises(TelegramApiError, match=r"\[400\] Bad Request"): + _telegram_result( + {"ok": False, "error_code": 400, "description": "Bad Request"}, + "sendMessage", + ) diff --git a/plan/telegram_mcp_plan.md b/plan/telegram_mcp_plan.md new file mode 100644 index 0000000..9a038b3 --- /dev/null +++ b/plan/telegram_mcp_plan.md @@ -0,0 +1,211 @@ +**Plan: Telegram MCP สำหรับ thClaws Marketplace** + +เป้าหมายคือเพิ่ม `telegram-mcp` เข้า `thClaws/marketplace` เป็น MCP server ที่ให้ thClaws agent ติดต่อ Telegram ได้ โดยไม่แก้ core ของ `thClaws/thClaws` + +**Scope** +ทำภายใต้ repo `thClaws/marketplace`: + +```text +mcp/ + telegram-mcp/ + README.md + LICENSE + NOTICE.md + pyproject.toml + telegram_mcp/ + __init__.py + server.py + tests/ + test_server.py +``` + +MCP tools รอบแรก: + +```text +telegram_send_message(chat_id, text, parse_mode?) +telegram_send_photo(chat_id, file_path, caption?) +telegram_send_document(chat_id, file_path, caption?) +telegram_get_updates(limit?) +``` + +ยังไม่ทำ Telegram เป็น UI chat หลัก และยังไม่ทำ approval flow แบบรอ reply ใน PR แรก เพื่อลด scope และ security risk + +**Implementation Plan** +1. Fork และสร้าง branch + +```bash +git clone https://github.com//marketplace.git +cd marketplace +git checkout -b feat/telegram-mcp +``` + +2. สร้าง MCP package ใต้ `mcp/telegram-mcp/` + +ใช้ Python เป็นตัวเลือก pragmatic เพราะทำ HTTP ไป Telegram Bot API ง่ายและ test ง่าย + +`pyproject.toml` ควรมี: +- package name เช่น `thclaws-telegram-mcp` +- dependencies: MCP SDK หรือ JSON-RPC stdio implementation ที่เลือกใช้, `httpx` +- dev dependencies: `pytest`, `respx` หรือ mock HTTP equivalent + +3. Implement config ผ่าน environment variables เท่านั้น + +```text +TELEGRAM_BOT_TOKEN required +TELEGRAM_ALLOWED_CHAT_IDS required/recommended, comma-separated +TELEGRAM_API_BASE optional, default https://api.telegram.org +``` + +ห้าม hardcode token และห้ามใส่ token ใน README ตัวอย่างจริง + +4. Implement security checks + +ทุก tool ที่รับ `chat_id` ต้องตรวจว่าอยู่ใน `TELEGRAM_ALLOWED_CHAT_IDS` + +ถ้าไม่อยู่ ให้ return error ชัดเจน เช่น: + +```text +chat_id is not allowed by TELEGRAM_ALLOWED_CHAT_IDS +``` + +สำหรับ `send_photo` / `send_document`: +- ตรวจว่า `file_path` มีอยู่จริง +- หลีกเลี่ยงอ่าน path จาก URL ใน PR แรก +- จำกัดเป็น local file path เท่านั้น + +5. Implement MCP tools + +`telegram_send_message` +- input: `chat_id`, `text`, optional `parse_mode` +- call Telegram `sendMessage` +- return summary เช่น message id, chat id + +`telegram_send_photo` +- input: `chat_id`, `file_path`, optional `caption` +- call `sendPhoto` multipart upload + +`telegram_send_document` +- input: `chat_id`, `file_path`, optional `caption` +- call `sendDocument` multipart upload + +`telegram_get_updates` +- input: optional `limit`, optional `offset` +- call `getUpdates` +- return compact JSON/text summary +- ไม่ควร poll ถาวรใน PR แรก + +6. Add tests + +Test cases ขั้นต่ำ: +- missing `TELEGRAM_BOT_TOKEN` fails clearly +- allowed chat id passes +- disallowed chat id fails +- `send_message` builds correct Telegram API request +- `get_updates` handles Telegram API response +- file upload rejects missing file +- Telegram API error surfaces as MCP tool error + +7. Add README + +README ต้องมี: +- what it does +- install / local run +- `.thclaws/mcp.json` example +- environment variables +- tool list +- security notes +- limitations + +ตัวอย่าง `.thclaws/mcp.json`: + +```json +{ + "mcpServers": { + "telegram": { + "command": "python", + "args": ["-m", "telegram_mcp"], + "env": { + "TELEGRAM_BOT_TOKEN": "replace-with-your-token", + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789" + } + } + } +} +``` + +8. Add license files + +ตาม marketplace requirement: +- `LICENSE` เป็น Apache-2.0 หรือ MIT +- `NOTICE.md` ถ้าเป็น original work ให้เขียนสั้น ๆ ว่า original implementation for thClaws marketplace +- ถ้าเอา code จาก repo อื่น ต้องระบุ source/license/modifications ให้ครบ + +9. Local verification + +จาก root ของ `marketplace`: + +```bash +python -m pytest mcp/telegram-mcp/tests +python -m compileall mcp/telegram-mcp/telegram_mcp +``` + +ถ้ามี formatter/linter ใน repo ให้รันตามนั้นด้วย + +10. Commit + +```bash +git add mcp/telegram-mcp +git commit -m "feat: add Telegram MCP server" +git push -u origin feat/telegram-mcp +``` + +**PR Plan** +เปิด PR ไปที่: + +```text +base: thClaws/marketplace:main +compare: /marketplace:feat/telegram-mcp +``` + +PR title: + +```text +feat: add Telegram MCP server +``` + +PR description: + +```md +## Summary +Adds a Telegram MCP server under `mcp/telegram-mcp` so thClaws agents can send messages, photos, documents, and read updates through the Telegram Bot API. + +## Scope +- Adds `telegram_send_message` +- Adds `telegram_send_photo` +- Adds `telegram_send_document` +- Adds `telegram_get_updates` +- Uses environment variables for configuration +- Enforces `TELEGRAM_ALLOWED_CHAT_IDS` + +## Security +- Bot token is read only from `TELEGRAM_BOT_TOKEN` +- No secrets are committed +- Outbound messages are restricted to allowlisted chat IDs +- File uploads only support local paths in this first version + +## Testing +- [ ] `python -m pytest mcp/telegram-mcp/tests` +- [ ] `python -m compileall mcp/telegram-mcp/telegram_mcp` + +## Notes +This PR intentionally does not implement Telegram as a thClaws UI transport. It only adds Telegram as an MCP/tool surface for agent-initiated communication. +``` + +**Definition Of Done** +PR พร้อมส่งเมื่อ: +- MCP server รันผ่าน stdio ได้ +- tools ทั้ง 4 ทำงานกับ mocked Telegram API ใน tests +- README มีตัวอย่าง config ใช้งานกับ thClaws +- ไม่มี token/example secret จริงใน repo +- มี `LICENSE` และ `NOTICE.md` +- PR scope ไม่แตะ core และไม่รวม Telegram UI bridge ในรอบนี้ \ No newline at end of file From 2b20a30eb822d23ff13e8729a662741d5453bb63 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 10:22:55 +0700 Subject: [PATCH 2/9] bug fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit branch `feat/telegram-mcp` ปรับหลัก ๆ: - Telegram HTTP 400/403 จะ parse JSON body ก่อน `raise_for_status()` แล้ว surface `description` เช่น `chat not found` - SSE default bind จาก `0.0.0.0` เป็น `127.0.0.1`, override ได้ด้วย `MCP_HOST` - เพิ่ม `TELEGRAM_ALLOWED_FILE_ROOTS` สำหรับ sandbox ไฟล์ upload - เพิ่ม size guard: photo 10 MB, document 50 MB - `_telegram_result` คืน raw result แล้ว caller จัดการเอง - validate `parse_mode` และข้อความเกิน 4096 chars - `getUpdates` 409 webhook conflict จะบอก hint เรื่อง `deleteWebhook` - รองรับ update kinds เพิ่ม: `my_chat_member`, `chat_member`, `chat_join_request` - README เพิ่ม config/security notes และปรับ install command ไม่ hardcode upstream path ที่ยังไม่มีไฟล์ก่อน PR merge เพิ่ม tests จาก 10 เป็น 19 ตัว และผ่านทั้งหมด: ```bash PYTHONPATH=mcp/telegram-mcp/src python3 -m pytest mcp/telegram-mcp/tests # 19 passed ``` ไฟล์ที่แก้: - [server.py](/Users/seal/Documents/GitHub/thclaws-marketplace/mcp/telegram-mcp/src/telegram_mcp/server.py) - [test_server.py](/Users/seal/Documents/GitHub/thclaws-marketplace/mcp/telegram-mcp/tests/test_server.py) - [README.md](/Users/seal/Documents/GitHub/thclaws-marketplace/mcp/telegram-mcp/README.md) --- mcp/telegram-mcp/README.md | 17 +- mcp/telegram-mcp/src/telegram_mcp/server.py | 166 +++++++++++++++++--- mcp/telegram-mcp/tests/test_server.py | 126 ++++++++++++++- 3 files changed, 281 insertions(+), 28 deletions(-) diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index b36f70d..192d267 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -18,8 +18,8 @@ This is a tool surface, not a thClaws UI transport. Use it for notifications, es ### Local stdio ```bash -git clone https://github.com/thClaws/marketplace.git -cd marketplace/mcp/telegram-mcp +git clone https://github.com//.git +cd /mcp/telegram-mcp pip install -e . ``` @@ -32,7 +32,8 @@ Then add the server to `.thclaws/mcp.json` in your project, or to `~/.config/thc "command": "thclaws-telegram", "env": { "TELEGRAM_BOT_TOKEN": "replace-with-your-bot-token", - "TELEGRAM_ALLOWED_CHAT_IDS": "123456789" + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789", + "TELEGRAM_ALLOWED_FILE_ROOTS": "/absolute/path/to/reports" } } } @@ -49,7 +50,8 @@ You can also run it with Python directly: "args": ["-m", "telegram_mcp"], "env": { "TELEGRAM_BOT_TOKEN": "replace-with-your-bot-token", - "TELEGRAM_ALLOWED_CHAT_IDS": "123456789" + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789", + "TELEGRAM_ALLOWED_FILE_ROOTS": "/absolute/path/to/reports" } } } @@ -62,8 +64,10 @@ You can also run it with Python directly: |---|---|---| | `TELEGRAM_BOT_TOKEN` | Required Telegram bot token from BotFather | unset | | `TELEGRAM_ALLOWED_CHAT_IDS` | Required comma-separated allowlist of chat IDs | unset | +| `TELEGRAM_ALLOWED_FILE_ROOTS` | Required for file uploads; comma-separated directories the agent may send files from | unset | | `TELEGRAM_API_BASE` | Optional Telegram-compatible API base URL | `https://api.telegram.org` | | `MCP_TRANSPORT` | `stdio` for local install, `sse` for HTTP/SSE hosting | `stdio` | +| `MCP_HOST` | Host bind address for SSE transport | `127.0.0.1` | | `MCP_PORT` | Port for SSE transport | `8000` | ## Finding Your Chat ID @@ -85,8 +89,11 @@ For group chats, Telegram chat IDs are usually negative. Keep the minus sign in - The bot token is read only from `TELEGRAM_BOT_TOKEN`. - No secrets should be committed into `mcp.json`, README examples, screenshots, or tests. - Every tool is constrained by `TELEGRAM_ALLOWED_CHAT_IDS`. -- File upload tools only accept local file paths. URL-based uploads are intentionally not supported in this first version. +- File upload tools only accept local file paths under `TELEGRAM_ALLOWED_FILE_ROOTS`. URL-based uploads are intentionally not supported in this first version. +- File upload tools check Telegram's size limits before upload: photos max 10 MB, documents max 50 MB. +- Text messages are limited to Telegram's 4096-character message limit. - `telegram_get_updates` filters output to allowlisted chats before returning data to the agent. +- SSE transport binds to `127.0.0.1` by default. If you set `MCP_HOST=0.0.0.0`, put the server behind network-level access control; this MCP exposes Telegram actions backed by your bot token. - Telegram messages may leave your local environment and be stored by Telegram. Do not send secrets unless your operational policy allows it. - Do not use this MCP server to publish another person's private information without their explicit permission. diff --git a/mcp/telegram-mcp/src/telegram_mcp/server.py b/mcp/telegram-mcp/src/telegram_mcp/server.py index 2603eb8..03415c2 100644 --- a/mcp/telegram-mcp/src/telegram_mcp/server.py +++ b/mcp/telegram-mcp/src/telegram_mcp/server.py @@ -16,6 +16,10 @@ from mcp.server.fastmcp import FastMCP DEFAULT_API_BASE = "https://api.telegram.org" +MAX_MESSAGE_CHARS = 4096 +MAX_PHOTO_BYTES = 10 * 1024 * 1024 +MAX_DOCUMENT_BYTES = 50 * 1024 * 1024 +PARSE_MODES = {"MarkdownV2", "HTML", "Markdown"} class TelegramConfigError(RuntimeError): @@ -68,24 +72,82 @@ def _api_url(method: str) -> str: return f"{_api_base()}/bot{_token()}/{method}" -def _telegram_result(data: dict[str, Any], method: str) -> dict[str, Any]: +def _telegram_result(data: dict[str, Any], method: str) -> Any: if data.get("ok") is not True: description = data.get("description") or "unknown Telegram API error" error_code = data.get("error_code") + if method == "getUpdates" and error_code == 409: + description = ( + f"{description}. Telegram returned a conflict while polling " + "updates; if this bot has a webhook configured, call " + "deleteWebhook before using telegram_get_updates." + ) if error_code is None: raise TelegramApiError(f"{method}: {description}") raise TelegramApiError(f"{method}: [{error_code}] {description}") - result = data.get("result") - if isinstance(result, dict): + return data.get("result") + + +async def _telegram_response(resp: httpx.Response, method: str) -> Any: + """Parse Telegram JSON before raising HTTP status errors. + + Telegram returns useful API errors such as "chat not found" inside a + JSON body that may arrive with HTTP 400/403. If we call + raise_for_status() first, that description is lost. + """ + try: + data = resp.json() + except ValueError: + resp.raise_for_status() + raise TelegramApiError(f"{method}: response was not valid JSON") + if isinstance(data, dict): + result = _telegram_result(data, method) + resp.raise_for_status() return result - return {"result": result} + resp.raise_for_status() + raise TelegramApiError(f"{method}: response JSON was not an object") -async def _post_json(method: str, payload: dict[str, Any]) -> dict[str, Any]: +async def _post_json(method: str, payload: dict[str, Any]) -> Any: async with httpx.AsyncClient() as client: resp = await client.post(_api_url(method), json=payload, timeout=20.0) - resp.raise_for_status() - return _telegram_result(resp.json(), method) + return await _telegram_response(resp, method) + + +def _allowed_file_roots() -> list[Path]: + raw = os.environ.get("TELEGRAM_ALLOWED_FILE_ROOTS", "") + roots: list[Path] = [] + for item in raw.split(","): + item = item.strip() + if not item: + continue + root = Path(item).expanduser().resolve() + if not root.is_dir(): + raise TelegramConfigError( + f"TELEGRAM_ALLOWED_FILE_ROOTS entry is not a directory: {item}" + ) + roots.append(root) + if not roots: + raise TelegramConfigError( + "TELEGRAM_ALLOWED_FILE_ROOTS is required for file uploads; " + "set a comma-separated list of directories the agent may send from" + ) + return roots + + +def _resolve_allowed_file(file_path: str) -> Path: + if str(file_path).startswith(("http://", "https://")): + raise TelegramConfigError("file_path must be a local path, not a URL") + path = Path(file_path).expanduser() + if not path.exists() or not path.is_file(): + raise TelegramConfigError(f"file_path does not exist or is not a file: {file_path}") + resolved = path.resolve() + for root in _allowed_file_roots(): + if resolved == root or root in resolved.parents: + return resolved + raise TelegramConfigError( + f"file_path is outside TELEGRAM_ALLOWED_FILE_ROOTS: {file_path}" + ) async def _post_file( @@ -94,12 +156,15 @@ async def _post_file( chat_id: str, file_path: str, caption: str | None, -) -> dict[str, Any]: - path = Path(file_path).expanduser() - if str(file_path).startswith(("http://", "https://")): - raise TelegramConfigError("file_path must be a local path, not a URL") - if not path.is_file(): - raise TelegramConfigError(f"file_path does not exist or is not a file: {file_path}") + max_bytes: int, +) -> Any: + path = _resolve_allowed_file(file_path) + size = path.stat().st_size + if size > max_bytes: + raise TelegramConfigError( + f"file_path is too large for {method}: {size} bytes " + f"(max {max_bytes} bytes)" + ) data: dict[str, str] = {"chat_id": chat_id} if caption: @@ -114,8 +179,26 @@ async def _post_file( files=files, timeout=60.0, ) - resp.raise_for_status() - return _telegram_result(resp.json(), method) + return await _telegram_response(resp, method) + + +def _validate_message_text(text: str) -> str: + if not text: + raise TelegramConfigError("text is required") + if len(text) > MAX_MESSAGE_CHARS: + raise TelegramConfigError( + f"text exceeds Telegram's {MAX_MESSAGE_CHARS}-character message limit" + ) + return text + + +def _validate_parse_mode(parse_mode: str | None) -> str | None: + if parse_mode is None or parse_mode == "": + return None + if parse_mode not in PARSE_MODES: + allowed = ", ".join(sorted(PARSE_MODES)) + raise TelegramConfigError(f"parse_mode must be one of: {allowed}") + return parse_mode def _message_summary(result: dict[str, Any]) -> str: @@ -138,6 +221,12 @@ def _chat_id_from_update(update: dict[str, Any]) -> str | None: chat = msg.get("chat") if isinstance(chat, dict) and chat.get("id") is not None: return str(chat["id"]) + for key in ("my_chat_member", "chat_member", "chat_join_request"): + item = update.get(key) + if isinstance(item, dict): + chat = item.get("chat") + if isinstance(chat, dict) and chat.get("id") is not None: + return str(chat["id"]) callback = update.get("callback_query") if isinstance(callback, dict): msg = callback.get("message") @@ -169,6 +258,22 @@ def _compact_update(update: dict[str, Any]) -> dict[str, Any]: ) return compact + for key in ("my_chat_member", "chat_member", "chat_join_request"): + item = update.get(key) + if not isinstance(item, dict): + continue + chat = item.get("chat") if isinstance(item.get("chat"), dict) else {} + sender = item.get("from") if isinstance(item.get("from"), dict) else {} + compact.update( + { + "kind": key, + "chat_id": chat.get("id"), + "chat_type": chat.get("type"), + "from_username": sender.get("username"), + } + ) + return compact + callback = update.get("callback_query") if isinstance(callback, dict): compact.update( @@ -208,10 +313,16 @@ async def telegram_send_message( parse_mode: Optional Telegram parse mode, e.g. "MarkdownV2" or "HTML". """ allowed_chat_id = _check_chat_allowed(chat_id) - payload: dict[str, Any] = {"chat_id": allowed_chat_id, "text": text} - if parse_mode: - payload["parse_mode"] = parse_mode + payload: dict[str, Any] = { + "chat_id": allowed_chat_id, + "text": _validate_message_text(text), + } + validated_parse_mode = _validate_parse_mode(parse_mode) + if validated_parse_mode: + payload["parse_mode"] = validated_parse_mode result = await _post_json("sendMessage", payload) + if not isinstance(result, dict): + raise TelegramApiError("sendMessage: result was not an object") return f"Telegram message sent: {_message_summary(result)}" @@ -229,7 +340,16 @@ async def telegram_send_photo( caption: Optional caption. """ allowed_chat_id = _check_chat_allowed(chat_id) - result = await _post_file("sendPhoto", "photo", allowed_chat_id, file_path, caption) + result = await _post_file( + "sendPhoto", + "photo", + allowed_chat_id, + file_path, + caption, + MAX_PHOTO_BYTES, + ) + if not isinstance(result, dict): + raise TelegramApiError("sendPhoto: result was not an object") return f"Telegram photo sent: {_message_summary(result)}" @@ -253,7 +373,10 @@ async def telegram_send_document( allowed_chat_id, file_path, caption, + MAX_DOCUMENT_BYTES, ) + if not isinstance(result, dict): + raise TelegramApiError("sendDocument: result was not an object") return f"Telegram document sent: {_message_summary(result)}" @@ -271,8 +394,7 @@ async def telegram_get_updates(limit: int = 10, offset: int | None = None) -> st if offset is not None: payload["offset"] = int(offset) - result = await _post_json("getUpdates", payload) - updates = result.get("result") if "result" in result else result + updates = await _post_json("getUpdates", payload) if not isinstance(updates, list): updates = [] @@ -293,7 +415,7 @@ def main() -> None: transport = os.environ.get("MCP_TRANSPORT", "stdio") if transport == "sse": port = int(os.environ.get("MCP_PORT", "8000")) - mcp.settings.host = "0.0.0.0" + mcp.settings.host = os.environ.get("MCP_HOST", "127.0.0.1") mcp.settings.port = port mcp.run(transport="sse") else: diff --git a/mcp/telegram-mcp/tests/test_server.py b/mcp/telegram-mcp/tests/test_server.py index 1b95818..f72c371 100644 --- a/mcp/telegram-mcp/tests/test_server.py +++ b/mcp/telegram-mcp/tests/test_server.py @@ -9,14 +9,19 @@ from pathlib import Path from unittest.mock import AsyncMock, patch +import httpx import pytest from telegram_mcp.server import ( TelegramApiError, TelegramConfigError, _api_url, + _chat_id_from_update, _check_chat_allowed, + _compact_update, + _telegram_response, _telegram_result, + main, telegram_get_updates, telegram_send_document, telegram_send_message, @@ -90,6 +95,19 @@ async def test_send_message_builds_telegram_request() -> None: assert kwargs["timeout"] == 20.0 +@pytest.mark.asyncio +async def test_telegram_http_error_body_is_preserved() -> None: + request = httpx.Request("POST", "https://api.telegram.org/bottest/sendMessage") + response = httpx.Response( + 400, + json={"ok": False, "error_code": 400, "description": "chat not found"}, + request=request, + ) + + with pytest.raises(TelegramApiError, match="chat not found"): + await _telegram_response(response, "sendMessage") + + @pytest.mark.asyncio async def test_get_updates_filters_to_allowed_chats() -> None: fake_post = AsyncMock( @@ -134,6 +152,23 @@ async def test_get_updates_filters_to_allowed_chats() -> None: assert kwargs["json"] == {"limit": 100, "timeout": 0, "offset": 3} +@pytest.mark.asyncio +async def test_get_updates_webhook_conflict_has_hint() -> None: + request = httpx.Request("POST", "https://api.telegram.org/bottest/getUpdates") + response = httpx.Response( + 409, + json={ + "ok": False, + "error_code": 409, + "description": "Conflict: can't use getUpdates method while webhook is active", + }, + request=request, + ) + + with pytest.raises(TelegramApiError, match="deleteWebhook"): + await _telegram_response(response, "getUpdates") + + @pytest.mark.asyncio async def test_file_upload_rejects_missing_file() -> None: with pytest.raises(TelegramConfigError, match="does not exist"): @@ -147,9 +182,41 @@ async def test_file_upload_rejects_url() -> None: @pytest.mark.asyncio -async def test_send_document_uploads_local_file(tmp_path: Path) -> None: +async def test_file_upload_rejects_path_outside_allowed_roots( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + allowed = tmp_path / "allowed" + outside = tmp_path / "outside" + allowed.mkdir() + outside.mkdir() + doc = outside / "secret.txt" + doc.write_text("secret", encoding="utf-8") + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(allowed)) + + with pytest.raises(TelegramConfigError, match="outside TELEGRAM_ALLOWED_FILE_ROOTS"): + await telegram_send_document("123", str(doc)) + + +@pytest.mark.asyncio +async def test_file_upload_rejects_oversized_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: doc = tmp_path / "report.txt" doc.write_text("hello", encoding="utf-8") + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(tmp_path)) + monkeypatch.setattr("telegram_mcp.server.MAX_DOCUMENT_BYTES", 3) + + with pytest.raises(TelegramConfigError, match="too large"): + await telegram_send_document("123", str(doc)) + + +@pytest.mark.asyncio +async def test_send_document_uploads_local_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + doc = tmp_path / "report.txt" + doc.write_text("hello", encoding="utf-8") + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(tmp_path)) fake_post = AsyncMock( return_value=_fake_response( { @@ -173,9 +240,66 @@ async def test_send_document_uploads_local_file(tmp_path: Path) -> None: assert "document" in kwargs["files"] +@pytest.mark.asyncio +async def test_send_message_rejects_invalid_parse_mode() -> None: + with pytest.raises(TelegramConfigError, match="parse_mode"): + await telegram_send_message("123", "hello", parse_mode="NotAMode") + + +@pytest.mark.asyncio +async def test_send_message_rejects_too_long_text() -> None: + with pytest.raises(TelegramConfigError, match="4096"): + await telegram_send_message("123", "x" * 4097) + + def test_telegram_api_error_surfaces() -> None: with pytest.raises(TelegramApiError, match=r"\[400\] Bad Request"): _telegram_result( {"ok": False, "error_code": 400, "description": "Bad Request"}, "sendMessage", ) + + +def test_update_chat_id_supports_membership_updates() -> None: + update = { + "update_id": 100, + "chat_join_request": { + "chat": {"id": -456, "type": "supergroup"}, + "from": {"username": "reviewer"}, + }, + } + + assert _chat_id_from_update(update) == "-456" + compact = _compact_update(update) + assert compact["kind"] == "chat_join_request" + assert compact["chat_id"] == -456 + + +def test_compact_update_callback_query_branch() -> None: + compact = _compact_update( + { + "update_id": 101, + "callback_query": { + "id": "cb-1", + "from": {"username": "alice"}, + "data": "approve", + }, + } + ) + + assert compact["kind"] == "callback_query" + assert compact["callback_query_id"] == "cb-1" + assert compact["data"] == "approve" + + +def test_sse_transport_binds_localhost_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MCP_TRANSPORT", "sse") + monkeypatch.delenv("MCP_HOST", raising=False) + + with patch("telegram_mcp.server.mcp.run") as run: + main() + + run.assert_called_once_with(transport="sse") + from telegram_mcp.server import mcp + + assert mcp.settings.host == "127.0.0.1" From 3e92a8dce9382b47199840f9652b7a799c67de2b Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 10:26:44 +0700 Subject: [PATCH 3/9] Update pyproject.toml --- mcp/telegram-mcp/pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mcp/telegram-mcp/pyproject.toml b/mcp/telegram-mcp/pyproject.toml index 0b79904..ee15c04 100644 --- a/mcp/telegram-mcp/pyproject.toml +++ b/mcp/telegram-mcp/pyproject.toml @@ -34,9 +34,9 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/thClaws/marketplace/tree/main/mcp/telegram-mcp" -Repository = "https://github.com/thClaws/marketplace" -Issues = "https://github.com/thClaws/marketplace/issues" +Homepage = "https://github.com/kaebmoo/thclaws-marketplace/tree/feat/telegram-mcp/mcp/telegram-mcp" +Repository = "https://github.com/kaebmoo/thclaws-marketplace" +Issues = "https://github.com/kaebmoo/thclaws-marketplace/issues" [project.scripts] thclaws-telegram = "telegram_mcp.server:main" From 77cb6c5ac4ca69e3e140525c08b79fa3895937d1 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 13:24:21 +0700 Subject: [PATCH 4/9] improve file roots, open allowed file --- mcp/telegram-mcp/src/telegram_mcp/server.py | 57 ++++++++++++++++----- mcp/telegram-mcp/tests/test_server.py | 42 +++++++++++++++ 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/mcp/telegram-mcp/src/telegram_mcp/server.py b/mcp/telegram-mcp/src/telegram_mcp/server.py index 03415c2..6be7520 100644 --- a/mcp/telegram-mcp/src/telegram_mcp/server.py +++ b/mcp/telegram-mcp/src/telegram_mcp/server.py @@ -9,8 +9,9 @@ import json import os +import stat from pathlib import Path -from typing import Any +from typing import Any, BinaryIO import httpx from mcp.server.fastmcp import FastMCP @@ -20,6 +21,7 @@ MAX_PHOTO_BYTES = 10 * 1024 * 1024 MAX_DOCUMENT_BYTES = 50 * 1024 * 1024 PARSE_MODES = {"MarkdownV2", "HTML", "Markdown"} +_ALLOWED_FILE_ROOTS_CACHE: tuple[str, tuple[Path, ...]] | None = None class TelegramConfigError(RuntimeError): @@ -114,8 +116,12 @@ async def _post_json(method: str, payload: dict[str, Any]) -> Any: return await _telegram_response(resp, method) -def _allowed_file_roots() -> list[Path]: +def _allowed_file_roots() -> tuple[Path, ...]: + global _ALLOWED_FILE_ROOTS_CACHE raw = os.environ.get("TELEGRAM_ALLOWED_FILE_ROOTS", "") + if _ALLOWED_FILE_ROOTS_CACHE and _ALLOWED_FILE_ROOTS_CACHE[0] == raw: + return _ALLOWED_FILE_ROOTS_CACHE[1] + roots: list[Path] = [] for item in raw.split(","): item = item.strip() @@ -132,16 +138,21 @@ def _allowed_file_roots() -> list[Path]: "TELEGRAM_ALLOWED_FILE_ROOTS is required for file uploads; " "set a comma-separated list of directories the agent may send from" ) - return roots + _ALLOWED_FILE_ROOTS_CACHE = (raw, tuple(roots)) + return _ALLOWED_FILE_ROOTS_CACHE[1] def _resolve_allowed_file(file_path: str) -> Path: if str(file_path).startswith(("http://", "https://")): raise TelegramConfigError("file_path must be a local path, not a URL") - path = Path(file_path).expanduser() - if not path.exists() or not path.is_file(): + try: + resolved = Path(file_path).expanduser().resolve(strict=True) + except FileNotFoundError as exc: + raise TelegramConfigError(f"file_path does not exist or is not a file: {file_path}") + except OSError as exc: + raise TelegramConfigError(f"file_path cannot be resolved: {file_path}: {exc}") from exc + if not resolved.is_file(): raise TelegramConfigError(f"file_path does not exist or is not a file: {file_path}") - resolved = path.resolve() for root in _allowed_file_roots(): if resolved == root or root in resolved.parents: return resolved @@ -150,6 +161,30 @@ def _resolve_allowed_file(file_path: str) -> Path: ) +def _open_allowed_file(file_path: str, max_bytes: int) -> tuple[Path, BinaryIO]: + path = _resolve_allowed_file(file_path) + flags = os.O_RDONLY + flags |= getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(path, flags) + except OSError as exc: + raise TelegramConfigError(f"file_path cannot be opened safely: {file_path}: {exc}") from exc + + try: + opened_stat = os.fstat(fd) + if not stat.S_ISREG(opened_stat.st_mode): + raise TelegramConfigError(f"file_path is not a regular file: {file_path}") + if opened_stat.st_size > max_bytes: + raise TelegramConfigError( + f"file_path is too large: {opened_stat.st_size} bytes " + f"(max {max_bytes} bytes)" + ) + return path, os.fdopen(fd, "rb") + except Exception: + os.close(fd) + raise + + async def _post_file( method: str, file_field: str, @@ -158,19 +193,13 @@ async def _post_file( caption: str | None, max_bytes: int, ) -> Any: - path = _resolve_allowed_file(file_path) - size = path.stat().st_size - if size > max_bytes: - raise TelegramConfigError( - f"file_path is too large for {method}: {size} bytes " - f"(max {max_bytes} bytes)" - ) + path, handle = _open_allowed_file(file_path, max_bytes) data: dict[str, str] = {"chat_id": chat_id} if caption: data["caption"] = caption - with path.open("rb") as handle: + with handle: files = {file_field: (path.name, handle)} async with httpx.AsyncClient() as client: resp = await client.post( diff --git a/mcp/telegram-mcp/tests/test_server.py b/mcp/telegram-mcp/tests/test_server.py index f72c371..aba2353 100644 --- a/mcp/telegram-mcp/tests/test_server.py +++ b/mcp/telegram-mcp/tests/test_server.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +import os from pathlib import Path from unittest.mock import AsyncMock, patch @@ -15,10 +16,12 @@ from telegram_mcp.server import ( TelegramApiError, TelegramConfigError, + _allowed_file_roots, _api_url, _chat_id_from_update, _check_chat_allowed, _compact_update, + _open_allowed_file, _telegram_response, _telegram_result, main, @@ -64,6 +67,23 @@ def test_missing_allowlist_fails(monkeypatch: pytest.MonkeyPatch) -> None: _check_chat_allowed("123") +def test_allowed_file_roots_cache_tracks_env_changes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + first.mkdir() + second.mkdir() + + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(first)) + assert _allowed_file_roots() == (first.resolve(),) + # Same env value returns the cached tuple. + assert _allowed_file_roots() == (first.resolve(),) + + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(second)) + assert _allowed_file_roots() == (second.resolve(),) + + @pytest.mark.asyncio async def test_send_message_builds_telegram_request() -> None: fake_post = AsyncMock( @@ -197,6 +217,28 @@ async def test_file_upload_rejects_path_outside_allowed_roots( await telegram_send_document("123", str(doc)) +def test_open_allowed_file_uses_nofollow_when_available( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + doc = tmp_path / "report.txt" + doc.write_text("hello", encoding="utf-8") + monkeypatch.setenv("TELEGRAM_ALLOWED_FILE_ROOTS", str(tmp_path)) + real_open = os.open + seen: dict[str, int] = {} + + def recording_open(path: Path, flags: int) -> int: + seen["flags"] = flags + return real_open(path, flags) + + with patch("telegram_mcp.server.os.open", side_effect=recording_open): + _, handle = _open_allowed_file(str(doc), 100) + handle.close() + + nofollow = getattr(os, "O_NOFOLLOW", 0) + if nofollow: + assert seen["flags"] & nofollow + + @pytest.mark.asyncio async def test_file_upload_rejects_oversized_document( tmp_path: Path, monkeypatch: pytest.MonkeyPatch From 8065a32d8cb871ddc14f6bc5cadfb71b173e94e4 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 18:41:19 +0700 Subject: [PATCH 5/9] docs: document Telegram MCP env setup --- .gitignore | 4 ++++ mcp/telegram-mcp/.env.example | 3 +++ mcp/telegram-mcp/README.md | 22 ++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 mcp/telegram-mcp/.env.example diff --git a/.gitignore b/.gitignore index d077e45..833b622 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ /.DS_Store +.env +.env.* +!.env.example /mcp/telegram-mcp/src/telegram_mcp/__pycache__ *.pyc +mcp/telegram-mcp/src/telegram_mcp/.env diff --git a/mcp/telegram-mcp/.env.example b/mcp/telegram-mcp/.env.example new file mode 100644 index 0000000..126ff9d --- /dev/null +++ b/mcp/telegram-mcp/.env.example @@ -0,0 +1,3 @@ +TELEGRAM_BOT_TOKEN=replace-with-your-bot-token +TELEGRAM_ALLOWED_CHAT_IDS=123456789 +TELEGRAM_ALLOWED_FILE_ROOTS=/absolute/path/to/reports diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index 192d267..300ab01 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -70,6 +70,28 @@ You can also run it with Python directly: | `MCP_HOST` | Host bind address for SSE transport | `127.0.0.1` | | `MCP_PORT` | Port for SSE transport | `8000` | +### Local `.env` + +The server reads configuration from process environment variables. It does not parse `.env` files by itself. + +For local development, keep a private `.env` at the package root: + +```text +mcp/telegram-mcp/.env +``` + +Use [.env.example](./.env.example) as the template, then export it before running the server: + +```bash +cd mcp/telegram-mcp +set -a +source .env +set +a +thclaws-telegram +``` + +Do not put `.env` under `src/telegram_mcp/`, and do not commit it. + ## Finding Your Chat ID 1. Create a bot with Telegram BotFather and copy its token. From 83754c49090796972a887b8c6edf17c38e2df315 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 18:52:52 +0700 Subject: [PATCH 6/9] docs: add Telegram MCP smoke test --- mcp/telegram-mcp/README.md | 57 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index 300ab01..f31d43d 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -70,6 +70,28 @@ You can also run it with Python directly: | `MCP_HOST` | Host bind address for SSE transport | `127.0.0.1` | | `MCP_PORT` | Port for SSE transport | `8000` | +## Transport and Ports + +By default this server uses MCP over stdio: + +```text +MCP_TRANSPORT=stdio +``` + +In stdio mode it does not open a network port. thClaws starts the process and communicates over stdin/stdout. + +Only SSE mode opens a port: + +```bash +MCP_TRANSPORT=sse MCP_HOST=127.0.0.1 MCP_PORT=8000 thclaws-telegram +``` + +The default SSE endpoint is: + +```text +http://127.0.0.1:8000/sse +``` + ### Local `.env` The server reads configuration from process environment variables. It does not parse `.env` files by itself. @@ -92,6 +114,41 @@ thclaws-telegram Do not put `.env` under `src/telegram_mcp/`, and do not commit it. +## Test Without thClaws + +You can smoke-test the MCP server directly from this package directory. + +First export your local environment: + +```bash +cd mcp/telegram-mcp +set -a +source .env +set +a +``` + +Then list tools over stdio: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-smoke","version":"0.1.0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \ +| ./.venv/bin/python -m telegram_mcp +``` + +To send a real Telegram message without thClaws, replace `123456789` with an allowlisted chat ID: + +```bash +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-smoke","version":"0.1.0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"telegram_send_message","arguments":{"chat_id":"123456789","text":"Telegram MCP smoke test"}}}' \ +| ./.venv/bin/python -m telegram_mcp +``` + +Running `./.venv/bin/python -m telegram_mcp` by itself is also valid, but it will wait silently for MCP JSON-RPC messages on stdin. + ## Finding Your Chat ID 1. Create a bot with Telegram BotFather and copy its token. From da60ee4f65f8d756f479f0283bfb419ae04acca6 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 19:01:59 +0700 Subject: [PATCH 7/9] docs: keep stdio open during Telegram smoke call --- mcp/telegram-mcp/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index f31d43d..53ee204 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -140,10 +140,11 @@ printf '%s\n' \ To send a real Telegram message without thClaws, replace `123456789` with an allowlisted chat ID: ```bash -printf '%s\n' \ +{ printf '%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"manual-smoke","version":"0.1.0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \ - '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"telegram_send_message","arguments":{"chat_id":"123456789","text":"Telegram MCP smoke test"}}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"telegram_send_message","arguments":{"chat_id":"123456789","text":"Telegram MCP smoke test"}}}'; \ + sleep 5; } \ | ./.venv/bin/python -m telegram_mcp ``` From ec1eb284bf330dcb35978177b97444ee784233db Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 19:18:10 +0700 Subject: [PATCH 8/9] docs: explain thClaws MCP config --- mcp/telegram-mcp/README.md | 45 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index 53ee204..15e1fac 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -58,6 +58,51 @@ You can also run it with Python directly: } ``` +## Use With thClaws + +For normal thClaws usage, do not run this MCP server manually. Configure it in the project where you want the Telegram tools available, and thClaws will spawn the server process over stdio. + +Project-local config: + +```text +/.thclaws/mcp.json +``` + +Example: + +```json +{ + "mcpServers": { + "telegram": { + "command": "/absolute/path/to/thclaws-marketplace/mcp/telegram-mcp/.venv/bin/python", + "args": ["-m", "telegram_mcp"], + "env": { + "TELEGRAM_BOT_TOKEN": "replace-with-your-bot-token", + "TELEGRAM_ALLOWED_CHAT_IDS": "123456789", + "TELEGRAM_ALLOWED_FILE_ROOTS": "/absolute/path/to/your-project" + } + } + } +} +``` + +Then start thClaws from that project root: + +```bash +cd +thclaws +``` + +The server is spawned automatically. You only need to run `python -m telegram_mcp` yourself for local MCP smoke tests or SSE hosting. + +If you want the Telegram tools available in every project, put the same config in: + +```text +~/.config/thclaws/mcp.json +``` + +Avoid committing project-local MCP config files that contain real bot tokens. + ## Configuration | Env var | Purpose | Default | From 372f0ed52c027417cf1e2ff5e83c253df6cbc766 Mon Sep 17 00:00:00 2001 From: kaebmoo Date: Thu, 7 May 2026 19:28:54 +0700 Subject: [PATCH 9/9] docs: clarify Telegram MCP scope --- mcp/telegram-mcp/README.md | 86 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/mcp/telegram-mcp/README.md b/mcp/telegram-mcp/README.md index 15e1fac..f621892 100644 --- a/mcp/telegram-mcp/README.md +++ b/mcp/telegram-mcp/README.md @@ -4,6 +4,32 @@ Telegram Bot API MCP server for thClaws agents. It lets an agent send messages, This is a tool surface, not a thClaws UI transport. Use it for notifications, escalation, and sending artifacts after work completes. +## Scope + +This MCP server lets thClaws agents contact Telegram. It is useful for: + +- notifying a user when work finishes or fails +- sending build/test/deploy summaries +- sending generated reports, logs, photos, or documents +- escalating blockers to an allowlisted chat +- manually reading recent bot updates with `telegram_get_updates` + +It does not make Telegram a live thClaws chat UI. A Telegram user cannot send a message and have thClaws automatically continue the active session unless the agent explicitly calls `telegram_get_updates` or a separate bridge is built. + +Current flow: + +```text +thClaws agent -> Telegram MCP tool -> Telegram Bot API -> Telegram chat +``` + +Not implemented here: + +```text +Telegram user -> Telegram bot -> thClaws session -> Telegram reply +``` + +Keeping this first version as an MCP tool keeps the security surface small and avoids changing thClaws core. + ## Tools | Tool | Args | Returns | @@ -243,6 +269,66 @@ Generate the report, then send the PDF to chat 123456789 over Telegram. - It does not accept remote file URLs for uploads. - It does not manage Telegram webhooks. +## Future Work + +### Add Reply-Oriented Tools + +The next small step would still fit inside this MCP server: + +- `telegram_wait_for_reply(chat_id, prompt, timeout_seconds)` +- `telegram_ask_user(chat_id, question, timeout_seconds)` +- `telegram_ack_update(offset)` or helper state for update offsets + +These tools would let an agent ask a specific question and wait for a reply without turning Telegram into a full UI. Design points: + +- how to persist or pass Telegram `update_id` offsets +- timeout behavior +- whether replies must come from a specific user or any allowlisted chat member +- how to avoid consuming unrelated messages from a busy group +- how to phrase prompts so users know they are responding to an agent + +### Build a Telegram UI Bridge + +A full Telegram chat interface should be a separate bridge or a thClaws core feature, not an MCP tool. The bridge would receive Telegram updates continuously and forward user messages into a thClaws session. + +Possible architecture: + +```text +Telegram user + -> Telegram bot webhook or polling bridge + -> thclaws --serve WebSocket /ws + -> thClaws SharedSession + -> bridge sends assistant output back to Telegram +``` + +Key design decisions: + +- map `chat_id` to project root and thClaws session +- decide whether each chat has one persistent session or can create/load sessions +- authenticate and allowlist users/chats +- handle groups, mentions, replies, and accidental messages +- implement cancel/new-session controls +- handle approval prompts safely +- chunk long assistant output for Telegram message limits +- decide how files and images map between Telegram and thClaws +- manage polling/webhook lifecycle and reconnects +- prevent prompt injection from untrusted group messages +- define logs and audit behavior without leaking private chat data + +Security cautions: + +- a UI bridge is a remote-control surface for thClaws, not just a notification tool +- any chat that reaches the bridge may cause tools to run on the host machine +- group chats are higher risk than one-on-one chats +- use explicit allowlists and least-privilege file roots +- keep bot tokens out of git, screenshots, and issue comments + +Recommended path: + +1. Use this MCP server first as a notification and artifact-sending tool. +2. Add reply-oriented tools only if real workflows need them. +3. Build a Telegram UI bridge only after the session/auth/security model is clear. + ## License Apache-2.0 - see [LICENSE](./LICENSE).