MCP: привязка сессии к проекту (--project), инструменты projects и project_create (#61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 21:09:19 +04:00
parent e2e63dab23
commit c8741be309
5 changed files with 227 additions and 13 deletions

View File

@ -270,3 +270,12 @@
28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без 28. 2026-10-19 — **метки временного ключа для `ensure_sprint` берутся только из его `ensure_issue` в пакете, без
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта) обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача Jira; метки пакета (с дописанной меткой проекта)
единственный надёжный источник. Цена ошибки: ветка в `_assign_boards`. единственный надёжный источник. Цена ошибки: ветка в `_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`.

View File

@ -18,7 +18,8 @@ def main(argv=None) -> int:
p.add_argument("--port", type=int) p.add_argument("--port", type=int)
p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки") p = sub.add_parser("import", help="импорт CSV-пакета admin/jira-sync в заметки")
p.add_argument("folder") 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="показать токены агента и клиента") sub.add_parser("tokens", help="показать токены агента и клиента")
p = sub.add_parser("agent", help="версия агента для самообновления") p = sub.add_parser("agent", help="версия агента для самообновления")
agent_sub = p.add_subparsers(dest="agent_cmd", required=True) agent_sub = p.add_subparsers(dest="agent_cmd", required=True)
@ -46,7 +47,7 @@ def main(argv=None) -> int:
return 0 return 0
if args.cmd == "mcp": if args.cmd == "mcp":
from . import mcp_server from . import mcp_server
mcp_server.run() mcp_server.run(args.project)
return 0 return 0
conn = db.connect(paths.db_path()) conn = db.connect(paths.db_path())

View File

@ -12,16 +12,28 @@ from pathlib import Path
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from .client import HubClient, HubError, HubUnavailable from .client import HubClient, HubError, HubUnavailable
from .commands import describe from .commands import describe, is_temp
from .files import MAX_SIZE from .files import MAX_SIZE
OFFLINE = "хаб не запущен: jira-hub serve" OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5 POLL_SECONDS = 5
AUTHOR = "claude" AUTHOR = "claude"
BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" 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") 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: def _client() -> HubClient:
return HubClient.from_home() return HubClient.from_home()
@ -54,6 +66,33 @@ def guard(fn):
return wrapper 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): def _cut(text, n=80):
@ -65,6 +104,16 @@ def _v(value):
return "—" if value in (None, "", []) else str(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: def fmt_issues(issues) -> str:
if not issues: if not issues:
return "задач нет" return "задач нет"
@ -137,17 +186,19 @@ def fmt_agent(a) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(True)
def issues(sprint: str = "", assignee: str = "", work_kind: str = "", fact_status: str = "", status: str = "", 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: label: str = "", epic: str = "", q: str = "", neighbor: str = "", limit: int = 50) -> str:
"""Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик, """Задачи зеркала по фильтрам (спринт, исполнитель, вид работы, статус по факту, статус Jira, метка, эпик,
подстрока темы/ключа, neighbor=1 — только соседние). Компактная таблица.""" подстрока темы/ключа, 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) status=status, label=label, epic=epic, q=q, neighbor=neighbor, limit=limit)
return fmt_issues(data["issues"]) return fmt_issues(data["issues"])
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(True)
def issue(key: str) -> str: def issue(key: str) -> str:
"""Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание.""" """Задача целиком: поля, связи в обе стороны, служебные заметки, последние команды, описание."""
return fmt_issue(_client().get(f"/issues/{key}")) return fmt_issue(_client().get(f"/issues/{key}"))
@ -155,12 +206,16 @@ def issue(key: str) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(True)
def notes_set(key: str, fact_status: str | None = None, work_kind: str | None = None, done_text: str | None = None, 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: 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, 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} rest_text=rest_text, owner_note=owner_note).items() if v is not None}
fields["updated_by"] = AUTHOR 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) 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)) 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() @mcp.tool()
@guard @guard
@scoped(True)
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам. @_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]): Параметры по типам (необязательные — в [ ]):
""" + describe() + """ """ + describe() + """
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют, Проект: метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб.
задача без неё выпадет из базовой JQL и из зеркала.
ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит ensure_attachment из чата: вместо file передайте path — абсолютный путь к локальному файлу (до 10 МБ); MCP загрузит
файл в хаб и подставит file, filename по умолчанию — имя файла.""") файл в хаб и подставит file, filename по умолчанию — имя файла.""")
def batch_create(title: str, commands: list[dict]) -> str: 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) prepared = _attach_files(client, commands)
if isinstance(prepared, str): if isinstance(prepared, str):
return prepared 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)} мин" return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def batch_status(id: int) -> str: def batch_status(id: int) -> str:
"""Состояние пакета и его команд.""" """Состояние пакета и его команд."""
return fmt_batch(_client().get(f"/batches/{id}")) return fmt_batch(_client().get(f"/batches/{id}"))
@ -257,6 +313,7 @@ def batch_status(id: int) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
async def batch_wait(id: int, timeout_minutes: int = 10) -> str: async def batch_wait(id: int, timeout_minutes: int = 10) -> str:
"""Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера.""" """Ждать завершения пакета (до 60 мин), вернуть итог по командам. Ожидание не блокирует event loop сервера."""
deadline = time.monotonic() + min(max(int(timeout_minutes), 1), 60) * 60 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() @mcp.tool()
@guard @guard
@scoped(False)
def batches(limit: int = 10) -> str: 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 "пакетов нет" return "\n".join(fmt_batch(b) for b in items) or "пакетов нет"
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(True)
def command_retry(id: int) -> str: 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']} команд" return f"пакет #{b['id']} «Повтор команды {id}»: {b['count']} команд"
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def batch_cancel(id: int) -> str: def batch_cancel(id: int) -> str:
"""Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает — """Отменить пакет: команды в очереди отменяются; взятую агентом команду отмена не останавливает —
в ответе подсказка (POST /commands/N/cancel с force).""" в ответе подсказка (POST /commands/N/cancel с force)."""
@ -294,6 +354,7 @@ def batch_cancel(id: int) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(True)
async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str: async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool = False) -> str:
"""Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет; """Обновить зеркало из Jira: по ключам или полностью (базовая JQL). По умолчанию только ставит пакет;
wait=true — дождаться результата (до 30 мин).""" wait=true — дождаться результата (до 30 мин)."""
@ -305,6 +366,7 @@ async def refresh(keys: list[str] | None = None, full: bool = False, wait: bool
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def agent_status() -> str: def agent_status() -> str:
"""Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия, """Агент: онлайн/офлайн, версия, текущая команда, 429 за сутки, команды без ответа, опубликованная версия,
состояние самообновления и запускатель.""" состояние самообновления и запускатель."""
@ -313,6 +375,7 @@ def agent_status() -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def sync_log(limit: int = 10) -> str: def sync_log(limit: int = 10) -> str:
"""Журнал обновлений зеркала.""" """Журнал обновлений зеркала."""
runs = _client().get("/sync-runs", limit=limit)["runs"] runs = _client().get("/sync-runs", limit=limit)["runs"]
@ -323,6 +386,7 @@ def sync_log(limit: int = 10) -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def settings_get(key: str = "") -> str: def settings_get(key: str = "") -> str:
"""Настройки хаба (все или одна по ключу).""" """Настройки хаба (все или одна по ключу)."""
data = _client().get("/settings") data = _client().get("/settings")
@ -331,6 +395,7 @@ def settings_get(key: str = "") -> str:
@mcp.tool() @mcp.tool()
@guard @guard
@scoped(False)
def settings_set(key: str, value: str) -> str: def settings_set(key: str, value: str) -> str:
"""Записать настройку: value — JSON. Поднимает settings_version.""" """Записать настройку: value — JSON. Поднимает settings_version."""
try: try:
@ -341,5 +406,30 @@ def settings_set(key: str, value: str) -> str:
return f"settings_version = {out['settings_version']}" 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") mcp.run(transport="stdio")

View File

@ -64,3 +64,12 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys):
monkeypatch.setattr(os, "replace", real) monkeypatch.setattr(os, "replace", real)
assert release.load(paths.release_dir())["version"] == 8 assert release.load(paths.release_dir())["version"] == 8
assert sorted(p.name for p in paths.release_dir().iterdir()) == ["agent.py", "manifest.json"] 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]

View File

@ -25,12 +25,14 @@ def live(tmp_path, monkeypatch, add_project):
base = f"http://127.0.0.1:{srv.server_port}" base = f"http://127.0.0.1:{srv.server_port}"
monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"])) monkeypatch.setattr(mcp_server, "_client", lambda: HubClient(base, tok["client"]))
monkeypatch.setattr(mcp_server, "POLL_SECONDS", 0.05) 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.shutdown()
srv.server_close() srv.server_close()
def test_offline_message(monkeypatch): 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)) 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.issues() == mcp_server.OFFLINE
assert mcp_server.agent_status() == 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): 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)) 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.batch_wait(1)) == mcp_server.OFFLINE
assert asyncio.run(mcp_server.refresh(full=True)) == 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(): def test_batch_create_mentions_labels():
doc = mcp_server.batch_create.__doc__ or "" 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(): 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(): for name, hint in spec.get("hints", {}).items():
assert f"{name} ({hint})" in line, (ctype, name) assert f"{name} ({hint})" in line, (ctype, name)
assert "target (статус или список-путь)" in desc and "sprint (имя спринта или backlog)" in desc 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(): 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): def test_batch_create_explicit_filename_and_tilde(live, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path)) 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") (tmp_path / "скрин.png").write_bytes(b"png")
out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": { out = mcp_server.batch_create("скрин", [{"type": "ensure_attachment", "params": {
"key": "A-1", "path": "~/скрин.png", "filename": "Экран 1.png"}}]) "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 "file (sha256 из POST /files; в MCP — path к локальному файлу)" in desc
assert "issue_type (task|epic|bug)" in desc assert "issue_type (task|epic|bug)" in desc
assert "вместо file передайте path" in desc and "10 МБ" in desc and "filename по умолчанию — имя файла" 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"