Merge branch 'main' into fix/entity-resolution-stringsession
This commit is contained in:
commit
dad686a2d5
7 changed files with 1055 additions and 154 deletions
40
README.md
40
README.md
|
|
@ -101,16 +101,24 @@ This MCP server exposes a huge suite of Telegram tools. **Every major Telegram/T
|
||||||
### User & Profile
|
### User & Profile
|
||||||
- **get_me()**: Get your user info
|
- **get_me()**: Get your user info
|
||||||
- **update_profile(first_name, last_name, about)**: Update your profile
|
- **update_profile(first_name, last_name, about)**: Update your profile
|
||||||
|
- **set_profile_photo(file_path)**: Set a profile photo from an allowed root path
|
||||||
- **delete_profile_photo()**: Remove your profile photo
|
- **delete_profile_photo()**: Remove your profile photo
|
||||||
- **get_user_photos(user_id, limit)**: Get a user's profile photos
|
- **get_user_photos(user_id, limit)**: Get a user's profile photos
|
||||||
- **get_user_status(user_id)**: Get a user's online status
|
- **get_user_status(user_id)**: Get a user's online status
|
||||||
|
|
||||||
### Media
|
### Media
|
||||||
- **get_media_info(chat_id, message_id)**: Get info about media in a message
|
- **get_media_info(chat_id, message_id)**: Get info about media in a message
|
||||||
|
- **send_file(chat_id, file_path, caption)**: Send a local file from allowed roots
|
||||||
|
- **download_media(chat_id, message_id, file_path)**: Save message media under allowed roots
|
||||||
|
- **upload_file(file_path)**: Upload a local file and return upload metadata
|
||||||
|
- **send_voice(chat_id, file_path)**: Send `.ogg/.opus` voice note from allowed roots
|
||||||
|
- **send_sticker(chat_id, file_path)**: Send `.webp` sticker from allowed roots
|
||||||
|
- **edit_chat_photo(chat_id, file_path)**: Update chat photo from allowed roots
|
||||||
|
|
||||||
### Search & Discovery
|
### Search & Discovery
|
||||||
- **search_public_chats(query)**: Search public chats/channels/bots
|
- **search_public_chats(query, limit)**: Search public chats/channels/bots with a configurable result limit
|
||||||
- **search_messages(chat_id, query, limit)**: Search messages in a chat
|
- **search_messages(chat_id, query, limit)**: Search messages in a chat
|
||||||
|
- **search_global(query, page, page_size)**: Search messages globally with pagination
|
||||||
- **resolve_username(username)**: Resolve a username to ID
|
- **resolve_username(username)**: Resolve a username to ID
|
||||||
|
|
||||||
### Stickers, GIFs, Bots
|
### Stickers, GIFs, Bots
|
||||||
|
|
@ -142,11 +150,28 @@ To improve robustness, all functions accepting `chat_id` or `user_id` parameters
|
||||||
|
|
||||||
The server will automatically validate the input and convert it to the correct format before making a request to Telegram. If the input is invalid, a clear error message will be returned.
|
The server will automatically validate the input and convert it to the correct format before making a request to Telegram. If the input is invalid, a clear error message will be returned.
|
||||||
|
|
||||||
## Removed Functionality
|
## File-path Tools Security Model
|
||||||
|
|
||||||
Please note that tools requiring direct file path access on the server (`send_file`, `download_media`, `set_profile_photo`, `edit_chat_photo`, `send_voice`, `send_sticker`, `upload_file`) have been removed from `main.py`. This is due to limitations in the current MCP environment regarding handling file attachments and local file system paths.
|
File-path tools are available, but **disabled by default** until allowed roots are configured.
|
||||||
|
|
||||||
Additionally, GIF-related tools (`get_gif_search`, `get_saved_gifs`, `send_gif`) have been removed due to ongoing issues with reliability in the Telethon library or Telegram API interactions.
|
Supported file-path tools:
|
||||||
|
- `send_file`, `download_media`, `set_profile_photo`, `edit_chat_photo`, `send_voice`, `send_sticker`, `upload_file`
|
||||||
|
|
||||||
|
Security semantics (aligned with MCP filesystem server):
|
||||||
|
- Server-side allowlist via CLI positional arguments (fallback when Roots API is unsupported).
|
||||||
|
- Client-provided MCP Roots replace the server allowlist when available.
|
||||||
|
- If the client returns an empty Roots list, file-path tools are disabled (deny-all).
|
||||||
|
- All paths are resolved via realpath and must stay inside an allowed root.
|
||||||
|
- Traversal/glob-like patterns are rejected (`..`, `*`, `?`, `~`, etc.).
|
||||||
|
- Relative paths resolve against the first allowed root.
|
||||||
|
- Write tools default to `<first_root>/downloads/` when `file_path` is omitted.
|
||||||
|
|
||||||
|
Example server launch with allowlisted roots:
|
||||||
|
```bash
|
||||||
|
uv --directory /full/path/to/telegram-mcp run main.py /data/telegram /tmp/telegram-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
GIF tools are currently limited: `get_gif_search` and `send_gif` are available, while `get_saved_gifs` is not implemented due to reliability limits in Telethon/Telegram API interactions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -509,13 +534,14 @@ Successfully joined chat: Developer Community
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def search_public_chats(query: str) -> str:
|
async def search_public_chats(query: str, limit: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
Search for public chats, channels, or bots by username or title.
|
Search for public chats, channels, or bots by username or title.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await client(functions.contacts.SearchRequest(q=query, limit=20))
|
result = await client(functions.contacts.SearchRequest(q=query, limit=limit))
|
||||||
return json.dumps([format_entity(u) for u in result.users], indent=2)
|
entities = [format_entity(e) for e in result.chats + result.users]
|
||||||
|
return json.dumps(entities, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return f"Error searching public chats: {e}"
|
return f"Error searching public chats: {e}"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
602
main.py
602
main.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
|
|
@ -9,12 +10,15 @@ import mimetypes
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List, Dict, Optional, Union, Any
|
from typing import List, Dict, Optional, Union, Any
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
import nest_asyncio
|
import nest_asyncio
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from mcp.server.fastmcp import FastMCP
|
from mcp.server.fastmcp import FastMCP, Context
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
from pythonjsonlogger import jsonlogger
|
from pythonjsonlogger import jsonlogger
|
||||||
from telethon import TelegramClient, functions, types, utils
|
from telethon import TelegramClient, functions, types, utils
|
||||||
from telethon.sessions import StringSession
|
from telethon.sessions import StringSession
|
||||||
|
|
@ -33,6 +37,7 @@ from telethon.tl.types import (
|
||||||
InputPeerChat,
|
InputPeerChat,
|
||||||
InputPeerChannel,
|
InputPeerChannel,
|
||||||
DialogFilter,
|
DialogFilter,
|
||||||
|
DialogFilterChatlist,
|
||||||
DialogFilterDefault,
|
DialogFilterDefault,
|
||||||
TextWithEntities,
|
TextWithEntities,
|
||||||
)
|
)
|
||||||
|
|
@ -133,12 +138,38 @@ try:
|
||||||
logger.addHandler(file_handler)
|
logger.addHandler(file_handler)
|
||||||
logger.info(f"Logging initialized to {log_file_path}")
|
logger.info(f"Logging initialized to {log_file_path}")
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
print(f"WARNING: Error setting up log file: {log_error}")
|
print(f"WARNING: Error setting up log file: {log_error}", file=sys.stderr)
|
||||||
# Fallback to console-only logging
|
# Fallback to console-only logging
|
||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
logger.error(f"Failed to set up log file handler: {log_error}")
|
logger.error(f"Failed to set up log file handler: {log_error}")
|
||||||
|
|
||||||
|
|
||||||
|
# File-path tool security configuration
|
||||||
|
SERVER_ALLOWED_ROOTS: list[Path] = []
|
||||||
|
DEFAULT_DOWNLOAD_SUBDIR = "downloads"
|
||||||
|
DISALLOWED_PATH_PATTERNS = ("*", "?", "[", "]", "{", "}", "~", "\x00")
|
||||||
|
EXTENSION_ALLOWLISTS: dict[str, set[str]] = {
|
||||||
|
"send_voice": {".ogg", ".opus"},
|
||||||
|
"send_sticker": {".webp"},
|
||||||
|
"set_profile_photo": {".jpg", ".jpeg", ".png", ".webp"},
|
||||||
|
"edit_chat_photo": {".jpg", ".jpeg", ".png", ".webp"},
|
||||||
|
}
|
||||||
|
MAX_FILE_BYTES: dict[str, int] = {
|
||||||
|
"send_file": 200 * 1024 * 1024, # 200 MB
|
||||||
|
"upload_file": 200 * 1024 * 1024,
|
||||||
|
"send_voice": 100 * 1024 * 1024,
|
||||||
|
"send_sticker": 10 * 1024 * 1024,
|
||||||
|
"set_profile_photo": 50 * 1024 * 1024,
|
||||||
|
"edit_chat_photo": 50 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
ROOTS_UNSUPPORTED_ERROR_CODES = {-32601}
|
||||||
|
ROOTS_STATUS_READY = "ready"
|
||||||
|
ROOTS_STATUS_NOT_CONFIGURED = "not_configured"
|
||||||
|
ROOTS_STATUS_UNSUPPORTED_FALLBACK = "unsupported_fallback"
|
||||||
|
ROOTS_STATUS_CLIENT_DENY_ALL = "client_deny_all"
|
||||||
|
ROOTS_STATUS_ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
# Error code prefix mapping for better error tracing
|
# Error code prefix mapping for better error tracing
|
||||||
class ErrorCategory(str, Enum):
|
class ErrorCategory(str, Enum):
|
||||||
CHAT = "CHAT"
|
CHAT = "CHAT"
|
||||||
|
|
@ -386,6 +417,274 @@ def get_engagement_info(message) -> str:
|
||||||
return f" | {', '.join(engagement_parts)}" if engagement_parts else ""
|
return f" | {', '.join(engagement_parts)}" if engagement_parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_paths(paths: List[Path]) -> List[Path]:
|
||||||
|
seen: set[str] = set()
|
||||||
|
result: List[Path] = []
|
||||||
|
for path in paths:
|
||||||
|
key = str(path)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
result.append(path)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_forbidden_path_patterns(raw_path: str) -> Optional[str]:
|
||||||
|
value = raw_path.strip()
|
||||||
|
if not value:
|
||||||
|
return "Path must not be empty."
|
||||||
|
if any(token in value for token in DISALLOWED_PATH_PATTERNS):
|
||||||
|
return "Path contains disallowed wildcard/shell patterns."
|
||||||
|
if ".." in Path(value).parts:
|
||||||
|
return "Path traversal is not allowed."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_root_uri_to_path(uri: str) -> Path:
|
||||||
|
parsed = urlparse(uri)
|
||||||
|
if parsed.scheme != "file":
|
||||||
|
raise ValueError(f"Unsupported root URI scheme: {parsed.scheme}")
|
||||||
|
|
||||||
|
decoded_path = unquote(parsed.path or "")
|
||||||
|
if parsed.netloc and parsed.netloc not in ("", "localhost"):
|
||||||
|
decoded_path = f"//{parsed.netloc}{decoded_path}"
|
||||||
|
if os.name == "nt" and decoded_path.startswith("/") and len(decoded_path) > 2:
|
||||||
|
# file:///C:/tmp -> C:/tmp on Windows
|
||||||
|
if decoded_path[2] == ":":
|
||||||
|
decoded_path = decoded_path[1:]
|
||||||
|
return Path(decoded_path).resolve(strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _path_is_within_root(candidate: Path, root: Path) -> bool:
|
||||||
|
root = root.resolve()
|
||||||
|
if root.is_file():
|
||||||
|
return candidate == root
|
||||||
|
return candidate == root or root in candidate.parents
|
||||||
|
|
||||||
|
|
||||||
|
def _path_is_within_any_root(candidate: Path, roots: List[Path]) -> bool:
|
||||||
|
return any(_path_is_within_root(candidate, root) for root in roots)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_resolution_root(roots: List[Path]) -> Path:
|
||||||
|
first = roots[0]
|
||||||
|
return first if first.is_dir() else first.parent
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_extension_allowed(tool_name: str, candidate: Path) -> Optional[str]:
|
||||||
|
allowlist = EXTENSION_ALLOWLISTS.get(tool_name)
|
||||||
|
if not allowlist:
|
||||||
|
return None
|
||||||
|
if candidate.suffix.lower() not in allowlist:
|
||||||
|
allowed = ", ".join(sorted(allowlist))
|
||||||
|
return f"File extension is not allowed for {tool_name}. Allowed: {allowed}."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_size_within_limit(tool_name: str, candidate: Path) -> Optional[str]:
|
||||||
|
max_bytes = MAX_FILE_BYTES.get(tool_name)
|
||||||
|
if not max_bytes:
|
||||||
|
return None
|
||||||
|
size = candidate.stat().st_size
|
||||||
|
if size > max_bytes:
|
||||||
|
return f"File is too large for {tool_name}: {size} bytes " f"(limit: {max_bytes} bytes)."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_effective_allowed_roots(ctx: Optional[Context]) -> List[Path]:
|
||||||
|
roots, _status = await _get_effective_allowed_roots_with_status(ctx)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _is_roots_unsupported_error(error: Exception) -> bool:
|
||||||
|
if isinstance(error, McpError):
|
||||||
|
error_code = getattr(getattr(error, "error", None), "code", None)
|
||||||
|
error_message = (
|
||||||
|
getattr(getattr(error, "error", None), "message", None) or str(error)
|
||||||
|
).lower()
|
||||||
|
if error_code in ROOTS_UNSUPPORTED_ERROR_CODES:
|
||||||
|
return True
|
||||||
|
return "method not found" in error_message or "not implemented" in error_message
|
||||||
|
|
||||||
|
if isinstance(error, NotImplementedError):
|
||||||
|
return True
|
||||||
|
if isinstance(error, AttributeError):
|
||||||
|
return "list_roots" in str(error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_effective_allowed_roots_with_status(
|
||||||
|
ctx: Optional[Context],
|
||||||
|
) -> tuple[List[Path], str]:
|
||||||
|
fallback_roots = list(SERVER_ALLOWED_ROOTS)
|
||||||
|
if ctx is None:
|
||||||
|
if fallback_roots:
|
||||||
|
return fallback_roots, ROOTS_STATUS_READY
|
||||||
|
return [], ROOTS_STATUS_NOT_CONFIGURED
|
||||||
|
|
||||||
|
try:
|
||||||
|
list_roots_result = await ctx.session.list_roots()
|
||||||
|
except Exception as error:
|
||||||
|
if _is_roots_unsupported_error(error):
|
||||||
|
if fallback_roots:
|
||||||
|
return fallback_roots, ROOTS_STATUS_UNSUPPORTED_FALLBACK
|
||||||
|
return [], ROOTS_STATUS_NOT_CONFIGURED
|
||||||
|
logger.error(
|
||||||
|
"MCP roots request failed; disabling file-path tools for safety.", exc_info=True
|
||||||
|
)
|
||||||
|
return [], ROOTS_STATUS_ERROR
|
||||||
|
|
||||||
|
client_roots: List[Path] = []
|
||||||
|
for root in list_roots_result.roots:
|
||||||
|
try:
|
||||||
|
client_roots.append(_coerce_root_uri_to_path(str(root.uri)))
|
||||||
|
except Exception:
|
||||||
|
# Ignore invalid root entries supplied by a client.
|
||||||
|
continue
|
||||||
|
|
||||||
|
if client_roots:
|
||||||
|
return _dedupe_paths(client_roots), ROOTS_STATUS_READY
|
||||||
|
|
||||||
|
# Roots API succeeded; an empty roots list is treated as explicit deny-all.
|
||||||
|
return [], ROOTS_STATUS_CLIENT_DENY_ALL
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_allowed_roots(
|
||||||
|
ctx: Optional[Context], tool_name: str
|
||||||
|
) -> tuple[List[Path], Optional[str]]:
|
||||||
|
roots, status = await _get_effective_allowed_roots_with_status(ctx)
|
||||||
|
if not roots:
|
||||||
|
if status == ROOTS_STATUS_CLIENT_DENY_ALL:
|
||||||
|
return (
|
||||||
|
[],
|
||||||
|
(
|
||||||
|
f"{tool_name} is disabled because the client provided an empty "
|
||||||
|
"MCP Roots list (deny-all)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if status == ROOTS_STATUS_ERROR:
|
||||||
|
return (
|
||||||
|
[],
|
||||||
|
(
|
||||||
|
f"{tool_name} is disabled because MCP Roots could not be verified safely. "
|
||||||
|
"Check MCP client/server logs."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
[],
|
||||||
|
(
|
||||||
|
f"{tool_name} is disabled until allowed roots are configured. "
|
||||||
|
"Provide server CLI roots and/or client MCP Roots."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return roots, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_readable_file_path(
|
||||||
|
*,
|
||||||
|
raw_path: str,
|
||||||
|
ctx: Optional[Context],
|
||||||
|
tool_name: str,
|
||||||
|
) -> tuple[Optional[Path], Optional[str]]:
|
||||||
|
roots, error = await _ensure_allowed_roots(ctx, tool_name)
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
pattern_error = _contains_forbidden_path_patterns(raw_path)
|
||||||
|
if pattern_error:
|
||||||
|
return None, pattern_error
|
||||||
|
|
||||||
|
candidate = Path(raw_path.strip())
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = _first_resolution_root(roots) / candidate
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate = candidate.resolve(strict=True)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None, f"File not found: {raw_path}"
|
||||||
|
|
||||||
|
if not _path_is_within_any_root(candidate, roots):
|
||||||
|
return None, "Path is outside allowed roots."
|
||||||
|
if not candidate.is_file():
|
||||||
|
return None, f"Path is not a file: {candidate}"
|
||||||
|
if not os.access(candidate, os.R_OK):
|
||||||
|
return None, f"File is not readable: {candidate}"
|
||||||
|
|
||||||
|
extension_error = _ensure_extension_allowed(tool_name, candidate)
|
||||||
|
if extension_error:
|
||||||
|
return None, extension_error
|
||||||
|
|
||||||
|
size_error = _ensure_size_within_limit(tool_name, candidate)
|
||||||
|
if size_error:
|
||||||
|
return None, size_error
|
||||||
|
|
||||||
|
return candidate, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_writable_file_path(
|
||||||
|
*,
|
||||||
|
raw_path: Optional[str],
|
||||||
|
default_filename: str,
|
||||||
|
ctx: Optional[Context],
|
||||||
|
tool_name: str,
|
||||||
|
) -> tuple[Optional[Path], Optional[str]]:
|
||||||
|
roots, error = await _ensure_allowed_roots(ctx, tool_name)
|
||||||
|
if error:
|
||||||
|
return None, error
|
||||||
|
|
||||||
|
if raw_path and raw_path.strip():
|
||||||
|
pattern_error = _contains_forbidden_path_patterns(raw_path)
|
||||||
|
if pattern_error:
|
||||||
|
return None, pattern_error
|
||||||
|
candidate = Path(raw_path.strip())
|
||||||
|
if not candidate.is_absolute():
|
||||||
|
candidate = _first_resolution_root(roots) / candidate
|
||||||
|
else:
|
||||||
|
safe_name = Path(default_filename).name
|
||||||
|
candidate = _first_resolution_root(roots) / DEFAULT_DOWNLOAD_SUBDIR / safe_name
|
||||||
|
|
||||||
|
candidate = candidate.resolve(strict=False)
|
||||||
|
parent = candidate.parent.resolve(strict=False)
|
||||||
|
if not _path_is_within_any_root(candidate, roots) or not _path_is_within_any_root(
|
||||||
|
parent, roots
|
||||||
|
):
|
||||||
|
return None, "Path is outside allowed roots."
|
||||||
|
|
||||||
|
extension_error = _ensure_extension_allowed(tool_name, candidate)
|
||||||
|
if extension_error:
|
||||||
|
return None, extension_error
|
||||||
|
|
||||||
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not os.access(parent, os.W_OK):
|
||||||
|
return None, f"Directory not writable: {parent}"
|
||||||
|
|
||||||
|
return candidate, None
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_allowed_roots_from_cli(argv: Optional[List[str]] = None) -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="telegram-mcp",
|
||||||
|
add_help=False,
|
||||||
|
description=(
|
||||||
|
"Optional positional arguments define server-side allowed roots "
|
||||||
|
"for file-path tools."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("allowed_roots", nargs="*")
|
||||||
|
parsed, _unknown = parser.parse_known_args(argv or [])
|
||||||
|
|
||||||
|
resolved_roots: List[Path] = []
|
||||||
|
for raw_root in parsed.allowed_roots:
|
||||||
|
root = Path(raw_root).expanduser()
|
||||||
|
if not root.exists():
|
||||||
|
raise SystemExit(f"Allowed root does not exist: {root}")
|
||||||
|
resolved = root.resolve(strict=True)
|
||||||
|
resolved_roots.append(resolved)
|
||||||
|
|
||||||
|
global SERVER_ALLOWED_ROOTS
|
||||||
|
SERVER_ALLOWED_ROOTS = _dedupe_paths(resolved_roots)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True))
|
@mcp.tool(annotations=ToolAnnotations(title="Get Chats", openWorldHint=True, readOnlyHint=True))
|
||||||
async def get_chats(page: int = 1, page_size: int = 20) -> str:
|
async def get_chats(page: int = 1, page_size: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -451,12 +750,17 @@ async def get_messages(chat_id: Union[int, str], page: int = 1, page_size: int =
|
||||||
annotations=ToolAnnotations(title="Send Message", openWorldHint=True, destructiveHint=True)
|
annotations=ToolAnnotations(title="Send Message", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def send_message(chat_id: Union[int, str], message: str) -> str:
|
async def send_message(
|
||||||
|
chat_id: Union[int, str], message: str, parse_mode: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a message to a specific chat.
|
Send a message to a specific chat.
|
||||||
Args:
|
Args:
|
||||||
chat_id: The ID or username of the chat.
|
chat_id: The ID or username of the chat.
|
||||||
message: The message content to send.
|
message: The message content to send.
|
||||||
|
parse_mode: Optional formatting mode. Use 'html' for HTML tags (<b>, <i>, <code>, <pre>,
|
||||||
|
<a href="...">), 'md' or 'markdown' for Markdown (**bold**, __italic__, `code`,
|
||||||
|
```pre```), or omit for plain text (no formatting).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
entity = await resolve_entity(chat_id)
|
entity = await resolve_entity(chat_id)
|
||||||
|
|
@ -1753,12 +2057,17 @@ async def get_participants(chat_id: Union[int, str]) -> str:
|
||||||
|
|
||||||
@mcp.tool(annotations=ToolAnnotations(title="Send File", openWorldHint=True, destructiveHint=True))
|
@mcp.tool(annotations=ToolAnnotations(title="Send File", openWorldHint=True, destructiveHint=True))
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def send_file(chat_id: Union[int, str], file_path: str, caption: str = None) -> str:
|
async def send_file(
|
||||||
|
chat_id: Union[int, str],
|
||||||
|
file_path: str,
|
||||||
|
caption: str = None,
|
||||||
|
ctx: Optional[Context] = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a file to a chat.
|
Send a file to a chat.
|
||||||
Args:
|
Args:
|
||||||
chat_id: The chat ID or username.
|
chat_id: The chat ID or username.
|
||||||
file_path: Absolute path to the file to send (must exist and be readable).
|
file_path: Absolute or relative path to the file under allowed roots.
|
||||||
caption: Optional caption for the file.
|
caption: Optional caption for the file.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1776,30 +2085,51 @@ async def send_file(chat_id: Union[int, str], file_path: str, caption: str = Non
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations=ToolAnnotations(title="Download Media", openWorldHint=True, readOnlyHint=True)
|
annotations=ToolAnnotations(title="Download Media", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def download_media(chat_id: Union[int, str], message_id: int, file_path: str) -> str:
|
async def download_media(
|
||||||
|
chat_id: Union[int, str],
|
||||||
|
message_id: int,
|
||||||
|
file_path: Optional[str] = None,
|
||||||
|
ctx: Optional[Context] = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Download media from a message in a chat.
|
Download media from a message in a chat.
|
||||||
Args:
|
Args:
|
||||||
chat_id: The chat ID or username.
|
chat_id: The chat ID or username.
|
||||||
message_id: The message ID containing the media.
|
message_id: The message ID containing the media.
|
||||||
file_path: Absolute path to save the downloaded file (must be writable).
|
file_path: Optional absolute or relative path under allowed roots.
|
||||||
|
If omitted, saves into `<first_root>/downloads/`.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
entity = await resolve_entity(chat_id)
|
entity = await resolve_entity(chat_id)
|
||||||
msg = await client.get_messages(entity, ids=message_id)
|
msg = await client.get_messages(entity, ids=message_id)
|
||||||
if not msg or not msg.media:
|
if not msg or not msg.media:
|
||||||
return "No media found in the specified message."
|
return "No media found in the specified message."
|
||||||
# Check if directory is writable
|
|
||||||
dir_path = os.path.dirname(file_path) or "."
|
default_name = f"telegram_{chat_id}_{message_id}_{int(time.time())}"
|
||||||
if not os.access(dir_path, os.W_OK):
|
out_path, path_error = await _resolve_writable_file_path(
|
||||||
return f"Directory not writable: {dir_path}"
|
raw_path=file_path,
|
||||||
await client.download_media(msg, file=file_path)
|
default_filename=default_name,
|
||||||
if not os.path.isfile(file_path):
|
ctx=ctx,
|
||||||
return f"Download failed: file not created at {file_path}"
|
tool_name="download_media",
|
||||||
return f"Media downloaded to {file_path}."
|
)
|
||||||
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
|
||||||
|
downloaded = await client.download_media(msg, file=str(out_path))
|
||||||
|
if not downloaded:
|
||||||
|
return f"Download failed for message {message_id}."
|
||||||
|
|
||||||
|
final_path = Path(downloaded).resolve(strict=True)
|
||||||
|
roots, roots_error = await _ensure_allowed_roots(ctx, "download_media")
|
||||||
|
if roots_error:
|
||||||
|
return roots_error
|
||||||
|
if not _path_is_within_any_root(final_path, roots):
|
||||||
|
return "Download failed: resulting path is outside allowed roots."
|
||||||
|
|
||||||
|
return f"Media downloaded to {final_path}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return log_and_format_error(
|
return log_and_format_error(
|
||||||
"download_media",
|
"download_media",
|
||||||
|
|
@ -1837,15 +2167,24 @@ async def update_profile(first_name: str = None, last_name: str = None, about: s
|
||||||
title="Set Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True
|
title="Set Profile Photo", openWorldHint=True, destructiveHint=True, idempotentHint=True
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
async def set_profile_photo(file_path: str) -> str:
|
async def set_profile_photo(file_path: str, ctx: Optional[Context] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Set a new profile photo.
|
Set a new profile photo.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
await client(
|
safe_path, path_error = await _resolve_readable_file_path(
|
||||||
functions.photos.UploadProfilePhotoRequest(file=await client.upload_file(file_path))
|
raw_path=file_path,
|
||||||
|
ctx=ctx,
|
||||||
|
tool_name="set_profile_photo",
|
||||||
)
|
)
|
||||||
return "Profile photo updated."
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
await client(
|
||||||
|
functions.photos.UploadProfilePhotoRequest(
|
||||||
|
file=await client.upload_file(str(safe_path))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return f"Profile photo updated from {safe_path}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return log_and_format_error("set_profile_photo", e, file_path=file_path)
|
return log_and_format_error("set_profile_photo", e, file_path=file_path)
|
||||||
|
|
||||||
|
|
@ -1865,7 +2204,7 @@ async def delete_profile_photo() -> str:
|
||||||
)
|
)
|
||||||
if not photos.photos:
|
if not photos.photos:
|
||||||
return "No profile photo to delete."
|
return "No profile photo to delete."
|
||||||
await client(functions.photos.DeletePhotosRequest(id=[photos.photos[0].id]))
|
await client(functions.photos.DeletePhotosRequest(id=[photos.photos[0]]))
|
||||||
return "Profile photo deleted."
|
return "Profile photo deleted."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return log_and_format_error("delete_profile_photo", e)
|
return log_and_format_error("delete_profile_photo", e)
|
||||||
|
|
@ -2100,15 +2439,22 @@ async def edit_chat_title(chat_id: Union[int, str], title: str) -> str:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def edit_chat_photo(chat_id: Union[int, str], file_path: str) -> str:
|
async def edit_chat_photo(
|
||||||
|
chat_id: Union[int, str],
|
||||||
|
file_path: str,
|
||||||
|
ctx: Optional[Context] = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Edit the photo of a chat, group, or channel. Requires a file path to an image.
|
Edit the photo of a chat, group, or channel. Requires a file path to an image.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.isfile(file_path):
|
safe_path, path_error = await _resolve_readable_file_path(
|
||||||
return f"Photo file not found: {file_path}"
|
raw_path=file_path,
|
||||||
if not os.access(file_path, os.R_OK):
|
ctx=ctx,
|
||||||
return f"Photo file not readable: {file_path}"
|
tool_name="edit_chat_photo",
|
||||||
|
)
|
||||||
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
|
||||||
entity = await resolve_entity(chat_id)
|
entity = await resolve_entity(chat_id)
|
||||||
uploaded_file = await client.upload_file(file_path)
|
uploaded_file = await client.upload_file(file_path)
|
||||||
|
|
@ -2126,7 +2472,7 @@ async def edit_chat_photo(chat_id: Union[int, str], file_path: str) -> str:
|
||||||
else:
|
else:
|
||||||
return f"Cannot edit photo for this entity type ({type(entity)})."
|
return f"Cannot edit photo for this entity type ({type(entity)})."
|
||||||
|
|
||||||
return f"Chat {chat_id} photo updated."
|
return f"Chat {chat_id} photo updated from {safe_path}."
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"edit_chat_photo failed (chat_id={chat_id}, file_path='{file_path}')")
|
logger.exception(f"edit_chat_photo failed (chat_id={chat_id}, file_path='{file_path}')")
|
||||||
return log_and_format_error("edit_chat_photo", e, chat_id=chat_id, file_path=file_path)
|
return log_and_format_error("edit_chat_photo", e, chat_id=chat_id, file_path=file_path)
|
||||||
|
|
@ -2629,27 +2975,34 @@ async def import_chat_invite(hash: str) -> str:
|
||||||
annotations=ToolAnnotations(title="Send Voice", openWorldHint=True, destructiveHint=True)
|
annotations=ToolAnnotations(title="Send Voice", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def send_voice(chat_id: Union[int, str], file_path: str) -> str:
|
async def send_voice(
|
||||||
|
chat_id: Union[int, str],
|
||||||
|
file_path: str,
|
||||||
|
ctx: Optional[Context] = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a voice message to a chat. File must be an OGG/OPUS voice note.
|
Send a voice message to a chat. File must be an OGG/OPUS voice note.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chat_id: The chat ID or username.
|
chat_id: The chat ID or username.
|
||||||
file_path: Absolute path to the OGG/OPUS file.
|
file_path: Absolute or relative path under allowed roots to the OGG/OPUS file.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.isfile(file_path):
|
safe_path, path_error = await _resolve_readable_file_path(
|
||||||
return f"File not found: {file_path}"
|
raw_path=file_path,
|
||||||
if not os.access(file_path, os.R_OK):
|
ctx=ctx,
|
||||||
return f"File is not readable: {file_path}"
|
tool_name="send_voice",
|
||||||
|
)
|
||||||
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
|
||||||
mime, _ = mimetypes.guess_type(file_path)
|
mime, _ = mimetypes.guess_type(str(safe_path))
|
||||||
if not (
|
if not (
|
||||||
mime
|
mime
|
||||||
and (
|
and (
|
||||||
mime == "audio/ogg"
|
mime == "audio/ogg"
|
||||||
or file_path.lower().endswith(".ogg")
|
or str(safe_path).lower().endswith(".ogg")
|
||||||
or file_path.lower().endswith(".opus")
|
or str(safe_path).lower().endswith(".opus")
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
return "Voice file must be .ogg or .opus format."
|
return "Voice file must be .ogg or .opus format."
|
||||||
|
|
@ -2661,6 +3014,37 @@ async def send_voice(chat_id: Union[int, str], file_path: str) -> str:
|
||||||
return log_and_format_error("send_voice", e, chat_id=chat_id, file_path=file_path)
|
return log_and_format_error("send_voice", e, chat_id=chat_id, file_path=file_path)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(title="Upload File", openWorldHint=True, destructiveHint=True)
|
||||||
|
)
|
||||||
|
async def upload_file(file_path: str, ctx: Optional[Context] = None) -> str:
|
||||||
|
"""
|
||||||
|
Upload a local file to Telegram and return upload metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Absolute or relative path under allowed roots.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
safe_path, path_error = await _resolve_readable_file_path(
|
||||||
|
raw_path=file_path,
|
||||||
|
ctx=ctx,
|
||||||
|
tool_name="upload_file",
|
||||||
|
)
|
||||||
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
|
||||||
|
uploaded = await client.upload_file(str(safe_path))
|
||||||
|
payload = {
|
||||||
|
"path": str(safe_path),
|
||||||
|
"name": getattr(uploaded, "name", safe_path.name),
|
||||||
|
"size": getattr(uploaded, "size", safe_path.stat().st_size),
|
||||||
|
"md5_checksum": getattr(uploaded, "md5_checksum", None),
|
||||||
|
}
|
||||||
|
return json.dumps(payload, indent=2, default=json_serializer)
|
||||||
|
except Exception as e:
|
||||||
|
return log_and_format_error("upload_file", e, file_path=file_path)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations=ToolAnnotations(title="Forward Message", openWorldHint=True, destructiveHint=True)
|
annotations=ToolAnnotations(title="Forward Message", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
|
|
@ -2782,9 +3166,18 @@ async def mark_as_read(chat_id: Union[int, str]) -> str:
|
||||||
annotations=ToolAnnotations(title="Reply To Message", openWorldHint=True, destructiveHint=True)
|
annotations=ToolAnnotations(title="Reply To Message", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def reply_to_message(chat_id: Union[int, str], message_id: int, text: str) -> str:
|
async def reply_to_message(
|
||||||
|
chat_id: Union[int, str], message_id: int, text: str, parse_mode: Optional[str] = None
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Reply to a specific message in a chat.
|
Reply to a specific message in a chat.
|
||||||
|
Args:
|
||||||
|
chat_id: The chat ID or username.
|
||||||
|
message_id: The message ID to reply to.
|
||||||
|
text: The reply text.
|
||||||
|
parse_mode: Optional formatting mode. Use 'html' for HTML tags (<b>, <i>, <code>, <pre>,
|
||||||
|
<a href="...">), 'md' or 'markdown' for Markdown (**bold**, __italic__, `code`,
|
||||||
|
```pre```), or omit for plain text (no formatting).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
entity = await resolve_entity(chat_id)
|
entity = await resolve_entity(chat_id)
|
||||||
|
|
@ -2823,15 +3216,16 @@ async def get_media_info(chat_id: Union[int, str], message_id: int) -> str:
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations=ToolAnnotations(title="Search Public Chats", openWorldHint=True, readOnlyHint=True)
|
annotations=ToolAnnotations(title="Search Public Chats", openWorldHint=True, readOnlyHint=True)
|
||||||
)
|
)
|
||||||
async def search_public_chats(query: str) -> str:
|
async def search_public_chats(query: str, limit: int = 20) -> str:
|
||||||
"""
|
"""
|
||||||
Search for public chats, channels, or bots by username or title.
|
Search for public chats, channels, or bots by username or title.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = await client(functions.contacts.SearchRequest(q=query, limit=20))
|
result = await client(functions.contacts.SearchRequest(q=query, limit=limit))
|
||||||
return json.dumps([format_entity(u) for u in result.users], indent=2)
|
entities = [format_entity(e) for e in result.chats + result.users]
|
||||||
|
return json.dumps(entities, indent=2)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return log_and_format_error("search_public_chats", e, query=query)
|
return log_and_format_error("search_public_chats", e, query=query, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
|
|
@ -2862,6 +3256,45 @@ async def search_messages(chat_id: Union[int, str], query: str, limit: int = 20)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
title="Search Global Messages",
|
||||||
|
openWorldHint=True,
|
||||||
|
readOnlyHint=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def search_global(query: str, page: int = 1, page_size: int = 20) -> str:
|
||||||
|
"""
|
||||||
|
Search for messages across all public chats and channels by text content.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
messages = await client.get_messages(
|
||||||
|
None, limit=page_size, search=query, add_offset=offset
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return "No messages found for this page."
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for msg in messages:
|
||||||
|
chat = msg.chat
|
||||||
|
chat_name = (
|
||||||
|
getattr(chat, "title", None) or getattr(chat, "first_name", "") or str(msg.chat_id)
|
||||||
|
)
|
||||||
|
sender_name = get_sender_name(msg)
|
||||||
|
lines.append(
|
||||||
|
f"Chat: {chat_name} | ID: {msg.id} | {sender_name} | "
|
||||||
|
f"Date: {msg.date} | Message: {msg.message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as e:
|
||||||
|
return log_and_format_error(
|
||||||
|
"search_global", e, query=query, page=page, page_size=page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
annotations=ToolAnnotations(title="Resolve Username", openWorldHint=True, readOnlyHint=True)
|
annotations=ToolAnnotations(title="Resolve Username", openWorldHint=True, readOnlyHint=True)
|
||||||
)
|
)
|
||||||
|
|
@ -3026,21 +3459,26 @@ async def get_sticker_sets() -> str:
|
||||||
annotations=ToolAnnotations(title="Send Sticker", openWorldHint=True, destructiveHint=True)
|
annotations=ToolAnnotations(title="Send Sticker", openWorldHint=True, destructiveHint=True)
|
||||||
)
|
)
|
||||||
@validate_id("chat_id")
|
@validate_id("chat_id")
|
||||||
async def send_sticker(chat_id: Union[int, str], file_path: str) -> str:
|
async def send_sticker(
|
||||||
|
chat_id: Union[int, str],
|
||||||
|
file_path: str,
|
||||||
|
ctx: Optional[Context] = None,
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Send a sticker to a chat. File must be a valid .webp sticker file.
|
Send a sticker to a chat. File must be a valid .webp sticker file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chat_id: The chat ID or username.
|
chat_id: The chat ID or username.
|
||||||
file_path: Absolute path to the .webp sticker file.
|
file_path: Absolute or relative path under allowed roots to the .webp sticker file.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not os.path.isfile(file_path):
|
safe_path, path_error = await _resolve_readable_file_path(
|
||||||
return f"Sticker file not found: {file_path}"
|
raw_path=file_path,
|
||||||
if not os.access(file_path, os.R_OK):
|
ctx=ctx,
|
||||||
return f"Sticker file is not readable: {file_path}"
|
tool_name="send_sticker",
|
||||||
if not file_path.lower().endswith(".webp"):
|
)
|
||||||
return "Sticker file must be a .webp file."
|
if path_error:
|
||||||
|
return path_error
|
||||||
|
|
||||||
entity = await resolve_entity(chat_id)
|
entity = await resolve_entity(chat_id)
|
||||||
await client.send_file(entity, file_path, force_document=False)
|
await client.send_file(entity, file_path, force_document=False)
|
||||||
|
|
@ -3746,6 +4184,21 @@ async def list_folders() -> str:
|
||||||
}
|
}
|
||||||
folders.append(folder_data)
|
folders.append(folder_data)
|
||||||
|
|
||||||
|
elif isinstance(f, DialogFilterChatlist):
|
||||||
|
# Shared folders use DialogFilterChatlist type
|
||||||
|
title = f.title
|
||||||
|
if isinstance(title, TextWithEntities):
|
||||||
|
title = title.text
|
||||||
|
folder_data = {
|
||||||
|
"id": f.id,
|
||||||
|
"title": title,
|
||||||
|
"emoticon": getattr(f, "emoticon", None),
|
||||||
|
"type": "shared",
|
||||||
|
"included_peers_count": len(getattr(f, "include_peers", [])),
|
||||||
|
"pinned_peers_count": len(getattr(f, "pinned_peers", [])),
|
||||||
|
}
|
||||||
|
folders.append(folder_data)
|
||||||
|
|
||||||
if not folders:
|
if not folders:
|
||||||
return "No folders found. Create one with create_folder tool."
|
return "No folders found. Create one with create_folder tool."
|
||||||
|
|
||||||
|
|
@ -3770,7 +4223,7 @@ async def get_folder(folder_id: int) -> str:
|
||||||
|
|
||||||
target_folder = None
|
target_folder = None
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter) and f.id == folder_id:
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)) and f.id == folder_id:
|
||||||
target_folder = f
|
target_folder = f
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -3835,7 +4288,15 @@ async def get_folder(folder_id: int) -> str:
|
||||||
"id": target_folder.id,
|
"id": target_folder.id,
|
||||||
"title": title,
|
"title": title,
|
||||||
"emoticon": getattr(target_folder, "emoticon", None),
|
"emoticon": getattr(target_folder, "emoticon", None),
|
||||||
"filters": {
|
"included_chats": included_chats,
|
||||||
|
"excluded_chats": excluded_chats,
|
||||||
|
"pinned_chats": pinned_chats,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(target_folder, DialogFilterChatlist):
|
||||||
|
folder_data["type"] = "shared"
|
||||||
|
else:
|
||||||
|
folder_data["filters"] = {
|
||||||
"contacts": getattr(target_folder, "contacts", False),
|
"contacts": getattr(target_folder, "contacts", False),
|
||||||
"non_contacts": getattr(target_folder, "non_contacts", False),
|
"non_contacts": getattr(target_folder, "non_contacts", False),
|
||||||
"groups": getattr(target_folder, "groups", False),
|
"groups": getattr(target_folder, "groups", False),
|
||||||
|
|
@ -3844,10 +4305,6 @@ async def get_folder(folder_id: int) -> str:
|
||||||
"exclude_muted": getattr(target_folder, "exclude_muted", False),
|
"exclude_muted": getattr(target_folder, "exclude_muted", False),
|
||||||
"exclude_read": getattr(target_folder, "exclude_read", False),
|
"exclude_read": getattr(target_folder, "exclude_read", False),
|
||||||
"exclude_archived": getattr(target_folder, "exclude_archived", False),
|
"exclude_archived": getattr(target_folder, "exclude_archived", False),
|
||||||
},
|
|
||||||
"included_chats": included_chats,
|
|
||||||
"excluded_chats": excluded_chats,
|
|
||||||
"pinned_chats": pinned_chats,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.dumps(folder_data, indent=2, default=json_serializer)
|
return json.dumps(folder_data, indent=2, default=json_serializer)
|
||||||
|
|
@ -3897,7 +4354,7 @@ async def create_folder(
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
folder_count = 0
|
folder_count = 0
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter):
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)):
|
||||||
existing_ids.add(f.id)
|
existing_ids.add(f.id)
|
||||||
folder_count += 1
|
folder_count += 1
|
||||||
|
|
||||||
|
|
@ -3979,7 +4436,7 @@ async def add_chat_to_folder(
|
||||||
|
|
||||||
target_folder = None
|
target_folder = None
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter) and f.id == folder_id:
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)) and f.id == folder_id:
|
||||||
target_folder = f
|
target_folder = f
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -4013,6 +4470,17 @@ async def add_chat_to_folder(
|
||||||
pinned_peers.append(peer)
|
pinned_peers.append(peer)
|
||||||
|
|
||||||
# Update the folder (keep all original attributes)
|
# Update the folder (keep all original attributes)
|
||||||
|
if isinstance(target_folder, DialogFilterChatlist):
|
||||||
|
updated_filter = DialogFilterChatlist(
|
||||||
|
id=target_folder.id,
|
||||||
|
title=target_folder.title,
|
||||||
|
emoticon=getattr(target_folder, "emoticon", None),
|
||||||
|
pinned_peers=pinned_peers,
|
||||||
|
include_peers=include_peers,
|
||||||
|
title_noanimate=getattr(target_folder, "title_noanimate", None),
|
||||||
|
color=getattr(target_folder, "color", None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
updated_filter = DialogFilter(
|
updated_filter = DialogFilter(
|
||||||
id=target_folder.id,
|
id=target_folder.id,
|
||||||
title=target_folder.title,
|
title=target_folder.title,
|
||||||
|
|
@ -4069,7 +4537,7 @@ async def remove_chat_from_folder(folder_id: int, chat_id: Union[int, str]) -> s
|
||||||
|
|
||||||
target_folder = None
|
target_folder = None
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter) and f.id == folder_id:
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)) and f.id == folder_id:
|
||||||
target_folder = f
|
target_folder = f
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
@ -4108,6 +4576,17 @@ async def remove_chat_from_folder(folder_id: int, chat_id: Union[int, str]) -> s
|
||||||
return f"Chat {chat_id} was not in folder {folder_id}."
|
return f"Chat {chat_id} was not in folder {folder_id}."
|
||||||
|
|
||||||
# Update the folder (keep all original attributes)
|
# Update the folder (keep all original attributes)
|
||||||
|
if isinstance(target_folder, DialogFilterChatlist):
|
||||||
|
updated_filter = DialogFilterChatlist(
|
||||||
|
id=target_folder.id,
|
||||||
|
title=target_folder.title,
|
||||||
|
emoticon=getattr(target_folder, "emoticon", None),
|
||||||
|
pinned_peers=pinned_peers,
|
||||||
|
include_peers=include_peers,
|
||||||
|
title_noanimate=getattr(target_folder, "title_noanimate", None),
|
||||||
|
color=getattr(target_folder, "color", None),
|
||||||
|
)
|
||||||
|
else:
|
||||||
updated_filter = DialogFilter(
|
updated_filter = DialogFilter(
|
||||||
id=target_folder.id,
|
id=target_folder.id,
|
||||||
title=target_folder.title,
|
title=target_folder.title,
|
||||||
|
|
@ -4168,7 +4647,7 @@ async def delete_folder(folder_id: int) -> str:
|
||||||
folder_exists = False
|
folder_exists = False
|
||||||
folder_title = None
|
folder_title = None
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter) and f.id == folder_id:
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)) and f.id == folder_id:
|
||||||
folder_exists = True
|
folder_exists = True
|
||||||
# Handle title which can be str or TextWithEntities
|
# Handle title which can be str or TextWithEntities
|
||||||
title = f.title
|
title = f.title
|
||||||
|
|
@ -4207,7 +4686,7 @@ async def reorder_folders(folder_ids: List[int]) -> str:
|
||||||
|
|
||||||
existing_ids = set()
|
existing_ids = set()
|
||||||
for f in result.filters:
|
for f in result.filters:
|
||||||
if isinstance(f, DialogFilter):
|
if isinstance(f, (DialogFilter, DialogFilterChatlist)):
|
||||||
existing_ids.add(f.id)
|
existing_ids.add(f.id)
|
||||||
|
|
||||||
# Validate all provided IDs exist
|
# Validate all provided IDs exist
|
||||||
|
|
@ -4234,7 +4713,7 @@ async def reorder_folders(folder_ids: List[int]) -> str:
|
||||||
async def _main() -> None:
|
async def _main() -> None:
|
||||||
try:
|
try:
|
||||||
# Start the Telethon client non-interactively
|
# Start the Telethon client non-interactively
|
||||||
print("Starting Telegram client...")
|
print("Starting Telegram client...", file=sys.stderr)
|
||||||
await client.start()
|
await client.start()
|
||||||
|
|
||||||
# Warm entity cache — StringSession has no persistent cache,
|
# Warm entity cache — StringSession has no persistent cache,
|
||||||
|
|
@ -4256,6 +4735,7 @@ async def _main() -> None:
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
_configure_allowed_roots_from_cli(sys.argv[1:])
|
||||||
nest_asyncio.apply()
|
nest_asyncio.apply()
|
||||||
asyncio.run(_main())
|
asyncio.run(_main())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,8 @@ dependencies = [
|
||||||
"nest-asyncio>=1.6.0",
|
"nest-asyncio>=1.6.0",
|
||||||
"python-dotenv>=1.1.0",
|
"python-dotenv>=1.1.0",
|
||||||
"python-json-logger>=3.3.0",
|
"python-json-logger>=3.3.0",
|
||||||
"telethon>=1.39.0"
|
"qrcode>=8.2",
|
||||||
|
"telethon>=1.42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|
@ -64,4 +65,6 @@ exclude = [
|
||||||
dev = [
|
dev = [
|
||||||
"black>=25.9.0",
|
"black>=25.9.0",
|
||||||
"flake8>=7.3.0",
|
"flake8>=7.3.0",
|
||||||
|
"pytest>=9.0.2",
|
||||||
|
"pytest-asyncio>=1.3.0",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,4 +4,5 @@ mcp[cli]>=1.4.1
|
||||||
nest-asyncio>=1.6.0
|
nest-asyncio>=1.6.0
|
||||||
python-dotenv>=1.1.0
|
python-dotenv>=1.1.0
|
||||||
python-json-logger>=3.3.0
|
python-json-logger>=3.3.0
|
||||||
telethon>=1.39.0
|
qrcode>=8.2
|
||||||
|
telethon>=1.42.0
|
||||||
|
|
@ -19,16 +19,76 @@ parameters support integer IDs, string representations of IDs (e.g., "123456"),
|
||||||
and usernames (e.g., "@mychannel").
|
and usernames (e.g., "@mychannel").
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
from telethon.sync import TelegramClient
|
|
||||||
from telethon.sessions import StringSession
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Load environment variables from .env file
|
from dotenv import load_dotenv
|
||||||
|
from telethon import errors
|
||||||
|
from telethon.sessions import StringSession
|
||||||
|
from telethon.sync import TelegramClient
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_login(client: TelegramClient) -> None:
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
qr = client.qr_login()
|
||||||
|
|
||||||
|
print("\n----- QR Code Login -----\n")
|
||||||
|
|
||||||
|
qr_obj = qrcode.QRCode(border=1)
|
||||||
|
qr_obj.add_data(qr.url)
|
||||||
|
qr_obj.make(fit=True)
|
||||||
|
f = io.StringIO()
|
||||||
|
qr_obj.print_ascii(out=f, invert=True)
|
||||||
|
print(f.getvalue())
|
||||||
|
|
||||||
|
print("Scan the QR code above with your Telegram app:")
|
||||||
|
print(" Open Telegram > Settings > Devices > Link Desktop Device\n")
|
||||||
|
print(f"Or open this link on a device where you're logged in:\n {qr.url}\n")
|
||||||
|
print(f"Expires at: {qr.expires.strftime('%H:%M:%S')}")
|
||||||
|
print("Waiting for you to scan...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.loop.run_until_complete(qr.wait(timeout=120))
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
print("\nQR code expired. Please try again.")
|
||||||
|
client.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
except errors.SessionPasswordNeededError:
|
||||||
|
pw = input("\nTwo-factor authentication enabled. Please enter your password: ")
|
||||||
|
client.sign_in(password=pw)
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_login(client: TelegramClient) -> None:
|
||||||
|
phone = input("Please enter your phone (or bot token): ")
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.send_code_request(phone)
|
||||||
|
except errors.FloodWaitError as e:
|
||||||
|
print(f"\nFlood wait error; you must wait {e.seconds} seconds before trying again.")
|
||||||
|
client.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
except errors.PhoneNumberInvalidError:
|
||||||
|
print("\nThe phone number is invalid.")
|
||||||
|
client.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"\nError sending code: {e}")
|
||||||
|
client.disconnect()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
code = input("\nPlease enter the code you received: ")
|
||||||
|
try:
|
||||||
|
client.sign_in(phone, code)
|
||||||
|
except errors.SessionPasswordNeededError:
|
||||||
|
pw = input("Two-factor authentication enabled. Please enter your password: ")
|
||||||
|
client.sign_in(password=pw)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
API_ID = os.getenv("TELEGRAM_API_ID")
|
API_ID = os.getenv("TELEGRAM_API_ID")
|
||||||
API_HASH = os.getenv("TELEGRAM_API_HASH")
|
API_HASH = os.getenv("TELEGRAM_API_HASH")
|
||||||
|
|
@ -38,7 +98,6 @@ def main() -> None:
|
||||||
print("Create an .env file with your credentials from https://my.telegram.org/apps")
|
print("Create an .env file with your credentials from https://my.telegram.org/apps")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Convert API_ID to integer
|
|
||||||
try:
|
try:
|
||||||
API_ID = int(API_ID)
|
API_ID = int(API_ID)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
@ -47,18 +106,26 @@ def main() -> None:
|
||||||
|
|
||||||
print("\n----- Telegram Session String Generator -----\n")
|
print("\n----- Telegram Session String Generator -----\n")
|
||||||
print("This script will generate a session string for your Telegram account.")
|
print("This script will generate a session string for your Telegram account.")
|
||||||
print(
|
|
||||||
"You will be asked to enter your phone number and the verification code sent to your Telegram app."
|
|
||||||
)
|
|
||||||
print("The generated session string can be added to your .env file.")
|
print("The generated session string can be added to your .env file.")
|
||||||
print(
|
print(
|
||||||
"\nYour credentials will NOT be stored on any server and are only used for local authentication.\n"
|
"\nYour credentials will NOT be stored on any server and are only used for local authentication.\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
print("Choose login method:")
|
||||||
|
print(" 1) QR code login (recommended -- scan from your Telegram app)")
|
||||||
|
print(" 2) Phone number + verification code")
|
||||||
|
method = input("\nEnter 1 or 2 [default: 1]: ").strip() or "1"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Connect to Telegram and generate the session string
|
client = TelegramClient(StringSession(), API_ID, API_HASH)
|
||||||
with TelegramClient(StringSession(), API_ID, API_HASH) as client:
|
client.connect()
|
||||||
# The client.session.save() function from StringSession returns the session string
|
|
||||||
|
if not client.is_user_authorized():
|
||||||
|
if method == "1":
|
||||||
|
_qr_login(client)
|
||||||
|
else:
|
||||||
|
_phone_login(client)
|
||||||
|
|
||||||
session_string = StringSession.save(client.session)
|
session_string = StringSession.save(client.session)
|
||||||
|
|
||||||
print("\nAuthentication successful!")
|
print("\nAuthentication successful!")
|
||||||
|
|
@ -68,17 +135,14 @@ def main() -> None:
|
||||||
print(f"TELEGRAM_SESSION_STRING={session_string}")
|
print(f"TELEGRAM_SESSION_STRING={session_string}")
|
||||||
print("\nIMPORTANT: Keep this string private and never share it with anyone!")
|
print("\nIMPORTANT: Keep this string private and never share it with anyone!")
|
||||||
|
|
||||||
# Optional: auto-update the .env file
|
|
||||||
choice = input(
|
choice = input(
|
||||||
"\nWould you like to automatically update your .env file with this session string? (y/N): "
|
"\nWould you like to automatically update your .env file with this session string? (y/N): "
|
||||||
)
|
)
|
||||||
if choice.lower() == "y":
|
if choice.lower() == "y":
|
||||||
try:
|
try:
|
||||||
# Read the current .env file
|
|
||||||
with open(".env", "r") as file:
|
with open(".env", "r") as file:
|
||||||
env_contents = file.readlines()
|
env_contents = file.readlines()
|
||||||
|
|
||||||
# Update or add the SESSION_STRING line
|
|
||||||
session_string_line_found = False
|
session_string_line_found = False
|
||||||
for i, line in enumerate(env_contents):
|
for i, line in enumerate(env_contents):
|
||||||
if line.startswith("TELEGRAM_SESSION_STRING="):
|
if line.startswith("TELEGRAM_SESSION_STRING="):
|
||||||
|
|
@ -89,7 +153,6 @@ def main() -> None:
|
||||||
if not session_string_line_found:
|
if not session_string_line_found:
|
||||||
env_contents.append(f"TELEGRAM_SESSION_STRING={session_string}\n")
|
env_contents.append(f"TELEGRAM_SESSION_STRING={session_string}\n")
|
||||||
|
|
||||||
# Write back to the .env file
|
|
||||||
with open(".env", "w") as file:
|
with open(".env", "w") as file:
|
||||||
file.writelines(env_contents)
|
file.writelines(env_contents)
|
||||||
|
|
||||||
|
|
@ -98,6 +161,8 @@ def main() -> None:
|
||||||
print(f"\nError updating .env file: {e}")
|
print(f"\nError updating .env file: {e}")
|
||||||
print("Please manually add the session string to your .env file.")
|
print("Please manually add the session string to your .env file.")
|
||||||
|
|
||||||
|
client.disconnect()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"\nError: {e}")
|
print(f"\nError: {e}")
|
||||||
print("Failed to generate session string. Please try again.")
|
print("Failed to generate session string. Please try again.")
|
||||||
|
|
|
||||||
249
test_file_path_security.py
Normal file
249
test_file_path_security.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from mcp import types
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
|
os.environ.setdefault("TELEGRAM_API_ID", "12345")
|
||||||
|
os.environ.setdefault("TELEGRAM_API_HASH", "dummy_hash")
|
||||||
|
|
||||||
|
import main
|
||||||
|
|
||||||
|
|
||||||
|
class _DummySession:
|
||||||
|
def __init__(self, roots):
|
||||||
|
self._roots = roots
|
||||||
|
|
||||||
|
async def list_roots(self):
|
||||||
|
return types.ListRootsResult(roots=self._roots)
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyContext:
|
||||||
|
def __init__(self, roots):
|
||||||
|
self.session = _DummySession(roots)
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingSession:
|
||||||
|
def __init__(self, error):
|
||||||
|
self._error = error
|
||||||
|
|
||||||
|
async def list_roots(self):
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
|
||||||
|
class _FailingContext:
|
||||||
|
def __init__(self, error):
|
||||||
|
self.session = _FailingSession(error)
|
||||||
|
|
||||||
|
|
||||||
|
class _MissingRootsSession:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _MissingRootsContext:
|
||||||
|
def __init__(self):
|
||||||
|
self.session = _MissingRootsSession()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readable_relative_path_resolves_inside_first_server_root(tmp_path, monkeypatch):
|
||||||
|
root = (tmp_path / "root").resolve()
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
target = root / "document.txt"
|
||||||
|
target.write_text("ok", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="document.txt",
|
||||||
|
ctx=None,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert resolved == target.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readable_path_rejects_traversal(tmp_path, monkeypatch):
|
||||||
|
root = (tmp_path / "root").resolve()
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="../etc/passwd",
|
||||||
|
ctx=None,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved is None
|
||||||
|
assert error == "Path traversal is not allowed."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readable_path_rejects_outside_root(tmp_path, monkeypatch):
|
||||||
|
root = (tmp_path / "root").resolve()
|
||||||
|
outside_root = (tmp_path / "outside").resolve()
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
outside_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
outside_file = outside_root / "outside.txt"
|
||||||
|
outside_file.write_text("no", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path=str(outside_file),
|
||||||
|
ctx=None,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved is None
|
||||||
|
assert error == "Path is outside allowed roots."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_client_roots_replace_server_allowlist(tmp_path, monkeypatch):
|
||||||
|
server_root = (tmp_path / "server_root").resolve()
|
||||||
|
client_root = (tmp_path / "client_root").resolve()
|
||||||
|
server_root.mkdir(parents=True)
|
||||||
|
client_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
(server_root / "server.txt").write_text("server", encoding="utf-8")
|
||||||
|
client_file = client_root / "client.txt"
|
||||||
|
client_file.write_text("client", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
|
||||||
|
ctx = _DummyContext([types.Root(uri=client_root.as_uri())])
|
||||||
|
|
||||||
|
roots = await main._get_effective_allowed_roots(ctx)
|
||||||
|
assert roots == [client_root]
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="client.txt",
|
||||||
|
ctx=ctx,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
assert error is None
|
||||||
|
assert resolved == client_file.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_client_roots_disable_file_tools(tmp_path, monkeypatch):
|
||||||
|
server_root = (tmp_path / "server_root").resolve()
|
||||||
|
server_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
|
||||||
|
ctx = _DummyContext([])
|
||||||
|
|
||||||
|
roots = await main._get_effective_allowed_roots(ctx)
|
||||||
|
assert roots == []
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="server.txt",
|
||||||
|
ctx=ctx,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
assert resolved is None
|
||||||
|
assert error is not None
|
||||||
|
assert "empty MCP Roots list" in error
|
||||||
|
assert "deny-all" in error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mcp_method_not_found_falls_back_to_server_allowlist(tmp_path, monkeypatch):
|
||||||
|
server_root = (tmp_path / "server_root").resolve()
|
||||||
|
server_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
|
||||||
|
ctx = _FailingContext(McpError(ErrorData(code=-32601, message="Method not found")))
|
||||||
|
|
||||||
|
roots = await main._get_effective_allowed_roots(ctx)
|
||||||
|
assert roots == [server_root]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_list_roots_method_falls_back_to_server_allowlist(tmp_path, monkeypatch):
|
||||||
|
server_root = (tmp_path / "server_root").resolve()
|
||||||
|
server_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
|
||||||
|
ctx = _MissingRootsContext()
|
||||||
|
|
||||||
|
roots = await main._get_effective_allowed_roots(ctx)
|
||||||
|
assert roots == [server_root]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unexpected_roots_error_disables_file_path_tools(tmp_path, monkeypatch):
|
||||||
|
server_root = (tmp_path / "server_root").resolve()
|
||||||
|
server_root.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [server_root])
|
||||||
|
|
||||||
|
ctx = _FailingContext(RuntimeError("transport failure"))
|
||||||
|
roots = await main._get_effective_allowed_roots(ctx)
|
||||||
|
assert roots == []
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="anything.txt",
|
||||||
|
ctx=ctx,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
assert resolved is None
|
||||||
|
assert error is not None
|
||||||
|
assert "disabled" in error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_writable_default_path_uses_downloads_subdir(tmp_path, monkeypatch):
|
||||||
|
root = (tmp_path / "root").resolve()
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_writable_file_path(
|
||||||
|
raw_path=None,
|
||||||
|
default_filename="example.bin",
|
||||||
|
ctx=None,
|
||||||
|
tool_name="download_media",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert error is None
|
||||||
|
assert resolved == (root / "downloads" / "example.bin").resolve()
|
||||||
|
assert resolved.parent.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_allowlist_is_enforced_for_sticker(tmp_path, monkeypatch):
|
||||||
|
root = (tmp_path / "root").resolve()
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
file_path = root / "sticker.txt"
|
||||||
|
file_path.write_text("bad", encoding="utf-8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path=str(file_path),
|
||||||
|
ctx=None,
|
||||||
|
tool_name="send_sticker",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved is None
|
||||||
|
assert error is not None
|
||||||
|
assert "extension is not allowed" in error
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_file_tools_disabled_without_any_roots(monkeypatch):
|
||||||
|
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [])
|
||||||
|
|
||||||
|
resolved, error = await main._resolve_readable_file_path(
|
||||||
|
raw_path="anything.txt",
|
||||||
|
ctx=None,
|
||||||
|
tool_name="send_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolved is None
|
||||||
|
assert error is not None
|
||||||
|
assert "disabled" in error
|
||||||
85
uv.lock
85
uv.lock
|
|
@ -40,6 +40,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "backports-asyncio-runner"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "black"
|
name = "black"
|
||||||
version = "25.9.0"
|
version = "25.9.0"
|
||||||
|
|
@ -394,6 +403,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonschema"
|
name = "jsonschema"
|
||||||
version = "4.25.1"
|
version = "4.25.1"
|
||||||
|
|
@ -527,6 +545,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyaes"
|
name = "pyaes"
|
||||||
version = "1.6.1"
|
version = "1.6.1"
|
||||||
|
|
@ -708,6 +735,38 @@ crypto = [
|
||||||
{ name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" },
|
{ name = "cryptography", version = "46.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14' and platform_python_implementation != 'PyPy'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|
@ -766,6 +825,18 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcode"
|
||||||
|
version = "8.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "referencing"
|
name = "referencing"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
|
|
@ -982,6 +1053,7 @@ dependencies = [
|
||||||
{ name = "nest-asyncio" },
|
{ name = "nest-asyncio" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "python-json-logger" },
|
{ name = "python-json-logger" },
|
||||||
|
{ name = "qrcode" },
|
||||||
{ name = "telethon" },
|
{ name = "telethon" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -989,6 +1061,8 @@ dependencies = [
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black" },
|
{ name = "black" },
|
||||||
{ name = "flake8" },
|
{ name = "flake8" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
|
|
@ -999,26 +1073,29 @@ requires-dist = [
|
||||||
{ name = "nest-asyncio", specifier = ">=1.6.0" },
|
{ name = "nest-asyncio", specifier = ">=1.6.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
{ name = "python-dotenv", specifier = ">=1.1.0" },
|
||||||
{ name = "python-json-logger", specifier = ">=3.3.0" },
|
{ name = "python-json-logger", specifier = ">=3.3.0" },
|
||||||
{ name = "telethon", specifier = ">=1.39.0" },
|
{ name = "qrcode", specifier = ">=8.2" },
|
||||||
|
{ name = "telethon", specifier = ">=1.42.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "black", specifier = ">=25.9.0" },
|
{ name = "black", specifier = ">=25.9.0" },
|
||||||
{ name = "flake8", specifier = ">=7.3.0" },
|
{ name = "flake8", specifier = ">=7.3.0" },
|
||||||
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "telethon"
|
name = "telethon"
|
||||||
version = "1.39.0"
|
version = "1.42.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "pyaes" },
|
{ name = "pyaes" },
|
||||||
{ name = "rsa" },
|
{ name = "rsa" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/b2/6e48b593a0e4e678578a682332ef36e54648c78ef13a5d395e5cdd293c6d/telethon-1.39.0.tar.gz", hash = "sha256:35d4795d8c91deac515fb0bcb3723866b924de1c724e1d5c230460e96f284a63", size = 640634, upload-time = "2025-02-20T17:30:44.562Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/10/8c8c9476bfce767a856d8aaf9eae8ea1869df4e970da16f1c5b638fd1b0c/telethon-1.42.0.tar.gz", hash = "sha256:032e95511261d5ead719f75494c6c85ece2ce71816b54f3c65d6ccc371d6994d", size = 672734, upload-time = "2025-11-05T19:15:19.849Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/f2/4e66a59c6d0cb6a25c0c2a630b4b184d81cfe8cb982bf3b65ce32b020fc8/Telethon-1.39.0-py3-none-any.whl", hash = "sha256:aa9f394b94be144799a6f6a93ab463867bc7c63503ede9631751940a98f6c703", size = 715851, upload-time = "2025-02-20T17:30:41.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/e4/8ce0ff55251381966a7c3f88bd5b34abda79b225a8e7fb51ddef3b849c94/telethon-1.42.0-py3-none-any.whl", hash = "sha256:cf361c94586bcacd6d0fc8959a2bce509d1bb37007fe6476a80c4fb4a2decc29", size = 748466, upload-time = "2025-11-05T19:15:18.241Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue