Описание MCP-инструмента batch_create собирается из SPEC (commands.describe): обязательные и необязательные параметры каждого типа с подсказками формата (hints) и условием на набор (note). Ошибки validate «нет …» и «лишние параметры …» называют ожидаемый набор параметров типа. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
278 lines
14 KiB
Python
278 lines
14 KiB
Python
"""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
|
||
from .commands import describe
|
||
|
||
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", [])]
|
||
comments = d.get("comments") or []
|
||
if comments:
|
||
shown = [f" {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}" for c in comments[:5]]
|
||
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
|
||
else:
|
||
cmts = ["комментариев нет"]
|
||
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
|
||
+ ["команды:"] + (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']}")
|
||
rel = a.get("release")
|
||
out.append(f"публикация: v{rel['version']} от {_v(rel.get('published_at'))}" if rel else "публикации нет")
|
||
us = a.get("update_state")
|
||
if us:
|
||
out.append(f"обновление: {us.get('state')} v{_v(us.get('from_version'))} → v{_v(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()
|
||
@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))
|
||
|
||
|
||
def _doc(text: str):
|
||
"""Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации."""
|
||
def deco(fn):
|
||
fn.__doc__ = text
|
||
return fn
|
||
return deco
|
||
|
||
|
||
@mcp.tool()
|
||
@guard
|
||
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
||
Параметры по типам (необязательные — в [ ]):
|
||
""" + describe() + """
|
||
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
|
||
задача без неё выпадет из базовой JQL и из зеркала.""")
|
||
def batch_create(title: str, commands: list[dict]) -> str:
|
||
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")
|