sdlc-hub/src/jira_hub/mcp_server.py
Andrey Epifancev 62057606c0 Правки финального ревью: ссылки на вложения, размер, 413 в пакетах, имя копии, документация (#83)
I-1 I-2 M-1 M-2 M-3 M-5 M-6 M-7 T3.4 T4.3

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

681 lines
36 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 base64
import functools
import inspect
import json
import os
import stat
import tempfile
import time
from datetime import datetime, timedelta
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from . import paths
from .attachments import fmt_size
from .client import HubClient, HubError, HubUnavailable
from .commands import describe, is_temp
from .files import MAX_SIZE
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5
AUTHOR = "claude"
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"
HUB_ROOT = Path(__file__).resolve().parents[2] # корень репозитория хаба, из которого загружен пакет (решение 19)
PROJECT: str | None = None # привязка сессии, задаётся run(project)
mcp = FastMCP("jira-hub")
def unknown_project(label) -> str:
return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"
def mcp_json(label: str) -> dict:
return {"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp",
"--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 _v(value):
return "—" if value in (None, "", []) else str(value)
def fmt_projects(items, current: str | None) -> str:
if not items:
return "проектов нет"
lines = ["метка | название | доска | тип доски | задач"]
for p in items:
lines.append(" | ".join([p["label"], p["name"], _v(p.get("board_id")), p.get("board_type") or "неизвестен",
str(p.get("issues_count", 0))]) + (" (текущий)" if p["label"] == current else ""))
return "\n".join(lines)
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_attachment_line(a: dict, d: dict) -> str:
"""Строка вложения в issue: «#id имя (размер) — автор, создано» и через запятую, где оно упомянуто и скачано."""
aid = str(a["attachment_id"])
line = f" #{aid} {a['filename']} ({fmt_size(a.get('size'))}) — {_v(a.get('author'))}, {_v(a.get('created'))}"
marks = []
if aid in (d.get("description_attachment_ids") or []):
marks.append("в описании")
marks += [f"в #{c.get('comment_id')}" for c in d.get("comments") or [] if aid in (c.get("attachment_ids") or [])]
if a.get("state") == "ready":
marks.append("скачано")
if a.get("size") is not None and a["size"] > MAX_SIZE:
marks.append("больше 10 МБ — только в Jira")
return line + "".join(", " + m for m in marks)
def fmt_attachments(d: dict, atts: list) -> list[str]:
if not atts:
return ["вложений нет"]
return [f"вложения ({len(atts)}):"] + [fmt_attachment_line(a, d) for a in atts]
def _files_line(c: dict, atts: list) -> str:
"""«файлы: #id имя, …» для комментария; пусто, если ссылок на вложения нет."""
by_id = {str(a["attachment_id"]): a for a in atts}
parts = [f"#{i} {by_id[i]['filename']}" for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
return "файлы: " + ", ".join(parts) if parts else ""
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 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 = []
for c in comments[:5]:
shown.append(f" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}")
files_line = _files_line(c, d.get("attachments") or [])
if files_line:
shown.append(" " + files_line)
cmts = [f"комментарии ({len(comments)}):"] + shown + ([f" ещё {len(comments) - 5}"] if len(comments) > 5 else [])
else:
cmts = ["комментариев нет"]
atts = d.get("attachments") or []
cmts += fmt_attachments(d, atts)
changes = d.get("changes") or []
if changes:
cmts += [f"изменения ({len(changes)}):"] + [" " + fmt_event(e, None) for e in changes[:5]] + \
([f" ещё {len(changes) - 5}"] if len(changes) > 5 else [])
return "\n".join(head + ["связи:"] + (links or [" —"]) + ["заметки:"] + (notes or [" —"]) + cmts
+ ["команды:"] + (cmds or [" —"]) + ["описание:", d.get("description") or "—"])
DATE_LABELS = {"duedate": "срок", "planned_start": "плановое начало", "planned_end": "плановое окончание"}
def _day(value, text) -> str:
"""Дата для ленты: из значения вида ГГГГ-ММ-ДД — «ДД.ММ», иначе строка журнала как есть, пусто — «—»."""
if isinstance(value, str) and len(value) >= 10 and value[4] == "-" and value[7] == "-" and value[:4].isdigit():
return f"{value[8:10]}.{value[5:7]}"
return _v(text)
def fmt_comment(key, c, atts=None) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили.
atts — вложения задачи: ссылки комментария на них выводятся блоком «вложения:»."""
head = f"{key} #{c.get('comment_id')} {_v(c.get('author'))}, создан {_v(c.get('created'))}"
if c.get("updated") and c.get("updated") != c.get("created"):
head += f", изменён {c['updated']}"
out = head + "\n" + _v(c.get("body"))
by_id = {str(a["attachment_id"]): a for a in atts or []}
refs = [by_id[i] for i in (str(x) for x in c.get("attachment_ids") or []) if i in by_id]
if refs:
out += "\nвложения:" + "".join(f"\n #{a['attachment_id']} {a['filename']} ({fmt_size(a.get('size'))})" for a in refs)
return out
def fmt_event(e: dict, today) -> str:
"""Строка события ленты: «ЧЧ:ММ автор: что»; дата «ДД.ММ» перед временем, если событие не в сутках today
(today = None — дата всегда)."""
if e.get("at"):
local = datetime.fromisoformat(e["at"]).astimezone()
when = local.strftime("%H:%M") if today is not None and local.date() == today else local.strftime("%d.%m %H:%M")
else:
when = "—"
kind, frm, to = e.get("kind"), e.get("from"), e.get("to")
if kind == "comment":
what = "комментарий: " + _cut(to, 200)
elif kind == "status":
what = f"статус «{_v(frm)}» → «{_v(to)}»"
elif kind == "description":
what = "описание изменено"
elif kind == "dates":
what = (f"{DATE_LABELS.get(e.get('field'), e.get('field'))} {_day(e.get('from_value'), frm)} → "
f"{_day(e.get('to_value'), to)}")
else:
what = f"{e.get('field')}: «{_cut(frm) if frm else '—'}» → «{_cut(to) if to else '—'}»"
return f"{when} {_v(e.get('author'))}: {what}"
def fmt_changes(data: dict, now: datetime) -> str:
"""Лента по задачам: задачи — по последнему событию (лента уже новые сверху), под задачей — её события."""
def span(at):
return datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M")
lines = [f"изменения с {span(data['since'])} по {span(data['until'])}"]
events = data.get("changes") or []
if not events:
return "\n".join(lines + ["за период изменений нет"])
groups: dict[str, list] = {}
for e in events:
groups.setdefault(e["key"], []).append(e)
for key, items in groups.items():
lines.append(f"{key} {_cut(items[0].get('summary')) or '—'} — {_v(items[0].get('status'))}")
lines += [" " + fmt_event(e, now.date()) for e in items]
if data.get("truncated"):
lines.append(f"показаны последние {len(events)} событий, сузьте период")
return "\n".join(lines)
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
@scoped(True)
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", project=PROJECT, 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
@scoped(True)
def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком
(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment."""
return fmt_issue(_client().get(f"/issues/{key}"))
@mcp.tool()
@guard
@scoped(True)
def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#")
d = _client().get(f"/issues/{key}")
for c in d.get("comments") or []:
if c.get("comment_id") == cid:
return fmt_comment(key, c, d.get("attachments"))
return f"у {key} нет комментария #{cid}"
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
@mcp.tool()
@guard
@scoped(True)
async def attachment(key: str, attachment: str, wait_seconds: int = 120) -> str:
"""Скачать вложение задачи и вернуть путь к локальной копии (читайте файл по этому пути). attachment — номер из
issue/comment («#31337» или «31337») либо точное имя файла; при нескольких одноимённых берётся самое новое.
Хаб просит агента скачать файл и сохраняет копию в downloads/ каталога данных; ожидание — до wait_seconds (1…600,
по умолчанию 120), не дождался — вызовите инструмент ещё раз: вторая команда не поставится. Файлы больше 10 МБ
скачиваются только в Jira."""
client = _client()
wait = min(max(int(wait_seconds), 1), 600)
d = await asyncio.to_thread(client.get, f"/issues/{key}")
atts = d.get("attachments") or []
ref = attachment.strip().lstrip("#")
found = [a for a in atts if str(a["attachment_id"]) == ref] if ref.isdigit() else []
prefix = ""
if not found:
found = [a for a in atts if a["filename"] == attachment.strip()] # хаб отдаёт вложения новыми сверху
if len(found) > 1:
prefix = f"у {key} {len(found)} вложений «{found[0]['filename']}», взято самое новое #{found[0]['attachment_id']}\n"
if not found:
return f"у {key} нет вложения «{attachment}»; список — issue {key}"
att = found[0]
aid = str(att["attachment_id"])
try:
st = await asyncio.to_thread(client.post, f"/attachments/{aid}/fetch", {"author": AUTHOR})
except HubError as e:
if e.status == 413:
return f"файл больше 10 МБ ({fmt_size(att.get('size'))}) — скачайте в Jira: задача {key}, вложение «{att['filename']}»"
raise
deadline = time.monotonic() + wait
while st.get("state") == "fetching" and time.monotonic() < deadline:
await asyncio.sleep(POLL_SECONDS / 2)
st = await asyncio.to_thread(client.get, f"/attachments/{aid}")
if st.get("state") == "fetching":
online = (await asyncio.to_thread(client.get, "/agent")).get("online")
return (f"скачивание ещё идёт (команда #{st.get('command_id')}, агент {'онлайн' if online else 'офлайн'}); "
"вызовите attachment ещё раз — вторая команда не поставится")
if st.get("state") != "ready":
return f"не удалось скачать: {_v(st.get('error'))}"
data = await asyncio.to_thread(client.get_bytes, f"/attachments/{aid}/content")
path = await asyncio.to_thread(_store_copy, aid, safe_name(att["filename"], aid), data)
return (f"{prefix}{path}\n{att['filename']} ({fmt_size(att.get('size'))}), {_v(att.get('mime_type'))}, задача {key}")
def _local_if_naive(value: str) -> str:
"""Время без смещения из чата — местное время машины MCP (хаб такое отвергает, M-2); неразбираемое — как есть."""
if not value:
return value
try:
d = datetime.fromisoformat(value)
except ValueError:
return value
return d.astimezone().isoformat() if d.tzinfo is None else value
@mcp.tool()
@guard
def changes(hours: float | None = None, since: str = "", until: str = "", kinds: str = "", key: str = "") -> str:
"""Лента изменений задач: смена статуса, правка описания, сроки, новые комментарии. По умолчанию — с начала
сегодняшнего дня; hours — за последние N часов; since/until — ISO-8601 (без смещения — местное время); kinds — через запятую из
status, description, dates, comment, other или all; key — одна задача (без привязки к проекту)."""
if hours is not None and since:
return "либо hours, либо since"
if hours is not None and hours <= 0:
return "hours — число больше 0"
refusal = _unbound_or_unknown(required=not key) # с key — любая задача, как issue
if refusal is not None:
return refusal
now = datetime.now().astimezone()
if hours is not None:
since = (now - timedelta(hours=hours)).isoformat()
elif not since:
since = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
data = _client().get("/changes", since=_local_if_naive(since), until=_local_if_naive(until), kinds=kinds, key=key,
project=None if key else PROJECT)
return fmt_changes(data, now)
@mcp.tool()
@guard
@scoped(True)
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
if not is_temp(key): # новая задача пакета проекта — своя по построению
if PROJECT not in (_client().get(f"/issues/{key}").get("labels") or []):
return f"{key} не в проекте {PROJECT}"
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 _read_local(path) -> bytes | str:
"""Содержимое локального файла для ensure_attachment.path или текст причины с путём (spec mcp, решение 19)."""
if not isinstance(path, str) or not path:
return "path — строка с абсолютным путём к файлу"
p = Path(path).expanduser()
if not p.is_absolute():
return f"нужен абсолютный путь: {path}"
try:
st = p.stat()
if not stat.S_ISREG(st.st_mode):
return f"не файл: {path}"
if st.st_size > MAX_SIZE:
return f"файл больше 10 МБ ({st.st_size} байт): {path}"
data = p.read_bytes()
except FileNotFoundError:
return f"файл не найден: {path}"
except PermissionError:
return f"нет прав на чтение: {path}"
except OSError as e:
return f"файл не прочитан ({e.strerror or e}): {path}"
if not data:
return f"пустой файл: {path}"
if len(data) > MAX_SIZE: # вырос между stat и чтением
return f"файл больше 10 МБ ({len(data)} байт): {path}"
return data
def _attach_files(client, commands):
"""ensure_attachment с path: сначала прочитать все файлы пакета, затем загрузить их POST /files и подставить file и
filename (по умолчанию — имя файла из path). Ошибка любого файла — текст, в хаб ничего не загружено. Остальные
команды и форму пакета проверяет хаб."""
if not isinstance(commands, list):
return commands
read = {}
for i, cmd in enumerate(commands):
params = cmd.get("params") if isinstance(cmd, dict) and cmd.get("type") == "ensure_attachment" else None
if not isinstance(params, dict) or "path" not in params:
continue
if params.get("file") not in (None, ""):
return BOTH_FILE_AND_PATH
data = _read_local(params["path"])
if isinstance(data, str):
return data
read[i] = data
out = []
for i, cmd in enumerate(commands):
if i not in read:
out.append(cmd)
continue
params = {k: v for k, v in cmd["params"].items() if k != "path"}
up = client.post("/files", {"content": base64.b64encode(read[i]).decode("ascii")})
params["file"] = up["file"]
params["filename"] = params.get("filename") or Path(cmd["params"]["path"]).expanduser().name
out.append({**cmd, "params": params})
return out
def _doc(text: str):
"""Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации."""
def deco(fn):
fn.__doc__ = text
return fn
return deco
@mcp.tool()
@guard
@scoped(True)
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str:
client = _client()
prepared = _attach_files(client, commands)
if isinstance(prepared, str):
return prepared
b = client.post("/batches", {"title": title, "author": AUTHOR, "project": PROJECT, "commands": prepared})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@mcp.tool()
@guard
@scoped(False)
def batch_status(id: int) -> str:
"""Состояние пакета и его команд."""
return fmt_batch(_client().get(f"/batches/{id}"))
@mcp.tool()
@guard
@scoped(False)
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
@scoped(False)
def batches(limit: int = 10) -> str:
"""Последние пакеты со статусом."""
items = _client().get("/batches", project=PROJECT, limit=limit)["batches"]
return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
@mcp.tool()
@guard
@scoped(True)
def command_retry(id: int) -> str:
"""Повторить команду — новый пакет с теми же параметрами."""
b = _client().post(f"/commands/{id}/retry", {"author": AUTHOR, "project": PROJECT})
return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
@mcp.tool()
@guard
@scoped(False)
def batch_cancel(id: int) -> str:
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force)."""
return fmt_batch(_client().post(f"/batches/{id}/cancel", {}))
@mcp.tool()
@guard
@scoped(True)
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
@scoped(False)
def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель."""
return fmt_agent(_client().get("/agent"))
@mcp.tool()
@guard
@scoped(False)
def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала."""
runs = _client().get("/sync-runs", limit=limit)["runs"]
def journal(r):
at = r.get("history_at")
return "журнал: принят " + datetime.fromisoformat(at).astimezone().strftime("%d.%m %H:%M") if at else "журнал: нет"
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']}, "
f"{journal(r)}" for r in runs) or "обновлений не было"
@mcp.tool()
@guard
@scoped(False)
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
@scoped(False)
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']}"
@mcp.tool()
@guard
def projects() -> str:
"""Проекты хаба: метка, название, доска, тип доски, число задач; текущий проект сессии отмечен."""
return fmt_projects(_client().get("/projects")["projects"], PROJECT)
@mcp.tool()
@guard
def project_create(label: str, name: str, board_id: int | None = None) -> str:
"""Завести проект (метка Jira, название, необязательно id доски). Хаб ставит пакет обновления; в ответе — фрагмент
.mcp.json для репозитория проекта."""
body = {"label": label, "name": name, "author": AUTHOR}
if board_id is not None:
body["board_id"] = board_id
out = _client().post("/projects", body)
p = out["project"]
return (f"проект {p['label']} «{p['name']}» заведён, пакет обновления #{out['batch_id']}\n"
"Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: "
"/mcp → reconnect, одобрите сервер проекта.\n"
+ json.dumps(mcp_json(p["label"]), ensure_ascii=False, indent=2))
def run(project: str | None = None) -> None:
global PROJECT
PROJECT = project
mcp.run(transport="stdio")