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>
681 lines
36 KiB
Python
681 lines
36 KiB
Python
"""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")
|