From 494c5b77c1019463b8e0479523499a138a365b04 Mon Sep 17 00:00:00 2001
From: speech115 <201258852+speech115@users.noreply.github.com>
Date: Fri, 26 Jun 2026 19:09:35 +0400
Subject: [PATCH] Fix Telegram MCP error boundaries
---
README.md | 101 ++++--
control-plane/.gitignore | 1 +
control-plane/tests/test_command_registry.py | 4 +-
control-plane/tests/test_surface_docs.py | 14 +-
docs/operator-workflows.md | 63 ++--
mcp/src/telegram_mcp/__init__.py | 2 +
mcp/src/telegram_mcp/approval_server.py | 79 ++++-
mcp/src/telegram_mcp/client_groups.py | 10 +-
mcp/src/telegram_mcp/errors.py | 40 +++
mcp/src/telegram_mcp/fast_read_today.py | 189 ++--------
mcp/src/telegram_mcp/mcp_http_client.py | 57 ++-
mcp/src/telegram_mcp/mcp_prewarm.py | 2 +-
mcp/src/telegram_mcp/member_export_paths.py | 4 +
mcp/src/telegram_mcp/runtime.py | 8 +-
mcp/src/telegram_mcp/send_confirmation.py | 162 +++++----
mcp/src/telegram_mcp/tg_cli.py | 335 ++++++++----------
mcp/src/telegram_mcp/tools/__init__.py | 1 -
.../telegram_mcp/tools/dialog_facade_tools.py | 16 +-
mcp/src/telegram_mcp/tools/group_tools.py | 18 +-
mcp/src/telegram_mcp/tools/media_tools.py | 2 -
mcp/tests/test_dialog_facade_tools.py | 28 ++
mcp/tests/test_fast_read_today.py | 31 ++
mcp/tests/test_mcp_http_client.py | 64 +++-
mcp/tests/test_member_export_paths.py | 14 +
mcp/tests/test_registration.py | 1 +
mcp/tests/test_runtime.py | 27 ++
mcp/tests/test_send_confirmation.py | 63 +++-
mcp/tests/test_tg_cli.py | 47 ++-
28 files changed, 836 insertions(+), 547 deletions(-)
diff --git a/README.md b/README.md
index ddaa723..78e3ef5 100644
--- a/README.md
+++ b/README.md
@@ -18,33 +18,35 @@
> Community-maintained integration. Not an official Telegram product or
> Telegram LLC publication.
-Telegram Plugin packages a working local stack for agent-safe Telegram access:
+Telegram Plugin packages a working local stack for owner-local Telegram access:
a Telethon-backed MCP server, a Codex plugin bundle, and an optional
control-plane for local audits and repair planning.
-Most Telegram automation tools expose too much too quickly. This project takes
-the opposite position: agents should start with a narrow read/search/context
-surface, clear source routing, drift checks, and explicit boundaries around
-sessions, media, archives, and write actions.
+Most Telegram automation tools hide their safety boundary. This project takes
+the opposite position: the owner-local full MCP surface is explicit, the
+restricted facade profile is still available for narrow compatibility, and
+control-plane checks make drift visible.
## Why It Exists
-Use this project when you want an AI coding agent to inspect Telegram context
-without handing it the whole account by default.
+Use this project when you want an AI coding agent to work with a local Telegram
+account under an explicit owner-controlled MCP contract.
- **Local-first:** Telegram credentials and sessions stay on your machine.
-- **Narrow by default:** Default Mode reads, searches, collects context, drafts,
- previews, downloads scoped media, and transcribes voice locally.
-- **Explicit writes:** sending, admin actions, profile changes, and broad export
- workflows require separate Power Mode or operator wiring.
+- **Owner-local full surface:** `plugin/.mcp.json` points at the owner-local
+ MCP daemons and intentionally does not use a legacy `allowedTools` allowlist.
+- **Restricted facade available:** set `TELEGRAM_MCP_TOOL_PROFILE=facade` when
+ you need the narrow read/search/context/draft surface.
+- **Explicit write discipline:** sending, admin actions, profile changes, and
+ broad export workflows are visible in the full surface and should be routed
+ through preview/confirmation or operator workflows when risk warrants it.
- **Auditable setup:** contract smoke checks, plugin drift checks, and
control-plane reports make the local state explainable.
## What Is Included
- `mcp/` - a Telethon-backed MCP server with high-level dialog facade tools.
-- `plugin/` - a Codex plugin bundle that points at a local MCP daemon and
- exposes a restricted default allowlist.
+- `plugin/` - a Codex plugin bundle that points at local owner MCP daemons.
- `control-plane/` - optional local doctor/status/audit commands for plugin
drift, LaunchAgent inventory, sessions, source routing, and repair planning.
- `docs/` - safety model and routing notes for operating the stack.
@@ -53,32 +55,54 @@ without handing it the whole account by default.
| Mode | Use it for | Can change Telegram? | Enabled by default |
| --- | --- | --- | --- |
-| Default Mode | read, search, context, drafts, previews, scoped media, voice transcription | No direct writes | Yes |
-| Power Mode | send/reply, contacts, groups/channels, stories, profile, privacy state | Yes | No |
+| Owner Local Full MCP | owner-controlled live work across the local full Telegram surface | Yes | Yes |
+| Facade Profile | read, search, context, drafts, previews, scoped media inspection/download | No direct writes | No |
+| Power Mode Example | wildcard client config for the same full local surface | Yes | No |
| Operator Workflows | mirror/archive, subscriber export, control-plane repair and audits | Can read in bulk or create sensitive artifacts | No |
-Power Mode is not a hidden feature. It is the explicit mode for users who want
-the broader Telegram MCP surface and accept that agents can perform externally
-visible actions.
+The full surface is not a hidden feature. It is the explicit owner-local mode
+for users who want agents to work with the broader Telegram MCP surface and
+accept that tools can perform externally visible actions.
For a short, private-data-free example, see
[Default Mode demo](docs/demo-default-mode.md).
+## Surface Contract
+
+Current healthy local mode is `owner_local_full_mcp`.
+
+- `plugin/.mcp.json` points at owner-local MCP daemons and intentionally exposes
+ their full local surface without a legacy `allowedTools` allowlist.
+- `TELEGRAM_MCP_TOOL_PROFILE=default` is not a restricted profile. In current
+ runtime behavior, unset/unknown/default profile names register the full
+ surface.
+- The restricted facade profile is explicit: use
+ `TELEGRAM_MCP_TOOL_PROFILE=facade` (or `safe` / `restricted`) for narrow
+ read/search/context/draft workflows.
+- `plugin/.mcp.full.example.json` is only a wildcard client example, not the
+ only way to reach full MCP.
+
+## Release Gate
+
+Before publishing or treating the local stack as healthy, run the release gates
+that compare runtime tools, plugin metadata, docs, and control-plane policy.
+
## Safety Model
-The runtime boundary is enforced in two layers:
+The runtime boundary is enforced by explicit local operator choice and audit
+checks:
-- MCP runtime profile (`TELEGRAM_MCP_TOOL_PROFILE=default`) keeps write/admin
- tools outside the default served surface.
-- Plugin allowlist (`plugin/.mcp.json`) exposes only Default Mode facade tools
- even when a broader local daemon exists.
+- Owner-local full MCP is represented by
+ `control-plane/policy/surface-contract.json`.
+- Restricted facade mode is represented by explicit MCP profile selection
+ (`TELEGRAM_MCP_TOOL_PROFILE=facade`, `safe`, or `restricted`).
- HTTP/SSE daemon transports require `TELEGRAM_MCP_AUTH_TOKEN`; stdio remains
local process-only.
```mermaid
flowchart LR
- Agent["AI agent"] --> Plugin["Codex plugin
restricted allowlist"]
- Plugin --> MCP["Local Telegram MCP
default profile"]
+ Agent["AI agent"] --> Plugin["Codex plugin
owner-local config"]
+ Plugin --> MCP["Local Telegram MCP
owner_local_full_mcp"]
MCP --> Telethon["Telethon session
on this machine"]
Control["Control-plane audits"] -.-> Plugin
Control -.-> MCP
@@ -155,18 +179,18 @@ manual `.mcp.json` wiring only as a fallback:
- For HTTP daemon mode, set `TELEGRAM_MCP_AUTH_TOKEN` in client environment;
the plugin MCP config references it via `bearer_token_env_var`.
-6. Unified workflow rule: use facade tools in Default Mode first. Switch to
-Power Mode only for explicit write/admin operations. Direct Telethon calls are
-an operator/debug path, not normal user onboarding.
+6. Unified workflow rule: use task-shaped tools first, and treat direct
+write/admin operations as explicit owner-local actions. Direct Telethon calls
+are an operator/debug path, not normal user onboarding.
-To inspect the full server surface locally, run the daemon with:
+To inspect a restricted facade server surface locally, run the daemon with:
```bash
-TELEGRAM_MCP_POWER_MODE=enabled TELEGRAM_MCP_TOOL_PROFILE=full .venv/bin/telegram-mcp
+TELEGRAM_MCP_TOOL_PROFILE=facade .venv/bin/telegram-mcp
```
-Use `plugin/.mcp.full.example.json` only when you intentionally want Power Mode
-in a local agent client.
+Use `plugin/.mcp.full.example.json` only when you intentionally want a wildcard
+client config for the full local surface.
Dependency strategy for `mcp/`: commit and review `uv.lock` changes for
reproducible installs. Do not run broad upgrades as part of routine docs or
@@ -222,13 +246,14 @@ blocks those by default.
## Capability Boundaries
-- Default Mode: live read/search/context/draft/preview, scoped local media
- download, and selected voice/video transcription.
-- Power Mode: broader Telegram API operations, including writes, contacts,
- groups/channels, stories, profile, and privacy state. This is opt-in and not
- enabled by the Default Mode allowlist.
+- Owner Local Full MCP: broader Telegram API operations, including writes,
+ contacts, groups/channels, stories, profile, and privacy state. This is the
+ current local owner default.
+- Facade Profile: live read/search/context/draft/preview, scoped local media
+ inspection/download, and selected voice/video transcription. This is opt-in
+ via `TELEGRAM_MCP_TOOL_PROFILE=facade`.
- Operator Workflows: mirror/archive/subscriber export/control-plane work. These
- are not Default Mode functions and require their own setup and safety checks.
+ require their own setup and safety checks.
## Safety Defaults
diff --git a/control-plane/.gitignore b/control-plane/.gitignore
index 544171a..b3f9f7d 100644
--- a/control-plane/.gitignore
+++ b/control-plane/.gitignore
@@ -2,6 +2,7 @@
__pycache__/
*.pyc
.DS_Store
+*.egg-info/
.cursor/
backups/
outputs/
diff --git a/control-plane/tests/test_command_registry.py b/control-plane/tests/test_command_registry.py
index 5e3945c..2fd2016 100644
--- a/control-plane/tests/test_command_registry.py
+++ b/control-plane/tests/test_command_registry.py
@@ -24,7 +24,9 @@ def bin_wrapper_names() -> set[str]:
return {
path.name
for path in BIN_DIR.iterdir()
- if path.is_file() and path.name not in NON_COMMAND_BIN
+ if path.is_file()
+ and not path.name.startswith(".")
+ and path.name not in NON_COMMAND_BIN
}
diff --git a/control-plane/tests/test_surface_docs.py b/control-plane/tests/test_surface_docs.py
index 1852413..5470535 100644
--- a/control-plane/tests/test_surface_docs.py
+++ b/control-plane/tests/test_surface_docs.py
@@ -2,12 +2,24 @@
from pathlib import Path
+REPO_ROOT = Path(__file__).resolve().parents[2]
+
def test_readme_surface_contract_uses_owner_local_full_mcp_as_healthy_default() -> None:
- readme = Path("README.md").read_text(encoding="utf-8")
+ readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8")
surface_start = readme.index("## Surface Contract")
release_start = readme.index("## Release Gate")
surface_section = readme[surface_start:release_start]
assert "owner_local_full_mcp" in surface_section
+ assert "TELEGRAM_MCP_TOOL_PROFILE=default" in surface_section
+ assert "is not a restricted profile" in surface_section
assert "must not expose raw send/reply" not in surface_section
+
+
+def test_operator_workflows_do_not_claim_default_profile_is_restricted() -> None:
+ text = (REPO_ROOT / "docs/operator-workflows.md").read_text(encoding="utf-8")
+
+ assert "owner_local_full_mcp" in text
+ assert "TELEGRAM_MCP_TOOL_PROFILE=default" not in text
+ assert "TELEGRAM_MCP_TOOL_PROFILE=facade" in text
diff --git a/docs/operator-workflows.md b/docs/operator-workflows.md
index 88b4733..f379bcc 100644
--- a/docs/operator-workflows.md
+++ b/docs/operator-workflows.md
@@ -1,31 +1,47 @@
# Power Mode And Operator Workflows
-Default Mode is intentionally safe for first install. The repository also
-contains a full Telegram MCP surface and operator workflows. Those are not
-second-class features; they are explicit modes because they can change Telegram
-state, read in bulk, or create sensitive artifacts.
+The current local owner setup uses `owner_local_full_mcp`. The repository also
+keeps a restricted facade profile and broader operator workflows. These modes
+must stay explicit because they differ in how much Telegram state they can read
+or change.
Unified workflow for users:
-- Default path: Telegram facade in Default Mode.
-- Escalation path: Power Mode for explicit write/admin requests.
+- Default owner path: local full MCP on explicit owner accounts.
+- Restricted path: Telegram facade profile for read/search/context/draft work.
- Non-default path: direct Telethon is operator/debug only.
-## Default Mode
+## Owner Local Full MCP
-Default Mode is the normal plugin path. It supports live read/search/context,
-non-sending drafts and previews, scoped local media download, and selected
-voice/video transcription.
+Owner local full MCP is the normal local plugin path in this repository. It
+exposes the full Telegram MCP surface on explicit owner accounts and relies on
+local operator discipline, bearer auth, and control-plane checks.
-Default Mode should be safe to try without the agent sending messages, changing
-chats, changing profile state, or launching background mirror/archive jobs.
+This boundary is runtime-enforced when these controls are kept intact:
-This boundary is runtime-enforced when both controls are kept intact:
-
-- MCP daemon runs with `TELEGRAM_MCP_TOOL_PROFILE=default`.
+- MCP daemon runs with unset/default/full profile for the owner local full
+ surface, or with an explicit account-specific launchd config.
- HTTP/SSE daemon transport has `TELEGRAM_MCP_AUTH_TOKEN` configured in both
server and client.
-- Plugin MCP config stays on `plugin/.mcp.json` allowlist and is not replaced by
- `plugin/.mcp.full.example.json`.
+- Plugin MCP config stays on `plugin/.mcp.json` without legacy `allowedTools`
+ allowlists.
+- Control-plane policy stays on `owner_local_full_mcp`.
+
+## Facade Profile
+
+Facade profile is the restricted compatibility path. It supports live
+read/search/context, non-sending drafts and previews, scoped local media
+download, and selected voice/video transcription.
+
+Facade profile should be safe to try without the agent sending messages,
+changing chats, changing profile state, or launching background mirror/archive
+jobs.
+
+Enable it intentionally:
+
+```bash
+cd mcp
+TELEGRAM_MCP_TOOL_PROFILE=facade .venv/bin/telegram-mcp
+```
## Power Mode
@@ -41,14 +57,13 @@ TELEGRAM_MCP_POWER_MODE=enabled TELEGRAM_MCP_TOOL_PROFILE=full .venv/bin/telegra
```
Then point a local client at the same MCP endpoint using
-`plugin/.mcp.full.example.json` as a starting point.
+`plugin/.mcp.full.example.json` as a wildcard client-config starting point.
-Power Mode is enforced by explicit operator choice (runtime profile + allowlist
-switch). It is not reachable through the default plugin files alone.
+Power Mode is enforced by explicit operator choice and local client config.
Before using Power Mode:
-- verify Default Mode first;
+- verify owner-local full MCP status first;
- use a test chat or explicit stable target;
- keep exact message text and target identity stable;
- do not copy Power Mode allowlists into `plugin/.mcp.json`;
@@ -65,7 +80,7 @@ Operator Workflows are broader local operations around the Telegram stack:
These workflows often involve allowlists, local databases, background jobs,
freshness checks, or PII-heavy outputs. They are intentionally separate from
-Default Mode and Power Mode.
+owner-local full MCP and facade profile.
## Mirror
@@ -81,7 +96,7 @@ Mirror use should be:
- kept cold unless there is a separate runtime plan.
The control-plane can audit mirror state, but it does not turn mirror runtime
-jobs into Default Mode tools.
+jobs into facade tools.
## Telecrawl Archive
@@ -95,7 +110,7 @@ freshness, and known import gaps.
## Subscriber Export
Subscriber/member export is sensitive and can produce PII-heavy local artifacts.
-It is not exposed in the Default Mode allowlist.
+It is not part of the facade profile.
Use subscriber export only with explicit user intent, private local output
paths, and clear reporting of `visible_count`, `exported_count`, `missing`, and
diff --git a/mcp/src/telegram_mcp/__init__.py b/mcp/src/telegram_mcp/__init__.py
index 4cbd20a..b4ac1c3 100644
--- a/mcp/src/telegram_mcp/__init__.py
+++ b/mcp/src/telegram_mcp/__init__.py
@@ -3,3 +3,5 @@
from .telethon_compat import apply_telethon_compat
apply_telethon_compat()
+
+from . import mcp_prewarm as mcp_prewarm # noqa: E402,F401
diff --git a/mcp/src/telegram_mcp/approval_server.py b/mcp/src/telegram_mcp/approval_server.py
index 36b8a5d..5b0d425 100644
--- a/mcp/src/telegram_mcp/approval_server.py
+++ b/mcp/src/telegram_mcp/approval_server.py
@@ -50,6 +50,48 @@ def _send(self, status: int, content_type: str, body: bytes) -> None:
self.end_headers()
self.wfile.write(body)
+ def _form_fields(self) -> dict[str, str]:
+ length = int(self.headers.get("Content-Length") or 0)
+ raw = self.rfile.read(length).decode("utf-8") if length else ""
+ return {
+ key: values[0]
+ for key, values in parse_qs(raw).items()
+ if values
+ }
+
+ def _mutate(self, *, token: str, nonce: str, action: str) -> str:
+ store = get_confirmation_store()
+ record = store.get(token)
+ if record is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
+ if nonce != record.one_time_nonce:
+ raise ToolContractError("invalid_confirmation_token", "approval nonce is invalid")
+ if action == "approve":
+ store.approve(token)
+ return "
Одобрено
Можно отправлять через telegram_confirmed_send.
"
+ if action == "reject":
+ store.reject(token)
+ return "Отклонено
Отправка заблокирована.
"
+ raise ToolContractError("invalid_input", "unknown approval action")
+
+ def do_POST(self) -> None: # noqa: N802
+ parsed = urlparse(self.path)
+ if parsed.path not in {"/telegram/approve", "/telegram/approve/"}:
+ self._send(404, "text/plain", b"not found\n")
+ return
+ fields = self._form_fields()
+ token = fields.get("token") or ""
+ nonce = fields.get("nonce") or ""
+ action = fields.get("action") or ""
+ try:
+ inner = self._mutate(token=token, nonce=nonce, action=action)
+ status = 200
+ except ToolContractError as exc:
+ inner = f"Ошибка
{html.escape(exc.message)}
"
+ status = 400
+ page_status, ctype, body = _page("Telegram approve", inner, status=status)
+ self._send(page_status, ctype, body)
+
def do_GET(self) -> None: # noqa: N802
parsed = urlparse(self.path)
if parsed.path not in {"/telegram/approve", "/telegram/approve/"}:
@@ -101,23 +143,22 @@ def do_GET(self) -> None: # noqa: N802
Кому: {chat} · инструмент: {tool} · статус: {state}
{reply_line}
{text}
-
-После одобрения агент может вызвать telegram_confirmed_send с тем же текстом.
-"""
-
- action = (parse_qs(parsed.query).get("action") or [None])[0]
- if action == "approve" and record.approval_state == "pending":
- try:
- store.approve(token)
- inner = "Одобрено
Можно отправлять через telegram_confirmed_send.
" + inner
- except ToolContractError as exc:
- inner = f"Ошибка
{html.escape(exc.message)}
"
- elif action == "reject" and record.approval_state == "pending":
- store.reject(token)
- inner = "Отклонено
Отправка заблокирована.
"
+
+
+
+
+ После одобрения агент может вызвать telegram_confirmed_send с тем же текстом.
+ """
status, ctype, body = _page("Telegram approve", inner)
self._send(status, ctype, body)
@@ -125,6 +166,8 @@ def do_GET(self) -> None: # noqa: N802
def start_approval_server(*, host: str, port: int) -> None:
global _server
+ if host not in {"127.0.0.1", "localhost", "::1"}:
+ raise ValueError("approval server host must be loopback")
with _server_lock:
if _server is not None:
return
@@ -145,4 +188,4 @@ def stop_approval_server() -> None:
return
_server.shutdown()
_server.server_close()
- _server = None
\ No newline at end of file
+ _server = None
diff --git a/mcp/src/telegram_mcp/client_groups.py b/mcp/src/telegram_mcp/client_groups.py
index c0ea02f..d0702ac 100644
--- a/mcp/src/telegram_mcp/client_groups.py
+++ b/mcp/src/telegram_mcp/client_groups.py
@@ -181,7 +181,7 @@ async def get_participants(
return participants, total
async def get_admins(
- self, chat: str | int
+ self, chat: str | int, limit: int = 200
) -> list[Participant]:
entity = await self._resolve_entity(chat)
admins = []
@@ -193,7 +193,7 @@ async def get_admins(
channel=entity,
filter=ChannelParticipantsAdmins(),
offset=0,
- limit=200,
+ limit=limit,
hash=0,
)
),
@@ -205,7 +205,7 @@ async def get_admins(
role = "creator" if "Creator" in type(p).__name__ else "admin"
admins.append(self._user_to_participant(user, role))
else:
- async for user in self.client.iter_participants(entity, aggressive=False):
+ async for user in self.client.iter_participants(entity, aggressive=False, limit=limit):
ptype = type(getattr(user, "participant", None)).__name__
if "Creator" in ptype or "Admin" in ptype:
role = "creator" if "Creator" in ptype else "admin"
@@ -213,7 +213,7 @@ async def get_admins(
return admins
async def get_banned_users(
- self, chat: str | int
+ self, chat: str | int, limit: int = 200
) -> list[Participant]:
entity = await self._resolve_entity(chat)
banned = []
@@ -225,7 +225,7 @@ async def get_banned_users(
channel=entity,
filter=ChannelParticipantsBanned(""),
offset=0,
- limit=200,
+ limit=limit,
hash=0,
)
),
diff --git a/mcp/src/telegram_mcp/errors.py b/mcp/src/telegram_mcp/errors.py
index 0a882c4..7ad0ac2 100644
--- a/mcp/src/telegram_mcp/errors.py
+++ b/mcp/src/telegram_mcp/errors.py
@@ -32,6 +32,46 @@ def __init__(self, code: str, message: str):
super().__init__(f"{code}: {message}")
+CONTRACT_ERROR_CODES = frozenset(
+ {
+ "archive_fallback_blocked",
+ "archive_route_blocked",
+ "confirmation_payload_mismatch",
+ "confirmation_rejected",
+ "expired_confirmation_token",
+ "human_approval_required",
+ "invalid_confirmation_token",
+ "invalid_context_mode",
+ "invalid_date_range",
+ "invalid_input",
+ "invalid_intent",
+ "live_intent_conflict",
+ "missing_confirmation_token",
+ "missing_send_target",
+ "permission_denied",
+ "rate_limited",
+ "telegram_tool_error",
+ "transport_unavailable",
+ }
+)
+
+
+def normalize_contract_error_code(value: object) -> str:
+ return str(value).strip().lower().split(":", 1)[0].strip()
+
+
+def payload_is_contract_error(payload: object | None) -> bool:
+ if isinstance(payload, str):
+ return normalize_contract_error_code(payload) in CONTRACT_ERROR_CODES
+ if isinstance(payload, dict):
+ code = payload.get("code") or payload.get("error_code")
+ if code is not None and normalize_contract_error_code(code) in CONTRACT_ERROR_CODES:
+ return True
+ message = payload.get("message") or payload.get("error")
+ return message is not None and payload_is_contract_error(str(message))
+ return False
+
+
_FRIENDLY: dict[type, tuple[str, str]] = {
AuthKeyUnregisteredError: (
"transport_unavailable",
diff --git a/mcp/src/telegram_mcp/fast_read_today.py b/mcp/src/telegram_mcp/fast_read_today.py
index 297eec2..7dc8348 100644
--- a/mcp/src/telegram_mcp/fast_read_today.py
+++ b/mcp/src/telegram_mcp/fast_read_today.py
@@ -5,138 +5,27 @@
import argparse
import asyncio
import json
-import os
-import time
-from dataclasses import asdict, dataclass
-from datetime import date, timedelta
+from datetime import date
from pathlib import Path
import httpx
-from mcp.client.session import ClientSession
-from mcp.client.streamable_http import streamable_http_client
-
-@dataclass(frozen=True)
-class EndpointAttempt:
- endpoint: str
- env_file: str
- port: int
+from .mcp_http_client import (
+ ACCOUNT_ENDPOINTS,
+ EndpointAttempt,
+ McpToolError,
+ call_tool_once,
+ endpoint_attempts,
+ payload_is_tool_error,
+)
class FastReadError(RuntimeError):
pass
-ACCOUNT_ENDPOINTS = {
- "main": (8799, "~/.telegram-mcp/launchd.env"),
- "crwddy": (8799, "~/.telegram-mcp/launchd.env"),
- "pl": (8800, "~/.telegram-mcp-pl/launchd.env"),
- "recklessou": (8801, "~/.telegram-mcp-recklessou/launchd.env"),
- "teamsyncsage": (8802, "~/.telegram-mcp-teamsyncsage/launchd.env"),
- "vermassov": (8803, "~/.telegram-mcp-vermassov/launchd.env"),
-}
-
-
-def load_env_file(path: Path) -> None:
- if not path.exists():
- return
- for raw_line in path.read_text().splitlines():
- line = raw_line.strip()
- if not line or line.startswith("#"):
- continue
- if line.startswith("export "):
- line = line[len("export ") :]
- if "=" not in line:
- continue
- key, value = line.split("=", 1)
- key = key.strip()
- value = value.strip().strip("'\"")
- if key.startswith("TELEGRAM_") and key not in os.environ:
- os.environ[key] = value
-
-
-def build_endpoint(host: str, port: int, path: str) -> str:
- if not path.startswith("/"):
- path = "/" + path
- return f"http://{host}:{port}{path}"
-
-
-def endpoint_attempts(
- *,
- explicit_endpoint: str | None = None,
- host: str | None = None,
- account: str | None = None,
- primary_port: int | None = None,
- failover_ports: list[int] | None = None,
- primary_env_file: str | Path | None = None,
- pl_env_file: str | Path | None = None,
-) -> list[EndpointAttempt]:
- if explicit_endpoint:
- return [EndpointAttempt(endpoint=explicit_endpoint, env_file=str(primary_env_file or ""), port=0)]
-
- host_name = host or os.environ.get("TELEGRAM_MCP_HOST", "127.0.0.1")
- path = os.environ.get("TELEGRAM_MCP_HTTP_PATH", "/mcp")
- selected_account = (account or os.environ.get("TELEGRAM_MCP_ACCOUNT", "main")).strip().lower()
- account_config = ACCOUNT_ENDPOINTS.get(selected_account)
- if account_config is None:
- known = ", ".join(sorted(ACCOUNT_ENDPOINTS))
- raise FastReadError(f"TELEGRAM_MCP_ACCOUNT must be one of: {known}")
-
- account_port, account_env_file = account_config
- primary = primary_port or account_port
- extra = failover_ports
- if extra is None:
- raw = os.environ.get("TELEGRAM_MCP_FAILOVER_PORTS", "")
- extra = [int(item.strip()) for item in raw.split(",") if item.strip()]
-
- ports: list[int] = []
- for candidate in [primary, *extra]:
- if candidate not in ports:
- ports.append(candidate)
-
- selected_env = Path(primary_env_file or account_env_file).expanduser()
-
- attempts: list[EndpointAttempt] = []
- for port in ports:
- attempts.append(
- EndpointAttempt(
- endpoint=build_endpoint(host_name, port, path),
- env_file=str(selected_env),
- port=port,
- )
- )
- return attempts
-
-
-def content_payload(result) -> object | None:
- if not result.content:
- return None
- first = result.content[0]
- text = getattr(first, "text", None)
- if text is None:
- return str(first)
- try:
- return json.loads(text)
- except json.JSONDecodeError:
- return text
-
-
-def payload_is_tool_error(payload: object | None) -> bool:
- if isinstance(payload, str):
- lower = payload.lower()
- return (
- "unknown tool" in lower
- or lower.startswith("error executing tool ")
- or "error executing tool " in lower
- )
- if isinstance(payload, dict):
- message = str(payload.get("message") or payload.get("error") or "")
- return payload_is_tool_error(message)
- return False
-
-
def exception_is_tool_error(exc: Exception) -> bool:
- return payload_is_tool_error(str(exc))
+ return isinstance(exc, McpToolError) or payload_is_tool_error(str(exc))
async def read_once(
@@ -149,48 +38,17 @@ async def read_once(
sender_names: bool,
timeout: float,
) -> dict[str, object]:
- if attempt.env_file:
- load_env_file(Path(attempt.env_file).expanduser())
-
- token = os.environ.get("TELEGRAM_MCP_AUTH_TOKEN", "").strip()
- headers = {"Authorization": f"Bearer {token}"} if token else {}
- http_timeout = httpx.Timeout(
- timeout,
- connect=min(timeout, 3.0),
- read=timeout,
- write=timeout,
- pool=min(timeout, 3.0),
- )
-
- started = time.perf_counter()
- async with httpx.AsyncClient(headers=headers, timeout=http_timeout) as http_client:
- async with streamable_http_client(attempt.endpoint, http_client=http_client) as (
- read_stream,
- write_stream,
- _,
- ):
- async with ClientSession(
- read_stream,
- write_stream,
- read_timeout_seconds=timedelta(seconds=timeout),
- ) as session:
- await session.initialize()
- mode = "full" if (voice or sender_names) else "fast"
- result = await session.call_tool(
- "telegram_read",
- {
- "chat": chat,
- "day": day,
- "limit": limit,
- "mode": mode,
- },
- )
-
- payload = content_payload(result)
- if payload_is_tool_error(payload):
- raise FastReadError(f"MCP tool error at {attempt.endpoint}: {payload!r}")
+ mode = "full" if (voice or sender_names) else "fast"
+ try:
+ payload, elapsed_seconds, completed_attempt = await call_tool_once(
+ attempt=attempt,
+ tool_name="telegram_read",
+ arguments={"chat": chat, "day": day, "limit": limit, "mode": mode},
+ timeout=timeout,
+ )
+ except McpToolError as exc:
+ raise FastReadError(str(exc)) from None
- elapsed_seconds = round(time.perf_counter() - started, 3)
from .agent_preflight import observe_fast_read
from .telemetry import record_telemetry, telemetry_fields_from_result
@@ -206,7 +64,7 @@ async def read_once(
status="ok",
duration_ms=duration_ms,
source="fast_read_cli",
- endpoint_port=attempt.port or None,
+ endpoint_port=completed_attempt.port or None,
arg_chat=chat,
arg_day=day,
arg_limit=limit,
@@ -215,8 +73,8 @@ async def read_once(
return {
"ok": True,
"mode": "telegram_fast_read_today",
- "endpoint": attempt.endpoint,
- "endpoint_port": attempt.port or None,
+ "endpoint": completed_attempt.endpoint,
+ "endpoint_port": completed_attempt.port or None,
"elapsed_seconds": elapsed_seconds,
"payload": payload,
}
@@ -258,7 +116,6 @@ async def read_with_failover(
httpx.NetworkError,
ConnectionError,
OSError,
- FastReadError,
) as exc:
errors.append(f"{attempt.endpoint}: {type(exc).__name__}: {exc}")
diff --git a/mcp/src/telegram_mcp/mcp_http_client.py b/mcp/src/telegram_mcp/mcp_http_client.py
index 93e4aab..b54c68f 100644
--- a/mcp/src/telegram_mcp/mcp_http_client.py
+++ b/mcp/src/telegram_mcp/mcp_http_client.py
@@ -13,6 +13,8 @@
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
+from .errors import payload_is_contract_error
+
@dataclass(frozen=True)
class EndpointAttempt:
@@ -25,6 +27,22 @@ class McpCliError(RuntimeError):
pass
+class McpToolError(McpCliError):
+ def __init__(
+ self,
+ *,
+ endpoint: str,
+ port: int | None,
+ payload: object | None,
+ elapsed_seconds: float,
+ ) -> None:
+ self.endpoint = endpoint
+ self.port = port
+ self.payload = payload
+ self.elapsed_seconds = elapsed_seconds
+ super().__init__(f"MCP tool error at {endpoint}: {payload!r}")
+
+
ACCOUNT_ENDPOINTS = {
"main": (8799, "~/.telegram-mcp/launchd.env"),
"crwddy": (8799, "~/.telegram-mcp/launchd.env"),
@@ -121,13 +139,32 @@ def content_payload(result) -> object | None:
def payload_is_tool_error(payload: object | None) -> bool:
if isinstance(payload, str):
- return "Unknown tool" in payload or "unknown tool" in payload.lower()
+ lower = payload.strip().lower()
+ return (
+ "unknown tool" in lower
+ or lower.startswith("error executing tool ")
+ or "error executing tool " in lower
+ or payload_is_contract_error(lower)
+ )
if isinstance(payload, dict):
- message = str(payload.get("message") or payload.get("error") or "")
- return "Unknown tool" in message
+ message = payload.get("message") or payload.get("error")
+ if message is not None and payload_is_tool_error(str(message)):
+ return True
+ return payload_is_contract_error(payload)
return False
+def result_is_tool_error(result, payload: object | None) -> bool:
+ return bool(getattr(result, "isError", False)) or payload_is_tool_error(payload)
+
+
+def tool_error_payload(result, payload: object | None) -> object | None:
+ structured = getattr(result, "structuredContent", None)
+ if structured is not None:
+ return structured
+ return payload
+
+
async def call_tool_once(
*,
attempt: EndpointAttempt,
@@ -164,10 +201,16 @@ async def call_tool_once(
result = await session.call_tool(tool_name, arguments)
payload = content_payload(result)
- if payload_is_tool_error(payload):
- raise McpCliError(f"MCP tool error at {attempt.endpoint}: {payload!r}")
-
elapsed_seconds = round(time.perf_counter() - started, 3)
+ if result_is_tool_error(result, payload):
+ error_payload = tool_error_payload(result, payload)
+ raise McpToolError(
+ endpoint=attempt.endpoint,
+ port=attempt.port or None,
+ payload=error_payload,
+ elapsed_seconds=elapsed_seconds,
+ )
+
return payload, elapsed_seconds, attempt
@@ -239,7 +282,6 @@ async def call_tool_with_failover(
httpx.NetworkError,
ConnectionError,
OSError,
- McpCliError,
) as exc:
errors.append(f"{attempt.endpoint}: {type(exc).__name__}: {exc}")
@@ -269,7 +311,6 @@ async def list_tools_with_failover(
httpx.NetworkError,
ConnectionError,
OSError,
- McpCliError,
) as exc:
errors.append(f"{attempt.endpoint}: {type(exc).__name__}: {exc}")
diff --git a/mcp/src/telegram_mcp/mcp_prewarm.py b/mcp/src/telegram_mcp/mcp_prewarm.py
index 3c15027..805f163 100644
--- a/mcp/src/telegram_mcp/mcp_prewarm.py
+++ b/mcp/src/telegram_mcp/mcp_prewarm.py
@@ -13,7 +13,7 @@
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
-from .fast_read_today import build_endpoint, endpoint_attempts, load_env_file
+from .mcp_http_client import build_endpoint, endpoint_attempts, load_env_file
@dataclass(frozen=True)
diff --git a/mcp/src/telegram_mcp/member_export_paths.py b/mcp/src/telegram_mcp/member_export_paths.py
index 533d421..7cb2559 100644
--- a/mcp/src/telegram_mcp/member_export_paths.py
+++ b/mcp/src/telegram_mcp/member_export_paths.py
@@ -6,6 +6,8 @@
def default_member_export_dir() -> Path:
path = Path.home() / ".cache" / "telegram-mcp" / "member-exports"
+ if path.is_symlink():
+ raise ValueError("member export root must not be a symlink")
path.mkdir(parents=True, exist_ok=True)
path.chmod(0o700)
return path
@@ -14,6 +16,8 @@ def default_member_export_dir() -> Path:
def resolve_member_export_dir(output_dir: str | None) -> Path:
root = default_member_export_dir().resolve(strict=False)
candidate = root if output_dir is None else Path(output_dir).expanduser()
+ if candidate.exists() and candidate.is_symlink():
+ raise ValueError("member export output_dir must not be a symlink")
resolved = candidate.resolve(strict=False)
try:
resolved.relative_to(root)
diff --git a/mcp/src/telegram_mcp/runtime.py b/mcp/src/telegram_mcp/runtime.py
index c581825..3453998 100644
--- a/mcp/src/telegram_mcp/runtime.py
+++ b/mcp/src/telegram_mcp/runtime.py
@@ -31,6 +31,7 @@
log = structlog.get_logger()
_shared_wrapper: TelegramWrapper | None = None
_shared_wrapper_lock = asyncio.Lock()
+OAUTH_AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server"
class StaticBearerTokenVerifier:
@@ -248,8 +249,13 @@ async def _oauth_as_metadata(request: _Request) -> _JSONResponse:
}
)
+ mcp._custom_starlette_routes = [
+ route
+ for route in mcp._custom_starlette_routes
+ if getattr(route, "path", None) != OAUTH_AUTHORIZATION_SERVER_METADATA_PATH
+ ]
mcp._custom_starlette_routes.append(
- _Route("/.well-known/oauth-authorization-server", _oauth_as_metadata, methods=["GET"])
+ _Route(OAUTH_AUTHORIZATION_SERVER_METADATA_PATH, _oauth_as_metadata, methods=["GET"])
)
diff --git a/mcp/src/telegram_mcp/send_confirmation.py b/mcp/src/telegram_mcp/send_confirmation.py
index c6313da..f6827da 100644
--- a/mcp/src/telegram_mcp/send_confirmation.py
+++ b/mcp/src/telegram_mcp/send_confirmation.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import secrets
+import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -44,6 +45,7 @@ def __init__(self, *, ttl_seconds: int = 600) -> None:
self._ttl_seconds = ttl_seconds
self._records: dict[str, SendConfirmationRecord] = {}
self._token_index: dict[str, str] = {}
+ self._lock = threading.RLock()
def _now(self) -> float:
return time.time()
@@ -68,53 +70,65 @@ def mint(
preview_text: str,
risk_class: str = "standard",
) -> tuple[str, str, datetime]:
- preview_id = secrets.token_urlsafe(16)
- token = secrets.token_urlsafe(24)
- nonce = secrets.token_urlsafe(12)
- expires_at = self._now() + self._ttl_seconds
- self._records[preview_id] = SendConfirmationRecord(
- preview_id=preview_id,
- expires_at=expires_at,
- payload=dict(payload),
- preview_text=preview_text,
- approval_state="pending",
- risk_class=risk_class,
- confirmation_token=token,
- one_time_nonce=nonce,
- )
- self._token_index[token] = preview_id
- return preview_id, token, datetime.fromtimestamp(expires_at, tz=timezone.utc)
+ with self._lock:
+ preview_id = secrets.token_urlsafe(16)
+ token = secrets.token_urlsafe(24)
+ nonce = secrets.token_urlsafe(12)
+ expires_at = self._now() + self._ttl_seconds
+ self._records[preview_id] = SendConfirmationRecord(
+ preview_id=preview_id,
+ expires_at=expires_at,
+ payload=dict(payload),
+ preview_text=preview_text,
+ approval_state="pending",
+ risk_class=risk_class,
+ confirmation_token=token,
+ one_time_nonce=nonce,
+ )
+ self._token_index[token] = preview_id
+ return preview_id, token, datetime.fromtimestamp(expires_at, tz=timezone.utc)
def get(self, key: str) -> SendConfirmationRecord | None:
- preview_id = self._resolve_key(key)
- if preview_id is None:
- return None
- record = self._records.get(preview_id)
- if record is None:
- return None
- return self._expire_if_needed(preview_id, record)
+ with self._lock:
+ preview_id = self._resolve_key(key)
+ if preview_id is None:
+ return None
+ record = self._records.get(preview_id)
+ if record is None:
+ return None
+ return self._expire_if_needed(preview_id, record)
def approve(self, token: str) -> SendConfirmationRecord:
- record = self.get(token)
- if record is None:
- raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
- if record.approval_state == "expired":
- raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
- if record.approval_state == "used":
- raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
- if record.approval_state == "rejected":
- raise ToolContractError("confirmation_rejected", "confirmation was rejected by the operator")
- record.approval_state = "approved"
- self._records[token] = record
- return record
+ with self._lock:
+ preview_id = self._resolve_key(token)
+ if preview_id is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
+ record = self._records.get(preview_id)
+ if record is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
+ record = self._expire_if_needed(preview_id, record)
+ if record.approval_state == "expired":
+ raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
+ if record.approval_state == "used":
+ raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
+ if record.approval_state == "rejected":
+ raise ToolContractError("confirmation_rejected", "confirmation was rejected by the operator")
+ record.approval_state = "approved"
+ return record
def reject(self, token: str) -> SendConfirmationRecord:
- record = self.get(token)
- if record is None:
- raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
- record.approval_state = "rejected"
- self._records[token] = record
- return record
+ with self._lock:
+ preview_id = self._resolve_key(token)
+ if preview_id is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
+ record = self._records.get(preview_id)
+ if record is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown")
+ record = self._expire_if_needed(preview_id, record)
+ if record.approval_state == "expired":
+ raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
+ record.approval_state = "rejected"
+ return record
def consume(
self,
@@ -124,35 +138,37 @@ def consume(
approval_required: bool,
preview_id_only: bool = False,
) -> SendConfirmationRecord:
- if not key:
- raise ToolContractError(
- "missing_confirmation_token",
- "send/reply requires preview_id or confirmation_token from prepare_*",
- )
- resolved = self._resolve_key(key)
- if resolved is None:
- raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown or already used")
- record = self._records.get(resolved)
- if record is None:
- raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown or already used")
- record = self._expire_if_needed(resolved, record)
- if record.approval_state == "expired":
- raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
- if record.approval_state == "used":
- raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
- if record.approval_state == "rejected":
- raise ToolContractError("confirmation_rejected", "confirmation was rejected by the operator")
- if approval_required and record.approval_state != "approved":
- raise ToolContractError(
- "human_approval_required",
- "open the approval URL and click Approve before sending",
- )
- if not preview_id_only and expected is not None and record.payload != expected:
- raise ToolContractError(
- "confirmation_payload_mismatch",
- "send/reply arguments do not match the preview confirmation",
- )
- record.approval_state = "used"
- self._records.pop(resolved, None)
- self._token_index.pop(record.confirmation_token, None)
- return record
\ No newline at end of file
+ with self._lock:
+ if not key:
+ raise ToolContractError(
+ "missing_confirmation_token",
+ "send/reply requires preview_id or confirmation_token from prepare_*",
+ )
+ resolved = self._resolve_key(key)
+ if resolved is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown or already used")
+ record = self._records.get(resolved)
+ if record is None:
+ raise ToolContractError("invalid_confirmation_token", "confirmation token is unknown or already used")
+ record = self._expire_if_needed(resolved, record)
+ if record.approval_state == "expired":
+ raise ToolContractError("expired_confirmation_token", "confirmation token has expired")
+ if record.approval_state == "used":
+ raise ToolContractError("invalid_confirmation_token", "confirmation token was already used")
+ if record.approval_state == "rejected":
+ raise ToolContractError("confirmation_rejected", "confirmation was rejected by the operator")
+ if approval_required and record.approval_state != "approved":
+ raise ToolContractError(
+ "human_approval_required",
+ "open the approval URL and click Approve before sending",
+ )
+ if not preview_id_only and expected is not None and record.payload != expected:
+ raise ToolContractError(
+ "confirmation_payload_mismatch",
+ "send/reply arguments do not match the preview confirmation",
+ )
+ record.approval_state = "used"
+ self._records.pop(resolved, None)
+ self._records.pop(record.confirmation_token, None)
+ self._token_index.pop(record.confirmation_token, None)
+ return record
diff --git a/mcp/src/telegram_mcp/tg_cli.py b/mcp/src/telegram_mcp/tg_cli.py
index f8701da..db54675 100644
--- a/mcp/src/telegram_mcp/tg_cli.py
+++ b/mcp/src/telegram_mcp/tg_cli.py
@@ -16,17 +16,17 @@
METADATA_LIST_SPECS,
MetadataCountSpec,
)
-from .mcp_http_client import ACCOUNT_ENDPOINTS, McpCliError, call_tool_with_failover
+from .mcp_http_client import ACCOUNT_ENDPOINTS, McpCliError, McpToolError, call_tool_with_failover
from .telemetry import record_telemetry, telemetry_fields_from_result
-def _payload_is_tool_error(payload: object | None) -> bool:
- if isinstance(payload, str):
- lower = payload.lower()
- return "unknown tool" in lower or "error executing tool " in lower
- if isinstance(payload, dict):
- return _payload_is_tool_error(payload.get("error") or payload.get("message"))
- return False
+def _record_tool_telemetry(event_name: str, *, status: str, payload: object | None, **fields: object) -> None:
+ record_telemetry(
+ event_name,
+ status=status,
+ **fields,
+ **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
+ )
def _wrap_ok(
@@ -41,23 +41,6 @@ def _wrap_ok(
data_source = None
if isinstance(payload, dict):
data_source = payload.get("data_source")
- if _payload_is_tool_error(payload):
- wrapped = {
- "ok": False,
- "command": command,
- "intent": intent,
- "data_source": data_source or "live_telegram",
- "endpoint": endpoint,
- "endpoint_port": endpoint_port,
- "elapsed_seconds": elapsed_seconds,
- "error": "telegram_tool_error",
- "message": "Live Telegram tool returned an error payload.",
- }
- if isinstance(payload, dict):
- wrapped["tool_error_payload"] = payload
- elif isinstance(payload, str):
- wrapped["tool_error_payload"] = payload[:2000]
- return wrapped
return {
"ok": True,
"command": command,
@@ -70,6 +53,36 @@ def _wrap_ok(
}
+def _wrap_tool_error(
+ *,
+ command: str,
+ endpoint: str,
+ endpoint_port: int | None,
+ elapsed_seconds: float,
+ payload: object | None,
+ intent: str,
+) -> dict[str, object]:
+ data_source = payload.get("data_source") if isinstance(payload, dict) else None
+ wrapped = {
+ "ok": False,
+ "command": command,
+ "intent": intent,
+ "data_source": data_source or "live_telegram",
+ "endpoint": endpoint,
+ "endpoint_port": endpoint_port,
+ "elapsed_seconds": elapsed_seconds,
+ "error": "telegram_tool_error",
+ "message": "Live Telegram tool returned an error.",
+ }
+ if isinstance(payload, dict):
+ wrapped["tool_error_payload"] = payload
+ elif isinstance(payload, str):
+ wrapped["tool_error_payload"] = payload[:2000]
+ elif payload is not None:
+ wrapped["tool_error_payload"] = repr(payload)[:2000]
+ return wrapped
+
+
def _wrap_err(*, command: str, exc: Exception) -> dict[str, object]:
return {
"ok": False,
@@ -79,6 +92,66 @@ def _wrap_err(*, command: str, exc: Exception) -> dict[str, object]:
}
+async def _run_tool_command(
+ *,
+ command: str,
+ intent: str,
+ event_name: str,
+ tool_name: str,
+ arguments: dict[str, object],
+ timeout: float,
+ endpoint: str | None,
+ env_file: str | None,
+ account: str,
+ telemetry_fields: dict[str, object],
+) -> dict[str, object]:
+ try:
+ payload, elapsed, attempt = await call_tool_with_failover(
+ tool_name=tool_name,
+ arguments=arguments,
+ timeout=timeout,
+ explicit_endpoint=endpoint,
+ env_file=env_file,
+ account=account,
+ )
+ except McpToolError as exc:
+ _record_tool_telemetry(
+ event_name,
+ status="error",
+ payload=exc.payload,
+ duration_ms=round(exc.elapsed_seconds * 1000, 3),
+ source="tg_cli",
+ endpoint_port=exc.port,
+ **telemetry_fields,
+ )
+ return _wrap_tool_error(
+ command=command,
+ endpoint=exc.endpoint,
+ endpoint_port=exc.port,
+ elapsed_seconds=exc.elapsed_seconds,
+ payload=exc.payload,
+ intent=intent,
+ )
+
+ _record_tool_telemetry(
+ event_name,
+ status="ok",
+ payload=payload,
+ duration_ms=round(elapsed * 1000, 3),
+ source="tg_cli",
+ endpoint_port=attempt.port or None,
+ **telemetry_fields,
+ )
+ return _wrap_ok(
+ command=command,
+ endpoint=attempt.endpoint,
+ endpoint_port=attempt.port or None,
+ elapsed_seconds=elapsed,
+ payload=payload,
+ intent=intent,
+ )
+
+
CHAT_RE = re.compile(r"(?P@[A-Za-z0-9_]{5,}|tg://dialog/[^\s]+|-100\d{6,}|\bme\b)", re.IGNORECASE)
COUNT_POSTS_RE = re.compile(
r"(сколько|count|how many|total).{0,80}(пост|posts?|messages?|сообщен)",
@@ -272,37 +345,24 @@ async def cmd_read_today(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ output = await _run_tool_command(
+ command="read today",
+ intent="live_today",
+ event_name="tg_read_today",
tool_name="telegram_read",
arguments={"chat": chat, "day": day, "limit": limit, "mode": "fast"},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
+ telemetry_fields={"arg_chat": chat, "arg_day": day, "arg_limit": limit},
)
- duration_ms = round(elapsed * 1000, 3)
from .agent_preflight import observe_fast_read
- observe_fast_read(tool="tg_read_today", status="ok", source="tg_cli", duration_ms=duration_ms)
- record_telemetry(
- "tg_read_today",
- status="ok",
- duration_ms=duration_ms,
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- arg_day=day,
- arg_limit=limit,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="read today",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="live_today",
- )
+ duration_ms = round(float(output.get("elapsed_seconds") or 0.0) * 1000, 3)
+ status = "ok" if output.get("ok") else "error"
+ observe_fast_read(tool="tg_read_today", status=status, source="tg_cli", duration_ms=duration_ms)
+ return output
async def cmd_read_recent(
@@ -314,31 +374,17 @@ async def cmd_read_recent(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command="read recent",
+ intent="live_recent",
+ event_name="tg_read_recent",
tool_name="telegram_read",
arguments={"chat": chat, "limit": limit, "mode": "fast"},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- "tg_read_recent",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- arg_limit=limit,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="read recent",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="live_recent",
+ telemetry_fields={"arg_chat": chat, "arg_limit": limit},
)
@@ -352,30 +398,17 @@ async def cmd_search(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command="search",
+ intent="live_search",
+ event_name="tg_search",
tool_name="telegram_search",
arguments={"chat": chat, "query": query, "limit": limit},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- "tg_search",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="search",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="live_search",
+ telemetry_fields={"arg_chat": chat},
)
@@ -388,30 +421,17 @@ async def cmd_count_metadata(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command=f"count {spec.cli_name}",
+ intent=f"count_channel_{spec.key}",
+ event_name=f"tg_count_{spec.key}",
tool_name=spec.tool_name,
arguments={"chat": chat},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- f"tg_count_{spec.key}",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command=f"count {spec.cli_name}",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent=f"count_channel_{spec.key}",
+ telemetry_fields={"arg_chat": chat},
)
@@ -446,32 +466,17 @@ async def cmd_list_metadata(
) -> dict[str, object]:
if not spec.list_tool_name:
raise McpCliError(f"{spec.cli_name} does not support bounded list")
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command=f"list {spec.list_cli_name}",
+ intent=f"list_channel_{spec.key}",
+ event_name=f"tg_list_{spec.key}",
tool_name=spec.list_tool_name,
arguments={"chat": chat, "limit": limit, "offset_id": offset_id},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- f"tg_list_{spec.key}",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- arg_limit=limit,
- arg_offset_id=offset_id,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command=f"list {spec.list_cli_name}",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent=f"list_channel_{spec.key}",
+ telemetry_fields={"arg_chat": chat, "arg_limit": limit, "arg_offset_id": offset_id},
)
@@ -483,30 +488,17 @@ async def cmd_latest(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command="latest",
+ intent="latest_message_metadata",
+ event_name="tg_latest_message",
tool_name="telegram_latest_message",
arguments={"chat": chat},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- "tg_latest_message",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="latest",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="latest_message_metadata",
+ telemetry_fields={"arg_chat": chat},
)
@@ -518,30 +510,17 @@ async def cmd_info(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command="info",
+ intent="dialog_metadata",
+ event_name="tg_dialog_metadata",
tool_name="telegram_dialog_metadata",
arguments={"chat": chat},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- "tg_dialog_metadata",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="info",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="dialog_metadata",
+ telemetry_fields={"arg_chat": chat},
)
@@ -554,31 +533,17 @@ async def cmd_message(
env_file: str | None,
account: str,
) -> dict[str, object]:
- payload, elapsed, attempt = await call_tool_with_failover(
+ return await _run_tool_command(
+ command="message",
+ intent="get_message_by_id",
+ event_name="tg_get_message",
tool_name="telegram_get_message",
arguments={"chat": chat, "message_id": message_id},
timeout=timeout,
- explicit_endpoint=endpoint,
+ endpoint=endpoint,
env_file=env_file,
account=account,
- )
- record_telemetry(
- "tg_get_message",
- status="ok",
- duration_ms=round(elapsed * 1000, 3),
- source="tg_cli",
- endpoint_port=attempt.port or None,
- arg_chat=chat,
- arg_message_id=message_id,
- **telemetry_fields_from_result(payload if isinstance(payload, dict) else None),
- )
- return _wrap_ok(
- command="message",
- endpoint=attempt.endpoint,
- endpoint_port=attempt.port or None,
- elapsed_seconds=elapsed,
- payload=payload,
- intent="get_message_by_id",
+ telemetry_fields={"arg_chat": chat, "arg_message_id": message_id},
)
diff --git a/mcp/src/telegram_mcp/tools/__init__.py b/mcp/src/telegram_mcp/tools/__init__.py
index adf68ea..a913ce4 100644
--- a/mcp/src/telegram_mcp/tools/__init__.py
+++ b/mcp/src/telegram_mcp/tools/__init__.py
@@ -192,7 +192,6 @@
"telegram_prepare_reply",
"telegram_read",
"telegram_search",
- "send_file",
}
FACADE_TOOL_PROFILES = {"facade", "safe", "restricted"}
diff --git a/mcp/src/telegram_mcp/tools/dialog_facade_tools.py b/mcp/src/telegram_mcp/tools/dialog_facade_tools.py
index 672a7e8..be9a10d 100644
--- a/mcp/src/telegram_mcp/tools/dialog_facade_tools.py
+++ b/mcp/src/telegram_mcp/tools/dialog_facade_tools.py
@@ -34,6 +34,17 @@
READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True)
ADDITIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True)
CONFIRMED_WRITE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True)
+MEMBER_EXPORT_FILTERS = {"all", "admins", "banned"}
+
+
+def _normalize_member_export_filter(filter_value: str) -> str:
+ normalized = filter_value.strip().lower()
+ if normalized not in MEMBER_EXPORT_FILTERS:
+ raise ToolContractError(
+ "invalid_input",
+ "member export filter must be one of: all, admins, banned",
+ )
+ return normalized
async def resolve_dialog(query: str | int):
@@ -584,13 +595,14 @@ async def telegram_export_members(
):
"""Export channel/group members to a private local JSON artifact."""
limit = clamp_member_export_limit(limit)
+ filter = _normalize_member_export_filter(filter)
tg = await runtime.get_tg()
handle = await tg.resolve_dialog(chat)
if filter == "admins":
- participants = await tg.get_admins(chat=handle.dialog_ref)
+ participants = await tg.get_admins(chat=handle.dialog_ref, limit=limit)
total = len(participants)
elif filter == "banned":
- participants = await tg.get_banned_users(chat=handle.dialog_ref)
+ participants = await tg.get_banned_users(chat=handle.dialog_ref, limit=limit)
total = len(participants)
else:
participants, total = await tg.get_participants(chat=handle.dialog_ref, limit=limit)
diff --git a/mcp/src/telegram_mcp/tools/group_tools.py b/mcp/src/telegram_mcp/tools/group_tools.py
index c908139..4f71734 100644
--- a/mcp/src/telegram_mcp/tools/group_tools.py
+++ b/mcp/src/telegram_mcp/tools/group_tools.py
@@ -5,12 +5,23 @@
from mcp.types import ToolAnnotations
from .. import runtime
-from ..errors import tool_error_handler
+from ..errors import ToolContractError, tool_error_handler
from ..types import ChatInfo, InviteLinkInfo, OperationResult, ParticipantsResult
READONLY = ToolAnnotations(readOnlyHint=True, destructiveHint=False, idempotentHint=True, openWorldHint=True)
ADDITIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=True)
DESTRUCTIVE = ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=True)
+PARTICIPANT_FILTERS = {"all", "admins", "banned"}
+
+
+def _normalize_participant_filter(filter_value: str) -> str:
+ normalized = filter_value.strip().lower()
+ if normalized not in PARTICIPANT_FILTERS:
+ raise ToolContractError(
+ "invalid_input",
+ "participant filter must be one of: all, admins, banned",
+ )
+ return normalized
async def create_group(title: str, user_ids: list[int]) -> ChatInfo:
@@ -53,11 +64,12 @@ async def get_participants(
) -> ParticipantsResult:
"""Get members of a group or channel (filter: 'all', 'admins', 'banned')."""
tg = await runtime.get_tg()
+ filter = _normalize_participant_filter(filter)
if filter == "admins":
- participants = await tg.get_admins(chat=chat)
+ participants = await tg.get_admins(chat=chat, limit=limit)
return ParticipantsResult(participants=participants, total=len(participants))
elif filter == "banned":
- participants = await tg.get_banned_users(chat=chat)
+ participants = await tg.get_banned_users(chat=chat, limit=limit)
return ParticipantsResult(participants=participants, total=len(participants))
else:
participants, total = await tg.get_participants(chat=chat, limit=limit)
diff --git a/mcp/src/telegram_mcp/tools/media_tools.py b/mcp/src/telegram_mcp/tools/media_tools.py
index 7b0d0c4..3aceea5 100644
--- a/mcp/src/telegram_mcp/tools/media_tools.py
+++ b/mcp/src/telegram_mcp/tools/media_tools.py
@@ -116,5 +116,3 @@ def register_facade(mcp) -> None:
mcp.tool(annotations=READONLY)(tool_error_handler(download_dialog_media))
mcp.tool(annotations=READONLY)(tool_error_handler(prepare_media_inspection_manifest))
mcp.tool(annotations=READONLY)(tool_error_handler(telegram_inspect_media))
- # notes-runner assemble delivery uses digest-runner send-file -> MCP send_file.
- mcp.tool(annotations=ADDITIVE)(tool_error_handler(send_file))
diff --git a/mcp/tests/test_dialog_facade_tools.py b/mcp/tests/test_dialog_facade_tools.py
index c9fdb2f..0e9646a 100644
--- a/mcp/tests/test_dialog_facade_tools.py
+++ b/mcp/tests/test_dialog_facade_tools.py
@@ -5,6 +5,7 @@
from unittest.mock import AsyncMock, patch
from telegram_mcp import server
+from telegram_mcp.errors import ToolContractError
from telegram_mcp.tools.dialog_facade_tools import telegram_export_members
from telegram_mcp.types import (
DialogContextResult,
@@ -520,6 +521,33 @@ def test_telegram_export_members_runs_without_extra_pii_acknowledgement(self) ->
self.assertEqual(result.participants[0].username, "ada")
wrapper.get_participants.assert_awaited_once_with(chat="tg://dialog/channel/10", limit=200)
+ def test_telegram_export_members_applies_limit_to_admin_filter(self) -> None:
+ wrapper = AsyncMock()
+ wrapper.resolve_dialog.return_value = DialogHandle(
+ dialog_ref="tg://dialog/channel/10",
+ id=10,
+ name="Target",
+ type="channel",
+ username="targetdaddy",
+ resolved_from="@targetdaddy",
+ )
+ wrapper.get_admins.return_value = [Participant(id=1, first_name="Ada", username="ada")]
+
+ with TemporaryDirectory() as tmp:
+ export_dir = Path(tmp)
+ with patch("telegram_mcp.runtime.get_tg", AsyncMock(return_value=wrapper)), patch(
+ "telegram_mcp.tools.dialog_facade_tools.resolve_member_export_dir",
+ return_value=export_dir,
+ ):
+ result = _run(telegram_export_members(chat="@targetdaddy", filter="admins", limit=1))
+
+ self.assertEqual(result.total, 1)
+ wrapper.get_admins.assert_awaited_once_with(chat="tg://dialog/channel/10", limit=1)
+
+ def test_telegram_export_members_rejects_unknown_filter(self) -> None:
+ with self.assertRaisesRegex(ToolContractError, "member export filter"):
+ _run(telegram_export_members(chat="@targetdaddy", filter="owners"))
+
def test_search_dialog_messages_returns_dialog_read_result(self):
wrapper = AsyncMock()
wrapper.search_dialog_messages.return_value = DialogReadResult(
diff --git a/mcp/tests/test_fast_read_today.py b/mcp/tests/test_fast_read_today.py
index c81f803..f22c405 100644
--- a/mcp/tests/test_fast_read_today.py
+++ b/mcp/tests/test_fast_read_today.py
@@ -58,6 +58,8 @@ def test_payload_is_tool_error_detects_unknown_tool(self):
self.assertTrue(payload_is_tool_error("Unknown tool: telegram_read"))
self.assertTrue(payload_is_tool_error("Error executing tool telegram_read: raw failure"))
self.assertTrue(payload_is_tool_error({"error": "Error executing tool telegram_read: raw failure"}))
+ self.assertTrue(payload_is_tool_error("permission_denied: private channel | next: ask user"))
+ self.assertTrue(payload_is_tool_error({"error": "rate_limited: retry later"}))
self.assertFalse(payload_is_tool_error({"data_source": "live_telegram"}))
def test_exception_is_tool_error_detects_nested_tool_failure(self):
@@ -98,3 +100,32 @@ async def fake_read_once(**kwargs):
)
self.assertIn("8799", str(ctx.exception))
+
+ def test_read_with_failover_does_not_retry_tool_error(self):
+ attempts = [
+ EndpointAttempt("http://127.0.0.1:8799/mcp", "/tmp/a.env", 8799),
+ EndpointAttempt("http://127.0.0.1:8798/mcp", "/tmp/a.env", 8798),
+ ]
+
+ async def fake_read_once(**kwargs):
+ raise FastReadError("MCP tool error at http://127.0.0.1:8799/mcp: permission_denied")
+
+ with patch("telegram_mcp.fast_read_today.endpoint_attempts", return_value=attempts), patch(
+ "telegram_mcp.fast_read_today.read_once",
+ side_effect=fake_read_once,
+ ) as read_once:
+ import asyncio
+
+ with self.assertRaises(FastReadError):
+ asyncio.run(
+ read_with_failover(
+ chat="me",
+ day="2026-06-02",
+ limit=1,
+ voice=False,
+ sender_names=False,
+ timeout=5.0,
+ )
+ )
+
+ self.assertEqual(read_once.await_count, 1)
diff --git a/mcp/tests/test_mcp_http_client.py b/mcp/tests/test_mcp_http_client.py
index aefe0c8..5f872d4 100644
--- a/mcp/tests/test_mcp_http_client.py
+++ b/mcp/tests/test_mcp_http_client.py
@@ -1,6 +1,17 @@
import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
-from telegram_mcp.mcp_http_client import McpCliError, endpoint_attempts
+from telegram_mcp.mcp_http_client import (
+ McpCliError,
+ McpToolError,
+ call_tool_with_failover,
+ content_payload,
+ endpoint_attempts,
+ payload_is_tool_error,
+ result_is_tool_error,
+ tool_error_payload,
+)
class McpHttpClientTests(unittest.TestCase):
@@ -50,6 +61,57 @@ def test_endpoint_attempts_allow_explicit_same_account_failover_ports(self):
self.assertEqual([attempt.port for attempt in attempts], [8799, 8798])
+ def test_payload_is_tool_error_detects_contract_error_strings(self):
+ for payload in (
+ "permission_denied: no access | next: ask user",
+ "rate_limited: retry later",
+ "archive_route_blocked: use live Telegram",
+ {"error": "invalid_date_range: date_from must not exceed date_to"},
+ {"code": "human_approval_required"},
+ ):
+ with self.subTest(payload=payload):
+ self.assertTrue(payload_is_tool_error(payload))
+
+ def test_result_is_tool_error_honors_mcp_is_error(self):
+ result = SimpleNamespace(
+ isError=True,
+ structuredContent={"error": "invalid_input: bad chat"},
+ content=[SimpleNamespace(text='{"error": "invalid_input: bad chat"}')],
+ )
+ payload = content_payload(result)
+
+ self.assertTrue(result_is_tool_error(result, payload))
+ self.assertEqual(tool_error_payload(result, payload), {"error": "invalid_input: bad chat"})
+
+ def test_tool_errors_do_not_fail_over_to_next_endpoint(self):
+ attempts = [
+ SimpleNamespace(endpoint="http://127.0.0.1:8799/mcp", env_file="", port=8799),
+ SimpleNamespace(endpoint="http://127.0.0.1:8798/mcp", env_file="", port=8798),
+ ]
+ tool_error = McpToolError(
+ endpoint=attempts[0].endpoint,
+ port=attempts[0].port,
+ payload={"error": "permission_denied: private channel"},
+ elapsed_seconds=0.1,
+ )
+
+ with patch("telegram_mcp.mcp_http_client.endpoint_attempts", return_value=attempts), patch(
+ "telegram_mcp.mcp_http_client.call_tool_once",
+ AsyncMock(side_effect=tool_error),
+ ) as call_once:
+ import asyncio
+
+ with self.assertRaises(McpToolError):
+ asyncio.run(
+ call_tool_with_failover(
+ tool_name="telegram_count_posts",
+ arguments={"chat": "@private"},
+ timeout=5,
+ )
+ )
+
+ self.assertEqual(call_once.await_count, 1)
+
if __name__ == "__main__":
unittest.main()
diff --git a/mcp/tests/test_member_export_paths.py b/mcp/tests/test_member_export_paths.py
index ee72d57..a419985 100644
--- a/mcp/tests/test_member_export_paths.py
+++ b/mcp/tests/test_member_export_paths.py
@@ -27,6 +27,20 @@ def test_explicit_output_dir_outside_private_export_root_is_rejected(self) -> No
with self.assertRaisesRegex(ValueError, "private export root"):
resolve_member_export_dir(str(outside))
+ @unittest.skipIf(not hasattr(Path, "symlink_to"), "symlinks are not supported")
+ def test_symlinked_private_export_root_is_rejected(self) -> None:
+ with TemporaryDirectory() as tmp:
+ home = Path(tmp) / "home"
+ target = Path(tmp) / "outside"
+ target.mkdir(parents=True)
+ root = home / ".cache" / "telegram-mcp" / "member-exports"
+ root.parent.mkdir(parents=True)
+ root.symlink_to(target, target_is_directory=True)
+
+ with patch.dict("os.environ", {"HOME": str(home)}):
+ with self.assertRaisesRegex(ValueError, "must not be a symlink"):
+ resolve_member_export_dir(None)
+
if __name__ == "__main__":
unittest.main()
diff --git a/mcp/tests/test_registration.py b/mcp/tests/test_registration.py
index 59b53c6..88067bb 100644
--- a/mcp/tests/test_registration.py
+++ b/mcp/tests/test_registration.py
@@ -33,6 +33,7 @@ def test_facade_profile_remains_available_when_explicit(self):
self.assertNotIn("create_channel", names)
self.assertNotIn("delete_messages", names)
self.assertNotIn("telegram_export_members", names)
+ self.assertNotIn("send_file", names)
def test_full_tool_registration_surface_is_stable(self):
mcp = FastMCP("test")
diff --git a/mcp/tests/test_runtime.py b/mcp/tests/test_runtime.py
index dfd95e5..9b62cc4 100644
--- a/mcp/tests/test_runtime.py
+++ b/mcp/tests/test_runtime.py
@@ -69,6 +69,33 @@ def test_run_server_allows_disabling_json_response(self):
self.assertFalse(runtime.mcp.settings.json_response)
mcp_run.assert_called_once_with(transport="streamable-http")
+ def test_configure_transport_auth_replaces_oauth_metadata_route(self):
+ original_routes = list(runtime.mcp._custom_starlette_routes)
+ try:
+ with patch.dict(
+ "os.environ",
+ {
+ "TELEGRAM_MCP_TRANSPORT": "streamable-http",
+ "TELEGRAM_MCP_HOST": "127.0.0.1",
+ "TELEGRAM_MCP_PORT": "8799",
+ "TELEGRAM_MCP_AUTH_TOKEN": "test-token",
+ },
+ clear=False,
+ ):
+ get_settings.cache_clear()
+ runtime.configure_transport_auth("streamable-http")
+ runtime.configure_transport_auth("streamable-http")
+
+ routes = [
+ route
+ for route in runtime.mcp._custom_starlette_routes
+ if getattr(route, "path", None)
+ == runtime.OAUTH_AUTHORIZATION_SERVER_METADATA_PATH
+ ]
+ self.assertEqual(len(routes), 1)
+ finally:
+ runtime.mcp._custom_starlette_routes = original_routes
+
def test_run_server_disconnects_shared_wrapper_on_shutdown(self):
with patch("telegram_mcp.runtime.shared_mode_enabled", return_value=True):
with patch.object(runtime.mcp, "run") as mcp_run:
diff --git a/mcp/tests/test_send_confirmation.py b/mcp/tests/test_send_confirmation.py
index 2e0cb38..42273ff 100644
--- a/mcp/tests/test_send_confirmation.py
+++ b/mcp/tests/test_send_confirmation.py
@@ -3,12 +3,15 @@
from __future__ import annotations
import unittest
+from http.client import HTTPConnection
+from urllib.parse import urlencode
from unittest.mock import patch
+import telegram_mcp.approval_server as approval_server
from telegram_mcp.client import TelegramWrapper
from telegram_mcp.config import Settings
from telegram_mcp.errors import ToolContractError
-from telegram_mcp.send_confirmation import SendConfirmationStore
+from telegram_mcp.send_confirmation import SendConfirmationStore, bind_confirmation_store
from tests.test_client import DummyTelegramClient, _run
@@ -104,6 +107,64 @@ def test_reject_blocks_consume(self):
store.consume(token, payload, approval_required=True)
self.assertEqual(ctx.exception.code, "confirmation_rejected")
+ def test_approve_by_token_then_consume_removes_token_lookup(self):
+ store = SendConfirmationStore(ttl_seconds=60)
+ payload = {"chat": "@x", "text_hash": "abc"}
+ preview_id, token, _ = store.mint(payload, preview_text="hi")
+
+ store.approve(token)
+ store.consume(preview_id, None, approval_required=True, preview_id_only=True)
+
+ self.assertIsNone(store.get(token))
+ self.assertIsNone(store.get(preview_id))
+
+
+class ApprovalServerTests(unittest.TestCase):
+ def tearDown(self):
+ approval_server.stop_approval_server()
+
+ def test_rejects_non_loopback_host(self):
+ with self.assertRaisesRegex(ValueError, "loopback"):
+ approval_server.start_approval_server(host="0.0.0.0", port=0)
+
+ def test_get_does_not_approve_and_post_requires_nonce(self):
+ store = SendConfirmationStore(ttl_seconds=60)
+ payload = {"chat": "@x", "send_tool": "send_dialog_message", "text_hash": "abc"}
+ _preview_id, token, _ = store.mint(payload, preview_text="hi")
+ record = store.get(token)
+ assert record is not None
+ bind_confirmation_store(store)
+ approval_server.start_approval_server(host="127.0.0.1", port=0)
+ server = approval_server._server
+ assert server is not None
+ port = server.server_address[1]
+
+ conn = HTTPConnection("127.0.0.1", port)
+ conn.request("GET", f"/telegram/approve?token={token}&action=approve")
+ response = conn.getresponse()
+ response.read()
+ conn.close()
+ self.assertEqual(response.status, 200)
+ self.assertEqual(store.get(token).approval_state, "pending") # type: ignore[union-attr]
+
+ body = urlencode({"token": token, "nonce": "wrong", "action": "approve"})
+ conn = HTTPConnection("127.0.0.1", port)
+ conn.request("POST", "/telegram/approve", body=body, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ response = conn.getresponse()
+ response.read()
+ conn.close()
+ self.assertEqual(response.status, 400)
+ self.assertEqual(store.get(token).approval_state, "pending") # type: ignore[union-attr]
+
+ body = urlencode({"token": token, "nonce": record.one_time_nonce, "action": "approve"})
+ conn = HTTPConnection("127.0.0.1", port)
+ conn.request("POST", "/telegram/approve", body=body, headers={"Content-Type": "application/x-www-form-urlencoded"})
+ response = conn.getresponse()
+ response.read()
+ conn.close()
+ self.assertEqual(response.status, 200)
+ self.assertEqual(store.get(token).approval_state, "approved") # type: ignore[union-attr]
+
if __name__ == "__main__":
unittest.main()
diff --git a/mcp/tests/test_tg_cli.py b/mcp/tests/test_tg_cli.py
index e26b09e..e10686b 100644
--- a/mcp/tests/test_tg_cli.py
+++ b/mcp/tests/test_tg_cli.py
@@ -2,25 +2,11 @@
from unittest.mock import AsyncMock, patch
from telegram_mcp.metadata_tools_spec import COUNT_SPECS_BY_CLI, LIST_SPECS_BY_CLI
-from telegram_mcp.tg_cli import _wrap_ok, cmd_count_metadata, cmd_count_posts, cmd_list_metadata, route_task
+from telegram_mcp.mcp_http_client import McpToolError
+from telegram_mcp.tg_cli import cmd_count_metadata, cmd_count_posts, cmd_list_metadata, route_task
class TgCliTests(unittest.TestCase):
- def test_wrap_ok_marks_tool_error_payload_as_failure(self):
- payload = _wrap_ok(
- command="read today",
- endpoint="http://127.0.0.1:8799/mcp",
- endpoint_port=8799,
- elapsed_seconds=0.1,
- payload="Error executing tool telegram_read: raw failure",
- intent="live_today",
- )
-
- self.assertIs(payload["ok"], False)
- self.assertEqual(payload["error"], "telegram_tool_error")
- self.assertEqual(payload["data_source"], "live_telegram")
- self.assertEqual(payload["tool_error_payload"], "Error executing tool telegram_read: raw failure")
-
def test_route_task_plans_channel_post_count_without_execution(self):
payload = route_task("@sral_v_nastav сколько постов в этом канале всего?")
@@ -116,6 +102,35 @@ async def fake_call_tool_with_failover(**kwargs):
self.assertTrue(payload["ok"])
self.assertEqual(payload["intent"], "count_channel_videos")
+ def test_count_metadata_records_error_telemetry_for_tool_error(self):
+ async def fake_call_tool_with_failover(**kwargs):
+ raise McpToolError(
+ endpoint="http://127.0.0.1:8799/mcp",
+ port=8799,
+ payload={"error": "invalid_input: bad chat"},
+ elapsed_seconds=0.1,
+ )
+
+ with patch("telegram_mcp.tg_cli.call_tool_with_failover", AsyncMock(side_effect=fake_call_tool_with_failover)):
+ with patch("telegram_mcp.tg_cli.record_telemetry") as telemetry:
+ import asyncio
+
+ payload = asyncio.run(
+ cmd_count_metadata(
+ chat="@bad",
+ spec=COUNT_SPECS_BY_CLI["videos"],
+ timeout=5,
+ endpoint=None,
+ env_file=None,
+ account="main",
+ )
+ )
+
+ self.assertFalse(payload["ok"])
+ self.assertEqual(payload["error"], "telegram_tool_error")
+ self.assertEqual(payload["tool_error_payload"], {"error": "invalid_input: bad chat"})
+ self.assertEqual(telemetry.call_args.kwargs["status"], "error")
+
def test_list_metadata_uses_spec_tool(self):
async def fake_call_tool_with_failover(**kwargs):
self.assertEqual(kwargs["tool_name"], "telegram_list_links")