docs: hado MCP design spec — agent as inbox owner via laravel/mcp

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-05 05:49:36 -03:00
parent b0b760e4aa
commit 2ec71ab710

View File

@@ -0,0 +1,244 @@
# Hado MCP — агент как хозяин инбокса
Дата: 2026-09-05. Статус: согласован в брейншторме, ждёт ревью.
Дополняет `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/ListEvents.php
app/Mcp/Tools/GetEvent.php
app/Mcp/Tools/CreateEvent.php
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/Mcp/EventPresenter.php — представление события для агента (§6)
app/Http/Middleware/AuthenticateAgent.php
app/Models/AgentToken.php
app/Console/Commands/AgentToken.php
routes/ai.php — Mcp::web('/mcp', HadoServer::class)
```
Риск: пакет должен встать на 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_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 ≤40, 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-клиентов.