Files
trello-mcp/server.py
T
maxerton b4cabbf146 trello-tools: плагин и маркетплейс gp-tools для Claude Code
Сервер запускается через uv run --script, ключи запрашиваются
при установке плагина (userConfig) вместо .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 14:14:29 +00:00

571 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# /// 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()