Files
hado/docs/superpowers/specs/2026-09-05-hado-mcp-design.md

16 KiB
Raw Blame History

Hado MCP — агент как хозяин инбокса

Дата: 2026-09-05. Статус: реализована (laravel/mcp 0.9.4). Дополняет 2026-09-03-hado-core-design.md: добавляет четвёртый вход в §11 и новый путь в §12. Ядро, модель данных и контракт клиентов не меняются.

1. Что это и зачем

MCP-сервер внутри ядра, через который нейросеть (Claude Code, claude.ai, любой MCP-клиент) работает с инбоксом пользователя: ставит напоминания, смотрит, что висит, жмёт «Сделано» и «Помню», правит и снимает свои. REST /api/* для этого не годится: он рассчитан на сервис-источник с сущностью в его базе, а не на разговор «поставь напоминание про визу 14-го».

Принципы:

  • Агент — это пользователь, не источник. Он действует от лица владельца токена: видит все события пользователя, от какого бы источника они ни пришли.
  • Свои события агент создаёт от источника manual — того же, что кнопка «Добавить» в инбоксе. Для пользователя «сказал Клоду» и «вбил руками» — одно и то же, и агент может править то, что пользователь вчера добавил сам.
  • Чужие источники — только чтение. docs пушит и снимает свои события сам; агент их видит и может закрыть («Сделано»/«Помню» — действия пользователя, как кнопки в инбоксе), но не правит и не снимает. Иначе следующая синхронизация источника молча перезапишет правку.
  • Ни строчки дублирующей логики. Тулы вызывают те же EventUpserter и EventActions, что инбокс и /api. MCP — тонкий слой над ядром.

2. Границы

В этой спеке: пакет laravel/mcp, маршрут /mcp, токен агента, набор тулов, изменения в Caddy.

Вне спеки: управление каналами через агента (telegram/webhook подключаются из инбокса), выдача токена из веб-морды (пока artisan), OAuth, ресурсы и промпты MCP — только тулы.

3. Подход

Официальный laravel/mcp, сервер живёт в приложении. Отвергнут отдельный Node-сервис по образцу enso_mcp: второй контейнер, второй набор валидации, вызов ядра через HTTP с чужим паролем в env, токен в query-строке.

Модуль app/Mcp/:

app/Mcp/HadoServer.php            — сервер: имя, instructions, список тулов
app/Mcp/Tools/AgentTool.php       — база: пользователь токена, своё событие по id
app/Mcp/Tools/ListEvents.php
app/Mcp/Tools/GetEvent.php
app/Mcp/Tools/CreateEvent.php     — схема и форма полей события, общие с UpdateEvent
app/Mcp/Tools/UpdateEvent.php
app/Mcp/Tools/DeleteEvent.php
app/Mcp/Tools/MarkDone.php
app/Mcp/Tools/MarkAck.php
app/Mcp/Tools/GetProfile.php
app/Mcp/Tools/UpdateProfile.php
app/Http/Middleware/AuthenticateAgent.php
app/Models/AgentToken.php
app/Console/Commands/AgentTokenCommand.php
routes/ai.php                     — Mcp::web('/mcp', HadoServer::class)

Общее с инбоксом и /api живёт вне app/Mcp/ — это те же классы, что используют контроллеры, не копии:

app/Presenters/EventPresenter.php — forClient (/api, webhook), forUser (+source, editable), closedAs, dueAt
app/Presenters/ProfilePresenter.php
app/Ingest/ManualEvents.php       — create / update / withdraw событий источника manual
app/Ingest/EventRules.php         — правила одного события (§5): и для пачки /api, и для manual
app/Users/ProfileUpdater.php      — пояс и тихие часы: PATCH /me и update_profile
app/Models/User.php               — ownEvent(id), liveEvents(states), archive()

Риск: пакет должен встать на Laravel 13 (laravel/framework ^13.17). Проверяется первым шагом реализации; если не встаёт — стоп и возврат к обсуждению, а не самодельный транспорт.

4. Доступ: токен агента

Четвёртая строка в §11 основной спеки:

вход маршруты авторизация
агент /mcp Bearer-токен агента; привязан к пользователю, в базе хэш

Таблица agent_tokens:

поле тип смысл
id bigint
user_id fk users, unique один токен на пользователя
token_hash string(64), unique sha256, как у sources.token_hash
created_at, updated_at

Один токен на пользователя — этого достаточно: пользователь один, клиентов у него может быть несколько, но все они его. Второй токен — это новая задача, не nullable-поле впрок.

Выдача — php artisan hado:agent:token <login>:

  • пользователя создаёт через EventUpserter::ensureUser (тот же путь, что X-Remote-User), чтобы токен можно было выдать до первого захода в инбокс;
  • если токен уже есть — перевыпускает: старая строка удаляется, печатается новый. Старый токен перестаёт работать сразу;
  • --revoke — удаляет строку, ничего не печатает;
  • формат hado_agent_<64 hex>, печатается один раз, как у hado:source:create.

Middleware AuthenticateAgent (алиас auth.agent): читает bearerToken(), ищет AgentToken по хэшу, кладёт $request->attributes['user']тот же атрибут, что ставит RemoteUser, поэтому тулы и контроллеры инбокса читают пользователя одинаково. Нет токена → 401 «Нужен Bearer-токен агента», неизвестный → 401 «Неизвестный токен агента». Ответы JSON.

Маршрут /mcp — публичный в смысле §11: без сессии, без CSRF, throttle:60,1, мимо forward_auth. X-Remote-User на нём игнорируется.

5. Тулы

Все тулы работают в рамках пользователя токена. Событие ищется как $user->events()->whereKey($id) — чужой id даёт «не найдено», а не 403, чтобы не подтверждать существование.

Даты — в формах §5 основной спеки: due_atYYYY-MM-DD, YYYY-MM-DDTHH:MM или со смещением; fire_at — только YYYY-MM-DD. Описания тулов (то, что видит модель) повторяют правило: смещение только если событие привязано к месту.

Чтение

list_events — инбокс.

аргумент тип смысл
state enum, optional одно из scheduled/preparing/today/overdue — фильтр по активным
archive bool, default false вместо активных — закрытые (done, expired), последние 100

Без аргументов — все нетерминальные, порядок по сроку, как GET /me/events. Ответ: { "events": [ …представление §6… ] }.

get_event — одно событие по id, представление §6.

Свои события (источник manual)

create_event

аргумент тип смысл
title string ≤200, required
due_at string, required срок, три формы
subtitle string ≤200, optional
fire_at date, optional день старта подготовки; по умолчанию — за 30 дней до дня срока, как у кнопки инбокса
after_due keep/expire, default keep как у кнопки инбокса
done_label string ≤64, optional подпись кнопки, по умолчанию «Сделано»; лимит тот же, что у клиентов
deep_link string, optional куда вести из напоминания

Реализация: Source::firstOrCreate(['name' => 'manual'])EventUpserter::upsert с source_ref = 'manual:'.ulid, topic = '' — один в один Me\EventsController::store, общий код выносится в App\Ingest\ManualEvents и используется обоими. Возвращает представление §6 созданного события. Валидация — та же, что у upsert (fire_at не позже дня срока и т.д.), ошибка 422 возвращается агенту текстом.

update_eventid + любое подмножество полей create_event.

Только для source = manual; иначе ошибка «событие источника docs, правит только docs». Реализация: текущее событие → слить поля → upsert с тем же (manual, source_ref, topic). Семантика §5 сохраняется автоматически: новый due_at — это перенос, сбрасывает quiet_until и пересчитывает состояние даже из терминального; тот же due_at — обновление полей, терминальное не трогается. Описание тула говорит это модели прямо.

delete_eventid. Только manual; EventUpserter::withdraw, состояние withdrawn. Возвращает { "id", "state": "withdrawn" }.

Действия пользователя (любой источник)

mark_doneid, EventActions::done. Терминальное — no-op, возвращает как есть.

mark_ackid, EventActions::ack. «Помню»: молчать до следующей контрольной точки. Терминальное — no-op.

Профиль

get_profile{ "login", "tz", "quiet_start", "quiet_end" }.

update_profiletz (валидный идентификатор зоны), quiet_start, quiet_end (HH:MM), любое подмножество. Те же правила, что PATCH /me.

6. Представление события для агента

ApiPresenter::present($event) плюс поля, которые нужны модели для решений:

поле смысл
source имя источника
editable true, если source = manual — можно update_event/delete_event
closed_as только в архиве: «Истекло» или done_label

editable — явный флаг, а не «пусть модель сама сравнит source со строкой».

7. Инструкции сервера

HadoServer::$instructions — короткий текст для модели: что такое состояния, что «Помню» ≠ «Сделано», что чужие события только читаются и закрываются, правило про смещение в due_at, что повторяющееся — отдельные события. Это те же абзацы, что в §1 и §5 основной спеки, сжатые до ~15 строк.

8. Подключение клиента

claude mcp add --transport http hado https://hado.kurotsuki.co/mcp \
  --header "Authorization: Bearer hado_agent_…"

Транспорт — streamable HTTP, POST /mcp; GET/DELETE пакет отвечает 405.

9. Деплой

  • Caddy: @public path /api/* /a/* /hooks/* /mcp /up/mcp в публичный матчер.
  • bootstrap/app.php: mcp в исключения CSRF и в shouldRenderJsonWhen; routes/ai.php регистрируется рядом с api.
  • Новых сервисов в compose нет.

10. Тесты

Feature-тесты через тестовый транспорт laravel/mcp (проверить имя хелпера в пакете при реализации):

  • middleware: без токена 401, с неизвестным 401, с валидным — тулы видят пользователя токена;
  • create_event создаёт событие manual у правильного пользователя, дефолты fire_at/after_due совпадают с кнопкой инбокса;
  • update_event: своё — правится, чужое (docs) — ошибка и событие не изменилось; смена due_at сбрасывает состояние из done;
  • delete_event: своё → withdrawn, чужое → ошибка;
  • mark_done/mark_ack работают на событии любого источника; чужой id (другого пользователя) → «не найдено»;
  • list_events: активные по умолчанию, state, archive; editable выставлен верно;
  • hado:agent:token: печатает токен, перевыпуск инвалидирует старый, --revoke.

Существующие тесты ядра не меняются: Me\EventsController::store после выноса в ManualEvents проходит те же проверки.

11. Отвергнутые варианты

  • Отдельный Node MCP как enso_mcp. См. §3.
  • Правка и снятие чужих событий агентом. Источник перезапишет при следующей синхронизации, правка живёт до первого пуша. Если понадобится — это новый путь в ядре (прямое изменение мимо upsert) и отдельная спека.
  • Отдельный источник agent вместо manual. Разница между «сказал Клоду» и «нажал кнопку» для пользователя не существует, а агент терял бы право править события, добавленные из инбокса.
  • users.mcp_token_hash nullable. Отдельная таблица: строка есть — токен есть.
  • Токен в query-строке (/mcp?token=). Попадает в логи прокси; Bearer стандартен для MCP-клиентов.