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` в пакете, без
обращения к зеркалу.** Обоснование: `new:<n>` в зеркале — не задача 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`.

View File

@ -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())

View File

@ -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")

View File

@ -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]

View File

@ -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"