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
+20
View File
@@ -35,6 +35,26 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
Поменять ключи потом — той же `/plugin configure trello-tools@gp-tools`. Поменять ключи потом — той же `/plugin configure trello-tools@gp-tools`.
После настройки перезапусти Claude Code (или `/reload-plugins`). После настройки перезапусти Claude Code (или `/reload-plugins`).
### Claude Desktop
Во вкладке **Code** (локальная сессия): **+ → Plugins → Add plugin**, выбрать
`trello-tools`, scope — **user**. Маркетплейс добавляется там же или из терминала.
Формы с ключами Desktop не показывает, а `/plugin configure` в нём отвечает
`isn't available in this environment`. Ключи задаются один раз из терминала:
Desktop и терминальный Claude Code на одном компьютере читают одни и те же
настройки.
1. Поставь Claude Code CLI, если его ещё нет:
macOS/Linux — `curl -fsSL https://claude.ai/install.sh | bash`,
Windows (PowerShell) — `irm https://claude.ai/install.ps1 | iex`.
2. В терминале запусти `claude`, введи `/plugin configure trello-tools@gp-tools`
и заполни форму.
3. Полностью перезапусти Claude Desktop.
Проверить, что всё задано: `claude plugin configure trello-tools@gp-tools` в
терминале покажет, какие поля `set`, а какие `not set`.
## Обновление ## Обновление
``` ```
+2 -2
View File
@@ -16,7 +16,7 @@ def _config() -> tuple[str, str]:
api_key = os.environ.get("QA_SERVICE_API_KEY") api_key = os.environ.get("QA_SERVICE_API_KEY")
if not url or not api_key: if not url or not api_key:
raise QaServiceConfigError( raise QaServiceConfigError(
"QA_SERVICE_URL / QA_SERVICE_API_KEY не заданы (плагин: /plugin configure trello-tools@gp-tools, локально: .env)" "QA_SERVICE_URL / QA_SERVICE_API_KEY не заданы (плагин: в терминале запусти claude и введи /plugin configure trello-tools@gp-tools — в Claude Desktop эта команда не работает, после настройки его нужно перезапустить; локально: .env)"
) )
return url.rstrip("/"), api_key return url.rstrip("/"), api_key
@@ -106,7 +106,7 @@ def _tasks_config() -> tuple[str, dict]:
token = os.environ.get("QA_SERVICE_API_KEY") or os.environ.get("QA_TASKS_TOKEN") token = os.environ.get("QA_SERVICE_API_KEY") or os.environ.get("QA_TASKS_TOKEN")
if not url or not token: if not url or not token:
raise QaServiceConfigError( raise QaServiceConfigError(
"QA_SERVICE_URL и QA_SERVICE_API_KEY или QA_TASKS_TOKEN не заданы (плагин: /plugin configure trello-tools@gp-tools, локально: .env)" "QA_SERVICE_URL и QA_SERVICE_API_KEY или QA_TASKS_TOKEN не заданы (плагин: в терминале запусти claude и введи /plugin configure trello-tools@gp-tools — в Claude Desktop эта команда не работает, после настройки его нужно перезапустить; локально: .env)"
) )
return url.rstrip("/"), {"Authorization": f"Bearer {token}"} return url.rstrip("/"), {"Authorization": f"Bearer {token}"}
+1 -1
View File
@@ -1,3 +1,3 @@
mcp[cli]>=2.0 mcp[cli]>=2.1
httpx>=0.28 httpx>=0.28
python-dotenv>=1.0 python-dotenv>=1.0
+114 -37
View File
@@ -25,11 +25,20 @@ from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Annotated, Literal
import httpx
from dotenv import load_dotenv from dotenv import load_dotenv
from mcp.server.mcpserver import AcceptedElicitation, Context, Image, MCPServer from mcp.server.mcpserver import (
from mcp.shared.exceptions import MCPError AcceptedElicitation,
DeclinedElicitation,
Elicit,
ElicitationResult,
Image,
MCPServer,
Resolve,
)
from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import qa_client import qa_client
@@ -56,7 +65,37 @@ for _name in PLUGIN_OPTIONS:
if _value and not os.environ.get(_name): if _value and not os.environ.get(_name):
os.environ[_name] = _value os.environ[_name] = _value
mcp = MCPServer("trello-tools")
def _user_facing(error: BaseException | None) -> str | None:
"""Текст ожидаемой ошибки для модели; None — настоящий сбой, текст не отдаём."""
if isinstance(error, httpx.HTTPStatusError):
# Без query: у Trello там key и token.
url = error.request.url
return f"HTTP {error.response.status_code} от {url.host}{url.path}"
if isinstance(error, (ValueError, trello.TrelloConfigError, qa_client.QaServiceConfigError)):
return str(error)
return None
class TrelloToolsServer(MCPServer):
"""MCPServer, у которого ожидаемые ошибки доходят до модели текстом.
SDK прячет текст любого исключения, кроме ToolError: модель видела бы
только «Error executing tool …» — без подсказки про незаданные ключи
и без 404, на который опираются описания тулов.
"""
async def call_tool(self, name: str, arguments: dict, context=None):
try:
return await super().call_tool(name, arguments, context)
except UnexpectedToolError as error:
message = _user_facing(error.__cause__)
if message is None:
raise
raise ToolError(f"Error executing tool {name}: {message}") from error.__cause__
mcp = TrelloToolsServer("trello-tools")
QA_DOC_TEMPLATE = """\ QA_DOC_TEMPLATE = """\
# <Заголовок задачи> — [ссылка на карточку Trello] # <Заголовок задачи> — [ссылка на карточку Trello]
@@ -98,6 +137,13 @@ SCENARIOS_NOTE = (
"Не меняй заголовки сценариев без необходимости." "Не меняй заголовки сценариев без необходимости."
) )
QA_DOC_LANGUAGE = (
"Язык документа — украинский, даже если разговор идёт по-русски: "
"все текстовые поля и заголовки разделов («Що зроблено», «Було», "
"«Стало», «Як перевірити», «Сценарій N: …», «Що могло зачепити»). "
"По-русски — только если пользователь прямо попросил."
)
@mcp.tool( @mcp.tool(
description=( description=(
@@ -229,40 +275,53 @@ def _card_preview(
) )
async def create_card_confirmed( async def resolve_card_plan(
ctx: Context, list_name: str, template: str | None = None, labels: list[str] | None = None
list_name: str,
name: str,
desc: str,
template_name: str | None = None,
label_names: list[str] | None = None,
) -> dict: ) -> dict:
"""Создаёт карточку только после подтверждения пользователем в форме клиента. """Столбец, шаблон и метки, сверенные с доской, — до вопроса пользователю."""
Подтверждение запрашивается у пользователя напрямую (MCP elicitation),
в обход модели: карточка создаётся ровно с тем текстом, который он видел.
"""
target = await trello.resolve_create_list(list_name)
template = await trello.resolve_template(template_name) if template_name else None
labels = await trello.resolve_labels(label_names) if label_names else []
try:
answer = await ctx.elicit(
_card_preview(target["name"], name, desc, template, labels), CardConfirmation
)
except MCPError as error:
return { return {
"created": False, "list": await trello.resolve_create_list(list_name),
"reason": ( "template": await trello.resolve_template(template) if template else None,
"Клиент не смог показать пользователю окно подтверждения " "labels": await trello.resolve_labels(labels) if labels else [],
f"({error}). Карточка НЕ создана."
),
} }
async def ask_card_confirmation(
name: str, desc: str, plan: Annotated[dict, Resolve(resolve_card_plan)]
) -> Elicit[CardConfirmation]:
"""Подтверждение запрашивается у пользователя напрямую (MCP elicitation),
в обход модели: карточка создаётся ровно с тем текстом, который он видел.
Через Resolve, а не ctx.elicit: на протоколе 2026-07-28 (Claude Code
согласует его и со stdio-серверами) сервер не может сам слать запросы
клиенту, и ctx.elicit падает с NoBackChannelError. Resolve спрашивает
так, как требует согласованная версия: input_required или elicitation/create.
"""
return Elicit(
_card_preview(plan["list"]["name"], name, desc, plan["template"], plan["labels"]),
CardConfirmation,
)
async def create_card_confirmed(
plan: dict, name: str, desc: str, answer: ElicitationResult[CardConfirmation]
) -> dict:
"""Создаёт карточку, только если пользователь подтвердил её в окне клиента."""
if isinstance(answer, DeclinedElicitation):
return {
"created": False,
"reason": "Пользователь отклонил создание в окне подтверждения. Карточка НЕ создана.",
}
if not isinstance(answer, AcceptedElicitation): if not isinstance(answer, AcceptedElicitation):
return { return {
"created": False, "created": False,
"reason": "Пользователь отклонил или закрыл окно подтверждения. Карточка НЕ создана.", "reason": (
"Окно подтверждения закрыто без ответа. Карточка НЕ создана. "
"Если пользователь окна не видел — его клиент не умеет "
"показывать окна MCP-серверов и закрывает их сам (так делают "
"claude -p и Agent SDK); создать карточку можно из Claude Code "
"в терминале."
),
} }
if not answer.data.confirm: if not answer.data.confirm:
return { return {
@@ -273,14 +332,15 @@ async def create_card_confirmed(
), ),
} }
template = plan["template"]
card = await trello.create_card( card = await trello.create_card(
target["id"], name, desc, template, [label["id"] for label in labels] plan["list"]["id"], name, desc, template, [label["id"] for label in plan["labels"]]
) )
return { return {
"created": True, "created": True,
"list": target["name"], "list": plan["list"]["name"],
"template": template["name"] if template else None, "template": template["name"] if template else None,
"labels": [label["name"] for label in labels], "labels": [label["name"] for label in plan["labels"]],
**card, **card,
} }
@@ -366,11 +426,12 @@ async def trello_create_card(
list_name: str, list_name: str,
name: str, name: str,
desc: str, desc: str,
ctx: Context, plan: Annotated[dict, Resolve(resolve_card_plan)],
answer: Annotated[ElicitationResult[CardConfirmation], Resolve(ask_card_confirmation)],
template: str | None = None, template: str | None = None,
labels: list[str] | None = None, labels: list[str] | None = None,
) -> dict: ) -> dict:
return await create_card_confirmed(ctx, list_name, name, desc, template, labels) return await create_card_confirmed(plan, name, desc, answer)
@mcp.tool( @mcp.tool(
@@ -384,6 +445,7 @@ async def trello_create_card(
"используй publish_standalone_qa_doc.\n\n" "используй publish_standalone_qa_doc.\n\n"
"markdown должен следовать шаблону:\n\n" "markdown должен следовать шаблону:\n\n"
f"{QA_DOC_TEMPLATE}\n\n" f"{QA_DOC_TEMPLATE}\n\n"
f"{QA_DOC_LANGUAGE}\n\n"
"После публикации, если известен card_id карточки Trello, вызови " "После публикации, если известен card_id карточки Trello, вызови "
'trello_add_comment(card_id, f"QA: {url}") с URL из ответа этого тула, ' 'trello_add_comment(card_id, f"QA: {url}") с URL из ответа этого тула, '
"чтобы тестировщик увидел ссылку в комментарии к карточке.\n\n" "чтобы тестировщик увидел ссылку в комментарии к карточке.\n\n"
@@ -408,6 +470,9 @@ async def publish_qa_doc(
".../docs/<slug>).\n\n" ".../docs/<slug>).\n\n"
"markdown — по тому же шаблону, что и в publish_qa_doc:\n\n" "markdown — по тому же шаблону, что и в publish_qa_doc:\n\n"
f"{QA_DOC_TEMPLATE}\n\n" f"{QA_DOC_TEMPLATE}\n\n"
f"{QA_DOC_LANGUAGE} Исключение — документ, уже опубликованный "
"по-русски: его язык без просьбы пользователя не меняй, иначе "
"сбросятся отметки тестировщиков (см. ниже про заголовки сценариев).\n\n"
"Если slug не найден на сервере — вернётся ошибка 404; в этом " "Если slug не найден на сервере — вернётся ошибка 404; в этом "
"случае используй publish_qa_doc, чтобы опубликовать заново.\n\n" "случае используй publish_qa_doc, чтобы опубликовать заново.\n\n"
"Чтобы поправить документ, которого нет в текущем разговоре, сначала " "Чтобы поправить документ, которого нет в текущем разговоре, сначала "
@@ -438,6 +503,7 @@ async def update_qa_doc(
"markdown — по тому же шаблону, что и в publish_qa_doc, только без " "markdown — по тому же шаблону, что и в publish_qa_doc, только без "
"ссылки на карточку в заголовке:\n\n" "ссылки на карточку в заголовке:\n\n"
f"{QA_DOC_TEMPLATE}\n\n" f"{QA_DOC_TEMPLATE}\n\n"
f"{QA_DOC_LANGUAGE}\n\n"
"Обновлять такой документ — через update_qa_doc, передавая problem " "Обновлять такой документ — через update_qa_doc, передавая problem "
"заново.\n\n" "заново.\n\n"
"Вход: title, problem — описание проблемы (что не так, где, как " "Вход: title, problem — описание проблемы (что не так, где, как "
@@ -539,9 +605,20 @@ async def update_dev_task(
@mcp.tool( @mcp.tool(
description=( description=(
"Читает задание для фронта/бэка по slug или ссылке вида " "Читает задание (ТЗ) для фронта/бэка по slug или ссылке вида "
".../tasks/<slug> — например, из комментария в карточке Trello. " ".../tasks/<slug> — например, из комментария в карточке Trello. "
"Вызывай без спроса, это только чтение.\n\n" "Вызывай без спроса, это только чтение.\n\n"
"Если по заданию предстоит работать — код сразу не пиши:\n"
"1. Сначала прочитай существующий код, который отвечает за то, что "
"описано в ТЗ: упомянутые эндпоинты, модели, компоненты, сервисы и "
"то, что с ними связано. Сверь ТЗ с кодом: что уже есть, что "
"придётся менять, где ТЗ расходится с реальностью.\n"
"2. Ответь пользователю. Начни с 2–4 строк: суть ТЗ и что по нему "
"собираешься сделать. Дальше, опираясь на прочитанный код, — "
"варианты реализации (плюсы/минусы, твоя рекомендация) или "
"вопросы, если без пользователя решение не принять: неоднозначности, "
"расхождения с кодом, открытые вопросы из самого ТЗ.\n"
"3. К реализации переходи после его ответа.\n\n"
"Выход: {slug, url, title, target, card_url, markdown, created_at, " "Выход: {slug, url, title, target, card_url, markdown, created_at, "
"updated_at}." "updated_at}."
) )
+77 -37
View File
@@ -5,8 +5,9 @@ from pathlib import Path
import httpx import httpx
import pytest import pytest
import respx import respx
from mcp.server.mcpserver import AcceptedElicitation, DeclinedElicitation from mcp import types
from mcp.shared.exceptions import MCPError from mcp.client import Client
from mcp.server.mcpserver import AcceptedElicitation, CancelledElicitation, DeclinedElicitation
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
@@ -30,17 +31,7 @@ def trello_env(monkeypatch):
monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,") monkeypatch.setenv("TRELLO_CREATE_LISTS", " Бэклог , Баги с прода,")
class FakeContext: ACCEPTED = AcceptedElicitation(data=server.CardConfirmation())
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
def test_create_targets_from_env(): def test_create_targets_from_env():
@@ -74,8 +65,6 @@ async def test_resolve_list_missing_on_board():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_confirmed_card_is_created_with_exact_text(): async def test_confirmed_card_is_created_with_exact_text():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=True)))
with respx.mock: with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
create = respx.post(CARDS_URL).mock( 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"} 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) == { assert json.loads(create.calls.last.request.content) == {
"idList": "list-backlog", "idList": "list-backlog",
@@ -99,28 +90,76 @@ async def test_confirmed_card_is_created_with_exact_text():
"name": "Падает экспорт", "name": "Падает экспорт",
"url": "https://trello.com/c/xyz", "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( @pytest.mark.parametrize(
"ctx", "answer",
[ [
FakeContext(DeclinedElicitation()), DeclinedElicitation(),
FakeContext(AcceptedElicitation(data=server.CardConfirmation(confirm=False))), CancelledElicitation(),
FakeContext(error=MCPError(code=-32601, message="elicitation not supported")), AcceptedElicitation(data=server.CardConfirmation(confirm=False)),
], ],
) )
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_card_not_created_without_confirmation(ctx): async def test_card_not_created_without_confirmation(answer):
with respx.mock: with respx.mock:
respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS)) respx.get(LISTS_URL).mock(return_value=httpx.Response(200, json=BOARD_LISTS))
create = respx.post(CARDS_URL) 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 result["created"] is False
assert not create.called 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_URL = "https://api.trello.com/1/boards/board1/cards"
BOARD_CARDS = [ BOARD_CARDS = [
{"id": "card-plain", "name": "Обычная", "desc": "", "idList": "list-backlog", "labels": [], "isTemplate": False}, {"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 @pytest.mark.asyncio
async def test_card_from_template_copies_and_fixes_desc_and_flag(): async def test_card_from_template_copies_and_fixes_desc_and_flag():
ctx = FakeContext(AcceptedElicitation(data=server.CardConfirmation()))
with respx.mock: with respx.mock:
_mock_board() _mock_board()
create = respx.post(CARDS_URL).mock( 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={})) 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( result = await server.create_card_confirmed(
ctx, plan, "Падает экспорт", "## Шаги\n1. Нажать", ACCEPTED
"Бэклог",
"Падает экспорт",
"## Шаги\n1. Нажать",
template_name="БАГ",
label_names=["НОВА ПОШТА", "bug"],
) )
sent = json.loads(create.calls.last.request.content) 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["template"] == "баг"
assert result["labels"] == ["Нова Пошта", "bug"] assert result["labels"] == ["Нова Пошта", "bug"]
preview = ctx.messages[0] preview = question.message
assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview assert "Шаблон: баг" in preview and "чек-лист «DoD»" in preview
assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview assert "участники: Alexey Poddubniy" in preview and "обложка: red" in preview
assert "Метки: Нова Пошта, bug" in preview assert "Метки: Нова Пошта, bug" in preview
@@ -266,17 +300,23 @@ async def test_list_labels_skips_unnamed_and_sorts():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_unknown_label_fails_before_confirmation(): 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: with respx.mock:
_mock_board() _mock_board()
create = respx.post(CARDS_URL) create = respx.post(CARDS_URL)
with pytest.raises(ValueError, match="'Нет такой'"): async with Client(server.mcp, mode="2026-07-28", elicitation_callback=answer) as client:
await server.create_card_confirmed( result = await client.call_tool(
ctx, "Бэклог", "T", "D", label_names=["bug", "Нет такой"] "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(): def test_confirmation_checked_by_default():
+59
View File
@@ -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
+2 -2
View File
@@ -30,7 +30,7 @@ def _credentials() -> tuple[str, str]:
token = os.environ.get("TRELLO_TOKEN") token = os.environ.get("TRELLO_TOKEN")
if not key or not token: if not key or not token:
raise TrelloConfigError( raise TrelloConfigError(
"TRELLO_API_KEY / TRELLO_TOKEN не заданы (плагин: /plugin configure trello-tools@gp-tools, локально: .env)" "TRELLO_API_KEY / TRELLO_TOKEN не заданы (плагин: в терминале запусти claude и введи /plugin configure trello-tools@gp-tools — в Claude Desktop эта команда не работает, после настройки его нужно перезапустить; локально: .env)"
) )
return key, token return key, token
@@ -226,7 +226,7 @@ def _board_id() -> str:
board_id = os.environ.get("TRELLO_BOARD_ID") board_id = os.environ.get("TRELLO_BOARD_ID")
if not board_id: if not board_id:
raise TrelloConfigError( raise TrelloConfigError(
"TRELLO_BOARD_ID не задан (плагин: /plugin configure trello-tools@gp-tools, локально: .env)" "TRELLO_BOARD_ID не задан (плагин: в терминале запусти claude и введи /plugin configure trello-tools@gp-tools — в Claude Desktop эта команда не работает, после настройки его нужно перезапустить; локально: .env)"
) )
return board_id return board_id