Merge pull request 'MCP batch_create: параметры команд в описании и в ошибке 422 (#44)' (#45) from feat/44-mcp-param-hints into main
This commit is contained in:
commit
68de173c24
@ -8,18 +8,31 @@ from . import mirror
|
|||||||
TEMP_KEY = re.compile(r"^new:\d+$")
|
TEMP_KEY = re.compile(r"^new:\d+$")
|
||||||
FIELD_PARAMS = ["summary", "description", "priority", "duedate", "planned_start", "planned_end", "epic_key", "assignee"]
|
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 = {
|
SPEC = {
|
||||||
"ensure_issue": {"required": ["temp_key", "issue_type", "summary"],
|
"ensure_issue": {"required": ["temp_key", "issue_type", "summary"],
|
||||||
"optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels",
|
"optional": ["description", "priority", "duedate", "planned_start", "planned_end", "labels",
|
||||||
"epic_key", "assignee"],
|
"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"],
|
"ensure_fields": {"required": ["key"], "optional": FIELD_PARAMS + ["labels_add", "labels_remove"],
|
||||||
"keys": ["key", "epic_key"]},
|
"keys": ["key", "epic_key"],
|
||||||
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"]},
|
"hints": {**FIELD_HINTS, "labels_add": "список", "labels_remove": "список"},
|
||||||
"ensure_status": {"required": ["key", "target"], "optional": [], "keys": ["key"]},
|
"note": "хотя бы одно поле"},
|
||||||
"ensure_link": {"required": ["source", "target", "link_type"], "optional": ["replace"], "keys": ["source", "target"]},
|
"ensure_comment": {"required": ["key", "marker", "body"], "optional": [], "keys": ["key"],
|
||||||
"ensure_sprint": {"required": ["key", "sprint"], "optional": [], "keys": ["key"]},
|
"hints": {"marker": "метка в начале комментария"}},
|
||||||
"refresh": {"required": [], "optional": ["keys", "jql"], "keys": ["keys"]},
|
"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"]
|
WRITE_TYPES = [t for t in SPEC if t != "refresh"]
|
||||||
LIST_PARAMS = {"labels", "labels_add", "labels_remove", "keys"}
|
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)
|
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]:
|
def validate(commands) -> list[dict]:
|
||||||
if not isinstance(commands, list) or not commands:
|
if not isinstance(commands, list) or not commands:
|
||||||
raise CommandError("пакет без команд")
|
raise CommandError("пакет без команд")
|
||||||
@ -84,10 +122,11 @@ def validate(commands) -> list[dict]:
|
|||||||
spec = SPEC[ctype]
|
spec = SPEC[ctype]
|
||||||
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
|
missing = [f for f in spec["required"] if params.get(f) in (None, "", [])]
|
||||||
if missing:
|
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"])
|
unknown = set(params) - set(spec["required"]) - set(spec["optional"])
|
||||||
if unknown:
|
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]
|
bad = [e for e in (_type_error(ctype, n, v) for n, v in params.items()) if e]
|
||||||
if bad:
|
if bad:
|
||||||
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
raise CommandError(f"команда {i} ({ctype}): " + "; ".join(bad))
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import time
|
|||||||
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
|
||||||
|
|
||||||
OFFLINE = "хаб не запущен: jira-hub serve"
|
OFFLINE = "хаб не запущен: jira-hub serve"
|
||||||
POLL_SECONDS = 5
|
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))
|
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()
|
@mcp.tool()
|
||||||
@guard
|
@guard
|
||||||
|
@_doc("""Поставить пакет команд. Каждая команда — {"type": <тип>, "params": {...}}. Хвостовой refresh хаб добавит сам.
|
||||||
|
Параметры по типам (необязательные — в [ ]):
|
||||||
|
""" + describe() + """
|
||||||
|
ensure_issue передавайте с labels, включающими базовую метку проекта (AWPCA): хаб и агент метки сами не добавляют,
|
||||||
|
задача без неё выпадет из базовой JQL и из зеркала.""")
|
||||||
def batch_create(title: str, commands: list[dict]) -> str:
|
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})
|
b = _client().post("/batches", {"title": title, "author": AUTHOR, "commands": commands})
|
||||||
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
return f"пакет #{b['id']} поставлен: {b['count']} команд, оценка ≈ {round(b['estimate_seconds'] / 60)} мин"
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,29 @@ def test_validate_errors(bad, msg):
|
|||||||
commands.validate(bad)
|
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():
|
def test_substitute_and_estimate():
|
||||||
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
|
p = commands.substitute("ensure_link", {"source": "new:60", "target": "A-1", "link_type": "связанные"}, {"new:60": "A-9"})
|
||||||
assert p["source"] == "A-9"
|
assert p["source"] == "A-9"
|
||||||
|
|||||||
@ -6,7 +6,7 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
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
|
from jira_hub.client import HubClient
|
||||||
|
|
||||||
|
|
||||||
@ -204,6 +204,23 @@ def test_batch_create_mentions_labels():
|
|||||||
assert "labels" in doc and "AWPCA" in doc
|
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():
|
def test_fmt_agent_update_lines():
|
||||||
base = {"online": True, "agent_version": "6", "current_command_id": None, "rate_limited_24h": 0, "redelivered": [],
|
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}
|
"warning": None, "last_seen_at": "2026-10-14T20:00:00+00:00", "settings_version": 3}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user