# /// 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-plugin/plugin.json передаёт поля userConfig # в env сервера как CLAUDE_PLUGIN_OPTION_ (незаполненные — пустой # строкой). Пустые пропускаем, уже заданные переменные (например, # 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/ — это задание для фронта/бэка от другой сессии " "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/) — используй вместо этого 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/).\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/).\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" "Задание для <фронта|бэка>: \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()