Andrey Epifancev 2a5a33f9c6 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>
2026-10-24 02:49:23 +04:00

213 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Ядро MCP хаба: общие инструменты (projects, project_create), охрана и привязка к проекту сессии, запуск сервера модуля
по stdio (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не
исключение. Инструменты модулей лежат в modules/<имя>/mcp.py и подключают общие через register_core_tools."""
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 paths, registry
from .client import HubClient, HubError, HubUnavailable
OFFLINE = "хаб не запущен: jira-hub serve"
AUTHOR = "claude"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19)
PROJECT: str | None = None # привязка сессии, задаётся run(module_name, project)
def unknown_project(label) -> str:
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
def mcp_json(label: str, module: str = "jira") -> dict:
"""Фрагмент .mcp.json для репозитория проекта: у jira прежний (сервер jira-hub), у прочих модулей — сервер
jira-hub-<модуль>."""
run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"]
if module == "jira":
return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}}
return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}}
def client() -> HubClient:
return HubClient.from_home()
def _hub_error(e: HubError) -> str:
return f"ошибка хаба {e.status} {e.error}: {e.detail or ''}".rstrip(": ")
def guard(fn):
"""Обёртка инструмента: сбои хаба превращаются в текст. Поддерживает и обычные, и async-функции."""
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
try:
return await fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
try:
return fn(*args, **kwargs)
except HubUnavailable:
return OFFLINE
except HubError as e:
return _hub_error(e)
return wrapper
def unbound_or_unknown(required: bool) -> str | None:
"""None — инструмент можно вызывать. Без привязки: required → NOT_BOUND без запросов к хабу. С привязкой: метка
сверяется с GET /projects при каждом вызове (сбой хаба поднимается к guard и становится OFFLINE)."""
if PROJECT is None:
return NOT_BOUND if required else None
labels = [p["label"] for p in client().get("/projects")["projects"]]
return None if PROJECT in labels else unknown_project(PROJECT)
def scoped(required: bool):
"""Привязка инструмента к проекту сессии (spec mcp). Ставится под guard: тот превращает сбой хаба в текст."""
def deco(fn):
if inspect.iscoroutinefunction(fn):
@functools.wraps(fn)
async def awrapper(*args, **kwargs):
refusal = await asyncio.to_thread(unbound_or_unknown, required)
return refusal if refusal is not None else await fn(*args, **kwargs)
return awrapper
@functools.wraps(fn)
def wrapper(*args, **kwargs):
refusal = unbound_or_unknown(required)
return refusal if refusal is not None else fn(*args, **kwargs)
return wrapper
return deco
def cut(text, n=80):
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
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-<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:
"""Таблица проектов: метка, название и колонки модуля (заголовок, функция строки проекта)."""
if not items:
return "проектов нет"
lines = [" | ".join(["метка", "название", *(title for title, _ in columns)])]
for p in items:
lines.append(" | ".join([p["label"], p["name"], *(fn(p) for _, fn in columns)])
+ (" (текущий)" if p["label"] == current else ""))
return "\n".join(lines)
def create_project(label: str, name: str, fields: dict, module: str) -> str:
"""POST /projects с автором claude и полями модуля; текст ответа и фрагмент .mcp.json модуля. Сбои хаба —
исключения клиента: вызывающий инструмент обёрнут в guard."""
out = client().post("/projects", {"label": label, "name": name, "author": AUTHOR, **fields})
p = out["project"]
batch = f", пакет обновления #{out['batch_id']}" if out.get("batch_id") is not None else ""
return (f"проект {p['label']} «{p['name']}» заведён{batch}\n"
"Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: "
"/mcp → reconnect, одобрите сервер проекта.\n"
+ json.dumps(mcp_json(p["label"], module), ensure_ascii=False, indent=2))
def register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Callable[[dict], str]]],
project_create: Callable) -> None:
"""Общие инструменты на сервере модуля: идут последними (порядок списка инструментов — часть контракта).
projects строится из колонок модуля, project_create — инструмент модуля (его параметры — поля проекта модуля)."""
@guard
def projects() -> str:
return fmt_projects(client().get("/projects")["projects"], PROJECT, columns)
projects.__doc__ = projects_doc
mcp.tool()(projects)
mcp.tool()(project_create)
def run(module_name: str, project: str | None = None) -> int:
"""Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует."""
global PROJECT
m = registry.module(module_name)
if m is None or m.mcp_server is None:
names = ", ".join(x.name for x in registry.modules() if x.mcp_server)
print(f"неизвестный модуль {module_name}; модули: {names}", file=sys.stderr)
return 2
PROJECT = project
m.mcp_server().run(transport="stdio")
return 0