Files
trello-mcp/tests/test_create_card.py
T
maxerton 4dd2e58b86 trello-tools: окно подтверждения на протоколе 2026-07-28, ошибки — модели
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>
2026-10-01 16:53:19 +00:00

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