diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 36c2ce4..3abb3f0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` и своих копий не держит. diff --git a/README.md b/README.md index 4ee25c4..669fd5b 100644 --- a/README.md +++ b/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» на виртуалке режет локальную сеть), агент ходит diff --git a/openspec/changes/bug-and-attachments/design.md b/openspec/changes/bug-and-attachments/design.md index 6f324ca..ced6e53 100644 --- a/openspec/changes/bug-and-attachments/design.md +++ b/openspec/changes/bug-and-attachments/design.md @@ -130,3 +130,13 @@ filename="<имя UTF-8>"; filename*=UTF-8''`, `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`: `~` раскрывается; относительный путь — «нужен абсолютный путь: »; + пустой файл отвергается до загрузки — «пустой файл: »; прочие тексты — «не файл: », «нет прав на + чтение: », «файл больше 10 МБ ( байт): », «файл не прочитан (<причина>): »; сначала читаются + все файлы пакета, затем загружаются — ошибка любого файла оставляет хаб без загрузок. Обоснование: 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` в команде). diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 548a788..157c8bb 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -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)} мин" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index f365f4b..3568442 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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