"""Сборка MCP-сервера tgclient (FastMCP, streamable HTTP, stateless).
Эндпоинты: /mcp (основной) и /mcp-protocol/ (канон handbook mcp.md — слэш в
конце значим для реверс-прокси). Транспорт stateless + json_response; процесс
тот же, что API деплоя. Авторизация — гард require_mcp на APIRoute
(app/security.py); личность в request.state.mcp_user, здесь переносится в
ContextVar (app/mcp/context.py), тулы читают current_mcp_user().
Инструкции агенту (AGENT_INSTRUCTIONS) отдаются и как instructions, и как
prompt agent_guide (паттерн hard-panel / gnexus-creds).
"""
from fastapi import Request
from mcp.server.fastmcp import FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from starlette.responses import Response
from app.mcp.context import set_mcp_user, reset_mcp_user
from app.mcp.tools import register_tools
AGENT_INSTRUCTIONS = """\
# tgclient — инструкция для ИИ-агента
## Назначение
Этот сервер — MCP-интерфейс мульти-аккаунтного Telegram-клиента (MTProto
юзер-аккаунты, не боты). Ты действуешь от лица владельца MCP-ключа: у тебя
доступ только к **его** Telegram-аккаунтам (админ-ключ видит все, но писать
обязан всё равно в чужой чат только по явной просьбе этого человека).
## Данные и аккаунты
- **Аккаунты** (`accounts_list`): каждый имеет `id`, phone, статус
(`pending|active|logged_out|error`) и отметку живости клиента.
- Почти все тулы принимают `account_id`; если у владельца один активный
аккаунт, указывать его не обязательно (иначе — 422 с просьбой уточнить).
- `dialog_id` — маркированный int из тулов (юзер >0, группа -id,
канал -100id). Не угадывай id: бери из `dialogs_list` / `messages_*`.
- Имя канала/юзера в запросе («@username») — сначала реши через
`chat_info`/`dialogs_list`, чтобы получить id.
## Карта инструментов — что вызывать
| Вопрос пользователя | Инструмент |
|---|---|
| «какие у меня акки?» | `accounts_list` |
| «кто я в этом акке?» | `me_get` |
| «список чатов / диалогов» | `dialogs_list` |
| «что в этом чате» (последние сообщения) | `messages_history` |
| «что за сообщение N» | `messages_read` |
| «найди сообщения про X» | `messages_search` |
| «напиши в этот чат …» | `message_send` (или `message_reply`) |
| «поправь сообщение N» | `message_edit` (только свои) |
| «удали сообщения …» | `message_delete` |
| «сходи по контактам / кто в группе» | `contacts_list` / `chat_participants` |
| «что за файл тут?» | `messages_read` (media-метаданные) |
| «скачай/файл есть?» | `download_media` (возвращает base64) |
| «отправь файл/фото» | `upload_file` (base64 или `file_path`) |
| «отправь голосовое» | `upload_voice` (base64 или `file_path`) |
| «отправь кружок» | `upload_round` (base64 или `file_path`) |
| «позвони ему» | `call_start` (сигналинг: ринг у абонента, без аудио) |
| «добавь мой акк / залогинь» | `account_login_start` → `code` → [`password`] |
## Добавление аккаунта (текст-режим — интерактив с человеком)
Телевижн-код и пароль 2FA знает только человек; ты не догадываешься и не
выдумываешь их. Сценарий:
1. Попроси номер телефона и подтверди вслух («логиним +7…123, верно?»).
2. `account_login_start(phone)` → попроси у пользователя код из Telegram.
3. `account_login_code(login_id, code)` — промах не фатален: вернётся
`attempts_left` — вежливо попроси код ещё раз.
4. Если сервер спросит пароль (`step: "awaiting_password"`) — это
Cloud-пароль (2FA): попроси его у пользователя, `account_login_password`.
5. Статус/отмена — `account_login_status` / `account_login_cancel`.
Лимиты: ≤3 параллельных логинов, ограничен поток кодов; код живёт ~10 минут.
## Правила
- **Мутации (send/edit/delete/upload/call_start и т.п.) — только по явной
просьбе пользователя.** Удаление и логаут аккаунта — необратимы.
- **Звонки (`call_start`) — сигналинг без звука** (Telethon не умеет
аудио-движок tgcalls): абонент увидит настоящий входящий вызов; примет —
вызов тут же корректно завершится (hangup_after_answer). Предупреждай
пользователя об этом заранее; статусы — `call_status`/`call_discard`.
- Ошибки — данные: `{"error": 403|404|409|413|422|429, "detail": "…"}` —
прочти detail и скажи человеку нормально (или исправь вызов сам).
- 403 по аккаунту = не твой аккаунт: не перебирай чужие id.
- 413 — файл больше лимита; 429 — rate-limit/флуд, скажи сколько ждать
(деталь содержит секунды).
- Голосовой файл (voice) и кружок (round) в медиа-метаданных видны сразу
(`is_voice`/`is_round`, duration и waveform) — не нужен download для
понимания, что это голосовое. Достать голосовое: `download_media` вернёт
ogg-opus в `data_base64`; duration/waveform уже в метаданных.
- Отправка файла с диска сервера: `file_path` — **только имя файла** внутри
каталога `TGCLIENT_UPLOAD_DIR` (пути запрещены). Каталог не настроен — 503;
тогда передавайте содержимое `data_base64` как раньше.
- Личные данные (peer-ids, тексты) — конфиденциальны: не выкладывай наружу.
## Подключение
```
claude mcp add --transport http tgclient https://<host>/mcp-protocol/ \\
--header "Authorization: Bearer mcp_<персональный ключ>"
```
`/mcp` — historический alias того же сервера. Ключи выпускаются на странице
«MCP-ключи» сервиса (≤10 активных на пользователя, отзыв — там же); роль
ключа — снейпшот роли владельца на момент выпуска.
"""
def build_mcp_server() -> FastMCP:
mcp = FastMCP(
"tgclient",
instructions=AGENT_INSTRUCTIONS,
stateless_http=True,
json_response=True,
streamable_http_path="/",
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
register_tools(mcp)
@mcp.prompt()
def agent_guide() -> str:
"""📘 Гайд: как работать с Telegram через tgclient (аккаунты, чаты, медиа)."""
return AGENT_INSTRUCTIONS
return mcp
mcp = build_mcp_server()
mcp_app = mcp.streamable_http_app()
# ASGI-приложениеstreamable-http: FastAPI валидирует сигнатуру endpoint-а,
# поэтому тело в receive() вручную и ответ собираем из send() (stateless +
# json_response — ответ всегда один JSON, SSE-моста нет).
mcp_asgi = mcp_app.router.routes[0].app
async def mcp_endpoint(request: Request) -> "Response":
"""Мост FastAPI → ASGI streamable-http-app.
Личность из require_mcp (request.state.mcp_user) переносится в ContextVar
на время выполнения запроса — тулы видят current_mcp_user().
"""
user = getattr(request.state, "mcp_user", None)
body = await request.body()
received = False
start: dict = {}
chunks: list[bytes] = []
async def receive():
nonlocal received
if received:
return {"type": "http.disconnect"}
received = True
return {"type": "http.request", "body": body, "more_body": False}
async def send(message) -> None:
if message["type"] == "http.response.start":
start.update(message)
else:
chunks.append(message.get("body", b""))
ctx_token = set_mcp_user(user) if user else None
try:
await mcp_asgi(request.scope, receive, send)
finally:
if ctx_token is not None:
reset_mcp_user(ctx_token)
response = Response(
content=b"".join(chunks) if chunks else b"", status_code=start["status"]
)
response.raw_headers.extend(start.get("headers") or [])
return response