MCP: вложения в issue и comment, инструмент attachment (#83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:43:59 +04:00
parent 5fdcc50451
commit 3ad63886a7
4 changed files with 306 additions and 13 deletions

View File

@ -21,7 +21,7 @@
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`), лента `changes` (`GET /changes`, по умолчанию — с начала суток); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета; `issue`/`comment` показывают вложения, инструмент `attachment` ставит скачивание (`POST /attachments/{id}/fetch`), опрашивает `GET /attachments/{id}` и кладёт копию в `<данные>/downloads/<id>/<имя>` (0700, через временный файл) | `client` (`get_bytes` — тело ответа как есть), `commands`, `paths`, `files` (только `MAX_SIZE`), `attachments` (только `fmt_size`) |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
@ -63,7 +63,7 @@
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`/`history` напрямую и ходит только через `client`; из `files` берёт только предел
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
`MAX_SIZE`, из `attachments` — только `fmt_size`; `downloads/` пишет только он (хаб эту папку лишь убирает) (файлы в хранилище кладёт хаб по `POST /files`).
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит.

View File

@ -31,19 +31,26 @@ class HubClient:
conn.close()
def call(self, method: str, path: str, body=None, params=None):
raw = self._raw(method, path, body, params, "application/json")
return json.loads(raw.decode("utf-8")) if raw else {}
def get_bytes(self, path: str) -> bytes:
"""Тело ответа как есть (содержимое вложения); ошибки — как у call."""
return self._raw("GET", path, None, None, "*/*")
def _raw(self, method, path, body, params, accept) -> bytes:
url = self.base + path
if params:
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v not in (None, "")})
data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + self.token)
req.add_header("Accept", "application/json")
req.add_header("Accept", accept)
if data is not None:
req.add_header("Content-Type", "application/json; charset=utf-8")
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
raw = resp.read()
return json.loads(raw.decode("utf-8")) if raw else {}
return resp.read()
except urllib.error.HTTPError as e:
try:
payload = json.loads(e.read().decode("utf-8"))

View File

@ -5,13 +5,17 @@ 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
@ -125,6 +129,40 @@ def fmt_issues(issues) -> str:
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) → «_»; пусто, «.», «..» → attachment-<id>."""
name = "".join("_" if ch in "/\\:" or ord(ch) < 32 or ord(ch) == 127 else ch for ch in filename or "")
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'))}; "
@ -138,11 +176,17 @@ def fmt_issue(d) -> str:
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" #{c.get('comment_id')} {_v(c.get('author'))} {_v(c.get('created'))}: {_cut(c.get('body'), 300)}"
for c in comments[:5]]
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]] + \
@ -161,12 +205,18 @@ def _day(value, text) -> str:
return _v(text)
def fmt_comment(key, c) -> str:
"""Один комментарий целиком (#80): шапка, затем текст с переносами строк; «изменён» — только если правили."""
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']}"
return head + "\n" + _v(c.get("body"))
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:
@ -267,7 +317,8 @@ def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_statu
@scoped(True)
def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание. Комментарии — 5
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру."""
последних с номером, текст до 300 символов; полный текст — инструмент comment по номеру. Вложения — списком
(номер, имя, размер, где упомянуты, скачано ли); содержимое — инструмент attachment."""
return fmt_issue(_client().get(f"/issues/{key}"))
@ -277,12 +328,80 @@ def issue(key: str) -> str:
def comment(key: str, comment_id: str) -> str:
"""Один комментарий задачи целиком: автор, время, полный текст. comment_id — номер из ответа issue («#123»)."""
cid = comment_id.lstrip("#")
for c in _client().get(f"/issues/{key}").get("comments") or []:
d = _client().get(f"/issues/{key}")
for c in d.get("comments") or []:
if c.get("comment_id") == cid:
return fmt_comment(key, c)
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):
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:

View File

@ -647,3 +647,170 @@ def test_sync_log_history(live):
live["agent"].post(f"/agent/commands/{cmd['id']}/result",
{"status": "done", "result": {"history": {"issues": 0, "changes": 0, "incomplete": []}}})
assert re.search(r"журнал: принят \d\d\.\d\d \d\d:\d\d", mcp_server.sync_log())
# --- вложения (#83) -----------------------------------------------------------------
ATT_KEY = "AURORA-1650"
def _att(aid, name, size, state="none", **over):
d = {"attachment_id": aid, "filename": name, "size": size, "mime_type": "image/png", "author": "golov",
"created": "2026-10-20T10:00:00.000+0300", "state": state, "error": None}
d.update(over)
return d
class FakeHub:
"""Фейковый HubClient: журнал вызовов и сценарий ответов по маршрутам."""
def __init__(self, issue, fetch=None, polls=None, data=b"x" * 10, online=True):
self.issue, self.fetch, self.polls, self.data, self.online = issue, fetch, polls or [], data, online
self.calls = []
def get(self, path, **params):
self.calls.append(("GET", path))
if path == f"/issues/{ATT_KEY}":
return self.issue
if path == "/agent":
return {"online": self.online}
if path.startswith("/attachments/"):
return self.polls.pop(0) if len(self.polls) > 1 else self.polls[0]
raise AssertionError(path)
def post(self, path, body=None):
self.calls.append(("POST", path, body))
if isinstance(self.fetch, Exception):
raise self.fetch
return self.fetch
def get_bytes(self, path):
self.calls.append(("BYTES", path))
return self.data
@pytest.fixture
def downloads(tmp_path, monkeypatch):
monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home"))
monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05)
return tmp_path / "home" / "downloads"
def _use(monkeypatch, hub):
monkeypatch.setattr(mcp_server, "_client", lambda: hub)
monkeypatch.setattr(mcp_server, "_unbound_or_unknown", lambda required: None)
def _issue_with_att():
return _detail(key=ATT_KEY, description="см. !скрин 1.png!", description_attachment_ids=["31337"],
comments=[{"comment_id": "101", "author": "golov", "created": "2026-10-20T10:00:00.000+0300",
"updated": None, "body": "!скрин 1.png!", "attachment_ids": ["31337"]}],
attachments=[_att("31338", "запись.mp4", 26214400, mime_type="video/mp4"),
_att("31337", "скрин 1.png", 239821, state="ready")])
def test_issue_attachments_block():
text = mcp_server.fmt_issue(_issue_with_att())
assert "вложения (2):" in text and text.index("комментарии") < text.index("вложения (2):")
lines = {l.split()[0]: l for l in text.splitlines() if l.strip().startswith("#3133")}
assert "больше 10 МБ — только в Jira" in lines["#31338"] and "25,0 МБ" in lines["#31338"]
assert "(234,2 КБ)" in lines["#31337"] and "в описании, в #101, скачано" in lines["#31337"]
assert "файлы: #31337 скрин 1.png" in text
assert "вложений нет" in mcp_server.fmt_issue(_detail(attachments=[]))
assert "вложений нет" in mcp_server.fmt_issue(_detail())
def test_comment_attachments():
d = _issue_with_att()
text = mcp_server.fmt_comment(ATT_KEY, d["comments"][0], d["attachments"])
assert text.endswith("вложения:\n #31337 скрин 1.png (234,2 КБ)")
assert "вложения" not in mcp_server.fmt_comment(ATT_KEY, {**d["comments"][0], "attachment_ids": []}, d["attachments"])
def test_safe_name():
assert mcp_server.safe_name("a/b\\c:d\x01.png", "7") == "a_b_c_d_.png"
assert mcp_server.safe_name(" ", "7") == "attachment-7"
assert mcp_server.safe_name("..", "7") == "attachment-7"
assert mcp_server.safe_name("a\x7f", "7") == "a_"
def _ready_hub(size=239821, data=b"PNG" * 3):
att = _att("31337", "скрин 1.png", size, state="ready")
return FakeHub(_detail(key=ATT_KEY, attachments=[att]),
fetch={"state": "ready", "command_id": 5}, polls=[att], data=data)
def test_attachment_by_id_ready(downloads, monkeypatch):
hub = _ready_hub()
_use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "#31337"))
path = downloads / "31337" / "скрин 1.png"
lines = out.splitlines()
assert lines[0] == str(path) and lines[1] == "скрин 1.png (234,2 КБ), image/png, задача AURORA-1650"
assert path.read_bytes() == b"PNG" * 3
assert (downloads.stat().st_mode & 0o777) == 0o700 and ((downloads / "31337").stat().st_mode & 0o777) == 0o700
assert ("POST", "/attachments/31337/fetch", {"author": "claude"}) in hub.calls
def test_attachment_by_name_ambiguous(downloads, monkeypatch):
a20, a10 = _att("20", "скрин.png", 3), _att("10", "скрин.png", 3) # новые сверху, как отдаёт хаб
hub = FakeHub(_detail(key=ATT_KEY, attachments=[a20, a10]), fetch={"state": "ready", "command_id": 1},
polls=[{**a20, "state": "ready"}], data=b"abc")
_use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "скрин.png"))
assert "у AURORA-1650 2 вложений «скрин.png», взято самое новое #20" in out
assert str(downloads / "20" / "скрин.png") in out
def test_attachment_not_found(downloads, monkeypatch):
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("1", "a.png", 1)]))
_use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "нет.png"))
assert out == "у AURORA-1650 нет вложения «нет.png»; список — issue AURORA-1650"
assert all(c[1] == f"/issues/{ATT_KEY}" for c in hub.calls)
def test_attachment_wait_timeout(downloads, monkeypatch):
att = _att("1", "a.png", 5)
fetching = {**att, "state": "fetching", "command_id": 9}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=fetching, polls=[fetching])
_use(monkeypatch, hub)
out = asyncio.run(mcp_server.attachment(ATT_KEY, "1", wait_seconds=1))
assert out == "скачивание ещё идёт (команда #9, агент онлайн); вызовите attachment ещё раз — вторая команда не поставится"
def test_attachment_failed(downloads, monkeypatch):
att = _att("1", "a.png", 5)
failed = {**att, "state": "failed", "error": "HTTP 404", "command_id": 3}
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch={"state": "fetching", "command_id": 3}, polls=[failed])
_use(monkeypatch, hub)
assert asyncio.run(mcp_server.attachment(ATT_KEY, "1")) == "не удалось скачать: HTTP 404"
def test_attachment_413(downloads, monkeypatch):
from jira_hub.client import HubError
att = _att("5", "запись.mp4", 26214400)
hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой"))
_use(monkeypatch, hub)
assert asyncio.run(mcp_server.attachment(ATT_KEY, "#5")) == \
"файл больше 10 МБ (25,0 МБ) — скачайте в Jira: задача AURORA-1650, вложение «запись.mp4»"
def test_attachment_409_passes_hub_text(downloads, monkeypatch):
from jira_hub.client import HubError
hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]),
fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14"))
_use(monkeypatch, hub)
assert "агент v9 не умеет" in asyncio.run(mcp_server.attachment(ATT_KEY, "5"))
def test_attachment_existing_copy_same_size(downloads, monkeypatch):
hub = _ready_hub(data=b"PNG" * 3)
_use(monkeypatch, hub)
path = downloads / "31337" / "скрин 1.png"
path.parent.mkdir(parents=True)
path.write_bytes(b"OLD" * 3)
os.utime(path, (1_000_000, 1_000_000))
asyncio.run(mcp_server.attachment(ATT_KEY, "31337"))
assert path.read_bytes() == b"OLD" * 3 and path.stat().st_mtime == 1_000_000