""" NNTP MCP-сервер: мониторинг новостных групп и ответы в тредах. """ import warnings warnings.filterwarnings("ignore") import sys import os # stderr отправляем в лог-файл, чтобы предупреждения и трейсбэки # не ломали stdio-транспорт, но ошибки оставались диагностируемыми sys.stderr = open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp.log"), "a") import json import re import datetime import nntplib import email from email.message import EmailMessage from email.header import decode_header from email.utils import parseaddr, formataddr, make_msgid, formatdate, parsedate_to_datetime from email.policy import default as email_policy # Автоматическое переключение импорта в зависимости от окружения try: from mcp.server.fastmcp import FastMCP mcp = FastMCP("nntp-monitor") # Используем прокси-декоратор для единообразия регистрации ниже def register_mcp_tool(func): return mcp.tool()(func) except ImportError: from mcp.server.mcpserver import MCPServer mcp = MCPServer("nntp-monitor") def register_mcp_tool(func): if hasattr(mcp, 'list_tools') and hasattr(mcp.list_tools, 'add_tool'): mcp.list_tools.add_tool(func) elif hasattr(mcp, 'register_tool'): mcp.register_tool(func) else: # ИСПРАВЛЕНО: Правильный вызов декоратора со скобками для базового MCPServer decorator = mcp.tool() decorator(func) return func STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "nntp_mcp_state.json") DEFAULT_WINDOW = 1000 MAX_THREAD_MSGS = 40 # ---------------------------------------------------------------- состояние def _state_key(server: str, group: str) -> str: return f"{server.lower()}/{group.lower()}" def _load_state() -> dict: try: with open(STATE_FILE, encoding="utf-8") as f: return json.load(f) except Exception: return {} def _save_state(state: dict) -> None: tmp = STATE_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=1) os.replace(tmp, STATE_FILE) # ---------------------------------------------------------------- утилиты def _decode(header_value) -> str: if not header_value: return "" try: parts = [] for text, enc in decode_header(str(header_value)): if isinstance(text, bytes): parts.append(text.decode(enc or "utf-8", errors="ignore")) else: parts.append(str(text)) return "".join(parts) except Exception: return str(header_value) def _parse_date(date_str: str) -> str: if not date_str: return "" try: dt = parsedate_to_datetime(date_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=datetime.timezone.utc) return dt.isoformat() except Exception: return str(date_str) def _clean_quotes(body_text: str) -> str: if not body_text: return "" intro = re.compile( r"on\s+.*?\s+wrote\s*:|в\s+письме\s+от.*?:|писал\s*\(а\)\s*:|" r"---.*?original\s+message.*?---", re.IGNORECASE, ) clean, prev_blank = [], False for line in body_text.splitlines(): stripped = line.strip() if stripped.startswith(">"): continue if intro.search(stripped): continue if not stripped and prev_blank: continue prev_blank = not stripped clean.append(line) return "\n".join(clean).strip() def _extract_body(msg) -> str: body = "" for part in (msg.walk() if msg.is_multipart() else [msg]): if str(part.get("Content-Disposition", "")).lower().startswith("attachment"): continue if part.get_content_type() != "text/plain": continue payload = part.get_payload(decode=True) if payload: body += payload.decode(part.get_content_charset() or "utf-8", errors="ignore") if not body.strip(): for part in (msg.walk() if msg.is_multipart() else [msg]): if part.get_content_type() == "text/html": payload = part.get_payload(decode=True) if payload: body = "[HTML]\n" + payload.decode( part.get_content_charset() or "utf-8", errors="ignore") break return body or "[Тело сообщения пустое]" def _connect(server: str, port: int, user: str, password: str): if port == 563: return nntplib.NNTP_SSL(server, port=port, timeout=30, user=user or None, password=password or None) return nntplib.NNTP(server, port=port, timeout=30, user=user or None, password=password or None) def _fetch_index(s, last: int, window: int) -> list[dict]: start = max(1, last - window + 1) try: _, overviews = s.over((str(start), str(last))) except Exception: return [] index = [] for num, over in overviews: msgid = (over.get("message-id") or "").strip() or f"" index.append({ "num": int(num), "msgid": msgid, "subject": _decode(over.get("subject", "")), "from": _decode(over.get("from", "")), "date": _parse_date(over.get("date", "")), "refs": [r for r in (over.get("references") or "").split() if r.startswith("<")], }) index.sort(key=lambda m: m["num"]) return index def _make_root_resolver(index_by_id: dict): resolved = {} def root_of(msg: dict) -> str: chain, cur = [], msg while True: key = cur["msgid"] if key in resolved: r = resolved[key] break if key in chain: r = key break chain.append(key) parent_id = cur["refs"][0] if cur["refs"] else None parent = index_by_id.get(parent_id) if parent_id else None if parent is None: r = parent_id or key break cur = parent for k in chain: resolved[k] = r return r return root_of def _thread_members(index: list[dict], target: dict) -> list[dict]: by_id = {m["msgid"]: m for m in index} root_of = _make_root_resolver(by_id) root = root_of(target) members = [m for m in index if root_of(m) == root] return members def _fetch_bodies(s, members: list[dict]) -> list[dict]: out = [] for m in members[-MAX_THREAD_MSGS:]: entry = dict(m) try: _, info = s.article(str(m["num"])) msg = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy) entry["body"] = _clean_quotes(_extract_body(msg)) except Exception as e: entry["body"] = f"[Не удалось прочитать: {e}]" out.append(entry) return out # ---------------------------------------------------------------- инструменты def scan_group(server: str, group: str, port: int = 119, user: str = "", password: str = "", window: int = DEFAULT_WINDOW) -> dict: """ НАЙТИ НОВОЕ: сравнивает группу с отметкой прошлого запуска, собирает затронутые треды целиком и возвращает их с контекстом. """ state = _load_state() key = _state_key(server, group) st = state.setdefault(key, {"last_seen": None, "my_ids": []}) my_ids = set(st.get("my_ids", [])) first_run = st["last_seen"] is None try: with _connect(server, port, user, password) as s: _, count, first, last, _ = s.group(group) first, last = int(first), int(last) if first_run: st["last_seen"] = max(first, last - 50) since = st["last_seen"] if last <= since: _save_state(state) return {"status": "no_new_messages", "group": group, "last_seen": since, "last_num": last, "threads": []} index = _fetch_index(s, last, window) new_msgs = [m for m in index if m["num"] > since] if not new_msgs: st["last_seen"] = last _save_state(state) return {"status": "no_new_messages_in_window", "group": group, "last_num": last, "threads": []} threads = {} for m in new_msgs: members = _thread_members(index, m) members_sorted = sorted(members, key=lambda x: x["num"]) root = members_sorted[0]["msgid"] t = threads.setdefault(root, {"members": {}, "new": set()}) for mem in members_sorted: t["members"][mem["msgid"]] = mem t["new"].add(m["msgid"]) result_threads = [] for root, t in list(threads.items())[:10]: members = sorted(t["members"].values(), key=lambda x: x["num"]) full = _fetch_bodies(s, members) refs_all = {r for m in members for r in m["refs"]} msg_ids = {m["msgid"] for m in members} reply_to_me = bool(refs_all & my_ids) or bool(msg_ids & my_ids) last_is_mine = members[-1]["msgid"] in my_ids result_threads.append({ "root_msgid": root, "subject": full[0]["subject"] or full[-1]["subject"], "reply_to_me": reply_to_me, "last_is_mine": last_is_mine, "needs_reply": bool(t["new"] - msg_ids & my_ids) and not last_is_mine, "new_msg_nums": sorted( m["num"] for m in members if m["msgid"] in t["new"]), "messages": [ {"num": m["num"], "msgid": m["msgid"], "from": m["from"], "date": m["date"], "body": m["body"]} for m in full ], }) st["last_seen"] = last _save_state(state) return {"status": "ok", "group": group, "first_run": first_run, "last_num": last, "threads": result_threads} except Exception as e: return {"status": "error", "group": group, "error": str(e)} def read_thread(server: str, group: str, msg_id: str, port: int = 119, user: str = "", password: str = "", window: int = DEFAULT_WINDOW) -> dict: """ ПРОЧИТАТЬ ВЕТКУ ЦЕЛИКОМ: msg_id — Message-ID (<...>) или номер статьи. Возвращает все сообщения треда в хронологическом порядке с телами. """ try: with _connect(server, port, user, password) as s: _, count, first, last, _ = s.group(group) last_num = int(last) try: _, info = s.article(msg_id) probe = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy) target_id = probe.get("Message-ID", msg_id) except Exception as e: return {"error": f"Сообщение {msg_id} не найдено: {e}"} index = _fetch_index(s, last_num, window) by_id = {m["msgid"]: m for m in index} target = by_id.get(target_id) if target is None: return {"error": f"{target_id} вне окна сканирования, увеличьте window"} members = _thread_members(index, target) full = _fetch_bodies(s, members) return {"thread_root": full[0]["msgid"], "subject": full[0]["subject"], "messages": [{"num": m["num"], "msgid": m["msgid"], "from": m["from"], "date": m["date"], "body": m["body"]} for m in full]} except Exception as e: return {"error": str(e)} def post_reply(server: str, group: str, reply_to_msg_id: str, body: str, sender_name: str, sender_email: str, port: int = 119, user: str = "", password: str = "") -> dict: """ ОТВЕТИТЬ: постит ответ на сообщение reply_to_msg_id в группу. Автоматически проставляет Re: в Subject, In-Reply-To и References. """ if not re.match(r"[^@\s]+@[^@\s]+", sender_email): return {"error": "Некорректный sender_email"} try: with _connect(server, port, user, password) as s: s.group(group) try: _, info = s.article(reply_to_msg_id) except Exception as e: return {"error": f"Не найдено сообщение {reply_to_msg_id}: {e}"} parent = email.message_from_bytes(b"\n".join(info.lines), policy=email_policy) parent_id = (parent.get("Message-ID") or reply_to_msg_id).strip() parent_subject = _decode(parent.get("Subject", "")) subject = parent_subject if re.match(r"(?i)^\s*(re|aw)\s*:", parent_subject) \ else f"Re: {parent_subject}" refs = [r for r in (parent.get("References") or "").split() if r.startswith("<")] refs.append(parent_id) msg = EmailMessage() msg["From"] = formataddr((sender_name, sender_email)) msg["Newsgroups"] = group msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=sender_email.split("@")[-1]) msg["In-Reply-To"] = parent_id msg["References"] = " ".join(refs) msg.set_content(body, charset="utf-8") data = msg.as_bytes(policy=email_policy) resp = s.post(data.split(b"\n")) state = _load_state() st = state.setdefault(_state_key(server, group), {"last_seen": None, "my_ids": []}) st.setdefault("my_ids", []).append(msg["Message-ID"]) st["my_ids"] = st["my_ids"][-200:] _save_state(state) return {"status": "posted", "message_id": msg["Message-ID"], "subject": subject, "server_response": str(resp)} except Exception as e: return {"error": f"Ошибка отправки: {e}"} def list_groups(server: str, port: int = 119, user: str = "", password: str = "") -> list: """Диагностика: список групп на сервере.""" try: with _connect(server, port, user, password) as s: _, groups = s.list() return [g for g in groups if g] except Exception as e: return [f"Ошибка: {e}"] # Безопасная централизованная регистрация инструментов через прокси-функцию scan_group = register_mcp_tool(scan_group) read_thread = register_mcp_tool(read_thread) post_reply = register_mcp_tool(post_reply) list_groups = register_mcp_tool(list_groups) if __name__ == "__main__": mcp.run(transport="stdio")