From 4dd2e58b8677ce4489b04f703ddd89d5a0eeaef5 Mon Sep 17 00:00:00 2001 From: maxerton Date: Thu, 1 Oct 2026 16:53:19 +0000 Subject: [PATCH] =?UTF-8?q?trello-tools:=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=BE=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=BB=D0=B5=202026-07-28,=20=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=B1=D0=BA=D0=B8=20=E2=80=94=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 20 ++++++ qa_client.py | 4 +- requirements.txt | 2 +- server.py | 145 +++++++++++++++++++++++++++++--------- tests/test_create_card.py | 114 ++++++++++++++++++++---------- tests/test_tool_errors.py | 59 ++++++++++++++++ trello.py | 4 +- 7 files changed, 272 insertions(+), 76 deletions(-) create mode 100644 tests/test_tool_errors.py diff --git a/README.md b/README.md index 5a138ec..a2c6361 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,26 @@ curl -LsSf https://astral.sh/uv/install.sh | sh Поменять ключи потом — той же `/plugin configure trello-tools@gp-tools`. После настройки перезапусти 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`. + ## Обновление ``` diff --git a/qa_client.py b/qa_client.py index 5e8ad46..83d41b2 100644 --- a/qa_client.py +++ b/qa_client.py @@ -16,7 +16,7 @@ def _config() -> tuple[str, str]: api_key = os.environ.get("QA_SERVICE_API_KEY") if not url or not api_key: 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 @@ -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") if not url or not token: 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}"} diff --git a/requirements.txt b/requirements.txt index a5a9f83..cf267d6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -mcp[cli]>=2.0 +mcp[cli]>=2.1 httpx>=0.28 python-dotenv>=1.0 diff --git a/server.py b/server.py index 0ce7414..d360efd 100644 --- a/server.py +++ b/server.py @@ -25,11 +25,20 @@ from __future__ import annotations import os from pathlib import Path -from typing import Literal +from typing import Annotated, Literal +import httpx from dotenv import load_dotenv -from mcp.server.mcpserver import AcceptedElicitation, Context, Image, MCPServer -from mcp.shared.exceptions import MCPError +from mcp.server.mcpserver import ( + AcceptedElicitation, + DeclinedElicitation, + Elicit, + ElicitationResult, + Image, + MCPServer, + Resolve, +) +from mcp.server.mcpserver.exceptions import ToolError, UnexpectedToolError from pydantic import BaseModel, Field import qa_client @@ -56,7 +65,37 @@ for _name in PLUGIN_OPTIONS: if _value and not os.environ.get(_name): 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 = """\ # <Заголовок задачи> — [ссылка на карточку Trello] @@ -98,6 +137,13 @@ SCENARIOS_NOTE = ( "Не меняй заголовки сценариев без необходимости." ) +QA_DOC_LANGUAGE = ( + "Язык документа — украинский, даже если разговор идёт по-русски: " + "все текстовые поля и заголовки разделов («Що зроблено», «Було», " + "«Стало», «Як перевірити», «Сценарій N: …», «Що могло зачепити»). " + "По-русски — только если пользователь прямо попросил." +) + @mcp.tool( description=( @@ -229,40 +275,53 @@ def _card_preview( ) -async def create_card_confirmed( - ctx: Context, - list_name: str, - name: str, - desc: str, - template_name: str | None = None, - label_names: list[str] | None = None, +async def resolve_card_plan( + list_name: str, template: str | None = None, labels: list[str] | None = None ) -> dict: - """Создаёт карточку только после подтверждения пользователем в форме клиента. + """Столбец, шаблон и метки, сверенные с доской, — до вопроса пользователю.""" + return { + "list": await trello.resolve_create_list(list_name), + "template": await trello.resolve_template(template) if template else None, + "labels": await trello.resolve_labels(labels) if labels else [], + } - Подтверждение запрашивается у пользователя напрямую (MCP elicitation), + +async def ask_card_confirmation( + name: str, desc: str, plan: Annotated[dict, Resolve(resolve_card_plan)] +) -> Elicit[CardConfirmation]: + """Подтверждение запрашивается у пользователя напрямую (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: + Через 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": ( - "Клиент не смог показать пользователю окно подтверждения " - f"({error}). Карточка НЕ создана." - ), + "reason": "Пользователь отклонил создание в окне подтверждения. Карточка НЕ создана.", } - if not isinstance(answer, AcceptedElicitation): return { "created": False, - "reason": "Пользователь отклонил или закрыл окно подтверждения. Карточка НЕ создана.", + "reason": ( + "Окно подтверждения закрыто без ответа. Карточка НЕ создана. " + "Если пользователь окна не видел — его клиент не умеет " + "показывать окна MCP-серверов и закрывает их сам (так делают " + "claude -p и Agent SDK); создать карточку можно из Claude Code " + "в терминале." + ), } if not answer.data.confirm: return { @@ -273,14 +332,15 @@ async def create_card_confirmed( ), } + template = plan["template"] 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 { "created": True, - "list": target["name"], + "list": plan["list"]["name"], "template": template["name"] if template else None, - "labels": [label["name"] for label in labels], + "labels": [label["name"] for label in plan["labels"]], **card, } @@ -366,11 +426,12 @@ async def trello_create_card( list_name: str, name: str, desc: str, - ctx: Context, + plan: Annotated[dict, Resolve(resolve_card_plan)], + answer: Annotated[ElicitationResult[CardConfirmation], Resolve(ask_card_confirmation)], template: str | None = None, labels: list[str] | None = None, ) -> dict: - return await create_card_confirmed(ctx, list_name, name, desc, template, labels) + return await create_card_confirmed(plan, name, desc, answer) @mcp.tool( @@ -384,6 +445,7 @@ async def trello_create_card( "используй publish_standalone_qa_doc.\n\n" "markdown должен следовать шаблону:\n\n" f"{QA_DOC_TEMPLATE}\n\n" + f"{QA_DOC_LANGUAGE}\n\n" "После публикации, если известен card_id карточки Trello, вызови " 'trello_add_comment(card_id, f"QA: {url}") с URL из ответа этого тула, ' "чтобы тестировщик увидел ссылку в комментарии к карточке.\n\n" @@ -408,6 +470,9 @@ async def publish_qa_doc( ".../docs/).\n\n" "markdown — по тому же шаблону, что и в publish_qa_doc:\n\n" f"{QA_DOC_TEMPLATE}\n\n" + f"{QA_DOC_LANGUAGE} Исключение — документ, уже опубликованный " + "по-русски: его язык без просьбы пользователя не меняй, иначе " + "сбросятся отметки тестировщиков (см. ниже про заголовки сценариев).\n\n" "Если slug не найден на сервере — вернётся ошибка 404; в этом " "случае используй publish_qa_doc, чтобы опубликовать заново.\n\n" "Чтобы поправить документ, которого нет в текущем разговоре, сначала " @@ -438,6 +503,7 @@ async def update_qa_doc( "markdown — по тому же шаблону, что и в publish_qa_doc, только без " "ссылки на карточку в заголовке:\n\n" f"{QA_DOC_TEMPLATE}\n\n" + f"{QA_DOC_LANGUAGE}\n\n" "Обновлять такой документ — через update_qa_doc, передавая problem " "заново.\n\n" "Вход: title, problem — описание проблемы (что не так, где, как " @@ -539,9 +605,20 @@ async def update_dev_task( @mcp.tool( description=( - "Читает задание для фронта/бэка по slug или ссылке вида " + "Читает задание (ТЗ) для фронта/бэка по slug или ссылке вида " ".../tasks/ — например, из комментария в карточке Trello. " "Вызывай без спроса, это только чтение.\n\n" + "Если по заданию предстоит работать — код сразу не пиши:\n" + "1. Сначала прочитай существующий код, который отвечает за то, что " + "описано в ТЗ: упомянутые эндпоинты, модели, компоненты, сервисы и " + "то, что с ними связано. Сверь ТЗ с кодом: что уже есть, что " + "придётся менять, где ТЗ расходится с реальностью.\n" + "2. Ответь пользователю. Начни с 2–4 строк: суть ТЗ и что по нему " + "собираешься сделать. Дальше, опираясь на прочитанный код, — " + "варианты реализации (плюсы/минусы, твоя рекомендация) или " + "вопросы, если без пользователя решение не принять: неоднозначности, " + "расхождения с кодом, открытые вопросы из самого ТЗ.\n" + "3. К реализации переходи после его ответа.\n\n" "Выход: {slug, url, title, target, card_url, markdown, created_at, " "updated_at}." ) diff --git a/tests/test_create_card.py b/tests/test_create_card.py index 7fa2fc6..adf562e 100644 --- a/tests/test_create_card.py +++ b/tests/test_create_card.py @@ -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(): diff --git a/tests/test_tool_errors.py b/tests/test_tool_errors.py new file mode 100644 index 0000000..f01d443 --- /dev/null +++ b/tests/test_tool_errors.py @@ -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 diff --git a/trello.py b/trello.py index 7d7caad..1dc30b2 100644 --- a/trello.py +++ b/trello.py @@ -30,7 +30,7 @@ def _credentials() -> tuple[str, str]: token = os.environ.get("TRELLO_TOKEN") if not key or not token: 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 @@ -226,7 +226,7 @@ def _board_id() -> str: board_id = os.environ.get("TRELLO_BOARD_ID") if not board_id: 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