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
+111 -34
View File
@@ -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/<slug>).\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/<slug> — например, из комментария в карточке Trello. "
"Вызывай без спроса, это только чтение.\n\n"
"Если по заданию предстоит работать — код сразу не пиши:\n"
"1. Сначала прочитай существующий код, который отвечает за то, что "
"описано в ТЗ: упомянутые эндпоинты, модели, компоненты, сервисы и "
"то, что с ними связано. Сверь ТЗ с кодом: что уже есть, что "
"придётся менять, где ТЗ расходится с реальностью.\n"
"2. Ответь пользователю. Начни с 2–4 строк: суть ТЗ и что по нему "
"собираешься сделать. Дальше, опираясь на прочитанный код, — "
"варианты реализации (плюсы/минусы, твоя рекомендация) или "
"вопросы, если без пользователя решение не принять: неоднозначности, "
"расхождения с кодом, открытые вопросы из самого ТЗ.\n"
"3. К реализации переходи после его ответа.\n\n"
"Выход: {slug, url, title, target, card_url, markdown, created_at, "
"updated_at}."
)