"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер sdlc-wiki) — инструменты сессии Claude поверх HTTP API хаба (specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename.""" import asyncio import re import time from urllib.parse import quote from mcp.server.fastmcp import FastMCP from ...core import mcp as core from ...core.client import HubError from . import roots as wiki_roots POLL_SECONDS = 2 ATTACHMENT_WAIT = 60 SYNC_WAIT = 300 CHUNK = 20000 CANDIDATES = 10 mcp = FastMCP("sdlc-wiki") _NEWLINES = re.compile(r"\s*[\r\n]+\s*") def _line(v) -> str: """Значение из Eva внутри строки ответа: переводы строк — пробел, иначе они подделали бы строки шапки (T9.3).""" return _NEWLINES.sub(" ", core.value(v)) def _project() -> dict: return next(p for p in core.client().get("/projects")["projects"] if p["label"] == core.PROJECT) def _no_root(label) -> str: return f"у проекта {label} нет корня вики: root_set DOC-…" def _gate(need_sync: bool = True) -> str | None: """Отказ вместо запроса к вики: нет корня или (need_sync) нет ни одной синхронизации. None — можно работать.""" p = _project() if not p.get("wiki_root"): return _no_root(p["label"]) if need_sync and not p.get("wiki_synced_at"): return "вики проекта ещё не синхронизирована: sync" return None def _walk(pages: list, code: str, depth: int) -> list: """Страницы поддерева code в порядке обхода до depth уровней ниже него; code не найден — None.""" start = next((i for i, p in enumerate(pages) if p["code"] == code), None) if start is None: return None base = pages[start]["depth"] out = [pages[start]] for p in pages[start + 1:]: if p["depth"] <= base: break out.append(p) return [p for p in out if p["depth"] - base <= depth] @mcp.tool() @core.guard @core.scoped(True) def tree(code: str = "", depth: int = 2) -> str: """Дерево вики проекта: от корня или от страницы code, depth уровней ниже неё (по умолчанию 2). Строка — отступ, код, название, размер Markdown в символах.""" if (refusal := _gate()) is not None: return refusal pages = core.client().get("/wiki/tree", project=core.PROJECT)["pages"] if not pages: return "страниц нет" depth = max(int(depth), 0) if code: shown = _walk(pages, code.strip(), depth) if shown is None: return f"нет страницы {code}; дерево без кода — tree" else: base = min(p["depth"] for p in pages) shown = [p for p in pages if p["depth"] - base <= depth] base = shown[0]["depth"] return "\n".join(f"{' ' * (p['depth'] - base)}{p['code']} {_line(p.get('title'))} ({core.value(p.get('size'))} симв.)" for p in shown) def _candidates(pages: list, title: str) -> str: low = title.casefold() found = [p for p in pages if low in (p.get("title") or "").casefold()] if not found: return f"нет страницы «{_line(title)}»; дерево — tree, поиск по тексту — search" lines = [f"{p['code']} {_line(p.get('title'))}" for p in found[:CANDIDATES]] more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else [] return "под название «%s» подходит несколько страниц, повторите page с code:\n" % _line(title) + "\n".join(lines + more) @mcp.tool() @core.guard @core.scoped(True) def page(code: str = "", title: str = "", offset: int = 0) -> str: """Страница вики: шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с id) и Markdown с символа offset, до 20 000 символов за вызов; если текст длиннее, последняя строка — «продолжение: offset=». Страница задаётся кодом (DOC-…) или названием: точное название без учёта регистра открывает страницу, иначе будут показаны до 10 кандидатов с кодами.""" if (refusal := _gate()) is not None: return refusal client = core.client() code, title = (code or "").strip(), (title or "").strip() if not code: if not title: return "нужен code или title" pages = client.get("/wiki/tree", project=core.PROJECT)["pages"] exact = [p for p in pages if (p.get("title") or "").casefold() == title.casefold()] if len(exact) != 1: return _candidates(exact or pages, title) code = exact[0]["code"] d = client.get(f"/wiki/pages/{quote(code, safe='')}", project=core.PROJECT) text, offset = d.get("markdown") or "", max(int(offset), 0) atts = d.get("attachments") or [] head = [f"{_line(d['code'])} — {_line(d.get('title'))}", f"путь: {_line(d.get('path'))}", f"Eva: {_line(d.get('url'))}", f"синхронизировано: {_line(d.get('synced_at'))}"] head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {_line(a.get('name'))} ({_line(a.get('state'))})" for a in atts] if atts else ["вложений нет"]) if offset >= len(text) and (offset > 0 or not text): body = f"offset={offset} за концом текста: длина {len(text)} символов" if offset > 0 else "страница пуста" return "\n".join(head + ["", body]) end = offset + CHUNK out = "\n".join(head + ["", text[offset:end]]) return out + f"\nпродолжение: offset={end}" if end < len(text) else out @mcp.tool() @core.guard @core.scoped(True) def search(query: str, limit: int = 10) -> str: """Полнотекстовый поиск по страницам вики проекта: код, название, путь и фрагмент (limit — 1…50, по умолчанию 10).""" if (refusal := _gate()) is not None: return refusal items = core.client().get("/wiki/search", project=core.PROJECT, q=query, limit=limit)["results"] if not items: return "ничего не найдено" return "\n".join(f"{r['code']} {_line(r.get('title'))} — {_line(r.get('path'))}\n {_line(r.get('snippet'))}" for r in items) def _store(attachment_id: int, name, data: bytes): """Копия вложения в downloads/wiki-/: имя из Eva недоверенное — берётся последний компонент пути.""" base = (name or "").replace("\\", "/").rsplit("/", 1)[-1] return core.store_copy(f"wiki-{attachment_id}", core.safe_name(base, str(attachment_id)), data) @mcp.tool() @core.guard @core.scoped(True) async def attachment(id: int) -> str: """Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из ответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая команда не поставится.""" refusal = await asyncio.to_thread(_gate) if refusal is not None: return refusal client = core.client() st = await asyncio.to_thread(client.post, f"/wiki/attachments/{id}/fetch", {"author": core.AUTHOR}) deadline = time.monotonic() + ATTACHMENT_WAIT while st.get("state") == "fetching" and time.monotonic() < deadline: await asyncio.sleep(POLL_SECONDS) st = await asyncio.to_thread(client.get, f"/wiki/attachments/{id}") if st.get("state") == "fetching": return f"скачивание ещё идёт (вложение #{id}); вызовите attachment ещё раз — вторая команда не поставится" if st.get("state") != "ready": return f"не удалось скачать: {_line(st.get('error'))}" data = await asyncio.to_thread(client.get_bytes, f"/wiki/attachments/{id}/content") path = await asyncio.to_thread(_store, id, st.get("name"), data) return f"{path}\n{_line(st.get('name'))} ({len(data)} байт), страница {_line(st.get('page_code'))}" def _summary(client) -> str: s = client.get("/wiki/status", project=core.PROJECT) lines = [f"корень: {core.value(s.get('root'))} {_line(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; " f"последняя синхронизация: {core.value(s.get('synced_at'))}"] if s.get("error"): lines.append(f"ошибка синхронизации: {_line(s['error'])}") b = s.get("batch") lines.append(f"пакет: #{b['id']} {b['status']}" if b else "пакета в работе нет") return "\n".join(lines) @mcp.tool() @core.guard @core.scoped(True) async def sync(wait: bool = False) -> str: """Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true — дождаться итога (до 300 с).""" refusal = await asyncio.to_thread(_gate, False) if refusal is not None: return refusal client = core.client() try: batch_id = (await asyncio.to_thread(client.post, "/wiki/sync", {"project": core.PROJECT, "author": core.AUTHOR}))["batch_id"] head = f"синхронизация поставлена: пакет #{batch_id}" except HubError as e: if e.error != "sync_in_progress" or not isinstance(e.detail, dict): raise batch_id = e.detail["batch_id"] head = f"синхронизация уже идёт: пакет #{batch_id}" if not wait: return head deadline = time.monotonic() + SYNC_WAIT while True: b = await asyncio.to_thread(client.get, f"/batches/{batch_id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: break await asyncio.sleep(POLL_SECONDS) lines = [f"пакет #{batch_id}: {b['status']}"] lines += [f" #{c['id']} {c['type']} {c['status']}" + (f": {_line(c['error'])}" if c.get("error") else "") for c in b.get("commands") or []] if b["status"] not in ("done", "cancelled"): return "\n".join(lines + ["(таймаут ожидания, пакет ещё выполняется)"]) return "\n".join(lines + [await asyncio.to_thread(_summary, client)]) @mcp.tool() @core.guard @core.scoped(True) def status() -> str: """Состояние вики проекта: корень, число страниц, последняя синхронизация, ошибка, пакет в работе; затем агент wiki (онлайн/офлайн, версия, публикация, обновление, запускатель).""" client = core.client() p = _project() head = _summary(client) if p.get("wiki_root") else _no_root(p["label"]) return head + "\n" + core.fmt_agent(client.get("/agent", module="wiki")) @mcp.tool() @core.guard @core.scoped(True) def root_set(root: str) -> str: """Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя копия остаётся до её успеха.""" try: code = wiki_roots.parse_root(root) except ValueError as e: return f"корень не задан: {e}; пример: root_set DOC-123" if code is None: return "корень не задан: укажите код DOC-… или ссылку на документ Eva" out = core.client().patch(f"/projects/{quote(core.PROJECT, safe='')}", {"wiki_root": code, "author": core.AUTHOR}) ids = out.get("batch_ids") or [] return (f"корень вики проекта {core.PROJECT}: {code}; синхронизация поставлена: пакет #{ids[0]}" if ids else f"корень вики проекта {core.PROJECT}: {code}; пакета синхронизации не поставлено (корень не изменился)") @core.guard def project_create(label: str, name: str, wiki_root: str = "") -> str: """Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki.""" try: code = wiki_roots.parse_root(wiki_root) except ValueError as e: return f"проект не заведён: {e}" return core.create_project(label, name, {} if code is None else {"wiki_root": code}, "wiki") PROJECT_COLUMNS = [("корень вики", lambda p: core.value(p.get("wiki_root"))), ("страниц", lambda p: core.value(p.get("wiki_pages"))), ("синхронизация", lambda p: core.value(p.get("wiki_synced_at")))] core.register_core_tools( mcp, projects_doc="Проекты хаба: метка, название, корень вики, страниц, последняя синхронизация; текущий проект сессии отмечен.", columns=PROJECT_COLUMNS, project_create=project_create)