wiki: MCP-сервер jira-hub-wiki (#91)

fmt_agent, safe_name и store_copy вынесены в core/mcp.py; сервер jira их переэкспортирует, тексты вывода не менялись.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 02:49:23 +04:00
parent c650b8fa08
commit 2a5a33f9c6
7 changed files with 812 additions and 62 deletions

View File

@ -5,11 +5,13 @@ import asyncio
import functools import functools
import inspect import inspect
import json import json
import os
import sys import sys
import tempfile
from collections.abc import Callable from collections.abc import Callable
from pathlib import Path from pathlib import Path
from . import registry from . import paths, registry
from .client import HubClient, HubError, HubUnavailable from .client import HubClient, HubError, HubUnavailable
OFFLINE = "хаб не запущен: jira-hub serve" OFFLINE = "хаб не запущен: jira-hub serve"
@ -100,6 +102,67 @@ def value(v):
return "—" if v in (None, "", []) else str(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-<id>."""
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/<id>/<имя>: каталоги 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: def fmt_projects(items, current: str | None, columns: list[tuple[str, Callable[[dict], str]]]) -> str:
"""Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта).""" """Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта)."""
if not items: if not items:

View File

@ -4,9 +4,7 @@
import asyncio import asyncio
import base64 import base64
import json import json
import os
import stat import stat
import tempfile
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
@ -14,7 +12,6 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from ...core import mcp as core from ...core import mcp as core
from ...core import paths
from ...core.client import HubError from ...core.client import HubError
from ...core.files import MAX_SIZE from ...core.files import MAX_SIZE
from .attachments import fmt_size from .attachments import fmt_size
@ -23,6 +20,7 @@ from .commands import describe, is_temp
POLL_SECONDS = 5 POLL_SECONDS = 5
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
mcp = FastMCP("jira-hub") mcp = FastMCP("jira-hub")
fmt_agent = core.fmt_agent
def fmt_issues(issues) -> str: def fmt_issues(issues) -> str:
@ -63,18 +61,7 @@ def _files_line(c: dict, atts: list) -> str:
return "файлы: " + ", ".join(parts) if parts else "" return "файлы: " + ", ".join(parts) if parts else ""
def safe_name(filename: str, attachment_id: str) -> str: safe_name = core.safe_name
"""Имя файла копии: «/ \\ :» и управляющие символы (< 32, 127) → «_»; не длиннее 255 байт UTF-8; пусто, «.», «..» → attachment-<id>."""
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 fmt_issue(d) -> str: def fmt_issue(d) -> str:
@ -188,30 +175,6 @@ def fmt_batch(b) -> str:
return "\n".join(lines) 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() @mcp.tool()
@ -249,27 +212,7 @@ def comment(key: str, comment_id: str) -> str:
return f"у {key} нет комментария #{cid}" return f"у {key} нет комментария #{cid}"
def _store_copy(attachment_id: str, name: str, data: bytes) -> Path: _store_copy = core.store_copy
"""Копия вложения в <каталог данных>/downloads/<id>/<имя>: каталоги 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
@mcp.tool() @mcp.tool()
@ -517,7 +460,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool
def agent_status() -> str: def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель.""" состояние самообновления и запускатель."""
return fmt_agent(core.client().get("/agent")) return core.fmt_agent(core.client().get("/agent"))
@mcp.tool() @mcp.tool()

View File

@ -2,6 +2,12 @@
from ...core.modules import Module from ...core.modules import Module
from . import api, attachments, commands, projects, schema, settings, tick from . import api, attachments, commands, projects, schema, settings, tick
def mcp_server():
"""MCP-сервер модуля; FastMCP тянется лениво — только когда сервер запускают."""
from . import mcp
return mcp.mcp
MODULE = Module( MODULE = Module(
name="wiki", name="wiki",
schema=schema.SQL, schema=schema.SQL,
@ -13,6 +19,7 @@ MODULE = Module(
routes=api.ROUTES, routes=api.ROUTES,
file_refs=attachments.file_refs, file_refs=attachments.file_refs,
tick=(tick.housekeeping_tick, tick.auto_sync_tick), tick=(tick.housekeeping_tick, tick.auto_sync_tick),
mcp_server=mcp_server,
project_fields=projects.FIELDS, project_fields=projects.FIELDS,
project_view=projects.view, project_view=projects.view,
on_project_created=projects.on_created, on_project_created=projects.on_created,

View File

@ -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=<N>». Страница
задаётся кодом (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-<id>/<basename>: имя из 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)

View File

@ -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>». Страница\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"
}
]

View File

@ -26,6 +26,13 @@ def test_mcp_tools():
for t in 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(): def test_routes():
rows = [[method, pattern, scope, fn.__name__] for method, pattern, fn, scope in api.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]))) _check("routes", sorted(rows, key=lambda r: (r[1], r[0])))

306
tests/test_wiki_mcp.py Normal file
View File

@ -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"