fmt_agent, safe_name и store_copy вынесены в core/mcp.py; сервер jira их переэкспортирует, тексты вывода не менялись. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
213 lines
11 KiB
Python
213 lines
11 KiB
Python
"""Ядро 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
|