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:
244
docs/superpowers/specs/2026-09-05-hado-mcp-design.md
Normal file
244
docs/superpowers/specs/2026-09-05-hado-mcp-design.md
Normal 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-клиентов.
|
||||||
Reference in New Issue
Block a user