MCP-сервер не должен работать на общем API-ключе

ai-agents

Представьте пост на доске обратной связи, который за ночь переехал в колонку Declined. В истории написано, что это сделал проект.

Никто в команде этого не помнит. Это сделал чей-то ассистент на MCP-ключе, который все скопировали с одной и той же страницы настроек, и чтобы выяснить чей, придётся опросить всех по очереди.

Так сейчас устроено большинство MCP-серверов: они позаимствовали модель API-ключей. В Kookee я только что заменил её личными токенами. Ниже — почему общий ключ не годится, что я сделал вместо него и какие детали оказались важными.

Общий ключ — привычка из мира API-ключей

API-ключ идентифицирует приложение. Одно приложение — один ключ, и ключ умеет только то, что нужно приложению: в публичном API Kookee — читать опубликованный контент. Для приложений такая модель годится.

MCP-сервер подключает другое — ассистента конкретного человека, который по его указаниям что-то меняет. Дайте ему ключ проекта, и сломаются сразу три вещи.

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

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

Ключ не отозвать у одного человека. Когда кто-то уходит из команды, отключить его ассистента можно, только заменив ключ, а это ломает настройку у всех остальных. Поэтому на практике ключ никто не меняет.

А ключ, который копируют все, оказывается везде: в дотфайлах, в закоммиченном .mcp.json, на скриншоте в переписке с поддержкой.

Что я сделал: один токен на человека и проект

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

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

По умолчанию выбрано «Только чтение». Доступ на запись — отдельный осознанный клик.

Шесть деталей, от которых зависит, сработает ли это

1. «Только чтение» значит, что инструментов записи нет

Очевидная реализация отказывает в записи в момент вызова. Но инструмент всё равно на виду: модель видит update_feedback в списке инструментов, строит вокруг него целый разбор обратной связи и на последнем шаге получает ошибку, которую потом пытается обойти.

Поэтому в сессии только для чтения инструменты записи вообще не регистрируются, а в инструкциях сервера нет ни одного сценария записи — вместо них сказано, как получить доступ на запись. В общих чертах:

TypeScript
registerReadTools(server, auth);
if (auth.token.canWrite) {
  registerWriteTools(server, auth);
}

Тогда ассистент прямо говорит пользователю, что его токен только для чтения, а не импровизирует.

2. Проверять человека за токеном при каждом запросе

Токен живёт дольше, чем причина, по которой его выдали. Поэтому проверка не заканчивается на «хеш совпал»: сервер загружает участника, которому принадлежит токен, и проверяет, что его аккаунт активен, проект не заблокирован, а сам он всё ещё в проекте.

TypeScript
if (!plain.startsWith('kkm_')) return fail('not_mcp_token');
const token = await findTokenByHash(sha256(plain));
if (!token) return fail('not_found');
if (!token.user.isActive) return fail('inactive');
if (token.project.bannedAt) return fail('banned');
if (!(await isMember(token.projectId, token.userId))) return fail('not_member');

При удалении или блокировке участника его токены тоже удаляются — в той же транзакции, что и изменение его участия в проекте. Доступ ему закрывает уже проверка на каждом запросе, а удаление строк нужно, чтобы список токенов не врал.

3. Хранить хеш, показывать один раз, давать префикс

Хранится только SHA-256-хеш токена. Сам токен показывается один раз, в диалоге создания, а дальше в списке видны только последние четыре символа.

У префикса две задачи. kkm_ отличается от kk_ у API-ключа, так что сканер секретов или человек сразу видит, что именно утекло. И по префиксу сервер может вернуть полезную ошибку: на API-ключ, отправленный на MCP-эндпоинт, он отвечает MCP_PERSONAL_TOKEN_REQUIRED, и человеку понятно, что делать. На любую другую ошибку приходит один общий MCP_TOKEN_INVALID, чтобы перебором нельзя было выяснить, почему токен отклонён; точная причина попадает только в лог сервера.

4. Показывать авторство там, куда смотрят люди

Журнал аудита — место, куда ответственность отправляют, чтобы о ней забыть. Я пишу туда каждое изменение через MCP, но важная часть на виду: каждая операция записи сохраняет id токена вместе с событием в истории или комментарием, который она создаёт, и панель управления помечает их через MCP.

Пост обратной связи: ассистент перенёс его в Planned, назначил ответственного и ответил на него; каждое изменение отмечено как сделанное через MCP

Инструмент истории, который читает ассистент, отдаёт тот же признак, так что на вопрос «кто перенёс это в Planned и был ли это ассистент?» есть ответ с обеих сторон. Публичный API комментариев этот признак убирает: посетители видят ответ команды, а не id токена.

5. Ограничивать частоту записи по человеку и инструменту

Агент, который вышел из-под контроля и повторяет неудачную правку, может делать сотни вызовов в минуту. Лимит записи на весь проект заблокировал бы коллег, которые ничего не нарушали, а лимит на токен обходится созданием второго токена.

Поэтому инструменты записи ограничены для каждого участника и каждого инструмента отдельно: 60 вызовов в минуту к update_feedback и отдельные 60 к create_comment. Общее окно лимита MCP для проекта тоже отделено от окна публичного API, так что агент, который рассылает запросы веером, не исчерпает лимит вашего сайта для клиентов.

6. Не хранить секреты в общем конфиге

Команде нужен один закоммиченный .mcp.json, чтобы у всех была одинаковая настройка. Но раз токены личные, ни одного из них в этом файле быть не должно. Вместо этого файл берёт токен из окружения каждого человека:

JSON
{
  "mcpServers": {
    "kookee": {
      "type": "http",
      "url": "https://api.kookee.dev/api/mcp",
      "headers": { "Authorization": "Bearer ${KOOKEE_MCP_TOKEN}" }
    }
  }
}

Каждый участник записывает в KOOKEE_MCP_TOKEN свой токен. Команда для Claude Code, которую показывает панель управления, делает то же через $KOOKEE_MCP_TOKEN — кроме момента сразу после создания токена: тогда она подставляет его за вас.

Если у вас свой MCP-сервер

  • Выдавайте доступы на человека и на рабочее пространство. MCP-клиент — это агент конкретного человека, а не приложение.

  • Делайте «только чтение» режимом по умолчанию и не регистрируйте инструменты, которые токену недоступны.

  • Перепроверяйте человека за токеном при каждом запросе: аккаунт, участие, блокировку.

  • Храните хеши, показывайте токены один раз и давайте им собственный префикс.

  • Отличайте «не тот тип доступа» от «недействителен» и больше ничего не сообщайте.

  • Записывайте токен с каждым изменением и показывайте его там, где люди читают историю.

  • Ограничивайте частоту записи по человеку и инструменту, отдельно от публичного API.

  • Не держите секреты в общем конфиге клиента: берите их из переменных окружения.


MCP-сервер Kookee теперь работает на личных токенах, а ассистенты умеют читать и разбирать доску обратной связи. Настройка описана в статье Управление контентом из ИИ-ассистента (MCP), новые инструменты — в статье Разбор обратной связи из ИИ-ассистента.