# 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 `: - пользователя создаёт через `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_at` — `YYYY-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_event`** — `id` + любое подмножество полей `create_event`. Только для `source = manual`; иначе ошибка «событие источника docs, правит только docs». Реализация: текущее событие → слить поля → `upsert` с тем же `(manual, source_ref, topic)`. Семантика §5 сохраняется автоматически: новый `due_at` — это перенос, сбрасывает `quiet_until` и пересчитывает состояние даже из терминального; тот же `due_at` — обновление полей, терминальное не трогается. Описание тула говорит это модели прямо. **`delete_event`** — `id`. Только `manual`; `EventUpserter::withdraw`, состояние `withdrawn`. Возвращает `{ "id", "state": "withdrawn" }`. ### Действия пользователя (любой источник) **`mark_done`** — `id`, `EventActions::done`. Терминальное — no-op, возвращает как есть. **`mark_ack`** — `id`, `EventActions::ack`. «Помню»: молчать до следующей контрольной точки. Терминальное — no-op. ### Профиль **`get_profile`** → `{ "login", "tz", "quiet_start", "quiet_end" }`. **`update_profile`** — `tz` (валидный идентификатор зоны), `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-клиентов.