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:
parent
ce9f5a1894
commit
1a66aea2de
@ -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))
|
||||
|
||||
@ -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)} мин"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user