"""Ядро 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-.""" 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: 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