sdlc-hub/src/jira_hub/mcp_server.py
Andrey Epifancev 1a66aea2de feat(mcp): параметры команд в описании batch_create и в ошибках 422 (#44)
Описание MCP-инструмента batch_create собирается из SPEC (commands.describe):
обязательные и необязательные параметры каждого типа с подсказками формата
(hints) и условием на набор (note). Ошибки validate «нет …» и «лишние
параметры …» называют ожидаемый набор параметров типа.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 23:13:14 +04:00

278 lines
14 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-сервер хаба (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")