"""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-.""" 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//<имя>: каталоги 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")