diff --git a/README.md b/README.md index 5f571f0..18c79b7 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,20 @@ docker compose exec app php artisan hado:source:create docs # токен пе `curl "https://api.telegram.org/bot/setWebhook?url=https://hado.<домен>/hooks/telegram&secret_token="` 3. В инбоксе «Подключить Telegram» → отправь боту `/start <код>`. +## MCP (агент) + +Нейросеть работает с инбоксом через MCP-сервер внутри ядра (`laravel/mcp`, `POST /mcp`). +Спека: `docs/superpowers/specs/2026-09-05-hado-mcp-design.md`. + +```bash +docker compose exec app php artisan hado:agent:token nikita # токен печатается один раз; повторный вызов перевыпускает, --revoke отзывает +claude mcp add --transport http hado https://hado.<домен>/mcp --header "Authorization: Bearer hado_agent_…" +``` + +Агент видит все события пользователя и закрывает любые (`mark_done`, `mark_ack`), а создаёт, +правит и снимает только свои — от источника `manual`, как кнопка «Добавить» в инбоксе +(`editable: true` в ответах). + ## Home Assistant В инбоксе добавь webhook-канал: `deliver_url` — HA webhook-триггер diff --git a/core/app/Http/Controllers/InboxController.php b/core/app/Http/Controllers/InboxController.php index 05c592f..3ad7317 100644 --- a/core/app/Http/Controllers/InboxController.php +++ b/core/app/Http/Controllers/InboxController.php @@ -4,11 +4,11 @@ namespace App\Http\Controllers; use App\Enums\EventState; use App\Http\Controllers\Me\ChannelsController; -use App\Http\Controllers\Me\ProfileController; use App\Ingest\ManualEvents; use App\Models\Event; use App\Models\User; use App\Presenters\EventPresenter; +use App\Presenters\ProfilePresenter; use App\Scheduling\DueResolver; use Carbon\CarbonImmutable; use Illuminate\Contracts\View\View; @@ -45,7 +45,7 @@ class InboxController extends Controller 'closed_as' => EventPresenter::closedAs($e), ])->values()->all(); - $profile = ProfileController::present($user); + $profile = ProfilePresenter::present($user); return view('inbox', [ 'profile' => $profile, diff --git a/core/app/Http/Controllers/Me/ProfileController.php b/core/app/Http/Controllers/Me/ProfileController.php index c2fe1a7..2036605 100644 --- a/core/app/Http/Controllers/Me/ProfileController.php +++ b/core/app/Http/Controllers/Me/ProfileController.php @@ -2,21 +2,22 @@ namespace App\Http\Controllers\Me; -use App\Enums\EventState; use App\Http\Controllers\Controller; use App\Models\User; +use App\Presenters\ProfilePresenter; +use App\Users\ProfileUpdater; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Cache; -use Illuminate\Validation\Rule; -use Illuminate\Validation\ValidationException; class ProfileController extends Controller { + public function __construct(private readonly ProfileUpdater $profile) {} + public function show(Request $request): JsonResponse { - return response()->json(self::present($request->attributes->get('user'))); + return response()->json(ProfilePresenter::present($request->attributes->get('user'))); } public function update(Request $request): JsonResponse @@ -24,27 +25,7 @@ class ProfileController extends Controller /** @var User $user */ $user = $request->attributes->get('user'); - $data = $request->validate([ - 'tz' => ['sometimes', 'string', Rule::in(\DateTimeZone::listIdentifiers())], - 'quiet_start' => ['sometimes', 'date_format:H:i'], - 'quiet_end' => ['sometimes', 'date_format:H:i'], - ]); - - $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5); - $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5); - if ($end >= $start) { - throw ValidationException::withMessages([ - 'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start', - ]); - } - - $user->fill([ - 'tz' => $data['tz'] ?? $user->tz, - 'quiet_start' => $start.':00', - 'quiet_end' => $end.':00', - ])->save(); - - return response()->json(self::present($user)); + return response()->json(ProfilePresenter::present($this->profile->update($user, $request->all()))); } public function heartbeat(Request $request): Response @@ -55,16 +36,4 @@ class ProfileController extends Controller return response()->noContent(); } - - /** @return array */ - public static function present(User $user): array - { - return [ - 'login' => $user->login, - 'tz' => $user->tz, - 'quiet_start' => substr($user->quiet_start, 0, 5), - 'quiet_end' => substr($user->quiet_end, 0, 5), - 'badge' => $user->events()->whereIn('state', EventState::badgeValues())->count(), - ]; - } } diff --git a/core/app/Mcp/HadoServer.php b/core/app/Mcp/HadoServer.php index 8181308..f82eb3b 100644 --- a/core/app/Mcp/HadoServer.php +++ b/core/app/Mcp/HadoServer.php @@ -36,5 +36,7 @@ class HadoServer extends Server Tools\DeleteEvent::class, Tools\MarkDone::class, Tools\MarkAck::class, + Tools\GetProfile::class, + Tools\UpdateProfile::class, ]; } diff --git a/core/app/Mcp/Tools/GetProfile.php b/core/app/Mcp/Tools/GetProfile.php new file mode 100644 index 0000000..b39452d --- /dev/null +++ b/core/app/Mcp/Tools/GetProfile.php @@ -0,0 +1,20 @@ +respond(ProfilePresenter::present($this->user())); + } +} diff --git a/core/app/Mcp/Tools/UpdateProfile.php b/core/app/Mcp/Tools/UpdateProfile.php new file mode 100644 index 0000000..8d3b889 --- /dev/null +++ b/core/app/Mcp/Tools/UpdateProfile.php @@ -0,0 +1,32 @@ + $schema->string()->description('Идентификатор зоны, напр. Europe/Belgrade'), + 'quiet_start' => $schema->string()->description('HH:MM, начало тихих часов'), + 'quiet_end' => $schema->string()->description('HH:MM, конец тихих часов'), + ]; + } + + public function handle(Request $request): ResponseFactory + { + return $this->respond(ProfilePresenter::present($this->profile->update($this->user(), $request->all()))); + } +} diff --git a/core/app/Presenters/ProfilePresenter.php b/core/app/Presenters/ProfilePresenter.php new file mode 100644 index 0000000..727d352 --- /dev/null +++ b/core/app/Presenters/ProfilePresenter.php @@ -0,0 +1,22 @@ + */ + public static function present(User $user): array + { + return [ + 'login' => $user->login, + 'tz' => $user->tz, + 'quiet_start' => substr($user->quiet_start, 0, 5), + 'quiet_end' => substr($user->quiet_end, 0, 5), + 'badge' => $user->events()->whereIn('state', EventState::badgeValues())->count(), + ]; + } +} diff --git a/core/app/Users/ProfileUpdater.php b/core/app/Users/ProfileUpdater.php new file mode 100644 index 0000000..ee5cc61 --- /dev/null +++ b/core/app/Users/ProfileUpdater.php @@ -0,0 +1,42 @@ + $input любое подмножество tz, quiet_start, quiet_end (HH:MM) + * + * @throws ValidationException + */ + public function update(User $user, array $input): User + { + $data = Validator::validate($input, [ + 'tz' => ['sometimes', 'string', Rule::in(\DateTimeZone::listIdentifiers())], + 'quiet_start' => ['sometimes', 'date_format:H:i'], + 'quiet_end' => ['sometimes', 'date_format:H:i'], + ]); + + $start = $data['quiet_start'] ?? substr($user->quiet_start, 0, 5); + $end = $data['quiet_end'] ?? substr($user->quiet_end, 0, 5); + if ($end >= $start) { + throw ValidationException::withMessages([ + 'quiet_end' => 'Тихие часы должны переходить через полночь: quiet_end раньше quiet_start', + ]); + } + + $user->fill([ + 'tz' => $data['tz'] ?? $user->tz, + 'quiet_start' => $start.':00', + 'quiet_end' => $end.':00', + ])->save(); + + return $user; + } +} diff --git a/core/tests/Feature/McpAuthTest.php b/core/tests/Feature/McpAuthTest.php index 7ebdeeb..a5435fd 100644 --- a/core/tests/Feature/McpAuthTest.php +++ b/core/tests/Feature/McpAuthTest.php @@ -36,4 +36,18 @@ class McpAuthTest extends TestCase ->assertOk() ->assertJsonPath('result.serverInfo.name', 'Hado'); } + + public function test_lists_all_tools_over_http(): void + { + $user = User::create(['login' => 'nikita', 'tz' => 'UTC']); + AgentToken::create(['user_id' => $user->id, 'token_hash' => hash('sha256', 'secret')]); + + $names = collect($this->postJson('/mcp', ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list'], ['Authorization' => 'Bearer secret']) + ->assertOk()->json('result.tools'))->pluck('name')->all(); + + $this->assertSame([ + 'list_events', 'get_event', 'create_event', 'update_event', 'delete_event', + 'mark_done', 'mark_ack', 'get_profile', 'update_profile', + ], $names); + } } diff --git a/core/tests/Feature/McpToolsTest.php b/core/tests/Feature/McpToolsTest.php index bcfd83f..879a907 100644 --- a/core/tests/Feature/McpToolsTest.php +++ b/core/tests/Feature/McpToolsTest.php @@ -36,7 +36,7 @@ class McpToolsTest extends TestCase parent::setUp(); config(['hado.default_tz' => 'UTC']); CarbonImmutable::setTestNow('2026-09-05T12:00:00Z'); - $this->user = User::create(['login' => 'nikita', 'tz' => 'UTC']); + $this->user = User::create(['login' => 'nikita', 'tz' => 'UTC'])->fresh(); // дефолты тихих часов — из БД $this->docs = Source::create(['name' => 'docs', 'token_hash' => hash('sha256', 'x')]); // Как AuthenticateAgent: пользователь токена лежит в атрибутах текущего запроса $this->app['request']->attributes->set('user', $this->user); @@ -168,6 +168,48 @@ class McpToolsTest extends TestCase $this->assertSame(EventState::Today, $docs->fresh()->state); } + // --- действия пользователя: любой источник --- + + public function test_mark_done_closes_foreign_event_like_the_inbox_button(): void + { + $docs = $this->docsEvent($this->user); + + HadoServer::tool(MarkDone::class, ['id' => $docs->id])->assertOk() + ->assertStructuredContent(fn (AssertableJson $j) => $j->where('state', 'done')->where('editable', false)->etc()); + $this->assertNotNull($docs->fresh()->done_at); + + // повторно — no-op, без ошибки + HadoServer::tool(MarkDone::class, ['id' => $docs->id])->assertOk(); + } + + public function test_mark_ack_silences_until_next_checkpoint(): void + { + $docs = $this->docsEvent($this->user); + $before = $docs->quiet_until; + + HadoServer::tool(MarkAck::class, ['id' => $docs->id])->assertOk() + ->assertStructuredContent(fn (AssertableJson $j) => $j->where('state', 'today')->etc()); + $this->assertTrue($docs->fresh()->quiet_until->gt($before)); + + $other = User::create(['login' => 'other', 'tz' => 'UTC']); + HadoServer::tool(MarkAck::class, ['id' => $this->docsEvent($other, EventState::Today, 'f')->id])->assertHasErrors(['не найдено']); + } + + // --- профиль --- + + public function test_profile_is_readable_and_editable_with_inbox_rules(): void + { + HadoServer::tool(GetProfile::class)->assertOk() + ->assertStructuredContent(fn (AssertableJson $j) => $j->where('login', 'nikita')->where('tz', 'UTC')->where('quiet_start', '22:00')->where('quiet_end', '09:00')->etc()); + + HadoServer::tool(UpdateProfile::class, ['tz' => 'America/Montevideo', 'quiet_start' => '23:30'])->assertOk() + ->assertStructuredContent(fn (AssertableJson $j) => $j->where('tz', 'America/Montevideo')->where('quiet_start', '23:30')->where('quiet_end', '09:00')->etc()); + $this->assertSame('America/Montevideo', $this->user->fresh()->tz); + + HadoServer::tool(UpdateProfile::class, ['tz' => 'Mars/Olympus'])->assertHasErrors(['tz']); + HadoServer::tool(UpdateProfile::class, ['quiet_end' => '23:45'])->assertHasErrors(['полночь']); + } + public function test_delete_event_withdraws_own_event(): void { $id = $this->createOwn(); diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 7dbbacc..caf9b0e 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -1,12 +1,13 @@ -# Hado: публичные пути (клиенты по токену, кнопки из каналов, Telegram) идут мимо SSO, -# всё остальное — через forward_auth хаба. Порядок матчеров важен. +# Hado: публичные пути (клиенты по токену, агент по MCP-токену, кнопки из каналов, Telegram) +# идут мимо SSO, всё остальное — через forward_auth хаба. Порядок матчеров важен. +# hado живёт на этой же машине, порт опубликован только на loopback (как fumi). hado.{$DOMAIN} { - @public path /api/* /a/* /hooks/* /up + @public path /api/* /a/* /hooks/* /mcp /up handle @public { - reverse_proxy :8080 + reverse_proxy 127.0.0.1:8091 } handle { import protected - reverse_proxy :8080 + reverse_proxy 127.0.0.1:8091 } } diff --git a/docs/superpowers/specs/2026-09-05-hado-mcp-design.md b/docs/superpowers/specs/2026-09-05-hado-mcp-design.md index b82b68e..4d84c4d 100644 --- a/docs/superpowers/specs/2026-09-05-hado-mcp-design.md +++ b/docs/superpowers/specs/2026-09-05-hado-mcp-design.md @@ -1,6 +1,6 @@ # Hado MCP — агент как хозяин инбокса -Дата: 2026-09-05. Статус: согласован в брейншторме, ждёт ревью. +Дата: 2026-09-05. Статус: реализована (laravel/mcp 0.9.4). Дополняет `2026-09-03-hado-core-design.md`: добавляет четвёртый вход в §11 и новый путь в §12. Ядро, модель данных и контракт клиентов не меняются. @@ -45,22 +45,34 @@ Node-сервис по образцу `enso_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 +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/Mcp/EventPresenter.php — представление события для агента (§6) app/Http/Middleware/AuthenticateAgent.php app/Models/AgentToken.php -app/Console/Commands/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`). Проверяется первым шагом реализации; если не встаёт — стоп и возврат к обсуждению, а не самодельный транспорт. @@ -139,7 +151,7 @@ Middleware `AuthenticateAgent` (алиас `auth.agent`): читает `bearerTo | `subtitle` | string ≤200, optional | | | `fire_at` | date, optional | день старта подготовки; по умолчанию — за 30 дней до дня срока, как у кнопки инбокса | | `after_due` | `keep`/`expire`, default `keep` | как у кнопки инбокса | -| `done_label` | string ≤40, optional | подпись кнопки, по умолчанию «Сделано» | +| `done_label` | string ≤64, optional | подпись кнопки, по умолчанию «Сделано»; лимит тот же, что у клиентов | | `deep_link` | string, optional | куда вести из напоминания | Реализация: `Source::firstOrCreate(['name' => 'manual'])` → `EventUpserter::upsert`