Блог

Как построить кастомные MCP-инструменты для управления проектами: 3 реальных примера с кодом

Лукман Нуриахметов
Лукман Нуриахметов· Основатель и CTO
mcpautomationintegrations

Большинство гайдов «постройте свои MCP-инструменты» останавливаются на обёртке над API: выставили create_issue, выставили list_issues, готово. Это коннектор, а не инструмент. Полезный кастомный MCP-инструмент объединяет два-три примитива, которые уже даёт трекер, в то, что раньше приходилось делать руками.

Вот три инструмента, которые можно построить прямо сегодня, каждый собран из инструментов, которые трекер уже выставляет через MCP, плюс то, что стоит подключать рядом.

Что подключать рядом с трекером, а не вместо него

Прежде чем строить что-то своё, полезно знать, что уже решено. Три MCP-сервера постоянно всплывают в реальных инженерных процессах, и ни один из них не конкурирует с трекером, они встают рядом:

  • [GitHub MCP Server](https://github.com/github/github-mcp-server) (официальный): напрямую выставляет состояние репозитория, PR, коммиты, прогоны CI, поэтому кастомному инструменту не нужен собственный GitHub-клиент, чтобы ответить на вопрос «этот PR уже зелёный».
  • [code-review-mcp](https://github.com/praneybehl/code-review-mcp) (open source): берёт дифф застейдженных изменений или сравнение веток и прогоняет их через модель на выбор, с параметрами фокуса ревью и контекста проекта.
  • Sequential Thinking MCP: превращает размытое суждение вроде «насколько сложно это изменение» в явную цепочку шагов вместо одного ответа из чёрного ящика.

Три инструмента ниже комбинируют это с собственным MCP-набором трекера (в примерах это TAM), чтобы делать то, что ни один из них по отдельности не умеет.

Инструмент 1: дайджест статус-апдейта, который читает реальное состояние вместо того, чтобы просить людей его печатать

Стандартная версия этой идеи просто суммирует переписку в Slack. Более полезная версия берёт данные из двух источников, которые реально отражают правду: что изменилось в трекере и что произошло в репозитории.

server.tool(
  "standup_digest",
  { sinceHours: z.number().default(24) },
  async ({ sinceHours }) => {
    const since = new Date(Date.now() - sinceHours * 3600_000);

    const { issues } = await tam.issueList({ status: "in_progress" });

    const perIssue = await Promise.all(
      issues.map(async (issue) => {
        const { activity } = await tam.issueActivityList({ issueId: issue.id });
        const recent = activity.filter((a) => new Date(a.createdAt) >= since);
        const { pullRequests } = await tam.githubPrStatus({ issueId: issue.id });
        return { issue, recent, pullRequests };
      })
    );

    return formatDigest(perIssue.filter((entry) => entry.recent.length > 0));
  }
);

tam.issue.list возвращает { issues }, а не голый массив, а tam.issue.activity.list работает по одной задаче за раз, единой ленты «всё за последние X часов» по всем задачам сразу нет, поэтому дайджест начинается с tam.issue.list за тем, что реально в работе, а затем берёт активность по каждой задаче отдельно и фильтрует её по временному окну уже на своей стороне. tam.github.pr.status принимает ID задачи напрямую (без отдельного поиска по URL PR) и возвращает pullRequests, поэтому статус PR едет вместе с тикетом, которому он принадлежит. Никому не нужно помнить, что нужно написать апдейт. Апдейт это запрос, а не отчёт, который кто-то пишет руками.

Инструмент 2: напоминание о зависших PR, которое смотрит и на трекер, а не только на GitHub

«PR открыт больше 48 часов» легко определить по одному GitHub. Это худший сигнал, чем кажется, потому что PR может специально висеть открытым, пока кто-то ждёт архитектурного решения. Полезная версия сначала сверяется с состоянием самого трекера, прежде чем кого-то дёргать.

server.tool(
  "flag_stale_prs",
  { hoursThreshold: z.number().default(48) },
  async ({ hoursThreshold }) => {
    const { issues } = await tam.issueList({ status: "in_progress" });

    for (const issue of issues) {
      const { pullRequests } = await tam.githubPrStatus({ issueId: issue.id });
      const openPr = pullRequests.find((pr) => pr.state === "open");
      if (!openPr) continue;

      const hoursSinceActivity = (Date.now() - new Date(openPr.updatedAt).getTime()) / 3600_000;
      if (hoursSinceActivity < hoursThreshold) continue;

      // Бакет по дню: повтор в рамках одного запуска использует тот же ключ
      // и схлопывается, а завтрашний прогон (если PR всё ещё висит) получает
      // новый ключ и не блокируется дедупликацией навсегда.
      const dayBucket = new Date().toISOString().slice(0, 10);
      await tam.issueAddProgress({
        issueId: issue.id,
        content: `No PR activity for ${Math.round(hoursSinceActivity)}h. Flagging for review.`,
        idempotencyKey: `stale-pr-${issue.id}-${dayBucket}`
      });
    }
  }
);

Это срабатывает только для задач, которые сам трекер всё ещё считает активной работой: через tam.issue.list со скоупом in_progress, сверенных с tam.github.pr.status по собственным полям state и updatedAt открытого PR, а напоминание логируется обратно через tam.issue.add_progress, видно прямо в тикете, а не теряется в треде Slack, который никто заново не откроет.

Инструмент 3: оценка story points, которая показывает свою логику и сразу её записывает

Оценка story points одним вызовом LLM в чате даёт число без всякой видимой логики, именно поэтому ему не доверяют. Если сначала прогнать дифф через структурированное рассуждение, а потом сразу записать результат в трекер, обе проблемы решаются одновременно.

server.tool(
  "estimate_story_points_from_diff",
  { issueId: z.string(), storyPointsFieldId: z.string(), diff: z.string() },
  async ({ issueId, storyPointsFieldId, diff }) => {
    const reasoning = await sequentialThinking.run({
      steps: ["identify changed modules", "assess test coverage impact", "flag cross-service risk"],
      input: diff
    });

    const points = scoreFromReasoning(reasoning); // ваша шкала Фибоначчи

    // expectedUpdatedAt: null означает только «значения ещё нет». Если этот
    // тикет уже оценивали раньше, запись требует updatedAt того значения,
    // иначе TAM отклонит её как конфликтующую перезапись.
    const { values } = await tam.customFieldValueList({ issueId });
    const existing = values.find((v) => v.fieldId === storyPointsFieldId);

    await tam.customFieldValueSet({
      issueId,
      fieldId: storyPointsFieldId, // берётся из tam.custom_field.definition.list
      value: points,
      expectedUpdatedAt: existing?.updatedAt ?? null,
      // Стабилен при повторе одного и того же диффа: сетевой ретрай схлопывается
      // в одну запись вместо конфликта с проверкой версии выше, а по-настоящему
      // другой дифф (новые коммиты) хэшируется в новый ключ и переоценивается заново.
      idempotencyKey: `estimate-${issueId}-${hashDiff(diff)}`
    });

    return { points, reasoning };
  }
);

Шаг Sequential Thinking превращает «модель сказала 5» в оценку с видимой цепочкой рассуждений за ней. tam.custom_field.value.set принимает поле по ID, а не по имени, и требует ту же проверку ожидаемой версии, что и любая другая запись в TAM: null работает только для поля, которое ещё ни разу не задавали, поэтому переоценка уже оценённого тикета означает сначала прочитать его текущее значение.

Паттерн, который стоит за всеми тремя

Каждый из этих инструментов устроен одинаково: прочитать реальное состояние трекера, объединить его с чем-то вне трекера (git, шаг рассуждения, результат скана), затем записать итог обратно через собственные инструменты трекера, а не в отдельный канал. Это тот же набор примитивов, из которого построен внутренний агент-фасилитатор TAM @tam и поверх которого он выходит: активность по каждой задаче, статус привязанного PR и кастомные поля, уже доступные для чтения и записи через MCP сегодня. Построить кастомный инструмент поверх TAM не значит построить альтернативу тому, что TAM уже делает. Это значит написать более узкую, специфичную для вашей команды версию того же самого, пока встроенная выходит.

Если у вас ещё не настроено MCP-подключение, которое предполагают эти примеры, подключение Cursor или Claude Code к TAM это отправная точка, а построение MCP-сервера с нуля разбирает вопросы скоупинга и безопасности, на которые эти три инструмента опираются, как только их начинает вызывать больше одного агента.