From c8741be3094272e0ad83e2f1e7f2ff61d993c9a9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 21:09:19 +0400 Subject: [PATCH] =?UTF-8?q?MCP:=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8=20=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=83=20(--project),=20?= =?UTF-8?q?=D0=B8=D0=BD=D1=81=D1=82=D1=80=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=8B=20projects=20=D0=B8=20project=5Fcreate=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 9 +++ src/jira_hub/cli.py | 5 +- src/jira_hub/mcp_server.py | 106 ++++++++++++++++++++++++-- tests/test_cli.py | 9 +++ tests/test_mcp.py | 111 +++++++++++++++++++++++++++- 5 files changed, 227 insertions(+), 13 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index dc44cfa..ef2c1eb 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -270,3 +270,12 @@ 28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без обращения к зеркалу.** Обоснование: `new:` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта) единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`. +29. 2026-10-19 — **проверка метки сессии (`GET /projects`) выполняется внутри `guard`, поэтому при выключенном хабе + привязанная сессия отвечает «хаб не запущен: jira-hub serve», а не «проект не заведён»; в async-инструментах + проверка идёт через `asyncio.to_thread`.** Обоснование: Review Focus 4 плана; `scoped` стоит под `guard`. Цена + ошибки: порядок декораторов; лишний `GET /projects` на каждый вызов (в `refresh(wait=True)` — второй, из + `batch_wait`). +30. 2026-10-19 — **`projects` и `project_create` без привязки не проверяют метку сессии; `notes_set` для не временного + ключа проверяет принадлежность по `labels` из `GET /issues/{key}` (точное совпадение, как в хабе).** Обоснование: + спека исключает эти два инструмента из проверки; «только своя задача» требует метки P. Цена ошибки: одна ветка в + `notes_set`. diff --git a/src/jira_hub/cli.py b/src/jira_hub/cli.py index 7e997eb..de3a4a1 100644 --- a/src/jira_hub/cli.py +++ b/src/jira_hub/cli.py @@ -18,7 +18,8 @@ def main(argv=None) -> int: p.add_argument("--port", type=int) p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") p.add_argument("folder") - sub.add_parser("mcp", help="MCP-сервер по stdio") + p = sub.add_parser("mcp", help="MCP-сервер по stdio") + p.add_argument("--project", help="метка проекта, к которому привязана сессия") sub.add_parser("tokens", help="показать токены агента и клиента") p = sub.add_parser("agent", help="версия агента для самообновления") agent_sub = p.add_subparsers(dest="agent_cmd", required=True) @@ -46,7 +47,7 @@ def main(argv=None) -> int: return 0 if args.cmd == "mcp": from . import mcp_server - mcp_server.run() + mcp_server.run(args.project) return 0 conn = db.connect(paths.db_path()) diff --git a/src/jira_hub/mcp_server.py b/src/jira_hub/mcp_server.py index 157c8bb..dd4400d 100644 --- a/src/jira_hub/mcp_server.py +++ b/src/jira_hub/mcp_server.py @@ -12,16 +12,28 @@ from pathlib import Path from mcp.server.fastmcp import FastMCP from .client import HubClient, HubError, HubUnavailable -from .commands import describe +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() @@ -54,6 +66,33 @@ def guard(fn): 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): @@ -65,6 +104,16 @@ 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 "задач нет" @@ -137,17 +186,19 @@ def fmt_agent(a) -> str: @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", sprint=sprint, assignee=assignee, work_kind=work_kind, fact_status=fact_status, + 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: """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" return fmt_issue(_client().get(f"/issues/{key}")) @@ -155,12 +206,16 @@ def issue(key: str) -> str: @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)) @@ -232,11 +287,11 @@ def _doc(text: str): @mcp.tool() @guard +@scoped(True) @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. Параметры по типам (необязательные — в [ ]): """ + describe() + """ -ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, -задача без неё выпадет из базовой JQL и из зеркала. +Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб. ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит файл в хаб и подставит file, filename по умолчанию — имя файла.""") def batch_create(title: str, commands: list[dict]) -> str: @@ -244,12 +299,13 @@ def batch_create(title: str, commands: list[dict]) -> str: prepared = _attach_files(client, commands) if isinstance(prepared, str): return prepared - b = client.post("/batches", {"title": title, "author": AUTHOR, "commands": 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}")) @@ -257,6 +313,7 @@ def batch_status(id: int) -> str: @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 @@ -270,22 +327,25 @@ async def batch_wait(id: int, timeout_minutes: int = 10) -> str: @mcp.tool() @guard +@scoped(False) def batches(limit: int = 10) -> str: """Последние пакеты со статусом.""" - items = _client().get("/batches", limit=limit)["batches"] + 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}) + 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).""" @@ -294,6 +354,7 @@ def batch_cancel(id: int) -> str: @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 мин).""" @@ -305,6 +366,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool @mcp.tool() @guard +@scoped(False) def agent_status() -> str: """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, состояние самообновления и запускатель.""" @@ -313,6 +375,7 @@ def agent_status() -> str: @mcp.tool() @guard +@scoped(False) def sync_log(limit: int = 10) -> str: """Журнал обновлений зеркала.""" runs = _client().get("/sync-runs", limit=limit)["runs"] @@ -323,6 +386,7 @@ def sync_log(limit: int = 10) -> str: @mcp.tool() @guard +@scoped(False) def settings_get(key: str = "") -> str: """Настройки хаба (все или одна по ключу).""" data = _client().get("/settings") @@ -331,6 +395,7 @@ def settings_get(key: str = "") -> str: @mcp.tool() @guard +@scoped(False) def settings_set(key: str, value: str) -> str: """Записать настройку: value — JSON. Поднимает settings_version.""" try: @@ -341,5 +406,30 @@ def settings_set(key: str, value: str) -> str: return f"settings_version = {out['settings_version']}" -def run() -> None: +@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") diff --git a/tests/test_cli.py b/tests/test_cli.py index 0f823a6..ec6e092 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -64,3 +64,12 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): monkeypatch.setattr(os, "replace", real) assert release.load(paths.release_dir())["version"] == 8 assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] + + +def test_mcp_project_option(monkeypatch): + from jira_hub import mcp_server + seen = [] + monkeypatch.setattr(mcp_server, "run", lambda project=None: seen.append(project)) + assert cli.main(["mcp", "--project", "AWPCA"]) == 0 + assert cli.main(["mcp"]) == 0 + assert seen == ["AWPCA", None] diff --git a/tests/test_mcp.py b/tests/test_mcp.py index c051975..20d629c 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -25,12 +25,14 @@ def live(tmp_path, monkeypatch, add_project): base = f"http://127.0.0.1:{srv.server_port}" monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) - yield {"agent": HubClient(base, tok["agent"])} + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + yield {"agent": HubClient(base, tok["agent"]), "client": HubClient(base, tok["client"])} srv.shutdown() srv.server_close() def test_offline_message(monkeypatch): + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) assert mcp_server.issues() == mcp_server.OFFLINE assert mcp_server.agent_status() == mcp_server.OFFLINE @@ -168,6 +170,7 @@ def test_async_tools_are_coroutines_and_registered(): def test_async_guard_offline(monkeypatch): + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) assert asyncio.run(mcp_server.batch_wait(1)) == mcp_server.OFFLINE assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE @@ -204,7 +207,9 @@ def test_fmt_batch_warns_about_claimed(): def test_batch_create_mentions_labels(): doc = mcp_server.batch_create.__doc__ or "" - assert "labels" in doc and "AWPCA" in doc + assert "метку проекта сессии хаб дописывает в ensure_issue сам" in doc + assert "метки других проектов запрещены" in doc and "board_id для ensure_sprint выставляет хаб" in doc + assert "AWPCA" not in doc def test_batch_create_description_lists_params_from_spec(): @@ -221,7 +226,7 @@ def test_batch_create_description_lists_params_from_spec(): for name, hint in spec.get("hints", {}).items(): assert f"{name} ({hint})" in line, (ctype, name) assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc - assert "labels" in desc and "AWPCA" in desc + assert "метку проекта сессии хаб дописывает в ensure_issue сам" in desc and "AWPCA" not in desc def test_fmt_agent_update_lines(): @@ -312,6 +317,7 @@ def test_batch_create_bug_with_local_file(live, tmp_path): def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(tmp_path)) + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) (tmp_path / "скрин.png").write_bytes(b"png") out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { "key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) @@ -385,3 +391,102 @@ def test_batch_create_description_mentions_path(): 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 + + +def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» + monkeypatch.setattr(mcp_server, "PROJECT", None) + calls = [] + real = HubClient.call + monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k)) + for out in (mcp_server.issues(), mcp_server.issue("A-1"), mcp_server.notes_set("A-1", work_kind="x"), + mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): + assert out == mcp_server.NOT_BOUND + assert calls == [] + assert "AWPCA" in mcp_server.projects() + assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки + + +def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте» + monkeypatch.setattr(mcp_server, "PROJECT", "STOKS") + assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" + assert mcp_server.agent_status().startswith("проект STOKS не заведён") + assert asyncio.run(mcp_server.batch_wait(1)).startswith("проект STOKS не заведён") + assert "AWPCA" in mcp_server.projects() + + +def test_bound_offline(monkeypatch): # Review Focus 4: хаб выключен — не «проект не заведён» + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) + assert mcp_server.issues() == mcp_server.OFFLINE + assert mcp_server.agent_status() == mcp_server.OFFLINE + assert asyncio.run(mcp_server.refresh(full=True)) == mcp_server.OFFLINE + assert mcp_server.projects() == mcp_server.OFFLINE + + +def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки» + live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]}, + {"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []}) + assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues() + out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) + b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}") + assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"] + assert mcp_server.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" + assert "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues + assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача + assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") + + +def test_bound_batches_and_retry(live, monkeypatch): + live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) + live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "т", "labels": ["AWPCA"]}], "links": []}) + mine = mcp_server.batch_create("моя", [{"type": "ensure_fields", "params": {"key": "A-1", "duedate": "2026-10-23"}}]) + mine_id = int(mine.split("#")[1].split()[0]) + monkeypatch.setattr(mcp_server, "PROJECT", "STOCKS_DRRK") + other = mcp_server.batch_create("чужого проекта", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "с"}}]) + other_id = int(other.split("#")[1].split()[0]) + out = mcp_server.batches(limit=50) + assert f"пакет #{other_id} " in out and f"пакет #{mine_id} " not in out + monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") + assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " not in mcp_server.batches(limit=50) + assert f"#{other_id}" in mcp_server.batch_status(other_id) # по id — без ограничения + other_cmd = live["client"].get(f"/batches/{other_id}")["commands"][0]["id"] + out = mcp_server.command_retry(other_cmd) + assert out.startswith("ошибка хаба 422") and "не из проекта AWPCA" in out + monkeypatch.setattr(mcp_server, "PROJECT", None) # без привязки batches — все + assert f"пакет #{mine_id} " in mcp_server.batches(limit=50) and f"пакет #{other_id} " in mcp_server.batches(limit=50) + + +def test_project_create(live, monkeypatch): # сценарий «Проект из чата» + monkeypatch.setattr(mcp_server, "PROJECT", None) + out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) + assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out + frag = json.loads(out[out.index("{"):]) + assert frag == mcp_server.mcp_json("STOCKS_DRRK") + assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] + assert live["client"].get("/batches")["batches"][0]["author"] == "claude" + assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409") + assert mcp_server.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") + + +def test_hub_root_is_repo(): + assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() + assert mcp_server.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(mcp_server.HUB_ROOT)] + + +def test_fmt_projects(): + items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190}, + {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}] + out = mcp_server.fmt_projects(items, "AWPCA").splitlines() + assert out[0] == "метка | название | доска | тип доски | задач" + assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" + assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" + assert mcp_server.fmt_projects([], None) == "проектов нет" + assert "(текущий)" not in mcp_server.fmt_projects(items, None) + + +def test_run_binds_project(monkeypatch): + monkeypatch.setattr(mcp_server, "PROJECT", None) + monkeypatch.setattr(mcp_server.mcp, "run", lambda transport: None) + mcp_server.run("AWPCA") + assert mcp_server.PROJECT == "AWPCA"