"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md). Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение.""" import asyncio import functools import inspect import json import time from mcp.server.fastmcp import FastMCP from .client import HubClient, HubError, HubUnavailable OFFLINE = "хаб не запущен: jira-hub serve" POLL_SECONDS = 5 AUTHOR = "claude" mcp = FastMCP("jira-hub") 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 _cut(text, n=80): text = (text or "").replace("\n", " ") return text if len(text) <= n else text[: n - 1] + "…" def _v(value): return "—" if value in (None, "", []) else str(value) def fmt_issues(issues) -> str: if not issues: return "задач нет" lines = ["ключ | тема | статус | срок | исполнитель | спринт | статус по факту"] for i in issues: lines.append(" | ".join([i["key"], _cut(i.get("summary")), _v(i.get("status")), _v(i.get("duedate")), _v(i.get("assignee")), _v(i.get("sprint")), _v(i.get("fact_status"))])) return "\n".join(lines) def fmt_issue(d) -> str: head = [f"{d['key']} — {d.get('summary')}", f"тип: {_v(d.get('issue_type'))}; статус: {_v(d.get('status'))}; приоритет: {_v(d.get('priority'))}; " f"исполнитель: {_v(d.get('assignee'))}; срок: {_v(d.get('duedate'))}; план: {_v(d.get('planned_start'))}…{_v(d.get('planned_end'))}", f"эпик: {_v(d.get('epic_key'))}; спринт: {_v(d.get('sprint'))}; метки: {', '.join(d.get('labels') or []) or '—'}; " f"сосед: {'да' if d.get('neighbor') else 'нет'}; обновлено в Jira: {_v(d.get('jira_updated'))}; прочитано: {_v(d.get('fetched_at'))}"] links = [f" → {l['target_key']} ({l['link_type']})" for l in d.get("links_out", [])] + \ [f" ← {l['source_key']} ({l['link_type']})" for l in d.get("links_in", [])] note = d.get("note") or {} notes = [f" {k}: {note[k]}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)] cmds = [f" #{c['id']} {c['type']} {c['status']} {c.get('finished_at') or ''} {c.get('error') or ''}".rstrip() for c in d.get("commands", [])] return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"]) def fmt_batch(b) -> str: est = round((b.get("remaining_seconds") or 0) / 60) lines = [f"пакет #{b['id']} «{b['title']}» ({b['author']}, {b['created_at']}): {b['status']}; осталось ≈ {est} мин; " + ", ".join(f"{k}: {v}" for k, v in (b.get("counts") or {}).items())] for c in b.get("commands", []): lines.append(f" {c['seq']}. #{c['id']} {c['type']} {json.dumps(c['params'], ensure_ascii=False)} → {c['status']}" + (f" ({c['error']})" if c.get("error") else "") + (f" {json.dumps(c['result'], ensure_ascii=False)}" if c.get("result") else "")) for s in b.get("still_claimed") or []: lines.append(f" команда #{s['id']} уже у агента и будет выполнена; остановить — " f"POST /commands/{s['id']}/cancel с force: true") 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} (версия {_v(a.get('agent_version'))}, настройки v{_v(a.get('settings_version'))}, последний контакт {_v(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']}") return "\n".join(out) # --- инструменты ----------------------------------------------------------------- @mcp.tool() @guard def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str: """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" data = _client().get("/issues", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit) return fmt_issues(data["issues"]) @mcp.tool() @guard def issue(key: str) -> str: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" return fmt_issue(_client().get(f"/issues/{key}")) @mcp.tool() @guard def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, rest_text: str | None = None, owner_note: str | None = None) -> str: """Правка служебных заметок задачи (только переданные поля).""" fields = {k: v for k, v in dict(fact_status=fact_status, work_kind=work_kind, done_text=done_text, rest_text=rest_text, owner_note=owner_note).items() if v is not None} fields["updated_by"] = AUTHOR note = _client().put(f"/issues/{key}/note", fields) return "заметки " + key + ": " + "; ".join(f"{k}: {note.get(k)}" for k in ("fact_status", "work_kind", "done_text", "rest_text", "owner_note") if note.get(k)) @mcp.tool() @guard def batch_create(title: str, commands: list[dict]) -> str: """Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status| ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам. ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, задача без неё выпадет из базовой JQL и из зеркала.""" b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands}) return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин" @mcp.tool() @guard def batch_status(id: int) -> str: """Состояние пакета и его команд.""" return fmt_batch(_client().get(f"/batches/{id}")) @mcp.tool() @guard async def batch_wait(id: int, timeout_minutes: int = 10) -> str: """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 client = _client() while True: b = await asyncio.to_thread(client.get, f"/batches/{id}") if b["status"] in ("done", "cancelled") or time.monotonic() >= deadline: return fmt_batch(b) + ("" if b["status"] in ("done", "cancelled") else "\n(таймаут ожидания, пакет ещё выполняется)") await asyncio.sleep(POLL_SECONDS) @mcp.tool() @guard def batches(limit: int = 10) -> str: """Последние пакеты со статусом.""" items = _client().get("/batches", limit=limit)["batches"] return "\n".join(fmt_batch(b) for b in items) or "пакетов нет" @mcp.tool() @guard def command_retry(id: int) -> str: """Повторить команду — новый пакет с теми же параметрами.""" b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR}) return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд" @mcp.tool() @guard def batch_cancel(id: int) -> str: """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — в ответе подсказка (POST /commands/N/cancel с force).""" return fmt_batch(_client().post(f"/batches/{id}/cancel", {})) @mcp.tool() @guard async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; wait=true — дождаться результата (до 30 мин).""" body = {"full": True} if full or not keys else {"keys": keys} body["author"] = AUTHOR b = await asyncio.to_thread(_client().post, "/refresh", body) return await batch_wait(b["id"], 30) if wait else f"пакет #{b['id']} «Обновление» поставлен" @mcp.tool() @guard def agent_status() -> str: """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа.""" return fmt_agent(_client().get("/agent")) @mcp.tool() @guard def sync_log(limit: int = 10) -> str: """Журнал обновлений зеркала.""" runs = _client().get("/sync-runs", limit=limit)["runs"] return "\n".join(f"#{r['id']} {r['kind']} {_cut(r.get('query'), 60)}: {r['started_at']} → {_v(r.get('finished_at'))}, " f"задач {r['issues_count']}, связей {r['links_count']}, вызовов {r['jira_calls']}, 429: {r['rate_limited']}" for r in runs) or "обновлений не было" @mcp.tool() @guard def settings_get(key: str = "") -> str: """Настройки хаба (все или одна по ключу).""" data = _client().get("/settings") return json.dumps(data.get(key) if key else data, ensure_ascii=False, indent=1) @mcp.tool() @guard def settings_set(key: str, value: str) -> str: """Записать настройку: value — JSON. Поднимает settings_version.""" try: parsed = json.loads(value) except ValueError as e: return f"значение не JSON: {e}" out = _client().put("/settings", {key: parsed}) return f"settings_version = {out['settings_version']}" def run() -> None: mcp.run(transport="stdio")