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:
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Создаёт карточку только после подтверждения пользователем в форме клиента.
|
"""Столбец, шаблон и метки, сверенные с доской, — до вопроса пользователю."""
|
||||||
|
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:
|
Через Resolve, а не ctx.elicit: на протоколе 2026-07-28 (Claude Code
|
||||||
answer = await ctx.elicit(
|
согласует его и со stdio-серверами) сервер не может сам слать запросы
|
||||||
_card_preview(target["name"], name, desc, template, labels), CardConfirmation
|
клиенту, и ctx.elicit падает с NoBackChannelError. Resolve спрашивает
|
||||||
)
|
так, как требует согласованная версия: input_required или elicitation/create.
|
||||||
except MCPError as error:
|
"""
|
||||||
|
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 {
|
return {
|
||||||
"created": False,
|
"created": False,
|
||||||
"reason": (
|
"reason": "Пользователь отклонил создание в окне подтверждения. Карточка НЕ создана.",
|
||||||
"Клиент не смог показать пользователю окно подтверждения "
|
|
||||||
f"({error}). Карточка НЕ создана."
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user