feat(mcp): batch_create принимает path у ensure_attachment и сам загружает файл (#43)

Все файлы пакета читаются до загрузки: «файл не найден: <path>», «у ensure_attachment либо path, либо file», больше 10 МБ — до POST /files; filename по умолчанию — имя файла; описание batch_create говорит про path (решение 19). design.md: решение 20 (R-2).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-16 01:51:44 +04:00
parent 9b21060d60
commit 4e397b563d
5 changed files with 204 additions and 6 deletions

View File

@ -18,7 +18,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 | `client` |
| `mcp_server` | инструменты MCP по stdio; `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
@ -44,7 +44,8 @@
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`.
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types`, `status_labels` берёт из `GET /settings` и своих копий не держит.

View File

@ -64,7 +64,7 @@ uv run jira-hub import ../admin/jira-sync
| `issues` | фильтры как у `GET /issues`, `limit` | таблица задач |
| `issue` | `key` | поля, связи, заметки, последние команды |
| `notes_set` | `key`, поля заметок | обновлённые заметки |
| `batch_create` | `title`, `commands` | `id`, оценка длительности, число команд |
| `batch_create` | `title`, `commands` (у `ensure_attachment` вместо `file` можно `path` — абсолютный путь к локальному файлу до 10 МБ) | `id`, оценка длительности, число команд |
| `batch_status` | `id` | статусы команд, ошибки, оценка остатка |
| `batch_wait` | `id`, `timeout_minutes` (до 60) | как `batch_status`, по завершении или таймауту; ожидание асинхронное, сервер не блокируется |
| `batches` | `limit` | последние пакеты |
@ -76,6 +76,16 @@ uv run jira-hub import ../admin/jira-sync
Автор команд из MCP: `claude`; из API без автора: `api`.
«Ошибка» с файлом одним пакетом (нужна настройка `issue_types.bug`, см. «Контракт команд»): MCP читает файл по `path`,
загружает его `POST /files` и ставит `ensure_attachment` с `file` и `filename` (по умолчанию — имя файла). Нет файла,
нет прав, не файл, больше 10 МБ — ответ с причиной и путём, пакет не ставится.
```json
[{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана", "labels": ["AWPCA"]}},
{"type": "ensure_sprint", "params": {"key": "new:1", "sprint": "Спринт 2"}},
{"type": "ensure_attachment", "params": {"key": "new:1", "path": "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}}]
```
## Транспорт через общую папку
Если сеть между виртуалкой и Mac mini закрыта (VPN «Континент ZTN» на виртуалке режет локальную сеть), агент ходит

View File

@ -130,3 +130,13 @@ filename="<имя UTF-8>"; filename*=UTF-8''<percent-encoded>`, `Content-Type: a
умолчанию `None`: вложение не ставится — «файл не загружен», при выдаче `size = null`); `api` передаёт
`Hub.files_dir`; `GET /agent/commands/{id}` дописывает `size` через `queue.deliver_params`. Обоснование: `queue`
остаётся чистой логикой над соединением (решение 10). Цена: новый вызов без `files_dir` молча отвергает вложения.
19. 15.10.2026 — MCP `batch_create` с `path`: `~` раскрывается; относительный путь — «нужен абсолютный путь: <path>»;
пустой файл отвергается до загрузки — «пустой файл: <path>»; прочие тексты — «не файл: <path>», «нет прав на
чтение: <path>», «файл больше 10 МБ (<n> байт): <path>», «файл не прочитан (<причина>): <path>»; сначала читаются
все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: cwd сервера MCP
неочевиден; спека дословно задаёт только «файл не найден» и «либо path, либо file». Цена: пакет с N файлами держит
их в памяти MCP до загрузки (≤ N × 10 МБ).
20. 16.10.2026 — хаб при постановке отвергает `filename` с `"` и управляющими символами (код < 32 и 127, в том числе CR
и LF) — «команда N: filename без кавычек и управляющих символов (CR, LF и др.)» (рулинг R-2). Обоснование: такие
имена ломают заголовок части `multipart/form-data` у агента; кодирование имени в агенте остаётся запасной защитой.
Цена: файл с кавычкой в имени прикладывается под другим именем (`filename` в команде).

View File

@ -1,19 +1,24 @@
"""MCP-сервер хаба (stdio, FastMCP) — инструменты сессии Claude поверх HTTP API (specs/mcp/spec.md).
Автор команд из MCP — «claude». Если хаб выключен, инструмент возвращает подсказку, а не исключение."""
import asyncio
import base64
import functools
import inspect
import json
import stat
import time
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from .client import HubClient, HubError, HubUnavailable
from .commands import describe
from .files import MAX_SIZE
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5
AUTHOR = "claude"
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file"
mcp = FastMCP("jira-hub")
@ -160,6 +165,63 @@ def notes_set(key: str, fact_status: str | None = None, work_kind: str | None =
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):
@ -174,9 +236,15 @@ def _doc(text: str):
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
задача без неё выпадет из базовой JQL и из зеркала.""")
задача без неё выпадет из базовой JQL и из зеркала.
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str:
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
client = _client()
prepared = _attach_files(client, commands)
if isinstance(prepared, str):
return prepared
b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": prepared})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"

View File

@ -1,12 +1,14 @@
import asyncio
import hashlib
import json
import os
import re
import threading
from types import SimpleNamespace
import pytest
from jira_hub import api, commands, mcp_server, settings
from jira_hub import api, commands, files, mcp_server, settings
from jira_hub.client import HubClient
@ -275,3 +277,110 @@ def test_fmt_issue_comments():
no_author = mcp_server.fmt_issue(_detail(comments=[{"comment_id": "1", "author": None, "created": None,
"updated": None, "body": ""}]))
assert "комментарии (1):" in no_author and "ещё" not in no_author
BUG = {"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "bug", "summary": "ОСВ 60 не распознана",
"labels": ["AWPCA"]}}
def _batch_commands(out):
batch_id = int(out.split("#")[1].split()[0])
return mcp_server._client().get(f"/batches/{batch_id}")["commands"]
def _write(path, data):
path.write_bytes(data)
return str(path)
def test_batch_create_bug_with_local_file(live, tmp_path):
"""Спека mcp «Ошибка с файлом из чата»: файл загружен в хаб, path заменён на file и filename."""
mcp_server.settings_set("issue_types", json.dumps({"task": {"name": "Задача", "id": "10100"},
"bug": {"name": "Ошибка", "id": None}}))
f = tmp_path / "ОСВ сч. 60 за 2 кв. 2026.xlsx"
f.write_bytes(b"PK\x03\x04 xlsx")
out = mcp_server.batch_create("Ошибка ОСВ 60", [BUG, {"type": "ensure_attachment",
"params": {"key": "new:1", "path": str(f)}}])
assert "3 команд" in out
cmds = _batch_commands(out)
sha = hashlib.sha256(f.read_bytes()).hexdigest()
assert [c["type"] for c in cmds] == ["ensure_issue", "ensure_attachment", "refresh"]
assert cmds[1]["params"] == {"key": "new:1", "file": sha, "filename": "ОСВ сч. 60 за 2 кв. 2026.xlsx"}
assert (tmp_path / "files" / sha).read_bytes() == f.read_bytes()
def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
(tmp_path / "скрин.png").write_bytes(b"png")
out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}])
assert _batch_commands(out)[0]["params"] == {"key": "A-1", "file": hashlib.sha256(b"png").hexdigest(),
"filename": "Экран 1.png"}
@pytest.mark.parametrize("make, expected", [
(lambda d: str(d / "нет.xlsx"), "файл не найден: {p}"),
(lambda d: str(d), "не файл: {p}"),
(lambda d: "отчёт.xlsx", "нужен абсолютный путь: {p}"),
(lambda d: _write(d / "пусто.xlsx", b""), "пустой файл: {p}"),
(lambda d: _write(d / "большой.bin", b"\0" * (files.MAX_SIZE + 1)),
"файл больше 10 МБ (" + str(files.MAX_SIZE + 1) + " байт): {p}"),
])
def test_batch_create_bad_path(live, tmp_path, make, expected):
"""Спека mcp «Файла нет» и соседние причины: текст с путём, пакет не создан, в хаб ничего не загружено."""
src = tmp_path / "src"
src.mkdir()
p = make(src)
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": p}}])
assert out == expected.format(p=p)
assert mcp_server._client().get("/batches")["batches"] == []
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_unreadable_file(live, tmp_path):
f = tmp_path / "закрыт.xlsx"
f.write_bytes(b"x")
f.chmod(0)
try:
if os.access(f, os.R_OK):
pytest.skip("файл читается и без прав (root)")
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(f)}}])
assert out == f"нет прав на чтение: {f}"
finally:
f.chmod(0o600)
def test_batch_create_path_and_file(live, tmp_path):
f = tmp_path / "a.xlsx"
f.write_bytes(b"a")
out = mcp_server.batch_create("x", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": str(f), "file": "0" * 64}}])
assert out == "у ensure_attachment либо path, либо file"
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_second_file_missing_uploads_nothing(live, tmp_path):
good = tmp_path / "a.xlsx"
good.write_bytes(b"a")
missing = tmp_path / "нет.xlsx"
out = mcp_server.batch_create("x", [
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(good)}},
{"type": "ensure_attachment", "params": {"key": "A-1", "path": str(missing)}}])
assert out == f"файл не найден: {missing}"
assert list((tmp_path / "files").iterdir()) == []
def test_batch_create_hub_422_after_upload(live, tmp_path):
"""Отказ хаба по другой команде — текст ошибки; загруженный файл остаётся до уборки (design, «MCP»)."""
f = tmp_path / "a.xlsx"
f.write_bytes(b"a")
out = mcp_server.batch_create("x", [BUG, {"type": "ensure_attachment", "params": {"key": "new:1", "path": str(f)}}])
assert out == "ошибка хаба 422 bad_batch: команда 1: нет настройки issue_types.bug"
assert mcp_server._client().get("/batches")["batches"] == []
def test_batch_create_description_mentions_path():
desc = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}["batch_create"].description
assert "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
assert "issue_type (task|epic|bug)" in desc
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" in desc