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>
This commit is contained in:
2026-10-01 16:53:19 +00:00
parent e37a30dadc
commit 4dd2e58b86
7 changed files with 272 additions and 76 deletions
+77 -37
View File
@@ -5,8 +5,9 @@ from pathlib import Path
import httpx
import pytest
import respx
from mcp.server.mcpserver import AcceptedElicitation, DeclinedElicitation
from mcp.shared.exceptions import MCPError
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]))
@@ -30,17 +31,7 @@ def trello_env(monkeypatch):
monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,")
class FakeContext:
def __init__(self, answer=None, error=None):
self.answer = answer
self.error = error
self.messages = []
async def elicit(self, message, schema):
self.messages.append(message)
if self.error:
raise self.error
return self.answer
ACCEPTED = AcceptedElicitation(data=server.CardConfirmation())
def test_create_targets_from_env():
@@ -74,8 +65,6 @@ async def test_resolve_list_missing_on_board():
@pytest.mark.asyncio
async def test_confirmed_card_is_created_with_exact_text():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=True)))
with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
create = respx.post(CARDS_URL).mock(
@@ -83,7 +72,9 @@ async def test_confirmed_card_is_created_with_exact_text():
200, json={"id": "c1", "name": "Падает экспорт", "shortUrl": "https://trello.com/c/xyz"}
)
)
result = await server.create_card_confirmed(ctx, "Бэклог", "Падает экспорт", "Шаги: ...")
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",
@@ -99,28 +90,76 @@ async def test_confirmed_card_is_created_with_exact_text():
"name": "Падает экспорт",
"url": "https://trello.com/c/xyz",
}
assert "Падает экспорт" in ctx.messages[0] and "Шаги: ..." in ctx.messages[0]
assert question.schema is server.CardConfirmation
assert "Падает экспорт" in question.message and "Шаги: ..." in question.message
@pytest.mark.parametrize(
"ctx",
"answer",
[
FakeContext(DeclinedElicitation()),
FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=False))),
FakeContext(error=MCPError(code=-32601, message="elicitation not supported")),
DeclinedElicitation(),
CancelledElicitation(),
AcceptedElicitation(data=server.CardConfirmation(confirm=False)),
],
)
@pytest.mark.asyncio
async def test_card_not_created_without_confirmation(ctx):
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)
result = await server.create_card_confirmed(ctx, "Бэклог", "T", "D")
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},
@@ -208,8 +247,6 @@ async def test_resolve_template_unknown_lists_available():
@pytest.mark.asyncio
async def test_card_from_template_copies_and_fixes_desc_and_flag():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation()))
with respx.mock:
_mock_board()
create = respx.post(CARDS_URL).mock(
@@ -227,13 +264,10 @@ async def test_card_from_template_copies_and_fixes_desc_and_flag():
)
)
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(
ctx,
"Бэклог",
"Падает экспорт",
"## Шаги\n1. Нажать",
template_name="БАГ",
label_names=["НОВА ПОШТА", "bug"],
plan, "Падает экспорт", "## Шаги\n1. Нажать", ACCEPTED
)
sent = json.loads(create.calls.last.request.content)
@@ -249,7 +283,7 @@ async def test_card_from_template_copies_and_fixes_desc_and_flag():
}
assert result["template"] == "баг"
assert result["labels"] == ["Нова Пошта", "bug"]
preview = ctx.messages[0]
preview = question.message
assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview
assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview
assert "Метки: Нова Пошта, bug" in preview
@@ -266,17 +300,23 @@ async def test_list_labels_skips_unnamed_and_sorts():
@pytest.mark.asyncio
async def test_unknown_label_fails_before_confirmation():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation()))
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)
with pytest.raises(ValueError, match="'Нет такой'"):
await server.create_card_confirmed(
ctx, "Бэклог", "T", "D", label_names=["bug", "Нет такой"]
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 not ctx.messages and not create.called
assert result.is_error and "'Нет такой'" in result.content[0].text
assert not asked and not create.called
def test_confirmation_checked_by_default():