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

257 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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_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-клиентов.