From 2a5a33f9c65c14127989523bcc5750eb8b2f5aa0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 02:49:23 +0400 Subject: [PATCH] =?UTF-8?q?wiki:=20MCP-=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=20jira-hub-wiki=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fmt_agent, safe_name и store_copy вынесены в core/mcp.py; сервер jira их переэкспортирует, тексты вывода не менялись. Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/core/mcp.py | 65 +++- src/jira_hub/modules/jira/mcp.py | 65 +--- src/jira_hub/modules/wiki/__init__.py | 7 + src/jira_hub/modules/wiki/mcp.py | 261 +++++++++++++++ .../baseline/mcp_wiki_tools.expected.json | 163 ++++++++++ tests/test_baseline.py | 7 + tests/test_wiki_mcp.py | 306 ++++++++++++++++++ 7 files changed, 812 insertions(+), 62 deletions(-) create mode 100644 src/jira_hub/modules/wiki/mcp.py create mode 100644 tests/fixtures/baseline/mcp_wiki_tools.expected.json create mode 100644 tests/test_wiki_mcp.py diff --git a/src/jira_hub/core/mcp.py b/src/jira_hub/core/mcp.py index c0202ff..375b673 100644 --- a/src/jira_hub/core/mcp.py +++ b/src/jira_hub/core/mcp.py @@ -5,11 +5,13 @@ import asyncio import functools import inspect import json +import os import sys +import tempfile from collections.abc import Callable from pathlib import Path -from . import registry +from . import paths, registry from .client import HubClient, HubError, HubUnavailable OFFLINE = "хаб не запущен: jira-hub serve" @@ -100,6 +102,67 @@ def value(v): return "—" if v in (None, "", []) else str(v) +def fmt_agent(a) -> str: + state = "онлайн" if a.get("online") else "офлайн" + cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" + out = [f"агент {state} (версия {value(a.get('agent_version'))}, настройки v{value(a.get('settings_version'))}, последний контакт {value(a.get('last_seen_at'))}); {cur}; " + f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"] + if a.get("warning"): + out.append("внимание: " + a["warning"]) + for s in a.get("redelivered", []): + out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") + rel = a.get("release") + out.append(f"публикация: v{rel['version']} от {value(rel.get('published_at'))}" if rel else "публикации нет") + us = a.get("update_state") + if us: + out.append(f"обновление: {us.get('state')} v{value(us.get('from_version'))} → v{value(us.get('to_version'))}") + lv = a.get("launcher_version") + if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт + out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") + else: + out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") + if a.get("update_hint"): + out.append(a["update_hint"]) + return "\n".join(out) + + +def safe_name(filename: str, attachment_id: str) -> str: + """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-.""" + name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "") + if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем + stem, dot, ext = name.rpartition(".") + if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя + stem, ext = name, "" + else: + ext = "." + ext + stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore") + name = stem + ext + return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}" + + +def store_copy(attachment_id: str, name: str, data: bytes) -> Path: + """Копия вложения в <каталог данных>/downloads//<имя>: каталоги 0700, запись через временный файл и + os.replace; файл того же размера уже на месте — не трогаем.""" + root = paths.db_path().parent / "downloads" + folder = root / attachment_id + for d in (root, folder): + d.mkdir(mode=0o700, parents=True, exist_ok=True) + d.chmod(0o700) + target = folder / name + if target.is_file() and target.stat().st_size == len(data): + os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет + return target + fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, target) + except BaseException: + Path(tmp).unlink(missing_ok=True) + raise + return target + + def fmt_projects(items, current: str | None, columns: list[tuple[str, Callable[[dict], str]]]) -> str: """Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта).""" if not items: diff --git a/src/jira_hub/modules/jira/mcp.py b/src/jira_hub/modules/jira/mcp.py index bf9ae95..d516816 100644 --- a/src/jira_hub/modules/jira/mcp.py +++ b/src/jira_hub/modules/jira/mcp.py @@ -4,9 +4,7 @@ import asyncio import base64 import json -import os import stat -import tempfile import time from datetime import datetime, timedelta from pathlib import Path @@ -14,7 +12,6 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP from ...core import mcp as core -from ...core import paths from ...core.client import HubError from ...core.files import MAX_SIZE from .attachments import fmt_size @@ -23,6 +20,7 @@ from .commands import describe, is_temp POLL_SECONDS = 5 BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" mcp = FastMCP("jira-hub") +fmt_agent = core.fmt_agent def fmt_issues(issues) -> str: @@ -63,18 +61,7 @@ def _files_line(c: dict, atts: list) -> str: return "файлы: " + ", ".join(parts) if parts else "" -def safe_name(filename: str, attachment_id: str) -> str: - """Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-.""" - name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "") - if len(name.encode()) > 255: # предел имени в ФС — 255 байт: режем основу, расширение сохраняем - stem, dot, ext = name.rpartition(".") - if not dot or len(ext.encode()) > 32: # «расширение» из одного длинного хвоста — просто режем всё имя - stem, ext = name, "" - else: - ext = "." + ext - stem = stem.encode()[:255 - len(ext.encode())].decode("utf-8", "ignore") - name = stem + ext - return name if name.strip() and name.strip() not in (".", "..") else f"attachment-{attachment_id}" +safe_name = core.safe_name def fmt_issue(d) -> str: @@ -188,30 +175,6 @@ def fmt_batch(b) -> str: return "\n".join(lines) -def fmt_agent(a) -> str: - state = "онлайн" if a.get("online") else "офлайн" - cur = f"выполняет #{a['current_command_id']}" if a.get("current_command_id") else "очередь свободна" - out = [f"агент {state} (версия {core.value(a.get('agent_version'))}, настройки v{core.value(a.get('settings_version'))}, последний контакт {core.value(a.get('last_seen_at'))}); {cur}; " - f"429/503 за сутки: {a.get('rate_limited_24h', 0)}"] - if a.get("warning"): - out.append("внимание: " + a["warning"]) - for s in a.get("redelivered", []): - out.append(f"выдана повторно: #{s['id']} {s['type']}, выдач {s['deliveries']}, последняя {s['claimed_at']}") - rel = a.get("release") - out.append(f"публикация: v{rel['version']} от {core.value(rel.get('published_at'))}" if rel else "публикации нет") - us = a.get("update_state") - if us: - out.append(f"обновление: {us.get('state')} v{core.value(us.get('from_version'))} → v{core.value(us.get('to_version'))}") - lv = a.get("launcher_version") - if a.get("agent_reported") is False: # хаб перезапущен, claim ещё не было (решение 8); старый хаб поля не шлёт - out.append("запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)") - else: - out.append(f"запускатель: v{lv}" if lv is not None else "без запускателя") - if a.get("update_hint"): - out.append(a["update_hint"]) - return "\n".join(out) - - # --- инструменты ----------------------------------------------------------------- @mcp.tool() @@ -249,27 +212,7 @@ def comment(key: str, comment_id: str) -> str: return f"у {key} нет комментария #{cid}" -def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: - """Копия вложения в <каталог данных>/downloads//<имя>: каталоги 0700, запись через временный файл и - os.replace; файл того же размера уже на месте — не трогаем.""" - root = paths.db_path().parent / "downloads" - folder = root / attachment_id - for d in (root, folder): - d.mkdir(mode=0o700, parents=True, exist_ok=True) - d.chmod(0o700) - target = folder / name - if target.is_file() and target.stat().st_size == len(data): - os.utime(folder) # копия снова нужна — уборка по mtime папки её не заденет - return target - fd, tmp = tempfile.mkstemp(dir=folder, prefix=".part-") - try: - with os.fdopen(fd, "wb") as f: - f.write(data) - os.replace(tmp, target) - except BaseException: - Path(tmp).unlink(missing_ok=True) - raise - return target +_store_copy = core.store_copy @mcp.tool() @@ -517,7 +460,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool def agent_status() -> str: """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, состояние самообновления и запускатель.""" - return fmt_agent(core.client().get("/agent")) + return core.fmt_agent(core.client().get("/agent")) @mcp.tool() diff --git a/src/jira_hub/modules/wiki/__init__.py b/src/jira_hub/modules/wiki/__init__.py index ed96c77..a1c22a9 100644 --- a/src/jira_hub/modules/wiki/__init__.py +++ b/src/jira_hub/modules/wiki/__init__.py @@ -2,6 +2,12 @@ from ...core.modules import Module from . import api, attachments, commands, projects, schema, settings, tick +def mcp_server(): + """MCP-сервер модуля; FastMCP тянется лениво — только когда сервер запускают.""" + from . import mcp + return mcp.mcp + + MODULE = Module( name="wiki", schema=schema.SQL, @@ -13,6 +19,7 @@ MODULE = Module( routes=api.ROUTES, file_refs=attachments.file_refs, tick=(tick.housekeeping_tick, tick.auto_sync_tick), + mcp_server=mcp_server, project_fields=projects.FIELDS, project_view=projects.view, on_project_created=projects.on_created, diff --git a/src/jira_hub/modules/wiki/mcp.py b/src/jira_hub/modules/wiki/mcp.py new file mode 100644 index 0000000..53f15eb --- /dev/null +++ b/src/jira_hub/modules/wiki/mcp.py @@ -0,0 +1,261 @@ +"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер jira-hub-wiki) — инструменты сессии Claude поверх HTTP API хаба +(specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и +имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename.""" +import asyncio +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 projects as wiki_projects + +POLL_SECONDS = 2 +ATTACHMENT_WAIT = 60 +SYNC_WAIT = 300 +CHUNK = 20000 +CANDIDATES = 10 +mcp = FastMCP("jira-hub-wiki") + + +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']} {core.value(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"нет страницы «{title}»; дерево — tree, поиск по тексту — search" + lines = [f"{p['code']} {p.get('title')}" for p in found[:CANDIDATES]] + more = [f"…и ещё {len(found) - CANDIDATES}"] if len(found) > CANDIDATES else [] + return "под название «%s» подходит несколько страниц, повторите page с code:\n" % 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"{d['code']} — {core.value(d.get('title'))}", f"путь: {core.value(d.get('path'))}", + f"Eva: {core.value(d.get('url'))}", f"синхронизировано: {core.value(d.get('synced_at'))}"] + head += ([f"вложения ({len(atts)}):"] + [f" #{a['id']} {a.get('name')} ({core.value(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']} {core.value(r.get('title'))} — {core.value(r.get('path'))}\n {core.value(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"не удалось скачать: {core.value(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{core.value(st.get('name'))} ({len(data)} байт), страница {core.value(st.get('page_code'))}" + + +def _summary(client) -> str: + s = client.get("/wiki/status", project=core.PROJECT) + lines = [f"корень: {core.value(s.get('root'))} {core.value(s.get('root_title'))}; страниц: {core.value(s.get('pages'))}; " + f"последняя синхронизация: {core.value(s.get('synced_at'))}"] + if s.get("error"): + lines.append(f"ошибка синхронизации: {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": {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_projects.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/{core.PROJECT}", {"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 для репозитория проекта с сервером jira-hub-wiki.""" + try: + code = wiki_projects.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) diff --git a/tests/fixtures/baseline/mcp_wiki_tools.expected.json b/tests/fixtures/baseline/mcp_wiki_tools.expected.json new file mode 100644 index 0000000..25149f2 --- /dev/null +++ b/tests/fixtures/baseline/mcp_wiki_tools.expected.json @@ -0,0 +1,163 @@ +[ + { + "description": "Дерево вики проекта: от корня или от страницы code, depth уровней ниже неё (по умолчанию 2). Строка — отступ, код,\nназвание, размер Markdown в символах.", + "inputSchema": { + "properties": { + "code": { + "default": "", + "title": "Code", + "type": "string" + }, + "depth": { + "default": 2, + "title": "Depth", + "type": "integer" + } + }, + "title": "treeArguments", + "type": "object" + }, + "name": "tree" + }, + { + "description": "Страница вики: шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с id) и Markdown с символа\noffset, до 20 000 символов за вызов; если текст длиннее, последняя строка — «продолжение: offset=». Страница\nзадаётся кодом (DOC-…) или названием: точное название без учёта регистра открывает страницу, иначе будут показаны\nдо 10 кандидатов с кодами.", + "inputSchema": { + "properties": { + "code": { + "default": "", + "title": "Code", + "type": "string" + }, + "offset": { + "default": 0, + "title": "Offset", + "type": "integer" + }, + "title": { + "default": "", + "title": "Title", + "type": "string" + } + }, + "title": "pageArguments", + "type": "object" + }, + "name": "page" + }, + { + "description": "Полнотекстовый поиск по страницам вики проекта: код, название, путь и фрагмент (limit — 1…50, по умолчанию 10).", + "inputSchema": { + "properties": { + "limit": { + "default": 10, + "title": "Limit", + "type": "integer" + }, + "query": { + "title": "Query", + "type": "string" + } + }, + "required": [ + "query" + ], + "title": "searchArguments", + "type": "object" + }, + "name": "search" + }, + { + "description": "Скачать вложение страницы вики и вернуть путь к локальной копии (читайте файл по этому пути). id — номер из\nответа page. Хаб просит агента скачать файл; ожидание до 60 с, не дождался — вызовите инструмент ещё раз: вторая\nкоманда не поставится.", + "inputSchema": { + "properties": { + "id": { + "title": "Id", + "type": "integer" + } + }, + "required": [ + "id" + ], + "title": "attachmentArguments", + "type": "object" + }, + "name": "attachment" + }, + { + "description": "Синхронизировать вики проекта с Eva: ставит пакет (или сообщает о текущем) и возвращает его id. wait=true —\nдождаться итога (до 300 с).", + "inputSchema": { + "properties": { + "wait": { + "default": false, + "title": "Wait", + "type": "boolean" + } + }, + "title": "syncArguments", + "type": "object" + }, + "name": "sync" + }, + { + "description": "Состояние вики проекта: корень, число страниц, последняя синхронизация, ошибка, пакет в работе; затем агент wiki\n(онлайн/офлайн, версия, публикация, обновление, запускатель).", + "inputSchema": { + "properties": {}, + "title": "statusArguments", + "type": "object" + }, + "name": "status" + }, + { + "description": "Задать корень вики проекта: код DOC-… или ссылка на документ Eva. Хаб ставит синхронизацию нового корня; прежняя\nкопия остаётся до её успеха.", + "inputSchema": { + "properties": { + "root": { + "title": "Root", + "type": "string" + } + }, + "required": [ + "root" + ], + "title": "root_setArguments", + "type": "object" + }, + "name": "root_set" + }, + { + "description": "Проекты хаба: метка, название, корень вики, страниц, последняя синхронизация; текущий проект сессии отмечен.", + "inputSchema": { + "properties": {}, + "title": "projectsArguments", + "type": "object" + }, + "name": "projects" + }, + { + "description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.", + "inputSchema": { + "properties": { + "label": { + "title": "Label", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "wiki_root": { + "default": "", + "title": "Wiki Root", + "type": "string" + } + }, + "required": [ + "label", + "name" + ], + "title": "project_createArguments", + "type": "object" + }, + "name": "project_create" + } +] diff --git a/tests/test_baseline.py b/tests/test_baseline.py index f2e8915..4cbdae4 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -26,6 +26,13 @@ def test_mcp_tools(): for t in tools]) +def test_mcp_wiki_tools(): + from jira_hub.modules.wiki import mcp as wiki_mcp + tools = asyncio.run(wiki_mcp.mcp.list_tools()) + _check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} + for t in tools]) + + def test_routes(): rows = [[method, pattern, scope, fn.__name__] for method, pattern, fn, scope in api.ROUTES] _check("routes", sorted(rows, key=lambda r: (r[1], r[0]))) diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py new file mode 100644 index 0000000..29fb284 --- /dev/null +++ b/tests/test_wiki_mcp.py @@ -0,0 +1,306 @@ +"""MCP вики (wiki-module, spec mcp «MCP вики»): клиент хаба подменяется, живой хаб не нужен.""" +import asyncio + +import pytest + +from jira_hub.core import mcp as core_mcp +from jira_hub.core import registry +from jira_hub.core.client import HubError +from jira_hub.modules.wiki import mcp as wmcp + +PROJECTS = {"projects": [ + {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1", "wiki_root_title": "Корень", "wiki_pages": 3, + "wiki_synced_at": "2026-10-24T10:00:00+00:00"}, + {"label": "sfm_kedr", "name": "СФМ", "wiki_root": None, "wiki_pages": None, "wiki_synced_at": None}, + {"label": "fresh", "name": "Новый", "wiki_root": "DOC-9", "wiki_pages": 0, "wiki_synced_at": None}]} +TREE = {"pages": [ + {"code": "DOC-1", "title": "Корень", "depth": 0, "parent_code": None, "size": 100}, + {"code": "DOC-2", "title": "Статусы", "depth": 1, "parent_code": "DOC-1", "size": 200}, + {"code": "DOC-3", "title": "Статистика", "depth": 1, "parent_code": "DOC-1", "size": 300}, + {"code": "DOC-4", "title": "Глубокая", "depth": 2, "parent_code": "DOC-3", "size": 50}, + {"code": "DOC-5", "title": "Ещё глубже", "depth": 3, "parent_code": "DOC-4", "size": 10}]} + + +class Fake: + """Клиент хаба: ответы по пути; callable получает (method, params/body).""" + def __init__(self, routes): + self.routes, self.calls = routes, [] + + def _answer(self, method, path, arg): + self.calls.append((method, path, arg)) + r = self.routes[(method, path)] if (method, path) in self.routes else self.routes[path] + if isinstance(r, list): + r = r.pop(0) if len(r) > 1 else r[0] + if isinstance(r, Exception): + raise r + return r(arg) if callable(r) else r + + def get(self, path, **params): + return self._answer("GET", path, params) + + def post(self, path, body=None): + return self._answer("POST", path, body) + + def patch(self, path, body): + return self._answer("PATCH", path, body) + + def get_bytes(self, path): + return self._answer("GET", path, None) + + +@pytest.fixture +def hub(monkeypatch, tmp_path): + monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setattr(wmcp, "POLL_SECONDS", 0.01) + monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") + + def make(routes, project="AWPCA"): + monkeypatch.setattr(core_mcp, "PROJECT", project) + fake = Fake({"/projects": PROJECTS, **routes}) + monkeypatch.setattr(core_mcp, "client", lambda: fake) + return fake + return make + + +def paths_of(fake): + return [p for _, p, _ in fake.calls] + + +def test_unbound_tools_refuse(monkeypatch): + monkeypatch.setattr(core_mcp, "PROJECT", None) + fake = Fake({}) + monkeypatch.setattr(core_mcp, "client", lambda: fake) + assert wmcp.tree() == core_mcp.NOT_BOUND + assert wmcp.search("x") == core_mcp.NOT_BOUND + assert wmcp.status() == core_mcp.NOT_BOUND + assert wmcp.root_set("DOC-1") == core_mcp.NOT_BOUND + assert asyncio.run(wmcp.sync()) == core_mcp.NOT_BOUND + assert fake.calls == [] + + +def test_no_root_message(hub): + fake = hub({}, project="sfm_kedr") + msg = "у проекта sfm_kedr нет корня вики: root_set DOC-…" + assert wmcp.tree() == msg + assert wmcp.page(code="DOC-1") == msg + assert wmcp.search("x") == msg + assert asyncio.run(wmcp.sync()) == msg + assert asyncio.run(wmcp.attachment(1)) == msg + assert not any(p.startswith("/wiki/") for p in paths_of(fake)) + + +def test_not_synced_message(hub): + fake = hub({}, project="fresh") + msg = "вики проекта ещё не синхронизирована: sync" + assert wmcp.tree() == msg and wmcp.search("x") == msg and wmcp.page(code="DOC-1") == msg + assert not any(p.startswith("/wiki/") for p in paths_of(fake)) + + +def test_page_chunks(hub): + text = "я" * 45000 + page = {"code": "DOC-3", "title": "Статистика", "path": "корень/стат", "url": "https://eva/doc3", + "synced_at": "2026-10-24T10:00:00+00:00", "markdown": text, + "attachments": [{"id": 7, "name": "m.xlsx", "state": "none", "size": 5}]} + hub({"/wiki/pages/DOC-3": page}) + first = wmcp.page(code="DOC-3") + assert first.endswith("\nпродолжение: offset=20000") + assert "я" * 20000 in first and "я" * 20001 not in first + assert "DOC-3" in first and "корень/стат" in first and "https://eva/doc3" in first and "#7 m.xlsx" in first + last = wmcp.page(code="DOC-3", offset=40000) + assert "продолжение" not in last and last.endswith("я" * 5000) + mid = wmcp.page(code="DOC-3", offset=20000) + assert mid.endswith("продолжение: offset=40000") + + +def test_page_offset_past_end(hub): + hub({"/wiki/pages/DOC-3": {"code": "DOC-3", "title": "T", "path": "p", "url": "u", "synced_at": "s", + "markdown": "abc", "attachments": []}}) + out = wmcp.page(code="DOC-3", offset=10) + assert "за концом" in out and "длина 3" in out and "продолжение" not in out + assert "за концом" in wmcp.page(code="DOC-3", offset=3) and "длина 3" in out + + +def test_page_by_title_candidates(hub): + fake = hub({"/wiki/tree": TREE}) + out = wmcp.page(title="Стат") + assert "DOC-2 Статусы" in out and "DOC-3 Статистика" in out and "DOC-1" not in out + assert not any(p.startswith("/wiki/pages") for p in paths_of(fake)) + + +def test_page_by_title_exact_case_insensitive(hub): + page = {"code": "DOC-3", "title": "Статистика", "path": "p", "url": "u", "synced_at": "s", "markdown": "тело", + "attachments": []} + fake = hub({"/wiki/tree": TREE, "/wiki/pages/DOC-3": page}) + out = wmcp.page(title="статистика") + assert "тело" in out and "/wiki/pages/DOC-3" in paths_of(fake) + + +def test_page_by_title_ambiguous_and_missing(hub): + tree = {"pages": TREE["pages"] + [{"code": "DOC-8", "title": "статистика", "depth": 1, "parent_code": "DOC-1", "size": 1}]} + hub({"/wiki/tree": tree}) + out = wmcp.page(title="Статистика") + assert "DOC-3 Статистика" in out and "DOC-8 статистика" in out and "тело" not in out + assert "нет страницы" in wmcp.page(title="Нет такой") + assert "code или title" in wmcp.page() + + +def test_page_candidates_capped(hub): + tree = {"pages": [{"code": f"DOC-{i}", "title": f"Раздел {i}", "depth": 1, "parent_code": None, "size": 1} for i in range(30)]} + hub({"/wiki/tree": tree}) + out = wmcp.page(title="Раздел") + assert len([l for l in out.splitlines() if l.startswith("DOC-")]) == 10 + + +def test_tree_depth_and_subtree(hub): + fake = hub({"/wiki/tree": TREE}) + out = wmcp.tree() + lines = out.splitlines() + assert lines[0].startswith("DOC-1 Корень") and lines[1].startswith(" DOC-2 Статусы") + assert "DOC-4" in out and "DOC-5" not in out # depth=2 по умолчанию: корень + два уровня + sub = wmcp.tree(code="DOC-3", depth=1) + assert sub.splitlines()[0].startswith("DOC-3") and "DOC-4" in sub and "DOC-5" not in sub and "DOC-2" not in sub + assert "нет страницы DOC-9" in wmcp.tree(code="DOC-9") + assert fake.calls[-1][2] == {"project": "AWPCA"} + + +def test_search(hub): + fake = hub({"/wiki/search": {"results": [{"code": "DOC-2", "title": "Статусы", "path": "к/с", "snippet": "…согласован…"}]}}) + out = wmcp.search("статус согласован", limit=5) + assert "DOC-2" in out and "Статусы" in out and "к/с" in out and "…согласован…" in out + assert fake.calls[-1] == ("GET", "/wiki/search", {"project": "AWPCA", "q": "статус согласован", "limit": 5}) + hub({"/wiki/search": {"results": []}}) + assert wmcp.search("zzz") == "ничего не найдено" + + +def test_root_set(hub): + fake = hub({("PATCH", "/projects/sfm_kedr"): {"project": {"label": "sfm_kedr", "wiki_root": "DOC-42"}, "batch_ids": [77]}}, + project="sfm_kedr") + out = wmcp.root_set("https://eva.example/docs/doc-42") + assert "DOC-42" in out and "#77" in out + assert fake.calls[-1] == ("PATCH", "/projects/sfm_kedr", {"wiki_root": "DOC-42", "author": "claude"}) + + +def test_root_set_invalid_refused_without_request(hub): + fake = hub({}, project="sfm_kedr") + for bad in ("", " ", "страница", "DOCX-1", 5): + out = wmcp.root_set(bad) + assert "DOC-" in out and "ошибка хаба" not in out, bad + assert not any(m == "PATCH" for m, _, _ in fake.calls) + + +def test_root_set_same_root_no_batch(hub): + hub({("PATCH", "/projects/AWPCA"): {"project": {}, "batch_ids": []}}) + out = wmcp.root_set("DOC-1") + assert "DOC-1" in out and "пакета синхронизации не поставлено" in out + + +def agent_ok(): + return {"online": True, "agent_version": "1", "settings_version": 2, "last_seen_at": "t", "rate_limited_24h": 0, + "release": None, "update_state": None, "agent_reported": True, "launcher_version": 1} + + +def test_status(hub): + fake = hub({"/wiki/status": {"root": "DOC-1", "root_title": "Корень", "synced_at": "2026-10-24T10:00:00+00:00", "pages": 3, + "error": "сбой чтения", "batch": {"id": 12, "status": "queued"}}, + "/agent": agent_ok()}) + out = wmcp.status() + assert "DOC-1" in out and "Корень" in out and "сбой чтения" in out and "#12" in out + assert "агент онлайн" in out and "запускатель: v1" in out + assert ("GET", "/agent", {"module": "wiki"}) in fake.calls + + +def test_status_no_root_still_shows_agent(hub): + fake = hub({"/agent": agent_ok()}, project="sfm_kedr") + out = wmcp.status() + assert "у проекта sfm_kedr нет корня вики" in out and "агент онлайн" in out + assert not any(p.startswith("/wiki/") for p in paths_of(fake)) + + +def test_sync_nowait_and_in_progress(hub): + fake = hub({("POST", "/wiki/sync"): [{"batch_id": 5}, HubError(409, "sync_in_progress", {"batch_id": 5})]}) + assert "#5" in asyncio.run(wmcp.sync()) + assert fake.calls[-1] == ("POST", "/wiki/sync", {"project": "AWPCA", "author": "claude"}) + out = asyncio.run(wmcp.sync()) + assert "уже идёт" in out and "#5" in out + + +def test_sync_wait(hub): + done = {"id": 5, "status": "done", "commands": [{"id": 1, "type": "wiki_sync", "status": "done", "error": None}]} + fake = hub({("POST", "/wiki/sync"): {"batch_id": 5}, + "/batches/5": [{"id": 5, "status": "running", "commands": []}, done], + "/wiki/status": {"root": "DOC-1", "root_title": "К", "synced_at": "s", "pages": 3, "error": None, "batch": None}}) + out = asyncio.run(wmcp.sync(wait=True)) + assert "#5" in out and "done" in out and "страниц: 3" in out + + +def test_sync_wait_timeout(hub, monkeypatch): + monkeypatch.setattr(wmcp, "SYNC_WAIT", 0.05) + hub({("POST", "/wiki/sync"): {"batch_id": 5}, "/batches/5": {"id": 5, "status": "running", "commands": []}}) + assert "ещё выполняется" in asyncio.run(wmcp.sync(wait=True)) + + +ATT_NAME = "../../etc/pass:wd.xlsx" + + +def att(state, **kw): + return {"id": 7, "name": ATT_NAME, "page_code": "DOC-3", "url": "u", "state": state, "size": 3, "error": None, **kw} + + +def test_attachment_ready_copy(hub, tmp_path): + fake = hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"), + "/wiki/attachments/7": [att("fetching"), att("ready")], + "/wiki/attachments/7/content": b"xyz"}) + out = asyncio.run(wmcp.attachment(7)) + target = tmp_path / "home" / "downloads" / "wiki-7" + files = list(target.iterdir()) + assert len(files) == 1 and files[0].read_bytes() == b"xyz" + assert files[0].name == "pass_wd.xlsx" and str(files[0]) in out + assert target.stat().st_mode & 0o777 == 0o700 + assert ("POST", "/wiki/attachments/7/fetch", {"author": "claude"}) in fake.calls + + +@pytest.mark.parametrize("name,expected", [("..", "attachment-7"), ("a/b/c.txt", "c.txt"), ("C:\\x\\y.doc", "y.doc"), + ("", "attachment-7"), (None, "attachment-7")]) +def test_attachment_name_basename(hub, tmp_path, name, expected): + hub({("POST", "/wiki/attachments/7/fetch"): att("ready", name=name), "/wiki/attachments/7/content": b"x"}) + asyncio.run(wmcp.attachment(7)) + assert [p.name for p in (tmp_path / "home" / "downloads" / "wiki-7").iterdir()] == [expected] + + +def test_attachment_timeout_and_failed(hub, monkeypatch): + monkeypatch.setattr(wmcp, "ATTACHMENT_WAIT", 0.05) + hub({("POST", "/wiki/attachments/7/fetch"): att("fetching"), "/wiki/attachments/7": att("fetching")}) + out = asyncio.run(wmcp.attachment(7)) + assert "ещё идёт" in out and "ещё раз" in out + hub({("POST", "/wiki/attachments/7/fetch"): att("failed", error="HTTP 404")}) + assert "не удалось скачать" in asyncio.run(wmcp.attachment(7)) and "HTTP 404" in asyncio.run(wmcp.attachment(7)) + + +def test_attachment_unknown(hub): + hub({("POST", "/wiki/attachments/99/fetch"): HubError(404, "not_found", "вложение 99")}) + assert asyncio.run(wmcp.attachment(99)).startswith("ошибка хаба 404") + + +def test_jira_server_unchanged(): + from test_baseline import test_mcp_tools + test_mcp_tools() + + +def test_run_wiki_module(): + m = registry.module("wiki") + assert m.mcp_server().name == "jira-hub-wiki" + + +def test_wiki_tool_names_order(): + names = [t.name for t in asyncio.run(wmcp.mcp.list_tools())] + assert names == ["tree", "page", "search", "attachment", "sync", "status", "root_set", "projects", "project_create"] + + +def test_projects_table_and_create(hub): + fake = hub({"/projects": PROJECTS, ("POST", "/projects"): {"project": {"label": "n", "name": "Н"}, "batch_id": 3}}) + out = asyncio.run(wmcp.mcp.call_tool("projects", {})) + text = str(out) + assert "AWPCA" in text and "DOC-1" in text and "(текущий)" in text + created = wmcp.project_create("n", "Н", "DOC-5") + assert "jira-hub-wiki" in created + assert fake.calls[-1][2]["wiki_root"] == "DOC-5"