feat(mcp): параметры команд в описании batch_create и в ошибках 422 (#44)

Описание MCP-инструмента batch_create собирается из SPEC (commands.describe):
обязательные и необязательные параметры каждого типа с подсказками формата
(hints) и условием на набор (note). Ошибки validate «нет …» и «лишние
параметры …» называют ожидаемый набор параметров типа.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 23:13:14 +04:00
parent ce9f5a1894
commit 1a66aea2de
4 changed files with 103 additions and 14 deletions

View File

@ -8,18 +8,31 @@ from . import mirror
TEMP_KEY = re.compile(r"^new:\d+$")
FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"]
DATE = "YYYY-MM-DD"
FIELD_HINTS = {"duedate": DATE, "planned_start": DATE, "planned_end": DATE, "epic_key": "ключ или new:<n>",
"assignee": "логин Jira"}
# hints — формат значения для описания MCP batch_create, note — условие на набор параметров (#44)
SPEC = {
"ensure_issue": {"required": ["temp_key", "issue_type", "summary"],
"optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels",
"epic_key", "assignee"],
"keys": ["temp_key", "epic_key"]},
"keys": ["temp_key", "epic_key"],
"hints": {"temp_key": "new:<n>", "issue_type": "task|epic", "labels": "список", **FIELD_HINTS}},
"ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
"keys": ["key", "epic_key"]},
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]},
"ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]},
"ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]},
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]},
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]},
"keys": ["key", "epic_key"],
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
"note": "хотя бы одно поле"},
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
"hints": {"marker": "метка в начале комментария"}},
"ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"],
"hints": {"target": "статус или список-путь"}},
"ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"],
"hints": {"link_type": "логическое имя из link_types", "replace": "true|false"}},
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"],
"hints": {"sprint": "имя спринта или backlog"}},
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"], "hints": {"keys": "список"},
"note": "keys или jql"},
}
WRITE_TYPES = [t for t in SPEC if t != "refresh"]
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
@ -69,6 +82,31 @@ def _is_str_list(value) -> bool:
return isinstance(value, list) and all(isinstance(v, str) for v in value)
def _names(spec, names) -> str:
return ", ".join(f"{n} ({spec['hints'][n]})" if n in spec.get("hints", {}) else n for n in names)
def describe() -> str:
"""Параметры всех типов команд, строка на тип (необязательные — в [ ]): описание MCP batch_create из SPEC (#44)."""
lines = []
for ctype, spec in SPEC.items():
line = f"- {ctype}: {_names(spec, spec['required']) or '—'}"
if spec["optional"]:
line += f"; [{_names(spec, spec['optional'])}]"
if spec.get("note"):
line += f" — {spec['note']}"
lines.append(line)
return "\n".join(lines)
def _expected(spec) -> str:
"""Хвост ошибки «нет …» / «лишние параметры …»: какие параметры тип ожидает (#44)."""
parts = [f"ожидаются {', '.join(spec['required'])}"] if spec["required"] else []
if spec["optional"]:
parts.append(f"необязательные {', '.join(spec['optional'])}")
return "; ".join(parts)
def validate(commands) -> list[dict]:
if not isinstance(commands, list) or not commands:
raise CommandError("пакет без команд")
@ -84,10 +122,11 @@ def validate(commands) -> list[dict]:
spec = SPEC[ctype]
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
if missing:
raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}")
raise CommandError(f"команда {i} ({ctype}): нет {', '.join(missing)}; {_expected(spec)}")
unknown = set(params) - set(spec["required"]) - set(spec["optional"])
if unknown:
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}")
raise CommandError(f"команда {i} ({ctype}): лишние параметры {', '.join(sorted(unknown))}; "
f"{_expected(spec)}")
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
if bad:
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))

View File

@ -9,6 +9,7 @@ import time
from mcp.server.fastmcp import FastMCP
from .client import HubClient, HubError, HubUnavailable
from .commands import describe
OFFLINE = "хаб не запущен: jira-hub serve"
POLL_SECONDS = 5
@ -159,13 +160,22 @@ 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 _doc(text: str):
"""Описание инструмента, собранное в коде: FastMCP берёт его из __doc__ при регистрации."""
def deco(fn):
fn.__doc__ = text
return fn
return deco
@mcp.tool()
@guard
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
Параметры по типам (необязательные — в [ ]):
""" + describe() + """
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
задача без неё выпадет из базовой JQL и из зеркала.""")
def batch_create(title: str, commands: list[dict]) -> str:
"""Поставить пакет команд. Каждая команда — {"type": ensure_issue|ensure_fields|ensure_comment|ensure_status|
ensure_link|ensure_sprint|refresh, "params": {...}} по контракту specs/commands. Хвостовой refresh хаб добавит сам.
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
задача без неё выпадет из базовой JQL и из зеркала."""
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"

View File

@ -37,6 +37,29 @@ def test_validate_errors(bad, msg):
commands.validate(bad)
def test_missing_param_error_names_expected():
"""Клиент со старым описанием исправляет пакет с одной попытки (#44): ensure_status {key, status}."""
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_status", "params": {"key": "A-1", "status": "Готово"}}])
assert str(e.value) == "команда 1 (ensure_status): нет target; ожидаются key, target"
def test_unknown_param_error_names_expected():
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "Спринт 2", "board": "1"}}])
assert str(e.value) == "команда 1 (ensure_sprint): лишние параметры board; ожидаются key, sprint"
with pytest.raises(CommandError) as e:
commands.validate([{"type": "ensure_link", "params": {"source": "A-1", "target": "A-2", "link_type": "связанные",
"force": True}}])
assert str(e.value) == ("команда 1 (ensure_link): лишние параметры force; "
"ожидаются source, target, link_type; необязательные replace")
def test_spec_hints_name_known_params():
for ctype, spec in commands.SPEC.items():
assert set(spec.get("hints", {})) <= set(spec["required"]) | set(spec["optional"]), ctype
def test_substitute_and_estimate():
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
assert p["source"] == "A-9"

View File

@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
from jira_hub import api, mcp_server, settings
from jira_hub import api, commands, mcp_server, settings
from jira_hub.client import HubClient
@ -204,6 +204,23 @@ def test_batch_create_mentions_labels():
assert "labels" in doc and "AWPCA" in doc
def test_batch_create_description_lists_params_from_spec():
"""Клиент MCP не видит specs/commands: описание инструмента перечисляет параметры каждого типа из SPEC (#44)."""
tools = {t.name: t for t in asyncio.run(mcp_server.mcp.list_tools())}
desc = tools["batch_create"].description
for ctype, spec in commands.SPEC.items():
line = next(ln for ln in desc.splitlines() if ln.startswith(f"- {ctype}:"))
required, _, optional = line.removeprefix(f"- {ctype}:").partition("; [")
for name in spec["required"]:
assert re.search(rf"\b{name}\b", required), (ctype, name)
for name in spec["optional"]:
assert re.search(rf"\b{name}\b", optional), (ctype, name)
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
def test_fmt_agent_update_lines():
base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
"warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3}