diff --git a/docs/superpowers/specs/2026-09-05-hado-mcp-design.md b/docs/superpowers/specs/2026-09-05-hado-mcp-design.md new file mode 100644 index 0000000..b82b68e --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-hado-mcp-design.md @@ -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 `: + +- пользователя создаёт через `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-клиентов.