Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
15 KiB
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_hashnullable. Отдельная таблица: строка есть — токен есть.- Токен в query-строке (
/mcp?token=). Попадает в логи прокси; Bearer стандартен для MCP-клиентов.