4dd2e58b86
Claude Code теперь согласует со stdio-серверами MCP 2026-07-28, где сервер не может сам слать запросы клиенту: ctx.elicit падал с NoBackChannelError, и trello_create_card не создавал карточки ни в терминале, ни в Desktop. Подтверждение переведено на Resolve/Elicit — SDK спрашивает через input_required или elicitation/create, смотря по версии протокола. SDK прячет от модели текст любых исключений, кроме ToolError, поэтому подсказки про незаданные ключи и 404 до неё не доходили. TrelloToolsServer отдаёт их текстом (HTTP-ошибки — без query, там ключи Trello). Ещё: - get_dev_task: перед реализацией ТЗ прочитать код, пересказать суть и предложить варианты или задать вопросы; - QA-документы по умолчанию на украинском; - README и подсказки в ошибках: в Claude Desktop /plugin configure нет, ключи задаются из терминала. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
12 KiB
Python
324 lines
12 KiB
Python
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from mcp import types
|
|
from mcp.client import Client
|
|
from mcp.server.mcpserver import AcceptedElicitation, CancelledElicitation, DeclinedElicitation
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import server # noqa: E402
|
|
import trello # noqa: E402
|
|
|
|
LISTS_URL = "https://api.trello.com/1/boards/board1/lists"
|
|
CARDS_URL = "https://api.trello.com/1/cards"
|
|
BOARD_LISTS = [
|
|
{"id": "list-backlog", "name": "Бэклог"},
|
|
{"id": "list-bugs", "name": "Баги с прода"},
|
|
{"id": "list-done", "name": "Готово"},
|
|
]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def trello_env(monkeypatch):
|
|
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
|
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
|
monkeypatch.setenv("TRELLO_BOARD_ID", "board1")
|
|
monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,")
|
|
|
|
|
|
ACCEPTED = AcceptedElicitation(data=server.CardConfirmation())
|
|
|
|
|
|
def test_create_targets_from_env():
|
|
assert trello.create_targets() == ["Бэклог", "Баги с прода"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_rejects_list_not_in_env():
|
|
# «Готово» есть на доске, но не разрешён — в API даже не ходим.
|
|
with respx.mock:
|
|
with pytest.raises(ValueError, match="не разрешён"):
|
|
await trello.resolve_create_list("Готово")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_finds_list_case_insensitive():
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
|
|
target = await trello.resolve_create_list("баги С прода")
|
|
|
|
assert target == {"id": "list-bugs", "name": "Баги с прода"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_list_missing_on_board():
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS[2:]))
|
|
with pytest.raises(ValueError, match="не найден"):
|
|
await trello.resolve_create_list("Бэклог")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirmed_card_is_created_with_exact_text():
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
|
|
create = respx.post(CARDS_URL).mock(
|
|
return_value=httpx.Response(
|
|
200, json={"id": "c1", "name": "Падает экспорт", "shortUrl": "https://trello.com/c/xyz"}
|
|
)
|
|
)
|
|
plan = await server.resolve_card_plan("Бэклог")
|
|
question = await server.ask_card_confirmation("Падает экспорт", "Шаги: ...", plan)
|
|
result = await server.create_card_confirmed(plan, "Падает экспорт", "Шаги: ...", ACCEPTED)
|
|
|
|
assert json.loads(create.calls.last.request.content) == {
|
|
"idList": "list-backlog",
|
|
"name": "Падает экспорт",
|
|
"desc": "Шаги: ...",
|
|
}
|
|
assert result == {
|
|
"created": True,
|
|
"list": "Бэклог",
|
|
"template": None,
|
|
"labels": [],
|
|
"id": "c1",
|
|
"name": "Падает экспорт",
|
|
"url": "https://trello.com/c/xyz",
|
|
}
|
|
assert question.schema is server.CardConfirmation
|
|
assert "Падает экспорт" in question.message and "Шаги: ..." in question.message
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"answer",
|
|
[
|
|
DeclinedElicitation(),
|
|
CancelledElicitation(),
|
|
AcceptedElicitation(data=server.CardConfirmation(confirm=False)),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_card_not_created_without_confirmation(answer):
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
|
|
create = respx.post(CARDS_URL)
|
|
plan = await server.resolve_card_plan("Бэклог")
|
|
result = await server.create_card_confirmed(plan, "T", "D", answer)
|
|
|
|
assert result["created"] is False
|
|
assert not create.called
|
|
|
|
|
|
# На 2026-07-28 сервер не может сам слать запросы клиенту: окно идёт через
|
|
# input_required и повторный вызов тула. ctx.elicit там падал с
|
|
# NoBackChannelError, и карточки не создавались вообще.
|
|
@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"])
|
|
@pytest.mark.asyncio
|
|
async def test_tool_asks_user_on_both_protocols(mode):
|
|
asked = []
|
|
|
|
async def answer(context, params):
|
|
asked.append(params.message)
|
|
return types.ElicitResult(action="accept", content={"confirm": True})
|
|
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
|
|
create = respx.post(CARDS_URL).mock(
|
|
return_value=httpx.Response(200, json={"id": "c1", "name": "T", "shortUrl": "https://trello.com/c/xyz"})
|
|
)
|
|
async with Client(server.mcp, mode=mode, elicitation_callback=answer) as client:
|
|
result = await client.call_tool(
|
|
"trello_create_card", {"list_name": "Бэклог", "name": "T", "desc": "D"}
|
|
)
|
|
|
|
assert not result.is_error, result
|
|
assert json.loads(result.content[0].text)["created"] is True
|
|
assert create.call_count == 1
|
|
assert len(asked) == 1 and "Название: T" in asked[0]
|
|
|
|
|
|
@pytest.mark.parametrize("mode", ["legacy", "2026-07-28"])
|
|
@pytest.mark.asyncio
|
|
async def test_tool_creates_nothing_when_user_declines(mode):
|
|
async def answer(context, params):
|
|
return types.ElicitResult(action="decline")
|
|
|
|
with respx.mock:
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
|
|
create = respx.post(CARDS_URL)
|
|
async with Client(server.mcp, mode=mode, elicitation_callback=answer) as client:
|
|
result = await client.call_tool(
|
|
"trello_create_card", {"list_name": "Бэклог", "name": "T", "desc": "D"}
|
|
)
|
|
|
|
assert json.loads(result.content[0].text)["created"] is False
|
|
assert not create.called
|
|
|
|
|
|
BOARD_CARDS_URL = "https://api.trello.com/1/boards/board1/cards"
|
|
BOARD_CARDS = [
|
|
{"id": "card-plain", "name": "Обычная", "desc": "", "idList": "list-backlog", "labels": [], "isTemplate": False},
|
|
{
|
|
"id": "tpl-bug",
|
|
"name": "баг",
|
|
"desc": "## Шаги\n## Ожидаемо",
|
|
"idList": "list-backlog",
|
|
"labels": [{"name": "Bug", "color": "red"}],
|
|
"isTemplate": True,
|
|
"idMembers": ["m1"],
|
|
"cover": {"color": "red", "size": "normal", "brightness": "light", "yPosition": 0.5},
|
|
},
|
|
# Архивный шаблон в архивном списке — тоже нужен.
|
|
{"id": "tpl-old", "name": "старый", "desc": "", "idList": "list-archived", "labels": [], "isTemplate": True},
|
|
]
|
|
TEMPLATE_CHECKLISTS = {
|
|
"tpl-bug": [{"name": "DoD", "checkItems": [{"name": "Тесты"}]}],
|
|
"tpl-old": [],
|
|
}
|
|
ALL_LISTS = BOARD_LISTS + [{"id": "list-archived", "name": "How to use this Board"}]
|
|
|
|
|
|
BOARD_MEMBERS_URL = "https://api.trello.com/1/boards/board1/members"
|
|
BOARD_LABELS_URL = "https://api.trello.com/1/boards/board1/labels"
|
|
BOARD_LABELS = [
|
|
{"id": "lb-bug", "name": "bug", "color": "orange", "uses": 102},
|
|
{"id": "lb-np", "name": "Нова Пошта", "color": "yellow_light", "uses": 41},
|
|
{"id": "lb-np-old", "name": "нова пошта", "color": "yellow", "uses": 1},
|
|
{"id": "lb-empty", "name": "", "color": "purple", "uses": 2},
|
|
]
|
|
|
|
|
|
def _mock_board():
|
|
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=ALL_LISTS))
|
|
respx.get(BOARD_MEMBERS_URL).mock(
|
|
return_value=httpx.Response(200, json=[{"id": "m1", "fullName": "Alexey Poddubniy"}])
|
|
)
|
|
respx.get(BOARD_LABELS_URL).mock(return_value=httpx.Response(200, json=BOARD_LABELS))
|
|
for card_id, checklists in TEMPLATE_CHECKLISTS.items():
|
|
respx.get(f"{CARDS_URL}/{card_id}/checklists").mock(
|
|
return_value=httpx.Response(200, json=checklists)
|
|
)
|
|
return respx.get(BOARD_CARDS_URL).mock(return_value=httpx.Response(200, json=BOARD_CARDS))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_templates_only_template_cards():
|
|
with respx.mock:
|
|
cards = _mock_board()
|
|
templates = await trello.list_templates()
|
|
|
|
assert cards.calls.last.request.url.params["filter"] == "all"
|
|
assert templates == [
|
|
{
|
|
"id": "tpl-bug",
|
|
"name": "баг",
|
|
"list": "Бэклог",
|
|
"desc": "## Шаги\n## Ожидаемо",
|
|
"members": ["Alexey Poddubniy"],
|
|
"cover": {"color": "red", "size": "normal", "brightness": "light"},
|
|
"labels": [{"name": "Bug", "color": "red"}],
|
|
"checklists": [{"name": "DoD", "items": ["Тесты"]}],
|
|
},
|
|
{
|
|
"id": "tpl-old",
|
|
"name": "старый",
|
|
"list": "How to use this Board",
|
|
"desc": "",
|
|
"members": [],
|
|
"cover": None,
|
|
"labels": [],
|
|
"checklists": [],
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_template_unknown_lists_available():
|
|
with respx.mock:
|
|
_mock_board()
|
|
with pytest.raises(ValueError, match="Есть: баг, старый"):
|
|
await trello.resolve_template("Фича")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_card_from_template_copies_and_fixes_desc_and_flag():
|
|
with respx.mock:
|
|
_mock_board()
|
|
create = respx.post(CARDS_URL).mock(
|
|
return_value=httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "c1",
|
|
"name": "Падает экспорт",
|
|
"shortUrl": "https://trello.com/c/xyz",
|
|
# Trello скопировал описание и флаг шаблона.
|
|
"desc": "## Шаги\n## Ожидаемо",
|
|
"isTemplate": True,
|
|
"idLabels": ["lb-tpl"],
|
|
},
|
|
)
|
|
)
|
|
fix = respx.put(f"{CARDS_URL}/c1").mock(return_value=httpx.Response(200, json={}))
|
|
plan = await server.resolve_card_plan("Бэклог", "БАГ", ["НОВА ПОШТА", "bug"])
|
|
question = await server.ask_card_confirmation("Падает экспорт", "## Шаги\n1. Нажать", plan)
|
|
result = await server.create_card_confirmed(
|
|
plan, "Падает экспорт", "## Шаги\n1. Нажать", ACCEPTED
|
|
)
|
|
|
|
sent = json.loads(create.calls.last.request.content)
|
|
assert sent["idCardSource"] == "tpl-bug"
|
|
assert {"checklists", "labels", "members"} <= set(sent["keepFromSource"].split(","))
|
|
assert json.loads(fix.calls.last.request.content) == {
|
|
"desc": "## Шаги\n1. Нажать",
|
|
"isTemplate": False,
|
|
"cover": {"color": "red", "size": "normal", "brightness": "light"},
|
|
# Метки шаблона сохраняются, выбранные добавляются; при дублях
|
|
# названий берётся самая используемая метка.
|
|
"idLabels": "lb-tpl,lb-np,lb-bug",
|
|
}
|
|
assert result["template"] == "баг"
|
|
assert result["labels"] == ["Нова Пошта", "bug"]
|
|
preview = question.message
|
|
assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview
|
|
assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview
|
|
assert "Метки: Нова Пошта, bug" in preview
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_labels_skips_unnamed_and_sorts():
|
|
with respx.mock:
|
|
_mock_board()
|
|
labels = await trello.list_labels()
|
|
|
|
assert [label["name"] for label in labels] == ["bug", "Нова Пошта", "нова пошта"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_label_fails_before_confirmation():
|
|
asked = []
|
|
|
|
async def answer(context, params):
|
|
asked.append(params.message)
|
|
return types.ElicitResult(action="accept", content={"confirm": True})
|
|
|
|
with respx.mock:
|
|
_mock_board()
|
|
create = respx.post(CARDS_URL)
|
|
async with Client(server.mcp, mode="2026-07-28", elicitation_callback=answer) as client:
|
|
result = await client.call_tool(
|
|
"trello_create_card",
|
|
{"list_name": "Бэклог", "name": "T", "desc": "D", "labels": ["bug", "Нет такой"]},
|
|
)
|
|
|
|
assert result.is_error and "'Нет такой'" in result.content[0].text
|
|
assert not asked and not create.called
|
|
|
|
|
|
def test_confirmation_checked_by_default():
|
|
assert server.CardConfirmation().confirm is True
|