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:
+77
-37
@@ -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():
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from mcp.client import Client
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import server # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_trello_env(monkeypatch):
|
||||
for name in ("TRELLO_API_KEY", "TRELLO_TOKEN", "TRELLO_BOARD_ID"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
async def _call(name, arguments):
|
||||
async with Client(server.mcp, mode="2026-07-28") as client:
|
||||
return await client.call_tool(name, arguments)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_config_hint_reaches_model():
|
||||
# SDK прячет текст обычных исключений — без TrelloToolsServer модель
|
||||
# видела бы только «Error executing tool trello_list_labels».
|
||||
result = await _call("trello_list_labels", {})
|
||||
|
||||
assert result.is_error
|
||||
assert "TRELLO_API_KEY / TRELLO_TOKEN не заданы" in result.content[0].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_error_shows_status_without_credentials(monkeypatch):
|
||||
monkeypatch.setenv("TRELLO_API_KEY", "key123")
|
||||
monkeypatch.setenv("TRELLO_TOKEN", "tok456")
|
||||
|
||||
with respx.mock:
|
||||
respx.get(url__startswith="https://api.trello.com/1/cards/abc").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
result = await _call("trello_get_card", {"card_url": "https://trello.com/c/abc"})
|
||||
|
||||
text = result.content[0].text
|
||||
assert result.is_error and "HTTP 404 от api.trello.com/1/cards/abc" in text
|
||||
assert "key123" not in text and "tok456" not in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_crash_stays_hidden(monkeypatch):
|
||||
async def crash(card_url):
|
||||
raise KeyError("internal detail")
|
||||
|
||||
monkeypatch.setattr(server.trello, "get_card", crash)
|
||||
result = await _call("trello_get_card", {"card_url": "https://trello.com/c/abc"})
|
||||
|
||||
assert result.is_error and "internal detail" not in result.content[0].text
|
||||
Reference in New Issue
Block a user