trello-tools: плагин и маркетплейс gp-tools для Claude Code

Сервер запускается через uv run --script, ключи запрашиваются
при установке плагина (userConfig) вместо .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 14:14:29 +00:00
commit b4cabbf146
14 changed files with 2100 additions and 0 deletions
+570
View File
@@ -0,0 +1,570 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["mcp>=2.1,<3", "httpx>=0.28", "python-dotenv>=1.0"]
# ///
"""trello-mcp — MCP-сервер (stdio) для связки Trello + qa-docs-service.
Запускается как обычный локальный процесс, Claude Code общается с ним
через stdin/stdout. Регистрация (пример, scope local):
claude mcp add trello-tools --scope local -- \\
~/projects/gp_mcp/trello-mcp/.venv/bin/python \\
~/projects/gp_mcp/trello-mcp/server.py
Либо как плагин trello-tools из маркетплейса gp-tools (см. README.md):
тогда сервер запускается через `uv run --script`, зависимости берутся из
блока `# /// script` выше, а настройки — из userConfig плагина.
Примечание: план изначально предполагал `mcp.server.fastmcp.FastMCP`
(SDK v1). В установленной версии `mcp` (2.x) декораторный API называется
`MCPServer` (`mcp.server.mcpserver.MCPServer`) — по сути тот же
декоратор `.tool()`, только с другим именем класса.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Literal
from dotenv import load_dotenv
from mcp.server.mcpserver import AcceptedElicitation, Context, Image, MCPServer
from mcp.shared.exceptions import MCPError
from pydantic import BaseModel, Field
import qa_client
import trello
load_dotenv(Path(__file__).parent / ".env")
# В плагине .env нет: Claude Code передаёт поля userConfig из
# .claude-plugin/plugin.json как CLAUDE_PLUGIN_OPTION_<KEY>. Уже заданные
# переменные (например, QA_TASKS_TOKEN из ~/.bashrc) не перетираются.
PLUGIN_OPTIONS = (
"TRELLO_API_KEY",
"TRELLO_TOKEN",
"TRELLO_BOARD_ID",
"TRELLO_CREATE_LISTS",
"TRELLO_ATTACHMENTS_DIR",
"QA_SERVICE_URL",
"QA_SERVICE_API_KEY",
"QA_TASKS_TOKEN",
)
for _name in PLUGIN_OPTIONS:
_value = os.environ.get(f"CLAUDE_PLUGIN_OPTION_{_name}")
if _value and not os.environ.get(_name):
os.environ[_name] = _value
mcp = MCPServer("trello-tools")
QA_DOC_TEMPLATE = """\
# <Заголовок задачи> — [ссылка на карточку Trello]
## Что сделано
2-4 предложения по сути изменений.
## Было
Как вело себя раньше, конкретно, с примером.
## Стало
Как работает сейчас.
## Как проверить
Каждый сценарий — отдельный заголовок третьего уровня, начинающийся со
слова «Сценарий» (в украинском документе — «Сценарій»). Под каждым таким
заголовком на страничке появятся галочка «Проверено», выбор «Работает /
Не работает» и поле комментария для тестировщика (что именно не
работает). Шаги — списком под заголовком.
### Сценарий 1: <короткое название>
1. Шаг
2. Шаг
Ожидаемый результат: ...
### Сценарий 2: <короткое название>
...
## Что могло зацепить
Смежные места / риски регрессии.
---
Дата, ветка/коммит — если уместно."""
SCENARIOS_NOTE = (
"Отметки тестировщиков хранятся отдельно от markdown и при обновлении "
"документа сохраняются — но привязаны к тексту заголовка сценария: "
"если переименовать «### Сценарий ...», его отметка сбросится. "
"Не меняй заголовки сценариев без необходимости."
)
@mcp.tool(
description=(
"Вызывай, когда пользователь прислал ссылку на карточку Trello — "
"чтобы подтянуть контекст задачи в разговор, включая обсуждение "
"в комментариях.\n\n"
"Вход: card_url — ссылка вида https://trello.com/c/abc123/45-название.\n"
"Выход: {id, name, desc, url, board_id, list_id, labels, attachments, "
"comments}.\n"
"labels — метки карточки [{name, color}].\n"
"attachments — только СПИСОК вложений, без содержимого: [{id, name, "
"kind, mime, size, date, url}], kind — image (картинка), file (файл), "
"card (другая карточка Trello), link (прочая ссылка). Содержимое "
"тяни через trello_get_attachment, только если оно нужно для задачи "
"(скриншот бага, макет, лог, ТЗ, связанная карточка) — не всё подряд.\n"
"comments — список {author, text, date} в хронологическом порядке "
"(старые → новые), до 20 последних.\n\n"
"Если в описании или комментариях есть ссылка на задание вида "
".../tasks/<slug> — это задание для фронта/бэка от другой сессии "
"Claude: прочитай его через get_dev_task. Все задания по карточке "
"можно найти через list_dev_tasks(card_url)."
)
)
async def trello_get_card(card_url: str) -> dict:
return await trello.get_card(card_url)
# Картинки больше этого не отдаём в контекст — только путь к файлу.
INLINE_IMAGE_MAX_BYTES = 5 * 1024 * 1024
# Текстовые файлы до этого размера отдаём прямо в ответе.
INLINE_TEXT_MAX_BYTES = 200 * 1024
_TEXT_MIMES = ("application/json", "application/xml", "application/x-yaml", "application/csv")
# Форматы, которые модель умеет смотреть как картинку; прочие (svg, heic…) — по path.
_INLINE_IMAGE_MIMES = ("image/png", "image/jpeg", "image/gif", "image/webp")
@mcp.tool(
description=(
"Достаёт одно вложение карточки Trello. Вызывай выборочно — когда "
"вложение из attachments в trello_get_card действительно нужно для "
"задачи.\n\n"
"Вход: card_id — id карточки (поле id из trello_get_card), "
"attachment_id — id вложения из attachments.\n"
"Что вернётся по kind:\n"
"- image — метаданные + сама картинка (png/jpeg/gif/webp до 5 МБ; "
"иначе — только path);\n"
"- file — метаданные + path к скачанному файлу; текстовые файлы до "
"200 КБ — ещё и text. Остальное (PDF, docx, архивы) читай по path "
"своими инструментами;\n"
"- card — связанная карточка целиком, как из trello_get_card;\n"
"- link — только метаданные и url, сам сайт не открывается.\n"
"Файлы > 25 МБ не скачиваются."
)
)
async def trello_get_attachment(card_id: str, attachment_id: str):
attachment = await trello.get_attachment(card_id, attachment_id)
if attachment["kind"] == "card":
return {**attachment, "card": await trello.get_card(attachment["url"])}
if attachment["kind"] == "link":
return attachment
data = attachment.pop("data")
mime = attachment["mime"] or ""
if mime in _INLINE_IMAGE_MIMES and len(data) <= INLINE_IMAGE_MAX_BYTES:
return [attachment, Image(data=data, format=mime.split("/", 1)[1])]
if (mime.startswith("text/") or mime in _TEXT_MIMES) and len(data) <= INLINE_TEXT_MAX_BYTES:
try:
attachment["text"] = data.decode("utf-8")
except UnicodeDecodeError:
pass
return attachment
@mcp.tool(
description=(
"Постит текстовый комментарий в карточку Trello — любой текст, "
"ссылка не обязательна.\n\n"
"Произвольные комментарии — ТОЛЬКО по прямой просьбе пользователя "
"(«напиши в карточку…», «оставь комментарий…»). Текст бери "
"дословно из просьбы; если сформулировать нужно тебе — сначала "
"покажи текст и дождись одобрения. Исключение — комментарий со "
"ссылкой после publish_qa_doc / publish_dev_task: его правила "
"описаны в этих инструментах.\n\n"
"Вход: card_id — полный id карточки (не shortLink; его отдаёт "
"trello_get_card в поле id), text — текст комментария.\n"
"Выход: {id} — id созданного комментария."
)
)
async def trello_add_comment(card_id: str, text: str) -> dict:
return await trello.add_comment(card_id, text)
class CardConfirmation(BaseModel):
# Включена по умолчанию: подтверждение — это нажатие «принять», вторая
# ступень в том же окне только путает (галочку не замечают, и карточка
# молча не создаётся). Снять её — тоже способ отказаться.
confirm: bool = Field(
default=True, description="Создать карточку именно с этим текстом"
)
def _card_preview(
list_name: str,
name: str,
desc: str,
template: dict | None = None,
labels: list[dict] | None = None,
) -> str:
lines = [f"Столбец: {list_name}"]
if template:
lines.append(f"Шаблон: {template['name']}")
if template["members"]:
lines.append(f" участники: {', '.join(template['members'])}")
if template["cover"]:
lines.append(f" обложка: {template['cover']['color']}")
if template["labels"]:
lines.append(f" метки шаблона: {', '.join(l['name'] or l['color'] for l in template['labels'])}")
lines += [f" чек-лист «{c['name']}»" for c in template["checklists"]]
if labels:
lines.append(f"Метки: {', '.join(label['name'] for label in labels)}")
return (
"Создать карточку в Trello?\n\n"
+ "\n".join(lines)
+ f"\nНазвание: {name}\n\n"
f"Описание:\n{desc or '(пусто)'}"
)
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,
) -> 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 {
"created": False,
"reason": (
"Клиент не смог показать пользователю окно подтверждения "
f"({error}). Карточка НЕ создана."
),
}
if not isinstance(answer, AcceptedElicitation):
return {
"created": False,
"reason": "Пользователь отклонил или закрыл окно подтверждения. Карточка НЕ создана.",
}
if not answer.data.confirm:
return {
"created": False,
"reason": (
"Пользователь принял окно, но снял галочку «Создать карточку». "
"Карточка НЕ создана."
),
}
card = await trello.create_card(
target["id"], name, desc, template, [label["id"] for label in labels]
)
return {
"created": True,
"list": target["name"],
"template": template["name"] if template else None,
"labels": [label["name"] for label in labels],
**card,
}
@mcp.tool(
description=(
"Список шаблонов карточек на доске Trello. Шаблон задаёт участников "
"карточки и оформление (обложку, метки, чек-листы) — по названию "
"видно, для какого типа задач он (баг, пожелание клиента, фронт…). "
"Вызывай перед trello_create_card. Только чтение — можно без спроса.\n\n"
"Выход: [{id, name, list, desc, members, cover, labels, checklists}]. "
"Всё это переносится в новую карточку автоматически; desc обычно "
"пуст, если нет — повтори его структуру в описании."
)
)
async def trello_list_card_templates() -> list[dict]:
return await trello.list_templates()
@mcp.tool(
description=(
"Список меток доски Trello [{id, name, color, uses}], самые "
"используемые первыми (uses — на скольких карточках стоит). Вызывай "
"перед trello_create_card, чтобы подобрать метки. Только чтение — "
"можно без спроса."
)
)
async def trello_list_labels() -> list[dict]:
return await trello.list_labels()
_create_targets = trello.create_targets()
@mcp.tool(
description=(
"Создаёт карточку в Trello. ВЫЗЫВАЙ ТОЛЬКО по прямой просьбе "
"пользователя создать карточку («создай карточку», «заведи задачу "
"в Trello» и т.п.). Не создавай карточки по своей инициативе — "
"даже если по ходу работы нашёл баг или это кажется полезным; "
"в таком случае максимум предложи.\n\n"
"Порядок:\n"
"1. Вызови trello_list_card_templates и выбери подходящий шаблон "
"(баг → «баг», пожелание клиента → «Пожелание клиента» и т.п.); если "
"пользователь назвал шаблон — бери его, если неочевидно — спроси. "
"Составь черновик: столбец, шаблон, метки, название, описание (markdown). "
"Если у шаблона есть desc — повтори его структуру (те же заголовки "
"и порядок) и заполни. Метки и чек-листы шаблона перенесутся сами — "
"в описании их не дублируй.\n"
" Подбери метки через trello_list_labels:\n"
" - по смыслу — тематические: раздел/модуль продукта, интеграция, "
"тип задачи (bug…), приоритет (Minor/Major/Critical…); ставь только "
"уверенно подходящие, лучше меньше, чем лишние;\n"
" - ТОЛЬКО по указанию пользователя — метки с именами людей и "
"статусные/процессные (Доробити, READY TO FRONT, NEED_HOTFIX, "
"WAITING…, Перевірити на проді и т.п.);\n"
" - метки, которые назвал пользователь, ставь всегда.\n"
" Метки шаблона добавятся сами, их в labels не повторяй.\n"
"2. Покажи черновик пользователю в чате ДОСЛОВНО и спроси, "
"создавать ли. Правки пользователя вноси и показывай снова.\n"
"3. Вызывай тул только после явного «да» и передавай ровно "
"одобренный текст, ничего не меняя.\n"
"Тул дополнительно сам покажет пользователю окно подтверждения; "
"если он откажет — карточка не создастся (created=false), "
"не пытайся вызвать повторно без новой просьбы.\n\n"
"Разрешённые столбцы (list_name — строго одно из): "
+ (
", ".join(f"«{name}»" for name in _create_targets)
if _create_targets
else "НЕ НАСТРОЕНЫ (TRELLO_CREATE_LISTS пуст) — создание недоступно"
)
+ ". Если пользователь не указал столбец и вариантов несколько — "
"спроси.\n\n"
"Вход: list_name, name — название карточки, desc — описание, "
"template — название шаблона из trello_list_card_templates "
"(опционально, без шаблона — пустая карточка), labels — названия "
"меток из trello_list_labels (опционально).\n"
"Выход: {created, reason} при отказе или {created, list, template, "
"labels, id, name, url}."
)
)
async def trello_create_card(
list_name: str,
name: str,
desc: str,
ctx: Context,
template: str | None = None,
labels: list[str] | None = None,
) -> dict:
return await create_card_confirmed(ctx, list_name, name, desc, template, labels)
@mcp.tool(
description=(
"Публикует НОВЫЙ QA-документ по итогам работы и отдаёт стабильную "
"ссылку. Если для этой же карточки Trello в этом разговоре уже "
"публиковался QA-документ и известен его slug (из url вида "
".../docs/<slug>) — используй вместо этого update_qa_doc, чтобы "
"обновить существующую ссылку, а не плодить новую при каждом "
"повторном прогоне. Если документ не привязан к карточке Trello — "
"используй publish_standalone_qa_doc.\n\n"
"markdown должен следовать шаблону:\n\n"
f"{QA_DOC_TEMPLATE}\n\n"
"После публикации, если известен card_id карточки Trello, вызови "
'trello_add_comment(card_id, f"QA: {url}") с URL из ответа этого тула, '
"чтобы тестировщик увидел ссылку в комментарии к карточке.\n\n"
"Вход: title, markdown (по шаблону выше), card_url — опционально, "
"ссылка на исходную карточку Trello для шапки странички.\n"
"Выход: {slug, url}."
)
)
async def publish_qa_doc(
title: str, markdown: str, card_url: str | None = None
) -> dict:
return await qa_client.publish_doc(title, markdown, card_url)
@mcp.tool(
description=(
"Обновляет УЖЕ ОПУБЛИКОВАННЫЙ QA-документ на месте — тот же slug, "
"та же ссылка, тестировщику не нужно переходить по новой. Используй "
"вместо publish_qa_doc, когда повторно дорабатываешь ту же карточку "
"Trello в рамках этого разговора и знаешь slug из предыдущего "
"publish_qa_doc/update_qa_doc (это последний сегмент url — "
".../docs/<slug>).\n\n"
"markdown — по тому же шаблону, что и в publish_qa_doc:\n\n"
f"{QA_DOC_TEMPLATE}\n\n"
"Если slug не найден на сервере — вернётся ошибка 404; в этом "
"случае используй publish_qa_doc, чтобы опубликовать заново.\n\n"
"Чтобы поправить документ, которого нет в текущем разговоре, сначала "
"скачай его через get_qa_doc. Поля перезаписываются целиком: передай "
"card_url и problem заново (их отдаёт get_qa_doc), иначе они "
f"очистятся. {SCENARIOS_NOTE}\n\n"
"Вход: slug (или полная ссылка), title, markdown (по шаблону выше), "
"card_url и problem — опционально.\n"
"Выход: {slug, url}."
)
)
async def update_qa_doc(
slug: str,
title: str,
markdown: str,
card_url: str | None = None,
problem: str | None = None,
) -> dict:
return await qa_client.update_doc(slug, title, markdown, card_url, problem)
@mcp.tool(
description=(
"Публикует документ проверки, НЕ привязанный к карточке Trello, — "
"например, когда пользователь просит выгрузить проверку по проблеме, "
"для которой карточки нет. Описание проблемы выводится отдельным "
"блоком в шапке странички, поэтому в markdown его не дублируй.\n\n"
"markdown — по тому же шаблону, что и в publish_qa_doc, только без "
"ссылки на карточку в заголовке:\n\n"
f"{QA_DOC_TEMPLATE}\n\n"
"Обновлять такой документ — через update_qa_doc, передавая problem "
"заново.\n\n"
"Вход: title, problem — описание проблемы (что не так, где, как "
"воспроизводится; можно markdown), markdown (по шаблону выше).\n"
"Выход: {slug, url}."
)
)
async def publish_standalone_qa_doc(title: str, problem: str, markdown: str) -> dict:
return await qa_client.publish_doc(title, markdown, card_url=None, problem=problem)
@mcp.tool(
description=(
"Скачивает уже опубликованный QA-документ: исходный markdown, "
"метаданные и отметки тестировщиков по каждому сценарию.\n\n"
"Используй, когда нужно:\n"
"- поправить существующий документ — скачай, отредактируй markdown "
"и отправь через update_qa_doc с тем же slug (ссылка не изменится);\n"
"- узнать результаты проверки — какие сценарии тестировщик проверил, "
"какие работают, какие нет и что он написал в комментариях.\n\n"
"Вход: slug или полная ссылка на документ (.../docs/<slug>).\n"
"Выход: {slug, url, title, markdown, card_url, problem, created_at, "
"updated_at, scenarios}. scenarios — список в порядке документа: "
"{scenario — заголовок, checked — проверен ли, passed — true "
"работает / false не работает / null не проверен, comment — "
"комментарий тестировщика (что именно не работает), updated_at}."
)
)
async def get_qa_doc(slug: str) -> dict:
return await qa_client.get_doc(slug)
TASK_TARGETS = "front | back"
TASK_GUIDE = (
"markdown — в свободной форме, но пишется для ДРУГОЙ нейронки, у которой "
"нет этого разговора и, возможно, нет доступа к этому репозиторию. "
"Поэтому явно: контекст задачи и зачем это нужно; что именно сделать; "
"контракт на стыке (эндпоинты, методы, поля запроса/ответа с типами и "
"примерами, коды ошибок); что уже сделано на нашей стороне и где "
"(ветка/файлы); открытые вопросы. Заголовок в markdown не повторяй — "
"он берётся из title."
)
@mcp.tool(
description=(
"Публикует задание для фронта или бэка — документ для другой "
"нейронки (Claude на той стороне), которая будет его делать.\n\n"
"Когда: по ходу работы над карточкой Trello понял, что нужна "
"работа на другой стороне (например, бэку нужен эндпоинт от фронта "
"или наоборот). ПРЕДЛОЖИ пользователю одной фразой: что за задание "
"и кому, и что оставишь ссылку в карточке. Если согласился (даже "
"коротко: «да», «давай», «ок») — публикуй сразу, дословно текст "
"на одобрение показывать не нужно. Без согласия не публикуй.\n\n"
f"{TASK_GUIDE}\n\n"
"После публикации, если известен card_id карточки, оставь "
"комментарий через trello_add_comment — ровно три строки:\n"
"Задание для <фронта|бэка>: <title>\n<url>\n"
'Получить: curl -H "Authorization: Bearer $QA_TASKS_TOKEN" <url>\n\n'
"Если по этой же карточке задание для той же стороны уже "
"публиковалось (slug известен или находится через list_dev_tasks) — "
"обнови его через update_dev_task, а не плоди новое.\n\n"
f"Вход: target ({TASK_TARGETS}) — кому задание, title, markdown, "
"card_url — ссылка на карточку Trello, если есть.\n"
"Выход: {slug, url, title, target, card_url, created_at, updated_at}."
)
)
async def publish_dev_task(
target: Literal["front", "back"],
title: str,
markdown: str,
card_url: str | None = None,
) -> dict:
return await qa_client.publish_task(target, title, markdown, card_url)
@mcp.tool(
description=(
"Обновляет уже опубликованное задание для фронта/бэка на месте — "
"ссылка не меняется. Правила согласия те же, что у "
"publish_dev_task: предложил — пользователь согласился — обновляешь. "
"Если текущего текста нет в разговоре, сначала скачай его через "
"get_dev_task. Поля перезаписываются целиком.\n\n"
f"{TASK_GUIDE}\n\n"
f"Вход: slug (или ссылка), target ({TASK_TARGETS}), title, markdown, "
"card_url.\nВыход: как у publish_dev_task."
)
)
async def update_dev_task(
slug: str,
target: Literal["front", "back"],
title: str,
markdown: str,
card_url: str | None = None,
) -> dict:
return await qa_client.update_task(slug, target, title, markdown, card_url)
@mcp.tool(
description=(
"Читает задание для фронта/бэка по slug или ссылке вида "
".../tasks/<slug> — например, из комментария в карточке Trello. "
"Вызывай без спроса, это только чтение.\n\n"
"Выход: {slug, url, title, target, card_url, markdown, created_at, "
"updated_at}."
)
)
async def get_dev_task(slug: str) -> dict:
return await qa_client.get_task(slug)
@mcp.tool(
description=(
"Список заданий для фронта/бэка (без текста, новые первыми). "
"Используй, когда пользователь прислал карточку Trello — чтобы "
"проверить, нет ли по ней заданий для твоей стороны; или чтобы "
"найти slug уже опубликованного задания перед обновлением. "
"Вызывай без спроса, это только чтение.\n\n"
f"Вход: card_url — опционально, target ({TASK_TARGETS}) — опционально.\n"
"Выход: [{slug, url, title, target, card_url, created_at, updated_at}]."
)
)
async def list_dev_tasks(
card_url: str | None = None, target: Literal["front", "back"] | None = None
) -> list[dict]:
return await qa_client.list_tasks(card_url, target)
if __name__ == "__main__":
mcp.run()