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:
parent
9b21060d60
commit
4e397b563d
@ -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` и своих копий не держит.
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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» на виртуалке режет локальную сеть), агент ходит
|
||||
|
||||
@ -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` в команде).
|
||||
|
||||
@ -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)} мин"
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user