From 00a1703bf7c520610e4b2c228cf227332543081d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 19:23:50 +0000 Subject: [PATCH 1/5] Add an MCP server that mimics the dashboard Introduces a first-party MCP server built on laravel/mcp, exposed as a streamable HTTP endpoint at {cachet.path}/mcp and gated by two new admin settings that mirror the existing API settings pair: - "Enable MCP server" (mcp_enabled, default off) returns 404 for all MCP requests when disabled. - "Require authentication" (mcp_protected, default on) requires a Sanctum API token for every MCP connection. When off, read-only tools are public while write tools still require a token with the matching ability, mirroring the REST API's semantics. The server registers 41 tools covering the ten API token resources: components, component groups, incidents, incident updates, incident templates, schedules, schedule updates, metrics, metric points, and subscribers. Write tools call the same Action classes and request data objects as the dashboard and REST API, so validation, webhooks, and subscriber notifications fire identically. Tools are hidden from both tools/list and tools/call unless the session's token holds the matching ability, using the existing ability names so dashboard-issued API keys work unchanged. Subscriber reads stay ability-guarded since they expose PII. laravel/mcp moves from require-dev to require at ^0.8, which raises the Laravel 11 floor to ^11.45.3 across the illuminate dependencies. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0157S7dDNDwYuqWsqeg1DB94 --- composer.json | 14 +- config/cachet.php | 29 ++ .../2026_07_13_000001_add_mcp_settings.php | 15 + resources/lang/en/settings.php | 5 + src/CachetCoreServiceProvider.php | 19 ++ src/Filament/Pages/Settings/ManageCachet.php | 14 + .../Middleware/AuthenticateMcpIfProtected.php | 31 +++ src/Http/Middleware/EnsureMcpIsEnabled.php | 19 ++ src/Mcp/CachetServer.php | 125 +++++++++ src/Mcp/Concerns/GuardsMcpAbilities.php | 26 ++ src/Mcp/Concerns/InteractsWithPagination.php | 32 +++ src/Mcp/Concerns/PresentsResources.php | 203 ++++++++++++++ .../ComponentGroups/CreateComponentGroup.php | 66 +++++ .../ComponentGroups/DeleteComponentGroup.php | 54 ++++ .../ComponentGroups/ListComponentGroups.php | 50 ++++ .../ComponentGroups/UpdateComponentGroup.php | 78 ++++++ src/Mcp/Tools/Components/CreateComponent.php | 58 ++++ src/Mcp/Tools/Components/DeleteComponent.php | 54 ++++ src/Mcp/Tools/Components/GetComponent.php | 43 +++ src/Mcp/Tools/Components/ListComponents.php | 58 ++++ src/Mcp/Tools/Components/UpdateComponent.php | 70 +++++ .../CreateIncidentTemplate.php | 55 ++++ .../DeleteIncidentTemplate.php | 54 ++++ .../ListIncidentTemplates.php | 48 ++++ .../UpdateIncidentTemplate.php | 67 +++++ .../IncidentUpdates/DeleteIncidentUpdate.php | 61 ++++ .../IncidentUpdates/EditIncidentUpdate.php | 70 +++++ .../IncidentUpdates/RecordIncidentUpdate.php | 62 +++++ src/Mcp/Tools/Incidents/CreateIncident.php | 73 +++++ src/Mcp/Tools/Incidents/DeleteIncident.php | 54 ++++ src/Mcp/Tools/Incidents/GetIncident.php | 43 +++ src/Mcp/Tools/Incidents/ListIncidents.php | 54 ++++ src/Mcp/Tools/Incidents/UpdateIncident.php | 70 +++++ src/Mcp/Tools/MetricPoints/AddMetricPoint.php | 58 ++++ .../Tools/MetricPoints/DeleteMetricPoint.php | 61 ++++ src/Mcp/Tools/Metrics/CreateMetric.php | 59 ++++ src/Mcp/Tools/Metrics/DeleteMetric.php | 54 ++++ src/Mcp/Tools/Metrics/GetMetric.php | 45 +++ src/Mcp/Tools/Metrics/ListMetrics.php | 49 ++++ src/Mcp/Tools/Metrics/UpdateMetric.php | 65 +++++ .../ScheduleUpdates/DeleteScheduleUpdate.php | 61 ++++ .../ScheduleUpdates/EditScheduleUpdate.php | 66 +++++ .../ScheduleUpdates/RecordScheduleUpdate.php | 58 ++++ src/Mcp/Tools/Schedules/CreateSchedule.php | 65 +++++ src/Mcp/Tools/Schedules/DeleteSchedule.php | 54 ++++ src/Mcp/Tools/Schedules/GetSchedule.php | 43 +++ src/Mcp/Tools/Schedules/ListSchedules.php | 49 ++++ src/Mcp/Tools/Schedules/UpdateSchedule.php | 74 +++++ src/Mcp/Tools/Status/GetStatus.php | 31 +++ .../Tools/Subscribers/CreateSubscriber.php | 65 +++++ src/Mcp/Tools/Subscribers/ListSubscribers.php | 62 +++++ .../Subscribers/UnsubscribeSubscriber.php | 54 ++++ .../Tools/Subscribers/UpdateSubscriber.php | 65 +++++ src/Settings/AppSettings.php | 4 + testbench.yaml | 1 + tests/Architecture/DataTest.php | 1 + .../Filament/Settings/ManageCachetTest.php | 15 + tests/Feature/Mcp/McpSettingsTest.php | 153 ++++++++++ .../Feature/Mcp/Tools/ComponentToolsTest.php | 145 ++++++++++ tests/Feature/Mcp/Tools/IncidentToolsTest.php | 263 ++++++++++++++++++ tests/Feature/Mcp/Tools/MetricToolsTest.php | 113 ++++++++ tests/Feature/Mcp/Tools/ScheduleToolsTest.php | 144 ++++++++++ tests/Feature/Mcp/Tools/StatusToolTest.php | 22 ++ .../Feature/Mcp/Tools/SubscriberToolsTest.php | 99 +++++++ 64 files changed, 3865 insertions(+), 7 deletions(-) create mode 100644 database/migrations/2026_07_13_000001_add_mcp_settings.php create mode 100644 src/Http/Middleware/AuthenticateMcpIfProtected.php create mode 100644 src/Http/Middleware/EnsureMcpIsEnabled.php create mode 100644 src/Mcp/CachetServer.php create mode 100644 src/Mcp/Concerns/GuardsMcpAbilities.php create mode 100644 src/Mcp/Concerns/InteractsWithPagination.php create mode 100644 src/Mcp/Concerns/PresentsResources.php create mode 100644 src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php create mode 100644 src/Mcp/Tools/ComponentGroups/DeleteComponentGroup.php create mode 100644 src/Mcp/Tools/ComponentGroups/ListComponentGroups.php create mode 100644 src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php create mode 100644 src/Mcp/Tools/Components/CreateComponent.php create mode 100644 src/Mcp/Tools/Components/DeleteComponent.php create mode 100644 src/Mcp/Tools/Components/GetComponent.php create mode 100644 src/Mcp/Tools/Components/ListComponents.php create mode 100644 src/Mcp/Tools/Components/UpdateComponent.php create mode 100644 src/Mcp/Tools/IncidentTemplates/CreateIncidentTemplate.php create mode 100644 src/Mcp/Tools/IncidentTemplates/DeleteIncidentTemplate.php create mode 100644 src/Mcp/Tools/IncidentTemplates/ListIncidentTemplates.php create mode 100644 src/Mcp/Tools/IncidentTemplates/UpdateIncidentTemplate.php create mode 100644 src/Mcp/Tools/IncidentUpdates/DeleteIncidentUpdate.php create mode 100644 src/Mcp/Tools/IncidentUpdates/EditIncidentUpdate.php create mode 100644 src/Mcp/Tools/IncidentUpdates/RecordIncidentUpdate.php create mode 100644 src/Mcp/Tools/Incidents/CreateIncident.php create mode 100644 src/Mcp/Tools/Incidents/DeleteIncident.php create mode 100644 src/Mcp/Tools/Incidents/GetIncident.php create mode 100644 src/Mcp/Tools/Incidents/ListIncidents.php create mode 100644 src/Mcp/Tools/Incidents/UpdateIncident.php create mode 100644 src/Mcp/Tools/MetricPoints/AddMetricPoint.php create mode 100644 src/Mcp/Tools/MetricPoints/DeleteMetricPoint.php create mode 100644 src/Mcp/Tools/Metrics/CreateMetric.php create mode 100644 src/Mcp/Tools/Metrics/DeleteMetric.php create mode 100644 src/Mcp/Tools/Metrics/GetMetric.php create mode 100644 src/Mcp/Tools/Metrics/ListMetrics.php create mode 100644 src/Mcp/Tools/Metrics/UpdateMetric.php create mode 100644 src/Mcp/Tools/ScheduleUpdates/DeleteScheduleUpdate.php create mode 100644 src/Mcp/Tools/ScheduleUpdates/EditScheduleUpdate.php create mode 100644 src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php create mode 100644 src/Mcp/Tools/Schedules/CreateSchedule.php create mode 100644 src/Mcp/Tools/Schedules/DeleteSchedule.php create mode 100644 src/Mcp/Tools/Schedules/GetSchedule.php create mode 100644 src/Mcp/Tools/Schedules/ListSchedules.php create mode 100644 src/Mcp/Tools/Schedules/UpdateSchedule.php create mode 100644 src/Mcp/Tools/Status/GetStatus.php create mode 100644 src/Mcp/Tools/Subscribers/CreateSubscriber.php create mode 100644 src/Mcp/Tools/Subscribers/ListSubscribers.php create mode 100644 src/Mcp/Tools/Subscribers/UnsubscribeSubscriber.php create mode 100644 src/Mcp/Tools/Subscribers/UpdateSubscriber.php create mode 100644 tests/Feature/Mcp/McpSettingsTest.php create mode 100644 tests/Feature/Mcp/Tools/ComponentToolsTest.php create mode 100644 tests/Feature/Mcp/Tools/IncidentToolsTest.php create mode 100644 tests/Feature/Mcp/Tools/MetricToolsTest.php create mode 100644 tests/Feature/Mcp/Tools/ScheduleToolsTest.php create mode 100644 tests/Feature/Mcp/Tools/StatusToolTest.php create mode 100644 tests/Feature/Mcp/Tools/SubscriberToolsTest.php diff --git a/composer.json b/composer.json index d9c60bc9..d80b33b0 100644 --- a/composer.json +++ b/composer.json @@ -25,12 +25,13 @@ "filament/filament": "^5.0", "filament/spatie-laravel-settings-plugin": "^5.0", "guzzlehttp/guzzle": "^7.8", - "illuminate/cache": "^11.35.0|^12.0|^13.0", - "illuminate/console": "^11.35.0|^12.0|^13.0", - "illuminate/database": "^11.35.0|^12.0|^13.0", - "illuminate/events": "^11.35.0|^12.0|^13.0", - "illuminate/queue": "^11.35.0|^12.0|^13.0", - "illuminate/support": "^11.35.0|^12.0|^13.0", + "illuminate/cache": "^11.45.3|^12.41.1|^13.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/database": "^11.45.3|^12.41.1|^13.0", + "illuminate/events": "^11.45.3|^12.41.1|^13.0", + "illuminate/queue": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.8", "laravel/sanctum": "^4.0", "nesbot/carbon": "^2.70|^3.0", "spatie/laravel-data": "^4.11", @@ -44,7 +45,6 @@ "dedoc/scramble": "^0.12|^0.13", "larastan/larastan": "^3.4", "laravel/boost": "^2.0", - "laravel/mcp": "^0.5", "laravel/pail": "^1.1", "laravel/pint": "^1.24", "orchestra/testbench": "^9.15.1|^10.0|^11.0", diff --git a/config/cachet.php b/config/cachet.php index 557f5f56..2ee1d489 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -2,7 +2,9 @@ use App\Models\User; use Cachet\Http\Middleware\AuthenticateApiIfProtected; +use Cachet\Http\Middleware\AuthenticateMcpIfProtected; use Cachet\Http\Middleware\EnsureApiIsEnabled; +use Cachet\Http\Middleware\EnsureMcpIsEnabled; use Illuminate\Routing\Middleware\SubstituteBindings; return [ @@ -96,6 +98,21 @@ SubstituteBindings::class, ], + /* + |-------------------------------------------------------------------------- + | Cachet MCP Middleware + |-------------------------------------------------------------------------- + | + | This is the middleware that will be applied to the Cachet MCP server + | endpoint. The MCP server is disabled until the "Enable MCP server" + | setting is turned on from the dashboard. + | + */ + 'mcp_middleware' => [ + EnsureMcpIsEnabled::class, + AuthenticateMcpIfProtected::class, + ], + 'trusted_proxies' => env('CACHET_TRUSTED_PROXIES', ''), /* @@ -110,6 +127,18 @@ */ 'api_rate_limit' => env('CACHET_API_RATE_LIMIT', 300), + /* + |-------------------------------------------------------------------------- + | Cachet MCP Rate Limit (attempts per minute) + |-------------------------------------------------------------------------- + | + | This is the rate limit for the Cachet MCP server. By default, the MCP + | server is rate limited to 300 requests a minute. You can adjust the + | limit as needed by your application. + | + */ + 'mcp_rate_limit' => env('CACHET_MCP_RATE_LIMIT', 300), + /* |-------------------------------------------------------------------------- | Cachet Beacon diff --git a/database/migrations/2026_07_13_000001_add_mcp_settings.php b/database/migrations/2026_07_13_000001_add_mcp_settings.php new file mode 100644 index 00000000..6e7b2ac3 --- /dev/null +++ b/database/migrations/2026_07_13_000001_add_mcp_settings.php @@ -0,0 +1,15 @@ + $this->migrator->add('app.mcp_enabled', false)); + rescue(fn () => $this->migrator->add('app.mcp_protected', true)); + } +}; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index acb6a92f..714b26ae 100644 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -36,11 +36,16 @@ 'api_enabled_helper' => 'Allow access to the Cachet API. When disabled, all API requests return a 404.', 'api_protected' => 'Require authentication', 'api_protected_helper' => 'Require an API token for all API requests, including read-only endpoints.', + 'mcp_enabled' => 'Enable MCP server', + 'mcp_enabled_helper' => 'Allow AI agents to connect to Cachet over the Model Context Protocol. When disabled, all MCP requests return a 404.', + 'mcp_protected' => 'Require authentication', + 'mcp_protected_helper' => 'Require an API token for all MCP connections. When off, read-only tools are public and write tools still require an API token with the matching ability.', 'dynamic_favicon' => 'Dynamic favicon', 'dynamic_favicon_helper' => 'Update the favicon to reflect the current status of your systems.', ], 'display_settings_title' => 'Display settings', 'api_settings_title' => 'API settings', + 'mcp_settings_title' => 'MCP server settings', ], 'manage_customization' => [ 'header_label' => 'Custom header HTML', diff --git a/src/CachetCoreServiceProvider.php b/src/CachetCoreServiceProvider.php index 0facc95b..e00f68dd 100644 --- a/src/CachetCoreServiceProvider.php +++ b/src/CachetCoreServiceProvider.php @@ -13,6 +13,7 @@ use Cachet\Database\Seeders\DemoMetricSeeder; use Cachet\Listeners\SendWebhookListener; use Cachet\Listeners\WebhookCallEventListener; +use Cachet\Mcp\CachetServer; use Cachet\Models\Component; use Cachet\Models\ComponentCheck; use Cachet\Models\ComponentGroup; @@ -43,6 +44,7 @@ use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Laravel\Mcp\Facades\Mcp; use Spatie\WebhookServer\Events\WebhookCallFailedEvent; use Spatie\WebhookServer\Events\WebhookCallSucceededEvent; @@ -88,6 +90,7 @@ public function boot(): void Route::middlewareGroup('cachet', config('cachet.middleware', [])); Route::middlewareGroup('cachet:api', config('cachet.api_middleware', [])); + Route::middlewareGroup('cachet:mcp', config('cachet.mcp_middleware', [])); Relation::morphMap([ 'component' => Component::class, @@ -178,6 +181,11 @@ private function configureRateLimiting(): void return Limit::perMinute(config('cachet.api_rate_limit', 300)) ->by($request->user()?->id ?: $request->ip()); }); + + RateLimiter::for('cachet-mcp', function ($request) { + return Limit::perMinute(config('cachet.mcp_rate_limit', 300)) + ->by($request->user('sanctum')?->getKey() ?: $request->ip()); + }); } /** @@ -195,6 +203,17 @@ private function registerRoutes(): void $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); }); + if ($application->runningInConsole() || ! $application->routesAreCached()) { + $router->group([ + 'domain' => config('cachet.domain'), + 'as' => 'cachet.mcp.', + 'prefix' => Cachet::path().'/mcp', + 'middleware' => ['cachet:mcp', 'throttle:cachet-mcp'], + ], function () { + Mcp::web('/', CachetServer::class)->name('server'); + }); + } + Cachet::routes() ->register(); }); diff --git a/src/Filament/Pages/Settings/ManageCachet.php b/src/Filament/Pages/Settings/ManageCachet.php index 29cc8141..4a3ba7e9 100644 --- a/src/Filament/Pages/Settings/ManageCachet.php +++ b/src/Filament/Pages/Settings/ManageCachet.php @@ -124,6 +124,20 @@ public function form(Schema $schema): Schema ->helperText(__('cachet::settings.manage_cachet.toggles.api_protected_helper')) ->visible(fn (Get $get) => $get('api_enabled') === true), ]), + + Section::make(__('cachet::settings.manage_cachet.mcp_settings_title')) + ->columns(2) + ->schema([ + Toggle::make('mcp_enabled') + ->label(__('cachet::settings.manage_cachet.toggles.mcp_enabled')) + ->helperText(__('cachet::settings.manage_cachet.toggles.mcp_enabled_helper')) + ->afterStateUpdated(fn (Set $set) => $set('mcp_protected', true)) + ->reactive(), + Toggle::make('mcp_protected') + ->label(__('cachet::settings.manage_cachet.toggles.mcp_protected')) + ->helperText(__('cachet::settings.manage_cachet.toggles.mcp_protected_helper')) + ->visible(fn (Get $get) => $get('mcp_enabled') === true), + ]), ]); } } diff --git a/src/Http/Middleware/AuthenticateMcpIfProtected.php b/src/Http/Middleware/AuthenticateMcpIfProtected.php new file mode 100644 index 00000000..01cbf616 --- /dev/null +++ b/src/Http/Middleware/AuthenticateMcpIfProtected.php @@ -0,0 +1,31 @@ +auth->guard('sanctum')->check(); + + if ($this->settings->mcp_protected && ! $authenticated) { + abort(401, 'Unauthenticated.'); + } + + if ($authenticated) { + $this->auth->shouldUse('sanctum'); + } + + return $next($request); + } +} diff --git a/src/Http/Middleware/EnsureMcpIsEnabled.php b/src/Http/Middleware/EnsureMcpIsEnabled.php new file mode 100644 index 00000000..20ea091f --- /dev/null +++ b/src/Http/Middleware/EnsureMcpIsEnabled.php @@ -0,0 +1,19 @@ +settings->mcp_enabled, 404); + + return $next($request); + } +} diff --git a/src/Mcp/CachetServer.php b/src/Mcp/CachetServer.php new file mode 100644 index 00000000..2fe868df --- /dev/null +++ b/src/Mcp/CachetServer.php @@ -0,0 +1,125 @@ +> + */ + protected array $tools = [ + GetStatus::class, + ListComponents::class, + GetComponent::class, + CreateComponent::class, + UpdateComponent::class, + DeleteComponent::class, + ListComponentGroups::class, + CreateComponentGroup::class, + UpdateComponentGroup::class, + DeleteComponentGroup::class, + ListIncidents::class, + GetIncident::class, + CreateIncident::class, + UpdateIncident::class, + DeleteIncident::class, + RecordIncidentUpdate::class, + EditIncidentUpdate::class, + DeleteIncidentUpdate::class, + ListIncidentTemplates::class, + CreateIncidentTemplate::class, + UpdateIncidentTemplate::class, + DeleteIncidentTemplate::class, + ListSchedules::class, + GetSchedule::class, + CreateSchedule::class, + UpdateSchedule::class, + DeleteSchedule::class, + RecordScheduleUpdate::class, + EditScheduleUpdate::class, + DeleteScheduleUpdate::class, + ListMetrics::class, + GetMetric::class, + CreateMetric::class, + UpdateMetric::class, + DeleteMetric::class, + AddMetricPoint::class, + DeleteMetricPoint::class, + ListSubscribers::class, + CreateSubscriber::class, + UpdateSubscriber::class, + UnsubscribeSubscriber::class, + ]; + + public function __construct(Transport $transport) + { + parent::__construct($transport); + + $this->version = Cachet::version(); + } +} diff --git a/src/Mcp/Concerns/GuardsMcpAbilities.php b/src/Mcp/Concerns/GuardsMcpAbilities.php new file mode 100644 index 00000000..8b18d4a4 --- /dev/null +++ b/src/Mcp/Concerns/GuardsMcpAbilities.php @@ -0,0 +1,26 @@ +user(); + + return $user !== null && $user->tokenCan($ability); + } + + /** + * Create the error response returned when the required token ability is missing. + */ + protected function missingAbility(string $ability): Response + { + return Response::error("Unauthorized. This tool requires an API token with the [{$ability}] ability."); + } +} diff --git a/src/Mcp/Concerns/InteractsWithPagination.php b/src/Mcp/Concerns/InteractsWithPagination.php new file mode 100644 index 00000000..d1525694 --- /dev/null +++ b/src/Mcp/Concerns/InteractsWithPagination.php @@ -0,0 +1,32 @@ +integer('per_page', 15), min: 1, max: 100); + } + + protected function page(Request $request): int + { + return max(1, $request->integer('page', 1)); + } + + /** + * @return array{current_page: int, per_page: int, has_more: bool} + */ + protected function paginationMeta(Paginator $paginator): array + { + return [ + 'current_page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'has_more' => $paginator->hasMorePages(), + ]; + } +} diff --git a/src/Mcp/Concerns/PresentsResources.php b/src/Mcp/Concerns/PresentsResources.php new file mode 100644 index 00000000..9daef9b4 --- /dev/null +++ b/src/Mcp/Concerns/PresentsResources.php @@ -0,0 +1,203 @@ + $enum instanceof BackedEnum ? $enum->value : $enum->name, + 'name' => $enum->name, + ]; + } + + /** + * @return array + */ + protected function presentComponent(Component $component): array + { + return [ + 'id' => $component->id, + 'name' => $component->name, + 'description' => $component->description, + 'link' => $component->link, + 'status' => $this->presentEnum($component->status), + 'order' => $component->order, + 'enabled' => $component->enabled, + 'component_group_id' => $component->component_group_id, + 'created_at' => $component->created_at?->toIso8601String(), + 'updated_at' => $component->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function presentComponentGroup(ComponentGroup $group): array + { + return array_merge([ + 'id' => $group->id, + 'name' => $group->name, + 'order' => $group->order, + 'visible' => $this->presentEnum($group->visible), + 'collapsed' => $this->presentEnum($group->collapsed), + 'order_column' => $this->presentEnum($group->order_column), + 'order_direction' => $this->presentEnum($group->order_direction), + 'created_at' => $group->created_at?->toIso8601String(), + 'updated_at' => $group->updated_at?->toIso8601String(), + ], $group->relationLoaded('components') ? [ + 'components' => $group->components->map(fn (Component $component) => $this->presentComponent($component))->all(), + ] : []); + } + + /** + * @return array + */ + protected function presentIncident(Incident $incident): array + { + return array_merge([ + 'id' => $incident->id, + 'guid' => $incident->guid, + 'name' => $incident->name, + 'status' => $this->presentEnum($incident->status), + 'message' => $incident->message, + 'visible' => $this->presentEnum($incident->visible), + 'stickied' => $incident->stickied, + 'occurred_at' => $incident->occurred_at?->toIso8601String(), + 'created_at' => $incident->created_at?->toIso8601String(), + 'updated_at' => $incident->updated_at?->toIso8601String(), + ], $incident->relationLoaded('components') ? [ + 'components' => $incident->components->map(fn (Component $component) => $this->presentComponent($component))->all(), + ] : [], $incident->relationLoaded('updates') ? [ + 'updates' => $incident->updates->map(fn (Update $update) => $this->presentUpdate($update))->all(), + ] : []); + } + + /** + * @return array + */ + protected function presentUpdate(Update $update): array + { + return [ + 'id' => $update->id, + 'status' => $this->presentEnum($update->status), + 'message' => $update->message, + 'user_id' => $update->user_id, + 'created_at' => $update->created_at?->toIso8601String(), + 'updated_at' => $update->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function presentIncidentTemplate(IncidentTemplate $template): array + { + return [ + 'id' => $template->id, + 'name' => $template->name, + 'slug' => $template->slug, + 'template' => $template->template, + 'engine' => $this->presentEnum($template->engine), + 'created_at' => $template->created_at?->toIso8601String(), + 'updated_at' => $template->updated_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function presentSchedule(Schedule $schedule): array + { + return array_merge([ + 'id' => $schedule->id, + 'name' => $schedule->name, + 'message' => $schedule->message, + 'status' => $this->presentEnum($schedule->status), + 'scheduled_at' => $schedule->scheduled_at?->toIso8601String(), + 'completed_at' => $schedule->completed_at?->toIso8601String(), + 'created_at' => $schedule->created_at?->toIso8601String(), + 'updated_at' => $schedule->updated_at?->toIso8601String(), + ], $schedule->relationLoaded('components') ? [ + 'components' => $schedule->components->map(fn (Component $component) => $this->presentComponent($component))->all(), + ] : [], $schedule->relationLoaded('updates') ? [ + 'updates' => $schedule->updates->map(fn (Update $update) => $this->presentUpdate($update))->all(), + ] : []); + } + + /** + * @return array + */ + protected function presentMetric(Metric $metric): array + { + return array_merge([ + 'id' => $metric->id, + 'name' => $metric->name, + 'suffix' => $metric->suffix, + 'description' => $metric->description, + 'calc_type' => $this->presentEnum($metric->calc_type), + 'display_chart' => $metric->display_chart, + 'places' => $metric->places, + 'default_value' => $metric->default_value, + 'threshold' => $metric->threshold, + 'visible' => $this->presentEnum($metric->visible), + 'order' => $metric->order, + 'created_at' => $metric->created_at?->toIso8601String(), + 'updated_at' => $metric->updated_at?->toIso8601String(), + ], $metric->relationLoaded('metricPoints') ? [ + 'metric_points' => $metric->metricPoints->map(fn (MetricPoint $point) => $this->presentMetricPoint($point))->all(), + ] : []); + } + + /** + * @return array + */ + protected function presentMetricPoint(MetricPoint $point): array + { + return [ + 'id' => $point->id, + 'metric_id' => $point->metric_id, + 'value' => $point->value, + 'counter' => $point->counter, + 'calculated_value' => $point->calculated_value, + 'created_at' => $point->created_at?->toIso8601String(), + ]; + } + + /** + * @return array + */ + protected function presentSubscriber(Subscriber $subscriber): array + { + return array_merge([ + 'id' => $subscriber->id, + 'email' => $subscriber->email, + 'verified' => $subscriber->hasVerifiedEmail(), + 'global' => (bool) $subscriber->global, + 'created_at' => $subscriber->created_at?->toIso8601String(), + 'updated_at' => $subscriber->updated_at?->toIso8601String(), + ], $subscriber->relationLoaded('components') ? [ + 'components' => $subscriber->components->map(fn (Component $component) => $this->presentComponent($component))->all(), + ] : []); + } +} diff --git a/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php b/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php new file mode 100644 index 00000000..a3f7487b --- /dev/null +++ b/src/Mcp/Tools/ComponentGroups/CreateComponentGroup.php @@ -0,0 +1,66 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the component group.'), + 'order' => $schema->integer()->min(0)->description('The display order of the component group.'), + 'visible' => $schema->boolean()->description('Whether the component group is visible to guests.'), + 'collapsed' => $schema->integer() + ->enum(array_column(ComponentGroupVisibilityEnum::cases(), 'value')) + ->description('Collapse behaviour: 0 expanded, 1 collapsed, 2 collapsed unless a component has an incident.'), + 'order_column' => $schema->string() + ->enum(array_column(ResourceOrderColumnEnum::cases(), 'value')) + ->description('How components within the group are ordered.'), + 'order_direction' => $schema->string() + ->enum(array_column(ResourceOrderDirectionEnum::cases(), 'value')) + ->description('Direction for the order column. Required unless order_column is manual.'), + 'components' => $schema->array() + ->items($schema->integer()) + ->description('IDs of components to attach to the group.'), + ]; + } + + public function handle(Request $request, CreateComponentGroupAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('component-groups.manage')) { + return $this->missingAbility('component-groups.manage'); + } + + $data = CreateComponentGroupRequestData::validateAndCreate($request->all()); + + return Response::structured(['data' => $this->presentComponentGroup($action->handle($data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('component-groups.manage'); + } +} diff --git a/src/Mcp/Tools/ComponentGroups/DeleteComponentGroup.php b/src/Mcp/Tools/ComponentGroups/DeleteComponentGroup.php new file mode 100644 index 00000000..f04b5df5 --- /dev/null +++ b/src/Mcp/Tools/ComponentGroups/DeleteComponentGroup.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component group ID.'), + ]; + } + + public function handle(Request $request, DeleteComponentGroupAction $action): Response + { + if (! $this->tokenCan('component-groups.delete')) { + return $this->missingAbility('component-groups.delete'); + } + + $group = ComponentGroup::query()->find($id = $request->integer('id')); + + if ($group === null) { + return Response::error("Component group [{$id}] not found."); + } + + $action->handle($group); + + return Response::text("Component group [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('component-groups.delete'); + } +} diff --git a/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php b/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php new file mode 100644 index 00000000..3a24c4c3 --- /dev/null +++ b/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php @@ -0,0 +1,50 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial component group name.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $groups = ComponentGroup::query() + ->with('components') + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->orderBy('order') + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $groups->getCollection()->map(fn (ComponentGroup $group) => $this->presentComponentGroup($group))->all(), + 'meta' => $this->paginationMeta($groups), + ]); + } +} diff --git a/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php b/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php new file mode 100644 index 00000000..62ae268b --- /dev/null +++ b/src/Mcp/Tools/ComponentGroups/UpdateComponentGroup.php @@ -0,0 +1,78 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component group ID.'), + 'name' => $schema->string()->max(255)->description('The name of the component group.'), + 'order' => $schema->integer()->min(0)->description('The display order of the component group.'), + 'visible' => $schema->boolean()->description('Whether the component group is visible to guests.'), + 'collapsed' => $schema->integer() + ->enum(array_column(ComponentGroupVisibilityEnum::cases(), 'value')) + ->description('Collapse behaviour: 0 expanded, 1 collapsed, 2 collapsed unless a component has an incident.'), + 'order_column' => $schema->string() + ->enum(array_column(ResourceOrderColumnEnum::cases(), 'value')) + ->description('How components within the group are ordered.'), + 'order_direction' => $schema->string() + ->enum(array_column(ResourceOrderDirectionEnum::cases(), 'value')) + ->description('Direction for the order column. Required unless order_column is manual.'), + 'components' => $schema->array() + ->items($schema->integer()) + ->description('IDs of components to attach to the group.'), + ]; + } + + public function handle(Request $request, UpdateComponentGroupAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('component-groups.manage')) { + return $this->missingAbility('component-groups.manage'); + } + + $group = ComponentGroup::query()->find($id = $request->integer('id')); + + if ($group === null) { + return Response::error("Component group [{$id}] not found."); + } + + $data = UpdateComponentGroupRequestData::validateAndCreate( + $request->only(['name', 'order', 'visible', 'collapsed', 'order_column', 'order_direction', 'components']) + ); + + return Response::structured(['data' => $this->presentComponentGroup($action->handle($group, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('component-groups.manage'); + } +} diff --git a/src/Mcp/Tools/Components/CreateComponent.php b/src/Mcp/Tools/Components/CreateComponent.php new file mode 100644 index 00000000..40d7da0b --- /dev/null +++ b/src/Mcp/Tools/Components/CreateComponent.php @@ -0,0 +1,58 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the component.'), + 'description' => $schema->string()->description('A description of the component.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->description('The status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + 'link' => $schema->string()->description('A link related to the component.'), + 'order' => $schema->integer()->min(0)->description('The display order of the component.'), + 'enabled' => $schema->boolean()->default(true), + 'component_group_id' => $schema->integer()->description('The ID of the component group this component belongs to.'), + ]; + } + + public function handle(Request $request, CreateComponentAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('components.manage')) { + return $this->missingAbility('components.manage'); + } + + $data = CreateComponentRequestData::validateAndCreate($request->all()); + + return Response::structured(['data' => $this->presentComponent($action->handle($data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('components.manage'); + } +} diff --git a/src/Mcp/Tools/Components/DeleteComponent.php b/src/Mcp/Tools/Components/DeleteComponent.php new file mode 100644 index 00000000..72a24be1 --- /dev/null +++ b/src/Mcp/Tools/Components/DeleteComponent.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component ID.'), + ]; + } + + public function handle(Request $request, DeleteComponentAction $action): Response + { + if (! $this->tokenCan('components.delete')) { + return $this->missingAbility('components.delete'); + } + + $component = Component::query()->find($id = $request->integer('id')); + + if ($component === null) { + return Response::error("Component [{$id}] not found."); + } + + $action->handle($component); + + return Response::text("Component [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('components.delete'); + } +} diff --git a/src/Mcp/Tools/Components/GetComponent.php b/src/Mcp/Tools/Components/GetComponent.php new file mode 100644 index 00000000..b0f52994 --- /dev/null +++ b/src/Mcp/Tools/Components/GetComponent.php @@ -0,0 +1,43 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component ID.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + $component = Component::query()->find($id = $request->integer('id')); + + if ($component === null) { + return Response::error("Component [{$id}] not found."); + } + + return Response::structured(['data' => $this->presentComponent($component)]); + } +} diff --git a/src/Mcp/Tools/Components/ListComponents.php b/src/Mcp/Tools/Components/ListComponents.php new file mode 100644 index 00000000..4835508b --- /dev/null +++ b/src/Mcp/Tools/Components/ListComponents.php @@ -0,0 +1,58 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial component name.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->description('Filter by status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + 'enabled' => $schema->boolean()->description('Filter by enabled state.'), + 'component_group_id' => $schema->integer()->description('Filter by component group ID.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $components = Component::query() + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) + ->when($request->filled('enabled'), fn ($query) => $query->where('enabled', $request->boolean('enabled'))) + ->when($request->filled('component_group_id'), fn ($query) => $query->where('component_group_id', $request->integer('component_group_id'))) + ->orderBy('order') + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $components->getCollection()->map(fn (Component $component) => $this->presentComponent($component))->all(), + 'meta' => $this->paginationMeta($components), + ]); + } +} diff --git a/src/Mcp/Tools/Components/UpdateComponent.php b/src/Mcp/Tools/Components/UpdateComponent.php new file mode 100644 index 00000000..e13419ba --- /dev/null +++ b/src/Mcp/Tools/Components/UpdateComponent.php @@ -0,0 +1,70 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component ID.'), + 'name' => $schema->string()->max(255)->description('The name of the component.'), + 'description' => $schema->string()->description('A description of the component.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->description('The status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + 'link' => $schema->string()->description('A link related to the component.'), + 'order' => $schema->integer()->min(0)->description('The display order of the component.'), + 'enabled' => $schema->boolean(), + 'component_group_id' => $schema->integer()->description('The ID of the component group this component belongs to.'), + ]; + } + + public function handle(Request $request, UpdateComponentAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('components.manage')) { + return $this->missingAbility('components.manage'); + } + + $component = Component::query()->find($id = $request->integer('id')); + + if ($component === null) { + return Response::error("Component [{$id}] not found."); + } + + $data = UpdateComponentRequestData::validateAndCreate( + $request->only(['name', 'description', 'status', 'link', 'order', 'enabled', 'component_group_id']) + ); + + return Response::structured(['data' => $this->presentComponent($action->handle($component, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('components.manage'); + } +} diff --git a/src/Mcp/Tools/IncidentTemplates/CreateIncidentTemplate.php b/src/Mcp/Tools/IncidentTemplates/CreateIncidentTemplate.php new file mode 100644 index 00000000..79793905 --- /dev/null +++ b/src/Mcp/Tools/IncidentTemplates/CreateIncidentTemplate.php @@ -0,0 +1,55 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the template.'), + 'template' => $schema->string()->required()->description('The template body, rendered with the chosen engine.'), + 'slug' => $schema->string()->description('The template slug. Defaults to a slug generated from the name.'), + 'engine' => $schema->string() + ->enum(array_column(IncidentTemplateEngineEnum::cases(), 'value')) + ->description('The template engine: blade or twig.'), + ]; + } + + public function handle(Request $request, CreateIncidentTemplateAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('incident-templates.manage')) { + return $this->missingAbility('incident-templates.manage'); + } + + $data = CreateIncidentTemplateRequestData::validateAndCreate($request->all()); + + return Response::structured(['data' => $this->presentIncidentTemplate($action->handle($data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-templates.manage'); + } +} diff --git a/src/Mcp/Tools/IncidentTemplates/DeleteIncidentTemplate.php b/src/Mcp/Tools/IncidentTemplates/DeleteIncidentTemplate.php new file mode 100644 index 00000000..20431773 --- /dev/null +++ b/src/Mcp/Tools/IncidentTemplates/DeleteIncidentTemplate.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The incident template ID.'), + ]; + } + + public function handle(Request $request, DeleteIncidentTemplateAction $action): Response + { + if (! $this->tokenCan('incident-templates.delete')) { + return $this->missingAbility('incident-templates.delete'); + } + + $template = IncidentTemplate::query()->find($id = $request->integer('id')); + + if ($template === null) { + return Response::error("Incident template [{$id}] not found."); + } + + $action->handle($template); + + return Response::text("Incident template [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-templates.delete'); + } +} diff --git a/src/Mcp/Tools/IncidentTemplates/ListIncidentTemplates.php b/src/Mcp/Tools/IncidentTemplates/ListIncidentTemplates.php new file mode 100644 index 00000000..4f5588f8 --- /dev/null +++ b/src/Mcp/Tools/IncidentTemplates/ListIncidentTemplates.php @@ -0,0 +1,48 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial template name.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $templates = IncidentTemplate::query() + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $templates->getCollection()->map(fn (IncidentTemplate $template) => $this->presentIncidentTemplate($template))->all(), + 'meta' => $this->paginationMeta($templates), + ]); + } +} diff --git a/src/Mcp/Tools/IncidentTemplates/UpdateIncidentTemplate.php b/src/Mcp/Tools/IncidentTemplates/UpdateIncidentTemplate.php new file mode 100644 index 00000000..2e152bc5 --- /dev/null +++ b/src/Mcp/Tools/IncidentTemplates/UpdateIncidentTemplate.php @@ -0,0 +1,67 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The incident template ID.'), + 'name' => $schema->string()->max(255)->description('The name of the template.'), + 'template' => $schema->string()->description('The template body, rendered with the chosen engine.'), + 'slug' => $schema->string()->description('The template slug.'), + 'engine' => $schema->string() + ->enum(array_column(IncidentTemplateEngineEnum::cases(), 'value')) + ->description('The template engine: blade or twig.'), + ]; + } + + public function handle(Request $request, UpdateIncidentTemplateAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('incident-templates.manage')) { + return $this->missingAbility('incident-templates.manage'); + } + + $template = IncidentTemplate::query()->find($id = $request->integer('id')); + + if ($template === null) { + return Response::error("Incident template [{$id}] not found."); + } + + $data = UpdateIncidentTemplateRequestData::validateAndCreate( + $request->only(['name', 'template', 'slug', 'engine']) + ); + + return Response::structured(['data' => $this->presentIncidentTemplate($action->handle($template, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-templates.manage'); + } +} diff --git a/src/Mcp/Tools/IncidentUpdates/DeleteIncidentUpdate.php b/src/Mcp/Tools/IncidentUpdates/DeleteIncidentUpdate.php new file mode 100644 index 00000000..85415638 --- /dev/null +++ b/src/Mcp/Tools/IncidentUpdates/DeleteIncidentUpdate.php @@ -0,0 +1,61 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'incident_id' => $schema->integer()->required()->description('The incident ID.'), + 'update_id' => $schema->integer()->required()->description('The incident update ID.'), + ]; + } + + public function handle(Request $request, DeleteUpdate $action): Response + { + if (! $this->tokenCan('incident-updates.delete')) { + return $this->missingAbility('incident-updates.delete'); + } + + $incident = Incident::query()->find($incidentId = $request->integer('incident_id')); + + if ($incident === null) { + return Response::error("Incident [{$incidentId}] not found."); + } + + $update = $incident->updates()->find($updateId = $request->integer('update_id')); + + if ($update === null) { + return Response::error("Update [{$updateId}] not found on incident [{$incidentId}]."); + } + + $action->handle($update); + + return Response::text("Update [{$updateId}] deleted from incident [{$incidentId}]."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-updates.delete'); + } +} diff --git a/src/Mcp/Tools/IncidentUpdates/EditIncidentUpdate.php b/src/Mcp/Tools/IncidentUpdates/EditIncidentUpdate.php new file mode 100644 index 00000000..00309968 --- /dev/null +++ b/src/Mcp/Tools/IncidentUpdates/EditIncidentUpdate.php @@ -0,0 +1,70 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'incident_id' => $schema->integer()->required()->description('The incident ID.'), + 'update_id' => $schema->integer()->required()->description('The incident update ID.'), + 'status' => $schema->integer() + ->enum(array_column(IncidentStatusEnum::cases(), 'value')) + ->description('The status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'message' => $schema->string()->description('The update message, in Markdown.'), + ]; + } + + public function handle(Request $request, EditUpdate $action): Response|ResponseFactory + { + if (! $this->tokenCan('incident-updates.manage')) { + return $this->missingAbility('incident-updates.manage'); + } + + $incident = Incident::query()->find($incidentId = $request->integer('incident_id')); + + if ($incident === null) { + return Response::error("Incident [{$incidentId}] not found."); + } + + $update = $incident->updates()->find($updateId = $request->integer('update_id')); + + if ($update === null) { + return Response::error("Update [{$updateId}] not found on incident [{$incidentId}]."); + } + + $data = EditIncidentUpdateRequestData::validateAndCreate($request->only(['status', 'message'])); + + return Response::structured(['data' => $this->presentUpdate($action->handle($update, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-updates.manage'); + } +} diff --git a/src/Mcp/Tools/IncidentUpdates/RecordIncidentUpdate.php b/src/Mcp/Tools/IncidentUpdates/RecordIncidentUpdate.php new file mode 100644 index 00000000..a9882a10 --- /dev/null +++ b/src/Mcp/Tools/IncidentUpdates/RecordIncidentUpdate.php @@ -0,0 +1,62 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'incident_id' => $schema->integer()->required()->description('The incident ID.'), + 'status' => $schema->integer() + ->enum(array_column(IncidentStatusEnum::cases(), 'value')) + ->required() + ->description('The new status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'message' => $schema->string()->required()->description('The update message, in Markdown.'), + ]; + } + + public function handle(Request $request, CreateUpdate $action): Response|ResponseFactory + { + if (! $this->tokenCan('incident-updates.manage')) { + return $this->missingAbility('incident-updates.manage'); + } + + $incident = Incident::query()->find($id = $request->integer('incident_id')); + + if ($incident === null) { + return Response::error("Incident [{$id}] not found."); + } + + $data = CreateIncidentUpdateRequestData::validateAndCreate($request->only(['status', 'message'])); + + return Response::structured(['data' => $this->presentUpdate($action->handle($incident, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incident-updates.manage'); + } +} diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php new file mode 100644 index 00000000..85f1bab5 --- /dev/null +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -0,0 +1,73 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the incident.'), + 'status' => $schema->integer() + ->enum(array_column(IncidentStatusEnum::cases(), 'value')) + ->required() + ->description('The status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'message' => $schema->string()->description('The incident message, in Markdown. Required unless template is given.'), + 'template' => $schema->string()->description('The slug of an incident template to render the message from.'), + 'template_vars' => $schema->object()->description('Variables passed to the incident template.'), + 'visible' => $schema->boolean()->default(false)->description('Whether the incident is visible to guests.'), + 'stickied' => $schema->boolean()->default(false)->description('Whether the incident is stickied to the top of the status page.'), + 'notifications' => $schema->boolean()->default(false)->description('Whether to notify verified subscribers.'), + 'occurred_at' => $schema->string()->description('When the incident occurred, as an ISO-8601 datetime. Defaults to now.'), + 'components' => $schema->array() + ->items($schema->object([ + 'id' => $schema->integer()->required()->description('The component ID.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->required() + ->description('The status to set on the component: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + ])) + ->description('Affected components and the status to set on each.'), + ]; + } + + public function handle(Request $request, CreateIncidentAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('incidents.manage')) { + return $this->missingAbility('incidents.manage'); + } + + $data = CreateIncidentRequestData::validateAndCreate($request->all()); + + $incident = $action->handle($data)->load(['components', 'updates']); + + return Response::structured(['data' => $this->presentIncident($incident)]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incidents.manage'); + } +} diff --git a/src/Mcp/Tools/Incidents/DeleteIncident.php b/src/Mcp/Tools/Incidents/DeleteIncident.php new file mode 100644 index 00000000..a3e92e03 --- /dev/null +++ b/src/Mcp/Tools/Incidents/DeleteIncident.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The incident ID.'), + ]; + } + + public function handle(Request $request, DeleteIncidentAction $action): Response + { + if (! $this->tokenCan('incidents.delete')) { + return $this->missingAbility('incidents.delete'); + } + + $incident = Incident::query()->find($id = $request->integer('id')); + + if ($incident === null) { + return Response::error("Incident [{$id}] not found."); + } + + $action->handle($incident); + + return Response::text("Incident [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incidents.delete'); + } +} diff --git a/src/Mcp/Tools/Incidents/GetIncident.php b/src/Mcp/Tools/Incidents/GetIncident.php new file mode 100644 index 00000000..0dd0d6ce --- /dev/null +++ b/src/Mcp/Tools/Incidents/GetIncident.php @@ -0,0 +1,43 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The incident ID.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + $incident = Incident::query()->with(['components', 'updates'])->find($id = $request->integer('id')); + + if ($incident === null) { + return Response::error("Incident [{$id}] not found."); + } + + return Response::structured(['data' => $this->presentIncident($incident)]); + } +} diff --git a/src/Mcp/Tools/Incidents/ListIncidents.php b/src/Mcp/Tools/Incidents/ListIncidents.php new file mode 100644 index 00000000..755fbfcf --- /dev/null +++ b/src/Mcp/Tools/Incidents/ListIncidents.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial incident name.'), + 'status' => $schema->integer() + ->enum(array_column(IncidentStatusEnum::cases(), 'value')) + ->description('Filter by status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $incidents = Incident::query() + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) + ->latest() + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $incidents->getCollection()->map(fn (Incident $incident) => $this->presentIncident($incident))->all(), + 'meta' => $this->paginationMeta($incidents), + ]); + } +} diff --git a/src/Mcp/Tools/Incidents/UpdateIncident.php b/src/Mcp/Tools/Incidents/UpdateIncident.php new file mode 100644 index 00000000..2baacb45 --- /dev/null +++ b/src/Mcp/Tools/Incidents/UpdateIncident.php @@ -0,0 +1,70 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The incident ID.'), + 'name' => $schema->string()->max(255)->description('The name of the incident.'), + 'message' => $schema->string()->description('The incident message, in Markdown.'), + 'status' => $schema->integer() + ->enum(array_column(IncidentStatusEnum::cases(), 'value')) + ->description('The status: 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed.'), + 'visible' => $schema->boolean()->description('Whether the incident is visible to guests.'), + 'stickied' => $schema->boolean()->description('Whether the incident is stickied to the top of the status page.'), + 'notifications' => $schema->boolean()->description('Whether to notify verified subscribers.'), + 'occurred_at' => $schema->string()->description('When the incident occurred, as an ISO-8601 datetime.'), + ]; + } + + public function handle(Request $request, UpdateIncidentAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('incidents.manage')) { + return $this->missingAbility('incidents.manage'); + } + + $incident = Incident::query()->find($id = $request->integer('id')); + + if ($incident === null) { + return Response::error("Incident [{$id}] not found."); + } + + $data = UpdateIncidentRequestData::validateAndCreate( + $request->only(['name', 'message', 'status', 'visible', 'stickied', 'notifications', 'occurred_at']) + ); + + return Response::structured(['data' => $this->presentIncident($action->handle($incident, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('incidents.manage'); + } +} diff --git a/src/Mcp/Tools/MetricPoints/AddMetricPoint.php b/src/Mcp/Tools/MetricPoints/AddMetricPoint.php new file mode 100644 index 00000000..5cb55256 --- /dev/null +++ b/src/Mcp/Tools/MetricPoints/AddMetricPoint.php @@ -0,0 +1,58 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'metric_id' => $schema->integer()->required()->description('The metric ID.'), + 'value' => $schema->number()->required()->description('The value to record.'), + 'timestamp' => $schema->string()->description('When the value was recorded, as an ISO-8601 datetime or Unix timestamp. Defaults to now.'), + ]; + } + + public function handle(Request $request, CreateMetricPoint $action): Response|ResponseFactory + { + if (! $this->tokenCan('metric-points.manage')) { + return $this->missingAbility('metric-points.manage'); + } + + $metric = Metric::query()->find($id = $request->integer('metric_id')); + + if ($metric === null) { + return Response::error("Metric [{$id}] not found."); + } + + $data = CreateMetricPointRequestData::validateAndCreate($request->only(['value', 'timestamp'])); + + return Response::structured(['data' => $this->presentMetricPoint($action->handle($metric, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('metric-points.manage'); + } +} diff --git a/src/Mcp/Tools/MetricPoints/DeleteMetricPoint.php b/src/Mcp/Tools/MetricPoints/DeleteMetricPoint.php new file mode 100644 index 00000000..0e18a4b0 --- /dev/null +++ b/src/Mcp/Tools/MetricPoints/DeleteMetricPoint.php @@ -0,0 +1,61 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'metric_id' => $schema->integer()->required()->description('The metric ID.'), + 'metric_point_id' => $schema->integer()->required()->description('The metric point ID.'), + ]; + } + + public function handle(Request $request, DeleteMetricPointAction $action): Response + { + if (! $this->tokenCan('metric-points.delete')) { + return $this->missingAbility('metric-points.delete'); + } + + $metric = Metric::query()->find($metricId = $request->integer('metric_id')); + + if ($metric === null) { + return Response::error("Metric [{$metricId}] not found."); + } + + $point = $metric->metricPoints()->find($pointId = $request->integer('metric_point_id')); + + if ($point === null) { + return Response::error("Metric point [{$pointId}] not found on metric [{$metricId}]."); + } + + $action->handle($point); + + return Response::text("Metric point [{$pointId}] deleted from metric [{$metricId}]."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('metric-points.delete'); + } +} diff --git a/src/Mcp/Tools/Metrics/CreateMetric.php b/src/Mcp/Tools/Metrics/CreateMetric.php new file mode 100644 index 00000000..70726b6b --- /dev/null +++ b/src/Mcp/Tools/Metrics/CreateMetric.php @@ -0,0 +1,59 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the metric.'), + 'suffix' => $schema->string()->max(255)->required()->description('The suffix shown after the metric value, such as ms or %.'), + 'description' => $schema->string()->description('A description of the metric.'), + 'calc_type' => $schema->integer() + ->enum(array_column(MetricTypeEnum::cases(), 'value')) + ->description('How points are aggregated: 0 sum, 1 average.'), + 'default_value' => $schema->number()->description('The default value applied when no points are recorded.'), + 'display_chart' => $schema->boolean()->description('Whether to render a chart for the metric on the status page.'), + 'threshold' => $schema->integer()->min(0)->max(60)->description('The number of minutes between plotted points. Must be a factor of sixty.'), + 'places' => $schema->integer()->description('The number of decimal places shown for values.'), + ]; + } + + public function handle(Request $request, CreateMetricAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('metrics.manage')) { + return $this->missingAbility('metrics.manage'); + } + + $data = CreateMetricRequestData::validateAndCreate($request->all()); + + return Response::structured(['data' => $this->presentMetric($action->handle($data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('metrics.manage'); + } +} diff --git a/src/Mcp/Tools/Metrics/DeleteMetric.php b/src/Mcp/Tools/Metrics/DeleteMetric.php new file mode 100644 index 00000000..f51cd8c4 --- /dev/null +++ b/src/Mcp/Tools/Metrics/DeleteMetric.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The metric ID.'), + ]; + } + + public function handle(Request $request, DeleteMetricAction $action): Response + { + if (! $this->tokenCan('metrics.delete')) { + return $this->missingAbility('metrics.delete'); + } + + $metric = Metric::query()->find($id = $request->integer('id')); + + if ($metric === null) { + return Response::error("Metric [{$id}] not found."); + } + + $action->handle($metric); + + return Response::text("Metric [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('metrics.delete'); + } +} diff --git a/src/Mcp/Tools/Metrics/GetMetric.php b/src/Mcp/Tools/Metrics/GetMetric.php new file mode 100644 index 00000000..169bcb0a --- /dev/null +++ b/src/Mcp/Tools/Metrics/GetMetric.php @@ -0,0 +1,45 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The metric ID.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + $metric = Metric::query() + ->with(['metricPoints' => fn ($query) => $query->latest()->limit(50)]) + ->find($id = $request->integer('id')); + + if ($metric === null) { + return Response::error("Metric [{$id}] not found."); + } + + return Response::structured(['data' => $this->presentMetric($metric)]); + } +} diff --git a/src/Mcp/Tools/Metrics/ListMetrics.php b/src/Mcp/Tools/Metrics/ListMetrics.php new file mode 100644 index 00000000..1c7e97c2 --- /dev/null +++ b/src/Mcp/Tools/Metrics/ListMetrics.php @@ -0,0 +1,49 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial metric name.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $metrics = Metric::query() + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->orderBy('order') + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $metrics->getCollection()->map(fn (Metric $metric) => $this->presentMetric($metric))->all(), + 'meta' => $this->paginationMeta($metrics), + ]); + } +} diff --git a/src/Mcp/Tools/Metrics/UpdateMetric.php b/src/Mcp/Tools/Metrics/UpdateMetric.php new file mode 100644 index 00000000..eb6cf1ab --- /dev/null +++ b/src/Mcp/Tools/Metrics/UpdateMetric.php @@ -0,0 +1,65 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The metric ID.'), + 'name' => $schema->string()->max(255)->description('The name of the metric.'), + 'suffix' => $schema->string()->max(255)->description('The suffix shown after the metric value, such as ms or %.'), + 'description' => $schema->string()->description('A description of the metric.'), + 'default_value' => $schema->number()->description('The default value applied when no points are recorded.'), + 'threshold' => $schema->integer()->min(0)->max(60)->description('The number of minutes between plotted points. Must be a factor of sixty.'), + ]; + } + + public function handle(Request $request, UpdateMetricAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('metrics.manage')) { + return $this->missingAbility('metrics.manage'); + } + + $metric = Metric::query()->find($id = $request->integer('id')); + + if ($metric === null) { + return Response::error("Metric [{$id}] not found."); + } + + $data = UpdateMetricRequestData::validateAndCreate( + $request->only(['name', 'suffix', 'description', 'default_value', 'threshold']) + ); + + return Response::structured(['data' => $this->presentMetric($action->handle($metric, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('metrics.manage'); + } +} diff --git a/src/Mcp/Tools/ScheduleUpdates/DeleteScheduleUpdate.php b/src/Mcp/Tools/ScheduleUpdates/DeleteScheduleUpdate.php new file mode 100644 index 00000000..b2238091 --- /dev/null +++ b/src/Mcp/Tools/ScheduleUpdates/DeleteScheduleUpdate.php @@ -0,0 +1,61 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'schedule_id' => $schema->integer()->required()->description('The schedule ID.'), + 'update_id' => $schema->integer()->required()->description('The schedule update ID.'), + ]; + } + + public function handle(Request $request, DeleteUpdate $action): Response + { + if (! $this->tokenCan('schedule-updates.delete')) { + return $this->missingAbility('schedule-updates.delete'); + } + + $schedule = Schedule::query()->find($scheduleId = $request->integer('schedule_id')); + + if ($schedule === null) { + return Response::error("Schedule [{$scheduleId}] not found."); + } + + $update = $schedule->updates()->find($updateId = $request->integer('update_id')); + + if ($update === null) { + return Response::error("Update [{$updateId}] not found on schedule [{$scheduleId}]."); + } + + $action->handle($update); + + return Response::text("Update [{$updateId}] deleted from schedule [{$scheduleId}]."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedule-updates.delete'); + } +} diff --git a/src/Mcp/Tools/ScheduleUpdates/EditScheduleUpdate.php b/src/Mcp/Tools/ScheduleUpdates/EditScheduleUpdate.php new file mode 100644 index 00000000..562e66a9 --- /dev/null +++ b/src/Mcp/Tools/ScheduleUpdates/EditScheduleUpdate.php @@ -0,0 +1,66 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'schedule_id' => $schema->integer()->required()->description('The schedule ID.'), + 'update_id' => $schema->integer()->required()->description('The schedule update ID.'), + 'message' => $schema->string()->description('The update message, in Markdown.'), + ]; + } + + public function handle(Request $request, EditUpdate $action): Response|ResponseFactory + { + if (! $this->tokenCan('schedule-updates.manage')) { + return $this->missingAbility('schedule-updates.manage'); + } + + $schedule = Schedule::query()->find($scheduleId = $request->integer('schedule_id')); + + if ($schedule === null) { + return Response::error("Schedule [{$scheduleId}] not found."); + } + + $update = $schedule->updates()->find($updateId = $request->integer('update_id')); + + if ($update === null) { + return Response::error("Update [{$updateId}] not found on schedule [{$scheduleId}]."); + } + + $data = EditScheduleUpdateRequestData::validateAndCreate($request->only(['message'])); + + return Response::structured(['data' => $this->presentUpdate($action->handle($update, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedule-updates.manage'); + } +} diff --git a/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php b/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php new file mode 100644 index 00000000..0d5c8c5b --- /dev/null +++ b/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php @@ -0,0 +1,58 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'schedule_id' => $schema->integer()->required()->description('The schedule ID.'), + 'message' => $schema->string()->required()->description('The update message, in Markdown.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s. Marks the schedule as complete.'), + ]; + } + + public function handle(Request $request, CreateUpdate $action): Response|ResponseFactory + { + if (! $this->tokenCan('schedule-updates.manage')) { + return $this->missingAbility('schedule-updates.manage'); + } + + $schedule = Schedule::query()->find($id = $request->integer('schedule_id')); + + if ($schedule === null) { + return Response::error("Schedule [{$id}] not found."); + } + + $data = CreateScheduleUpdateRequestData::validateAndCreate($request->only(['message', 'completed_at'])); + + return Response::structured(['data' => $this->presentUpdate($action->handle($schedule, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedule-updates.manage'); + } +} diff --git a/src/Mcp/Tools/Schedules/CreateSchedule.php b/src/Mcp/Tools/Schedules/CreateSchedule.php new file mode 100644 index 00000000..9f0cc7af --- /dev/null +++ b/src/Mcp/Tools/Schedules/CreateSchedule.php @@ -0,0 +1,65 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->max(255)->required()->description('The name of the maintenance schedule.'), + 'message' => $schema->string()->required()->description('The schedule message, in Markdown.'), + 'scheduled_at' => $schema->string()->required()->description('When the maintenance starts, formatted as Y-m-d H:i:s.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s. Leave empty for incomplete maintenance.'), + 'notifications' => $schema->boolean()->default(false)->description('Whether to notify verified subscribers.'), + 'components' => $schema->array() + ->items($schema->object([ + 'id' => $schema->integer()->required()->description('The component ID.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->required() + ->description('The status to show for the component during the window: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + ])) + ->description('Affected components and the status to show for each.'), + ]; + } + + public function handle(Request $request, CreateScheduleAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('schedules.manage')) { + return $this->missingAbility('schedules.manage'); + } + + $data = CreateScheduleRequestData::validateAndCreate($request->all()); + + $schedule = $action->handle($data)->load(['components', 'updates']); + + return Response::structured(['data' => $this->presentSchedule($schedule)]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedules.manage'); + } +} diff --git a/src/Mcp/Tools/Schedules/DeleteSchedule.php b/src/Mcp/Tools/Schedules/DeleteSchedule.php new file mode 100644 index 00000000..74642d09 --- /dev/null +++ b/src/Mcp/Tools/Schedules/DeleteSchedule.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The schedule ID.'), + ]; + } + + public function handle(Request $request, DeleteScheduleAction $action): Response + { + if (! $this->tokenCan('schedules.delete')) { + return $this->missingAbility('schedules.delete'); + } + + $schedule = Schedule::query()->find($id = $request->integer('id')); + + if ($schedule === null) { + return Response::error("Schedule [{$id}] not found."); + } + + $action->handle($schedule); + + return Response::text("Schedule [{$id}] deleted."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedules.delete'); + } +} diff --git a/src/Mcp/Tools/Schedules/GetSchedule.php b/src/Mcp/Tools/Schedules/GetSchedule.php new file mode 100644 index 00000000..7da350c0 --- /dev/null +++ b/src/Mcp/Tools/Schedules/GetSchedule.php @@ -0,0 +1,43 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The schedule ID.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + $schedule = Schedule::query()->with(['components', 'updates'])->find($id = $request->integer('id')); + + if ($schedule === null) { + return Response::error("Schedule [{$id}] not found."); + } + + return Response::structured(['data' => $this->presentSchedule($schedule)]); + } +} diff --git a/src/Mcp/Tools/Schedules/ListSchedules.php b/src/Mcp/Tools/Schedules/ListSchedules.php new file mode 100644 index 00000000..f4960224 --- /dev/null +++ b/src/Mcp/Tools/Schedules/ListSchedules.php @@ -0,0 +1,49 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Filter by partial schedule name.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $schedules = Schedule::query() + ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) + ->orderByDesc('scheduled_at') + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $schedules->getCollection()->map(fn (Schedule $schedule) => $this->presentSchedule($schedule))->all(), + 'meta' => $this->paginationMeta($schedules), + ]); + } +} diff --git a/src/Mcp/Tools/Schedules/UpdateSchedule.php b/src/Mcp/Tools/Schedules/UpdateSchedule.php new file mode 100644 index 00000000..7f54019f --- /dev/null +++ b/src/Mcp/Tools/Schedules/UpdateSchedule.php @@ -0,0 +1,74 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The schedule ID.'), + 'name' => $schema->string()->max(255)->description('The name of the maintenance schedule.'), + 'message' => $schema->string()->description('The schedule message, in Markdown.'), + 'scheduled_at' => $schema->string()->description('When the maintenance starts, formatted as Y-m-d H:i:s.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s.'), + 'components' => $schema->array() + ->items($schema->object([ + 'id' => $schema->integer()->required()->description('The component ID.'), + 'status' => $schema->integer() + ->enum(array_column(ComponentStatusEnum::cases(), 'value')) + ->required() + ->description('The status to show for the component during the window: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + ])) + ->description('Affected components and the status to show for each.'), + ]; + } + + public function handle(Request $request, UpdateScheduleAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('schedules.manage')) { + return $this->missingAbility('schedules.manage'); + } + + $schedule = Schedule::query()->find($id = $request->integer('id')); + + if ($schedule === null) { + return Response::error("Schedule [{$id}] not found."); + } + + $data = UpdateScheduleRequestData::validateAndCreate( + $request->only(['name', 'message', 'scheduled_at', 'completed_at', 'components']) + ); + + return Response::structured(['data' => $this->presentSchedule($action->handle($schedule, $data))]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('schedules.manage'); + } +} diff --git a/src/Mcp/Tools/Status/GetStatus.php b/src/Mcp/Tools/Status/GetStatus.php new file mode 100644 index 00000000..c5464ec6 --- /dev/null +++ b/src/Mcp/Tools/Status/GetStatus.php @@ -0,0 +1,31 @@ + [ + 'status' => $this->presentEnum($status->current()), + 'components' => array_map(intval(...), (array) $status->components()), + 'incidents' => array_map(intval(...), (array) $status->incidents()), + ], + ]); + } +} diff --git a/src/Mcp/Tools/Subscribers/CreateSubscriber.php b/src/Mcp/Tools/Subscribers/CreateSubscriber.php new file mode 100644 index 00000000..aefbb306 --- /dev/null +++ b/src/Mcp/Tools/Subscribers/CreateSubscriber.php @@ -0,0 +1,65 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'email' => $schema->string()->max(255)->required()->description('The email address to subscribe.'), + 'global' => $schema->boolean()->default(true)->description('Whether the subscriber receives notifications for all components.'), + 'components' => $schema->array() + ->items($schema->integer()) + ->description('IDs of components to subscribe to when not subscribing globally.'), + 'verified' => $schema->boolean()->default(false)->description('Whether to mark the subscriber as verified immediately, skipping the verification email.'), + ]; + } + + public function handle(Request $request, CreateSubscriberAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('subscribers.manage')) { + return $this->missingAbility('subscribers.manage'); + } + + $data = CreateSubscriberRequestData::validateAndCreate($request->all()); + + $subscriber = $action->handle( + $data->email, + $data->global ?? true, + $data->components ?? [], + $data->verified ?? false, + ); + + if (! $subscriber->hasVerifiedEmail()) { + $subscriber->sendEmailVerificationNotification(); + } + + return Response::structured(['data' => $this->presentSubscriber($subscriber)]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('subscribers.manage'); + } +} diff --git a/src/Mcp/Tools/Subscribers/ListSubscribers.php b/src/Mcp/Tools/Subscribers/ListSubscribers.php new file mode 100644 index 00000000..1f951fec --- /dev/null +++ b/src/Mcp/Tools/Subscribers/ListSubscribers.php @@ -0,0 +1,62 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'email' => $schema->string()->description('Filter by partial email address.'), + 'global' => $schema->boolean()->description('Filter by whether the subscriber is subscribed to all components.'), + 'per_page' => $schema->integer()->min(1)->max(100)->default(15), + 'page' => $schema->integer()->min(1)->default(1), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + if (! $this->tokenCan('subscribers.manage')) { + return $this->missingAbility('subscribers.manage'); + } + + $subscribers = Subscriber::query() + ->with('components') + ->when($request->filled('email'), fn ($query) => $query->where('email', 'like', '%'.$request->get('email').'%')) + ->when($request->filled('global'), fn ($query) => $query->where('global', $request->boolean('global'))) + ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); + + return Response::structured([ + 'data' => $subscribers->getCollection()->map(fn (Subscriber $subscriber) => $this->presentSubscriber($subscriber))->all(), + 'meta' => $this->paginationMeta($subscribers), + ]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('subscribers.manage'); + } +} diff --git a/src/Mcp/Tools/Subscribers/UnsubscribeSubscriber.php b/src/Mcp/Tools/Subscribers/UnsubscribeSubscriber.php new file mode 100644 index 00000000..4204628b --- /dev/null +++ b/src/Mcp/Tools/Subscribers/UnsubscribeSubscriber.php @@ -0,0 +1,54 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The subscriber ID.'), + ]; + } + + public function handle(Request $request, UnsubscribeSubscriberAction $action): Response + { + if (! $this->tokenCan('subscribers.delete')) { + return $this->missingAbility('subscribers.delete'); + } + + $subscriber = Subscriber::query()->find($id = $request->integer('id')); + + if ($subscriber === null) { + return Response::error("Subscriber [{$id}] not found."); + } + + $action->handle($subscriber); + + return Response::text("Subscriber [{$id}] unsubscribed."); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('subscribers.delete'); + } +} diff --git a/src/Mcp/Tools/Subscribers/UpdateSubscriber.php b/src/Mcp/Tools/Subscribers/UpdateSubscriber.php new file mode 100644 index 00000000..f545e49c --- /dev/null +++ b/src/Mcp/Tools/Subscribers/UpdateSubscriber.php @@ -0,0 +1,65 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The subscriber ID.'), + 'email' => $schema->string()->max(255)->description('The email address of the subscriber.'), + 'global' => $schema->boolean()->description('Whether the subscriber receives notifications for all components.'), + 'components' => $schema->array() + ->items($schema->integer()) + ->description('IDs of components the subscriber is subscribed to.'), + ]; + } + + public function handle(Request $request, UpdateSubscriberAction $action): Response|ResponseFactory + { + if (! $this->tokenCan('subscribers.manage')) { + return $this->missingAbility('subscribers.manage'); + } + + $subscriber = Subscriber::query()->find($id = $request->integer('id')); + + if ($subscriber === null) { + return Response::error("Subscriber [{$id}] not found."); + } + + $data = UpdateSubscriberRequestData::validateAndCreate($request->only(['email', 'global', 'components'])); + + $action->handle($subscriber, email: $data->email, global: $data->global, components: $data->components); + + return Response::structured(['data' => $this->presentSubscriber($subscriber->fresh())]); + } + + public function shouldRegister(): bool + { + return $this->tokenCan('subscribers.manage'); + } +} diff --git a/src/Settings/AppSettings.php b/src/Settings/AppSettings.php index 60b4a757..c642335b 100644 --- a/src/Settings/AppSettings.php +++ b/src/Settings/AppSettings.php @@ -42,6 +42,10 @@ class AppSettings extends Settings public bool $api_protected = false; + public bool $mcp_enabled = false; + + public bool $mcp_protected = true; + public bool $dynamic_favicon = false; public static function group(): string diff --git a/testbench.yaml b/testbench.yaml index 0b858636..c5945739 100644 --- a/testbench.yaml +++ b/testbench.yaml @@ -5,6 +5,7 @@ providers: - Spatie\LaravelSettings\LaravelSettingsServiceProvider - Spatie\LaravelData\LaravelDataServiceProvider - Laravel\Sanctum\SanctumServiceProvider + - Laravel\Mcp\Server\McpServiceProvider - Filament\FilamentServiceProvider env: diff --git a/tests/Architecture/DataTest.php b/tests/Architecture/DataTest.php index c2aaa720..3710200f 100644 --- a/tests/Architecture/DataTest.php +++ b/tests/Architecture/DataTest.php @@ -20,6 +20,7 @@ 'Cachet\Data', 'Cachet\Http\Controllers', 'Cachet\Filament\Resources', + 'Cachet\Mcp', ]); test('base data test') diff --git a/tests/Feature/Filament/Settings/ManageCachetTest.php b/tests/Feature/Filament/Settings/ManageCachetTest.php index f11add04..cedff6fb 100644 --- a/tests/Feature/Filament/Settings/ManageCachetTest.php +++ b/tests/Feature/Filament/Settings/ManageCachetTest.php @@ -30,3 +30,18 @@ expect(app(AppSettings::class)->refresh()->dynamic_favicon)->toBeTrue(); }); + +it('saves the mcp settings', function () { + expect(app(AppSettings::class)) + ->mcp_enabled->toBeFalse() + ->mcp_protected->toBeTrue(); + + livewire(ManageCachet::class) + ->fillForm(['mcp_enabled' => true, 'mcp_protected' => false]) + ->call('save') + ->assertHasNoFormErrors(); + + expect(app(AppSettings::class)->refresh()) + ->mcp_enabled->toBeTrue() + ->mcp_protected->toBeFalse(); +}); diff --git a/tests/Feature/Mcp/McpSettingsTest.php b/tests/Feature/Mcp/McpSettingsTest.php new file mode 100644 index 00000000..571edfd3 --- /dev/null +++ b/tests/Feature/Mcp/McpSettingsTest.php @@ -0,0 +1,153 @@ + '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ]; +} + +function mcpInitializeRequest(): array +{ + return mcpRequest('initialize', [ + 'protocolVersion' => '2025-03-26', + 'capabilities' => [], + 'clientInfo' => ['name' => 'pest', 'version' => '1.0.0'], + ]); +} + +function enableMcp(bool $protected = true): void +{ + $settings = app(AppSettings::class); + $settings->mcp_enabled = true; + $settings->mcp_protected = $protected; + $settings->save(); +} + +it('responds not found by default', function () { + postJson('/status/mcp', mcpInitializeRequest()) + ->assertNotFound(); +}); + +it('responds not found when disabled, even for authenticated users', function () { + Sanctum::actingAs(User::factory()->create(), ['*']); + + postJson('/status/mcp', mcpInitializeRequest()) + ->assertNotFound(); +}); + +it('requires authentication when the mcp server is protected', function () { + enableMcp(protected: true); + + postJson('/status/mcp', mcpInitializeRequest()) + ->assertUnauthorized(); +}); + +it('allows authenticated clients to connect when the mcp server is protected', function () { + enableMcp(protected: true); + + Sanctum::actingAs(User::factory()->create(), ['*']); + + postJson('/status/mcp', mcpInitializeRequest()) + ->assertOk() + ->assertJsonPath('result.serverInfo.name', 'Cachet'); +}); + +it('allows guests to connect when the mcp server is not protected', function () { + enableMcp(protected: false); + + postJson('/status/mcp', mcpInitializeRequest()) + ->assertOk() + ->assertJsonPath('result.serverInfo.name', 'Cachet'); +}); + +it('responds method not allowed to get requests when enabled', function () { + enableMcp(protected: false); + + getJson('/status/mcp') + ->assertStatus(405); +}); + +it('responds not found to get requests when disabled', function () { + getJson('/status/mcp') + ->assertNotFound(); +}); + +it('only exposes read tools to guests', function () { + enableMcp(protected: false); + + $tools = collect(postJson('/status/mcp', mcpRequest('tools/list')) + ->assertOk() + ->json('result.tools')) + ->pluck('name'); + + expect($tools) + ->toContain('get_status') + ->toContain('list_incidents') + ->toContain('list_components') + ->not->toContain('create_incident') + ->not->toContain('delete_component') + ->not->toContain('list_subscribers'); +}); + +it('exposes write tools matching the token abilities', function () { + enableMcp(protected: false); + + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $tools = collect(postJson('/status/mcp', mcpRequest('tools/list')) + ->assertOk() + ->json('result.tools')) + ->pluck('name'); + + expect($tools) + ->toContain('create_incident') + ->toContain('update_incident') + ->not->toContain('delete_incident') + ->not->toContain('create_component'); +}); + +it('exposes every tool to tokens with all abilities', function () { + enableMcp(protected: false); + + Sanctum::actingAs(User::factory()->create(), ['*']); + + $tools = collect(postJson('/status/mcp', mcpRequest('tools/list', ['per_page' => 50])) + ->assertOk() + ->json('result.tools')) + ->pluck('name'); + + expect($tools)->toHaveCount(41); +}); + +it('does not allow guests to call write tools', function () { + enableMcp(protected: false); + + postJson('/status/mcp', mcpRequest('tools/call', [ + 'name' => 'create_incident', + 'arguments' => ['name' => 'Incident', 'status' => 1, 'message' => 'Test'], + ])) + ->assertOk() + ->assertJsonPath('error.message', 'Tool [create_incident] not found.'); +}); + +it('allows guests to call read tools', function () { + enableMcp(protected: false); + + postJson('/status/mcp', mcpRequest('tools/call', [ + 'name' => 'list_components', + 'arguments' => [], + ])) + ->assertOk() + ->assertJsonPath('result.isError', false); +}); diff --git a/tests/Feature/Mcp/Tools/ComponentToolsTest.php b/tests/Feature/Mcp/Tools/ComponentToolsTest.php new file mode 100644 index 00000000..7558bf09 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ComponentToolsTest.php @@ -0,0 +1,145 @@ +create(); + + CachetServer::tool(ListComponents::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); +}); + +it('filters components by status', function () { + Component::factory()->create(['status' => ComponentStatusEnum::operational]); + Component::factory()->create(['status' => ComponentStatusEnum::major_outage]); + + CachetServer::tool(ListComponents::class, ['status' => ComponentStatusEnum::major_outage->value]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 1)->etc()); +}); + +it('gets a component by id', function () { + $component = Component::factory()->create(['name' => 'Documentation']); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertSee('Documentation'); +}); + +it('returns an error for an unknown component', function () { + CachetServer::tool(GetComponent::class, ['id' => 123]) + ->assertHasErrors(['Component [123] not found.']); +}); + +it('does not expose write tools without the ability', function () { + Sanctum::actingAs(User::factory()->create()); + + CachetServer::tool(CreateComponent::class, ['name' => 'API']) + ->assertHasErrors(); +}); + +it('creates a component', function () { + Sanctum::actingAs(User::factory()->create(), ['components.manage']); + + CachetServer::tool(CreateComponent::class, [ + 'name' => 'API', + 'status' => ComponentStatusEnum::operational->value, + ])->assertOk()->assertSee('API'); + + assertDatabaseHas('components', ['name' => 'API']); +}); + +it('validates component input', function () { + Sanctum::actingAs(User::factory()->create(), ['components.manage']); + + CachetServer::tool(CreateComponent::class, [ + 'name' => str_repeat('a', 300), + ])->assertHasErrors(); +}); + +it('updates a component', function () { + Sanctum::actingAs(User::factory()->create(), ['components.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + CachetServer::tool(UpdateComponent::class, [ + 'id' => $component->id, + 'status' => ComponentStatusEnum::partial_outage->value, + ])->assertOk(); + + expect($component->fresh()->status)->toBe(ComponentStatusEnum::partial_outage); +}); + +it('deletes a component', function () { + Sanctum::actingAs(User::factory()->create(), ['components.delete']); + + $component = Component::factory()->create(); + + CachetServer::tool(DeleteComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertSee("Component [{$component->id}] deleted."); + + expect(Component::query()->find($component->id))->toBeNull(); +}); + +it('lists component groups with their components', function () { + $group = ComponentGroup::factory()->create(['name' => 'Core Services']); + Component::factory()->create(['component_group_id' => $group->id]); + + CachetServer::tool(ListComponentGroups::class) + ->assertOk() + ->assertSee('Core Services'); +}); + +it('creates a component group', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + CachetServer::tool(CreateComponentGroup::class, ['name' => 'Websites']) + ->assertOk() + ->assertSee('Websites'); + + assertDatabaseHas('component_groups', ['name' => 'Websites']); +}); + +it('updates a component group', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.manage']); + + $group = ComponentGroup::factory()->create(['name' => 'Old Name']); + + CachetServer::tool(UpdateComponentGroup::class, [ + 'id' => $group->id, + 'name' => 'New Name', + ])->assertOk(); + + expect($group->fresh()->name)->toBe('New Name'); +}); + +it('deletes a component group', function () { + Sanctum::actingAs(User::factory()->create(), ['component-groups.delete']); + + $group = ComponentGroup::factory()->create(); + + CachetServer::tool(DeleteComponentGroup::class, ['id' => $group->id]) + ->assertOk(); + + assertDatabaseMissing('component_groups', ['id' => $group->id]); +}); diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php new file mode 100644 index 00000000..ad5e5aed --- /dev/null +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -0,0 +1,263 @@ +create(); + + CachetServer::tool(ListIncidents::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 3)->etc()); +}); + +it('filters incidents by status', function () { + Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + Incident::factory()->create(['status' => IncidentStatusEnum::fixed]); + + CachetServer::tool(ListIncidents::class, ['status' => IncidentStatusEnum::fixed->value]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 1)->etc()); +}); + +it('gets an incident with its updates and components', function () { + $incident = Incident::factory()->create(['name' => 'Database Outage']); + $incident->updates()->create(['status' => IncidentStatusEnum::watching, 'message' => 'Monitoring the fix.']); + + CachetServer::tool(GetIncident::class, ['id' => $incident->id]) + ->assertOk() + ->assertSee('Database Outage') + ->assertSee('Monitoring the fix.'); +}); + +it('returns an error for an unknown incident', function () { + CachetServer::tool(GetIncident::class, ['id' => 999]) + ->assertHasErrors(['Incident [999] not found.']); +}); + +it('hides incident write tools from guests', function () { + CachetServer::tool(CreateIncident::class, [ + 'name' => 'Incident', + 'status' => IncidentStatusEnum::investigating->value, + 'message' => 'Something broke.', + ])->assertHasErrors(); +}); + +it('creates an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'API Slowdown', + 'status' => IncidentStatusEnum::investigating->value, + 'message' => 'We are investigating API latency.', + ])->assertOk()->assertSee('API Slowdown'); + + assertDatabaseHas('incidents', ['name' => 'API Slowdown']); +}); + +it('creates an incident and updates the affected components', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'API Outage', + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is down.', + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], + ], + ])->assertOk(); + + $incident = Incident::query()->firstWhere('name', 'API Outage'); + + expect($incident->incidentComponents()->first()) + ->component_id->toBe($component->id) + ->component_status->toBe(ComponentStatusEnum::major_outage); +}); + +it('creates an incident from a template', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + IncidentTemplate::factory()->create([ + 'slug' => 'server-fire', + 'template' => 'The server is on fire.', + ]); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'Fire', + 'status' => IncidentStatusEnum::investigating->value, + 'template' => 'server-fire', + ])->assertOk()->assertSee('The server is on fire.'); +}); + +it('requires a message or template when creating an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'Incident', + 'status' => IncidentStatusEnum::investigating->value, + ])->assertHasErrors(); +}); + +it('updates an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $incident = Incident::factory()->create(['stickied' => false]); + + CachetServer::tool(UpdateIncident::class, [ + 'id' => $incident->id, + 'stickied' => true, + ])->assertOk(); + + expect($incident->fresh()->stickied)->toBeTrue(); +}); + +it('deletes an incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.delete']); + + $incident = Incident::factory()->create(); + + CachetServer::tool(DeleteIncident::class, ['id' => $incident->id]) + ->assertOk(); + + expect(Incident::query()->find($incident->id))->toBeNull(); +}); + +it('records an incident update', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::investigating]); + + CachetServer::tool(RecordIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'We found the problem.', + ])->assertOk()->assertSee('We found the problem.'); + + expect($incident->updates()->count())->toBe(1); +}); + +it('resolves the incident and its components when recording a fixed update', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::major_outage]); + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::identified]); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + CachetServer::tool(RecordIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'status' => IncidentStatusEnum::fixed->value, + 'message' => 'The problem is resolved.', + ])->assertOk(); + + expect($incident->fresh()->status)->toBe(IncidentStatusEnum::fixed) + ->and($incident->incidentComponents()->first()->component_status)->toBe(ComponentStatusEnum::operational); +}); + +it('edits an incident update', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + $update = $incident->updates()->create(['status' => IncidentStatusEnum::watching, 'message' => 'Old message.']); + + CachetServer::tool(EditIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'update_id' => $update->id, + 'message' => 'New message.', + ])->assertOk(); + + expect($update->fresh()->message)->toBe('New message.'); +}); + +it('deletes an incident update', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.delete']); + + $incident = Incident::factory()->create(); + $update = $incident->updates()->create(['status' => IncidentStatusEnum::watching, 'message' => 'Message.']); + + CachetServer::tool(DeleteIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'update_id' => $update->id, + ])->assertOk(); + + expect($incident->updates()->count())->toBe(0); +}); + +it('scopes incident updates to the incident', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-updates.manage']); + + $incident = Incident::factory()->create(); + $other = Incident::factory()->create(); + $update = $other->updates()->create(['status' => IncidentStatusEnum::watching, 'message' => 'Message.']); + + CachetServer::tool(EditIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'update_id' => $update->id, + 'message' => 'Hijacked.', + ])->assertHasErrors(["Update [{$update->id}] not found on incident [{$incident->id}]."]); +}); + +it('lists incident templates', function () { + IncidentTemplate::factory()->create(['name' => 'Standard Outage']); + + CachetServer::tool(ListIncidentTemplates::class) + ->assertOk() + ->assertSee('Standard Outage'); +}); + +it('creates an incident template', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); + + CachetServer::tool(CreateIncidentTemplate::class, [ + 'name' => 'Third Party Outage', + 'template' => 'A third party provider is having issues.', + ])->assertOk(); + + assertDatabaseHas('incident_templates', ['slug' => 'third-party-outage']); +}); + +it('updates an incident template', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-templates.manage']); + + $template = IncidentTemplate::factory()->create(); + + CachetServer::tool(UpdateIncidentTemplate::class, [ + 'id' => $template->id, + 'template' => 'Updated body.', + ])->assertOk(); + + expect($template->fresh()->template)->toBe('Updated body.'); +}); + +it('deletes an incident template', function () { + Sanctum::actingAs(User::factory()->create(), ['incident-templates.delete']); + + $template = IncidentTemplate::factory()->create(); + + CachetServer::tool(DeleteIncidentTemplate::class, ['id' => $template->id]) + ->assertOk(); + + expect(IncidentTemplate::query()->find($template->id))->toBeNull(); +}); diff --git a/tests/Feature/Mcp/Tools/MetricToolsTest.php b/tests/Feature/Mcp/Tools/MetricToolsTest.php new file mode 100644 index 00000000..6cb419bc --- /dev/null +++ b/tests/Feature/Mcp/Tools/MetricToolsTest.php @@ -0,0 +1,113 @@ +create(); + + CachetServer::tool(ListMetrics::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); +}); + +it('gets a metric with recent points', function () { + $metric = Metric::factory()->create(['name' => 'Response Time']); + $metric->metricPoints()->create(['value' => 42.5]); + + CachetServer::tool(GetMetric::class, ['id' => $metric->id]) + ->assertOk() + ->assertSee('Response Time'); +}); + +it('returns an error for an unknown metric', function () { + CachetServer::tool(GetMetric::class, ['id' => 999]) + ->assertHasErrors(['Metric [999] not found.']); +}); + +it('creates a metric', function () { + Sanctum::actingAs(User::factory()->create(), ['metrics.manage']); + + CachetServer::tool(CreateMetric::class, [ + 'name' => 'API Latency', + 'suffix' => 'ms', + ])->assertOk()->assertSee('API Latency'); + + assertDatabaseHas('metrics', ['name' => 'API Latency']); +}); + +it('updates a metric', function () { + Sanctum::actingAs(User::factory()->create(), ['metrics.manage']); + + $metric = Metric::factory()->create(['suffix' => 'ms']); + + CachetServer::tool(UpdateMetric::class, [ + 'id' => $metric->id, + 'suffix' => 'seconds', + ])->assertOk(); + + expect($metric->fresh()->suffix)->toBe('seconds'); +}); + +it('deletes a metric', function () { + Sanctum::actingAs(User::factory()->create(), ['metrics.delete']); + + $metric = Metric::factory()->create(); + + CachetServer::tool(DeleteMetric::class, ['id' => $metric->id]) + ->assertOk(); + + expect(Metric::query()->find($metric->id))->toBeNull(); +}); + +it('adds a metric point', function () { + Sanctum::actingAs(User::factory()->create(), ['metric-points.manage']); + + $metric = Metric::factory()->create(); + + CachetServer::tool(AddMetricPoint::class, [ + 'metric_id' => $metric->id, + 'value' => 128.5, + ])->assertOk(); + + expect($metric->metricPoints()->count())->toBe(1); +}); + +it('deletes a metric point', function () { + Sanctum::actingAs(User::factory()->create(), ['metric-points.delete']); + + $metric = Metric::factory()->create(); + $point = $metric->metricPoints()->create(['value' => 10]); + + CachetServer::tool(DeleteMetricPoint::class, [ + 'metric_id' => $metric->id, + 'metric_point_id' => $point->id, + ])->assertOk(); + + expect($metric->metricPoints()->count())->toBe(0); +}); + +it('scopes metric points to the metric', function () { + Sanctum::actingAs(User::factory()->create(), ['metric-points.delete']); + + $metric = Metric::factory()->create(); + $other = Metric::factory()->create(); + $point = $other->metricPoints()->create(['value' => 10]); + + CachetServer::tool(DeleteMetricPoint::class, [ + 'metric_id' => $metric->id, + 'metric_point_id' => $point->id, + ])->assertHasErrors(["Metric point [{$point->id}] not found on metric [{$metric->id}]."]); +}); diff --git a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php new file mode 100644 index 00000000..74459b52 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php @@ -0,0 +1,144 @@ +create(); + + CachetServer::tool(ListSchedules::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); +}); + +it('gets a schedule by id', function () { + $schedule = Schedule::factory()->create(['name' => 'Database Upgrade']); + + CachetServer::tool(GetSchedule::class, ['id' => $schedule->id]) + ->assertOk() + ->assertSee('Database Upgrade'); +}); + +it('returns an error for an unknown schedule', function () { + CachetServer::tool(GetSchedule::class, ['id' => 999]) + ->assertHasErrors(['Schedule [999] not found.']); +}); + +it('creates a schedule', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + CachetServer::tool(CreateSchedule::class, [ + 'name' => 'Server Maintenance', + 'message' => 'We will be upgrading the servers.', + 'scheduled_at' => now()->addDay()->format('Y-m-d H:i:s'), + ])->assertOk()->assertSee('Server Maintenance'); + + assertDatabaseHas('schedules', ['name' => 'Server Maintenance']); +}); + +it('validates schedule input', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + CachetServer::tool(CreateSchedule::class, [ + 'name' => 'Missing everything else', + ])->assertHasErrors(); +}); + +it('updates a schedule', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + $schedule = Schedule::factory()->create(['name' => 'Old Name']); + + CachetServer::tool(UpdateSchedule::class, [ + 'id' => $schedule->id, + 'name' => 'New Name', + ])->assertOk(); + + expect($schedule->fresh()->name)->toBe('New Name'); +}); + +it('deletes a schedule', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.delete']); + + $schedule = Schedule::factory()->create(); + + CachetServer::tool(DeleteSchedule::class, ['id' => $schedule->id]) + ->assertOk(); + + expect(Schedule::query()->find($schedule->id))->toBeNull(); +}); + +it('records a schedule update', function () { + Sanctum::actingAs(User::factory()->create(), ['schedule-updates.manage']); + + $schedule = Schedule::factory()->create(['completed_at' => null]); + + CachetServer::tool(RecordScheduleUpdate::class, [ + 'schedule_id' => $schedule->id, + 'message' => 'Maintenance is progressing well.', + ])->assertOk()->assertSee('Maintenance is progressing well.'); + + expect($schedule->updates()->count())->toBe(1); +}); + +it('completes the schedule when recording an update with completed at', function () { + Sanctum::actingAs(User::factory()->create(), ['schedule-updates.manage']); + + $schedule = Schedule::factory()->create([ + 'scheduled_at' => now()->subHour(), + 'completed_at' => null, + ]); + + CachetServer::tool(RecordScheduleUpdate::class, [ + 'schedule_id' => $schedule->id, + 'message' => 'Maintenance is complete.', + 'completed_at' => now()->format('Y-m-d H:i:s'), + ])->assertOk(); + + expect($schedule->fresh()) + ->completed_at->not->toBeNull() + ->status->toBe(ScheduleStatusEnum::complete); +}); + +it('edits a schedule update', function () { + Sanctum::actingAs(User::factory()->create(), ['schedule-updates.manage']); + + $schedule = Schedule::factory()->create(); + $update = $schedule->updates()->create(['message' => 'Old message.']); + + CachetServer::tool(EditScheduleUpdate::class, [ + 'schedule_id' => $schedule->id, + 'update_id' => $update->id, + 'message' => 'New message.', + ])->assertOk(); + + expect($update->fresh()->message)->toBe('New message.'); +}); + +it('deletes a schedule update', function () { + Sanctum::actingAs(User::factory()->create(), ['schedule-updates.delete']); + + $schedule = Schedule::factory()->create(); + $update = $schedule->updates()->create(['message' => 'Message.']); + + CachetServer::tool(DeleteScheduleUpdate::class, [ + 'schedule_id' => $schedule->id, + 'update_id' => $update->id, + ])->assertOk(); + + expect($schedule->updates()->count())->toBe(0); +}); diff --git a/tests/Feature/Mcp/Tools/StatusToolTest.php b/tests/Feature/Mcp/Tools/StatusToolTest.php new file mode 100644 index 00000000..a65526dc --- /dev/null +++ b/tests/Feature/Mcp/Tools/StatusToolTest.php @@ -0,0 +1,22 @@ +create(['status' => ComponentStatusEnum::operational]); + + CachetServer::tool(GetStatus::class) + ->assertOk() + ->assertSee('operational'); +}); + +it('reports a major outage', function () { + Component::factory(2)->create(['status' => ComponentStatusEnum::major_outage]); + + CachetServer::tool(GetStatus::class) + ->assertOk() + ->assertSee('major_outage'); +}); diff --git a/tests/Feature/Mcp/Tools/SubscriberToolsTest.php b/tests/Feature/Mcp/Tools/SubscriberToolsTest.php new file mode 100644 index 00000000..7dd711ce --- /dev/null +++ b/tests/Feature/Mcp/Tools/SubscriberToolsTest.php @@ -0,0 +1,99 @@ +create(); + + CachetServer::tool(ListSubscribers::class) + ->assertHasErrors(); +}); + +it('hides the subscribers list from tokens without the ability', function () { + Sanctum::actingAs(User::factory()->create()); + + Subscriber::factory(2)->create(); + + CachetServer::tool(ListSubscribers::class) + ->assertHasErrors(); +}); + +it('lists subscribers with the ability', function () { + Sanctum::actingAs(User::factory()->create(), ['subscribers.manage']); + + Subscriber::factory(2)->create(); + + CachetServer::tool(ListSubscribers::class) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json->has('data', 2)->etc()); +}); + +it('creates a subscriber and sends a verification email', function () { + Notification::fake(); + + Sanctum::actingAs(User::factory()->create(), ['subscribers.manage']); + + CachetServer::tool(CreateSubscriber::class, ['email' => 'james@example.com']) + ->assertOk() + ->assertSee('james@example.com'); + + $subscriber = Subscriber::query()->firstWhere('email', 'james@example.com'); + + expect($subscriber)->not->toBeNull(); + + Notification::assertSentTo($subscriber, VerifySubscriberEmail::class); +}); + +it('creates a verified subscriber without sending a verification email', function () { + Notification::fake(); + + Sanctum::actingAs(User::factory()->create(), ['subscribers.manage']); + + CachetServer::tool(CreateSubscriber::class, [ + 'email' => 'james@example.com', + 'verified' => true, + ])->assertOk(); + + Notification::assertNothingSent(); +}); + +it('validates the subscriber email', function () { + Sanctum::actingAs(User::factory()->create(), ['subscribers.manage']); + + CachetServer::tool(CreateSubscriber::class, ['email' => 'not-an-email']) + ->assertHasErrors(); +}); + +it('updates a subscriber', function () { + Sanctum::actingAs(User::factory()->create(), ['subscribers.manage']); + + $subscriber = Subscriber::factory()->create(['email' => 'old@example.com']); + + CachetServer::tool(UpdateSubscriber::class, [ + 'id' => $subscriber->id, + 'email' => 'new@example.com', + ])->assertOk(); + + expect($subscriber->fresh()->email)->toBe('new@example.com'); +}); + +it('unsubscribes a subscriber', function () { + Sanctum::actingAs(User::factory()->create(), ['subscribers.delete']); + + $subscriber = Subscriber::factory()->create(); + + CachetServer::tool(UnsubscribeSubscriber::class, ['id' => $subscriber->id]) + ->assertOk(); + + expect(Subscriber::query()->find($subscriber->id))->toBeNull(); +}); From f0a55ee27f84da5b9e5ff94c1c99b3c7e1bc2669 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 21:34:25 +0000 Subject: [PATCH 2/5] Scope MCP read tools to caller visibility and enable MCP in demo mode Applies the GHSA-6ghm-wf22-pvx5 fix (#367) to the MCP server, which was written against the pre-fix pattern: read tools now scope incidents, metrics, and component groups with the HasVisibility scope, and components inherit their group's visibility, mirroring ComponentController. Disabled components are hidden by default and only reachable by authenticated callers via the enabled argument; get tools return not-found errors for out-of-scope records. Guests also no longer see disabled components inside component groups. The caller check reuses the ChecksApiAuthentication concern introduced by the fix. The demo seeder now enables the MCP server with authentication not required, so demo instances expose the public read tools while write tools continue to require API token abilities. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0157S7dDNDwYuqWsqeg1DB94 --- database/seeders/DatabaseSeeder.php | 2 + .../Concerns/ScopesComponentVisibility.php | 34 ++++++ .../ComponentGroups/ListComponentGroups.php | 9 +- src/Mcp/Tools/Components/GetComponent.php | 5 +- src/Mcp/Tools/Components/ListComponents.php | 8 +- src/Mcp/Tools/Incidents/GetIncident.php | 7 +- src/Mcp/Tools/Incidents/ListIncidents.php | 3 + src/Mcp/Tools/Metrics/GetMetric.php | 3 + src/Mcp/Tools/Metrics/ListMetrics.php | 3 + .../Feature/Mcp/Tools/ComponentToolsTest.php | 110 ++++++++++++++++++ tests/Feature/Mcp/Tools/IncidentToolsTest.php | 42 +++++++ tests/Feature/Mcp/Tools/MetricToolsTest.php | 32 +++++ 12 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 src/Mcp/Concerns/ScopesComponentVisibility.php diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 34d42d45..1161e2b6 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -254,6 +254,8 @@ public function run(): void $appSettings->recent_incidents_days = 7; $appSettings->api_enabled = true; $appSettings->api_protected = false; + $appSettings->mcp_enabled = true; + $appSettings->mcp_protected = false; $appSettings->save(); $customizationSettings = app(CustomizationSettings::class); diff --git a/src/Mcp/Concerns/ScopesComponentVisibility.php b/src/Mcp/Concerns/ScopesComponentVisibility.php new file mode 100644 index 00000000..7cf0d316 --- /dev/null +++ b/src/Mcp/Concerns/ScopesComponentVisibility.php @@ -0,0 +1,34 @@ + + */ + protected function visibleComponents(): Builder + { + $visibleGroups = ComponentGroup::query()->visible($this->isAuthenticated())->select('id'); + + return Component::query() + ->unless($this->isAuthenticated(), fn (Builder $query) => $query->enabled()) + ->where(function ($query) use ($visibleGroups): void { + $query->whereNull('component_group_id') + ->orWhereIn('component_group_id', $visibleGroups); + }); + } +} diff --git a/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php b/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php index 3a24c4c3..adb06e6d 100644 --- a/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php +++ b/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\ComponentGroups; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Mcp\Concerns\InteractsWithPagination; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\ComponentGroup; @@ -15,6 +16,7 @@ #[IsReadOnly] class ListComponentGroups extends Tool { + use ChecksApiAuthentication; use InteractsWithPagination; use PresentsResources; @@ -37,7 +39,12 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): ResponseFactory { $groups = ComponentGroup::query() - ->with('components') + ->visible($this->isAuthenticated()) + ->with(['components' => function ($query): void { + if (! $this->isAuthenticated()) { + $query->enabled(); + } + }]) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->orderBy('order') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Components/GetComponent.php b/src/Mcp/Tools/Components/GetComponent.php index b0f52994..bf9a6bc3 100644 --- a/src/Mcp/Tools/Components/GetComponent.php +++ b/src/Mcp/Tools/Components/GetComponent.php @@ -3,7 +3,7 @@ namespace Cachet\Mcp\Tools\Components; use Cachet\Mcp\Concerns\PresentsResources; -use Cachet\Models\Component; +use Cachet\Mcp\Concerns\ScopesComponentVisibility; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -15,6 +15,7 @@ class GetComponent extends Tool { use PresentsResources; + use ScopesComponentVisibility; protected string $name = 'get_component'; @@ -32,7 +33,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response|ResponseFactory { - $component = Component::query()->find($id = $request->integer('id')); + $component = $this->visibleComponents()->find($id = $request->integer('id')); if ($component === null) { return Response::error("Component [{$id}] not found."); diff --git a/src/Mcp/Tools/Components/ListComponents.php b/src/Mcp/Tools/Components/ListComponents.php index 4835508b..a5ad341b 100644 --- a/src/Mcp/Tools/Components/ListComponents.php +++ b/src/Mcp/Tools/Components/ListComponents.php @@ -5,6 +5,7 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Mcp\Concerns\InteractsWithPagination; use Cachet\Mcp\Concerns\PresentsResources; +use Cachet\Mcp\Concerns\ScopesComponentVisibility; use Cachet\Models\Component; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; @@ -18,6 +19,7 @@ class ListComponents extends Tool { use InteractsWithPagination; use PresentsResources; + use ScopesComponentVisibility; protected string $name = 'list_components'; @@ -33,7 +35,7 @@ public function schema(JsonSchema $schema): array 'status' => $schema->integer() ->enum(array_column(ComponentStatusEnum::cases(), 'value')) ->description('Filter by status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), - 'enabled' => $schema->boolean()->description('Filter by enabled state.'), + 'enabled' => $schema->boolean()->default(true)->description('Filter by enabled state. Disabled components are hidden by default and are only visible to authenticated callers.'), 'component_group_id' => $schema->integer()->description('Filter by component group ID.'), 'per_page' => $schema->integer()->min(1)->max(100)->default(15), 'page' => $schema->integer()->min(1)->default(1), @@ -42,10 +44,10 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): ResponseFactory { - $components = Component::query() + $components = $this->visibleComponents() ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) - ->when($request->filled('enabled'), fn ($query) => $query->where('enabled', $request->boolean('enabled'))) + ->where('enabled', $request->boolean('enabled', true)) ->when($request->filled('component_group_id'), fn ($query) => $query->where('component_group_id', $request->integer('component_group_id'))) ->orderBy('order') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/src/Mcp/Tools/Incidents/GetIncident.php b/src/Mcp/Tools/Incidents/GetIncident.php index 0dd0d6ce..2b68e9d2 100644 --- a/src/Mcp/Tools/Incidents/GetIncident.php +++ b/src/Mcp/Tools/Incidents/GetIncident.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\Incidents; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Incident; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -14,6 +15,7 @@ #[IsReadOnly] class GetIncident extends Tool { + use ChecksApiAuthentication; use PresentsResources; protected string $name = 'get_incident'; @@ -32,7 +34,10 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response|ResponseFactory { - $incident = Incident::query()->with(['components', 'updates'])->find($id = $request->integer('id')); + $incident = Incident::query() + ->visible($this->isAuthenticated()) + ->with(['components', 'updates']) + ->find($id = $request->integer('id')); if ($incident === null) { return Response::error("Incident [{$id}] not found."); diff --git a/src/Mcp/Tools/Incidents/ListIncidents.php b/src/Mcp/Tools/Incidents/ListIncidents.php index 755fbfcf..c49dc37a 100644 --- a/src/Mcp/Tools/Incidents/ListIncidents.php +++ b/src/Mcp/Tools/Incidents/ListIncidents.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\Incidents; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Enums\IncidentStatusEnum; use Cachet\Mcp\Concerns\InteractsWithPagination; use Cachet\Mcp\Concerns\PresentsResources; @@ -16,6 +17,7 @@ #[IsReadOnly] class ListIncidents extends Tool { + use ChecksApiAuthentication; use InteractsWithPagination; use PresentsResources; @@ -41,6 +43,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): ResponseFactory { $incidents = Incident::query() + ->visible($this->isAuthenticated()) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->when($request->filled('status'), fn ($query) => $query->where('status', $request->integer('status'))) ->latest() diff --git a/src/Mcp/Tools/Metrics/GetMetric.php b/src/Mcp/Tools/Metrics/GetMetric.php index 169bcb0a..15d8f34b 100644 --- a/src/Mcp/Tools/Metrics/GetMetric.php +++ b/src/Mcp/Tools/Metrics/GetMetric.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\Metrics; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Metric; use Illuminate\Contracts\JsonSchema\JsonSchema; @@ -14,6 +15,7 @@ #[IsReadOnly] class GetMetric extends Tool { + use ChecksApiAuthentication; use PresentsResources; protected string $name = 'get_metric'; @@ -33,6 +35,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): Response|ResponseFactory { $metric = Metric::query() + ->visible($this->isAuthenticated()) ->with(['metricPoints' => fn ($query) => $query->latest()->limit(50)]) ->find($id = $request->integer('id')); diff --git a/src/Mcp/Tools/Metrics/ListMetrics.php b/src/Mcp/Tools/Metrics/ListMetrics.php index 1c7e97c2..1508878c 100644 --- a/src/Mcp/Tools/Metrics/ListMetrics.php +++ b/src/Mcp/Tools/Metrics/ListMetrics.php @@ -2,6 +2,7 @@ namespace Cachet\Mcp\Tools\Metrics; +use Cachet\Concerns\ChecksApiAuthentication; use Cachet\Mcp\Concerns\InteractsWithPagination; use Cachet\Mcp\Concerns\PresentsResources; use Cachet\Models\Metric; @@ -15,6 +16,7 @@ #[IsReadOnly] class ListMetrics extends Tool { + use ChecksApiAuthentication; use InteractsWithPagination; use PresentsResources; @@ -37,6 +39,7 @@ public function schema(JsonSchema $schema): array public function handle(Request $request): ResponseFactory { $metrics = Metric::query() + ->visible($this->isAuthenticated()) ->when($request->filled('name'), fn ($query) => $query->where('name', 'like', '%'.$request->get('name').'%')) ->orderBy('order') ->simplePaginate(perPage: $this->perPage($request), page: $this->page($request)); diff --git a/tests/Feature/Mcp/Tools/ComponentToolsTest.php b/tests/Feature/Mcp/Tools/ComponentToolsTest.php index 7558bf09..094e8755 100644 --- a/tests/Feature/Mcp/Tools/ComponentToolsTest.php +++ b/tests/Feature/Mcp/Tools/ComponentToolsTest.php @@ -1,6 +1,7 @@ $group->id]); }); + +it('hides components in restricted groups from guests', function () { + $hidden = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $internal = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Component::factory()->create(['name' => 'Hidden Component', 'component_group_id' => $hidden->id]); + Component::factory()->create(['name' => 'Internal Component', 'component_group_id' => $internal->id]); + Component::factory()->create(['name' => 'Ungrouped Component']); + + CachetServer::tool(ListComponents::class) + ->assertOk() + ->assertSee('Ungrouped Component') + ->assertDontSee('Hidden Component') + ->assertDontSee('Internal Component'); +}); + +it('shows components in authenticated-only groups to authenticated callers', function () { + Sanctum::actingAs(User::factory()->create()); + + $hidden = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::hidden]); + $internal = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + Component::factory()->create(['name' => 'Hidden Component', 'component_group_id' => $hidden->id]); + Component::factory()->create(['name' => 'Internal Component', 'component_group_id' => $internal->id]); + + CachetServer::tool(ListComponents::class) + ->assertOk() + ->assertSee('Internal Component') + ->assertDontSee('Hidden Component'); +}); + +it('hides disabled components from guests', function () { + Component::factory()->create(['name' => 'Enabled Component', 'enabled' => true]); + Component::factory()->create(['name' => 'Disabled Component', 'enabled' => false]); + + CachetServer::tool(ListComponents::class) + ->assertOk() + ->assertSee('Enabled Component') + ->assertDontSee('Disabled Component'); +}); + +it('lets authenticated callers list disabled components', function () { + Sanctum::actingAs(User::factory()->create()); + + Component::factory()->create(['name' => 'Enabled Component', 'enabled' => true]); + Component::factory()->create(['name' => 'Disabled Component', 'enabled' => false]); + + CachetServer::tool(ListComponents::class, ['enabled' => false]) + ->assertOk() + ->assertSee('Disabled Component') + ->assertDontSee('Enabled Component'); +}); + +it('does not reveal disabled components to guests by id', function () { + $component = Component::factory()->create(['enabled' => false]); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertHasErrors(["Component [{$component->id}] not found."]); +}); + +it('lets authenticated callers get disabled components by id', function () { + Sanctum::actingAs(User::factory()->create()); + + $component = Component::factory()->create(['name' => 'Disabled Component', 'enabled' => false]); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertSee('Disabled Component'); +}); + +it('does not reveal components in restricted groups to guests by id', function () { + $group = ComponentGroup::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + $component = Component::factory()->create(['component_group_id' => $group->id]); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertHasErrors(["Component [{$component->id}] not found."]); +}); + +it('hides restricted component groups from guests', function () { + ComponentGroup::factory()->create(['name' => 'Public Group', 'visible' => ResourceVisibilityEnum::guest]); + ComponentGroup::factory()->create(['name' => 'Internal Group', 'visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['name' => 'Hidden Group', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListComponentGroups::class) + ->assertOk() + ->assertSee('Public Group') + ->assertDontSee('Internal Group') + ->assertDontSee('Hidden Group'); +}); + +it('shows authenticated-only component groups to authenticated callers but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + ComponentGroup::factory()->create(['name' => 'Internal Group', 'visible' => ResourceVisibilityEnum::authenticated]); + ComponentGroup::factory()->create(['name' => 'Hidden Group', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListComponentGroups::class) + ->assertOk() + ->assertSee('Internal Group') + ->assertDontSee('Hidden Group'); +}); + +it('hides disabled components inside groups from guests', function () { + $group = ComponentGroup::factory()->create(['name' => 'Core Services']); + Component::factory()->create(['name' => 'Disabled Grouped Component', 'component_group_id' => $group->id, 'enabled' => false]); + + CachetServer::tool(ListComponentGroups::class) + ->assertOk() + ->assertSee('Core Services') + ->assertDontSee('Disabled Grouped Component'); +}); diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index ad5e5aed..5304fa0d 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -2,6 +2,7 @@ use Cachet\Enums\ComponentStatusEnum; use Cachet\Enums\IncidentStatusEnum; +use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\CachetServer; use Cachet\Mcp\Tools\Incidents\CreateIncident; use Cachet\Mcp\Tools\Incidents\DeleteIncident; @@ -261,3 +262,44 @@ expect(IncidentTemplate::query()->find($template->id))->toBeNull(); }); + +it('hides restricted incidents from guests', function () { + Incident::factory()->create(['name' => 'Public Incident', 'visible' => ResourceVisibilityEnum::guest]); + Incident::factory()->create(['name' => 'Internal Incident', 'visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['name' => 'Hidden Incident', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListIncidents::class) + ->assertOk() + ->assertSee('Public Incident') + ->assertDontSee('Internal Incident') + ->assertDontSee('Hidden Incident'); +}); + +it('shows authenticated-only incidents to authenticated callers but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Incident::factory()->create(['name' => 'Internal Incident', 'visible' => ResourceVisibilityEnum::authenticated]); + Incident::factory()->create(['name' => 'Hidden Incident', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListIncidents::class) + ->assertOk() + ->assertSee('Internal Incident') + ->assertDontSee('Hidden Incident'); +}); + +it('does not reveal restricted incidents to guests by id', function () { + $incident = Incident::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + CachetServer::tool(GetIncident::class, ['id' => $incident->id]) + ->assertHasErrors(["Incident [{$incident->id}] not found."]); +}); + +it('reveals authenticated-only incidents by id to authenticated callers', function () { + Sanctum::actingAs(User::factory()->create()); + + $incident = Incident::factory()->create(['name' => 'Internal Incident', 'visible' => ResourceVisibilityEnum::authenticated]); + + CachetServer::tool(GetIncident::class, ['id' => $incident->id]) + ->assertOk() + ->assertSee('Internal Incident'); +}); diff --git a/tests/Feature/Mcp/Tools/MetricToolsTest.php b/tests/Feature/Mcp/Tools/MetricToolsTest.php index 6cb419bc..7545e4ad 100644 --- a/tests/Feature/Mcp/Tools/MetricToolsTest.php +++ b/tests/Feature/Mcp/Tools/MetricToolsTest.php @@ -1,5 +1,6 @@ $point->id, ])->assertHasErrors(["Metric point [{$point->id}] not found on metric [{$metric->id}]."]); }); + +it('hides restricted metrics from guests', function () { + Metric::factory()->create(['name' => 'Public Metric', 'visible' => ResourceVisibilityEnum::guest]); + Metric::factory()->create(['name' => 'Internal Metric', 'visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['name' => 'Hidden Metric', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListMetrics::class) + ->assertOk() + ->assertSee('Public Metric') + ->assertDontSee('Internal Metric') + ->assertDontSee('Hidden Metric'); +}); + +it('shows authenticated-only metrics to authenticated callers but never hidden ones', function () { + Sanctum::actingAs(User::factory()->create()); + + Metric::factory()->create(['name' => 'Internal Metric', 'visible' => ResourceVisibilityEnum::authenticated]); + Metric::factory()->create(['name' => 'Hidden Metric', 'visible' => ResourceVisibilityEnum::hidden]); + + CachetServer::tool(ListMetrics::class) + ->assertOk() + ->assertSee('Internal Metric') + ->assertDontSee('Hidden Metric'); +}); + +it('does not reveal restricted metrics to guests by id', function () { + $metric = Metric::factory()->create(['visible' => ResourceVisibilityEnum::authenticated]); + + CachetServer::tool(GetMetric::class, ['id' => $metric->id]) + ->assertHasErrors(["Metric [{$metric->id}] not found."]); +}); From 38613504c64ea0ad614a472cebd790834270b685 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 09:21:29 +0000 Subject: [PATCH 3/5] Validate the incident message/template interdependency explicitly On the dependency floor, spatie/laravel-data skips a data object's validation rules for absent properties that declare default values, so create_incident calls with neither message nor template passed validation and failed on the database's NOT NULL constraint instead of returning a validation error. Enforce the required_without pair at the tool boundary so the behaviour is identical across all supported laravel-data versions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0157S7dDNDwYuqWsqeg1DB94 --- src/Mcp/Tools/Incidents/CreateIncident.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php index 85f1bab5..d79b298f 100644 --- a/src/Mcp/Tools/Incidents/CreateIncident.php +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -53,12 +53,22 @@ public function schema(JsonSchema $schema): array ]; } + /** + * The message/template interdependency is validated explicitly because + * older spatie/laravel-data versions skip the data object's rules for + * absent properties that declare default values. + */ public function handle(Request $request, CreateIncidentAction $action): Response|ResponseFactory { if (! $this->tokenCan('incidents.manage')) { return $this->missingAbility('incidents.manage'); } + $request->validate([ + 'message' => ['required_without:template', 'nullable', 'string'], + 'template' => ['required_without:message', 'nullable', 'string'], + ]); + $data = CreateIncidentRequestData::validateAndCreate($request->all()); $incident = $action->handle($data)->load(['components', 'updates']); From 8e5e14e119d74b0858416885b1142158f98fd8a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 17:55:08 +0000 Subject: [PATCH 4/5] Expose the effective component status over MCP Linking components to an incident writes the incident_components pivot, not the component's own status column - the status page overlays the pivot via Component::latest_status while the incident is unresolved. That is invisible over MCP, where the component presenter only returned the raw status column, so an agent that created an incident and re-read the component concluded the status write had failed. Component payloads now include latest_status alongside status, the create_incident schema explains that the component status is a display overlay rather than a mutation, and the server instructions describe the base-versus-displayed status model. New tests assert the overlay is visible through get_component after create_incident and reverts when a fixed update is recorded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0157S7dDNDwYuqWsqeg1DB94 --- src/Mcp/CachetServer.php | 10 +++-- src/Mcp/Concerns/PresentsResources.php | 1 + src/Mcp/Tools/Incidents/CreateIncident.php | 4 +- tests/Feature/Mcp/Tools/IncidentToolsTest.php | 44 +++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/Mcp/CachetServer.php b/src/Mcp/CachetServer.php index 2fe868df..6b490c3f 100644 --- a/src/Mcp/CachetServer.php +++ b/src/Mcp/CachetServer.php @@ -64,9 +64,13 @@ class CachetServer extends Server Statuses are integers. Component status: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance. Incident status: - 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed. Recording an incident - update with status 4 (fixed) resolves the incident and returns its affected components - to operational. + 0 unknown, 1 investigating, 2 identified, 3 watching, 4 fixed. + + Linking components to an incident overlays their displayed status rather than + changing the component itself: a component's `status` is its own base status, while + `latest_status` is what the status page shows, taking any unresolved incident into + account. Recording an incident update with status 4 (fixed) resolves the incident + and restores the displayed status of its affected components. MARKDOWN; /** diff --git a/src/Mcp/Concerns/PresentsResources.php b/src/Mcp/Concerns/PresentsResources.php index 9daef9b4..fcb21f22 100644 --- a/src/Mcp/Concerns/PresentsResources.php +++ b/src/Mcp/Concerns/PresentsResources.php @@ -42,6 +42,7 @@ protected function presentComponent(Component $component): array 'description' => $component->description, 'link' => $component->link, 'status' => $this->presentEnum($component->status), + 'latest_status' => $this->presentEnum($component->latest_status), 'order' => $component->order, 'enabled' => $component->enabled, 'component_group_id' => $component->component_group_id, diff --git a/src/Mcp/Tools/Incidents/CreateIncident.php b/src/Mcp/Tools/Incidents/CreateIncident.php index d79b298f..21d581a6 100644 --- a/src/Mcp/Tools/Incidents/CreateIncident.php +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -47,9 +47,9 @@ public function schema(JsonSchema $schema): array 'status' => $schema->integer() ->enum(array_column(ComponentStatusEnum::cases(), 'value')) ->required() - ->description('The status to set on the component: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance.'), + ->description('The status to display for the component while the incident is unresolved: 1 operational, 2 performance issues, 3 partial outage, 4 major outage, 5 unknown, 6 under maintenance. The component\'s own status is left unchanged; the overlay is reflected in its latest_status and reverts when the incident is fixed. Use update_component to change a component\'s own status.'), ])) - ->description('Affected components and the status to set on each.'), + ->description('Affected components and the status to display for each while the incident is unresolved.'), ]; } diff --git a/tests/Feature/Mcp/Tools/IncidentToolsTest.php b/tests/Feature/Mcp/Tools/IncidentToolsTest.php index 5304fa0d..655693d3 100644 --- a/tests/Feature/Mcp/Tools/IncidentToolsTest.php +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -4,6 +4,7 @@ use Cachet\Enums\IncidentStatusEnum; use Cachet\Enums\ResourceVisibilityEnum; use Cachet\Mcp\CachetServer; +use Cachet\Mcp\Tools\Components\GetComponent; use Cachet\Mcp\Tools\Incidents\CreateIncident; use Cachet\Mcp\Tools\Incidents\DeleteIncident; use Cachet\Mcp\Tools\Incidents\GetIncident; @@ -303,3 +304,46 @@ ->assertOk() ->assertSee('Internal Incident'); }); + +it('overlays the displayed component status while an incident is unresolved', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + + CachetServer::tool(CreateIncident::class, [ + 'name' => 'API Outage', + 'status' => IncidentStatusEnum::identified->value, + 'message' => 'The API is down.', + 'components' => [ + ['id' => $component->id, 'status' => ComponentStatusEnum::major_outage->value], + ], + ])->assertOk(); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.status.name', 'operational') + ->where('data.latest_status.name', 'major_outage') + ->etc()); +}); + +it('restores the displayed component status when the incident is fixed', function () { + Sanctum::actingAs(User::factory()->create(), ['incidents.manage', 'incident-updates.manage']); + + $component = Component::factory()->create(['status' => ComponentStatusEnum::operational]); + $incident = Incident::factory()->create(['status' => IncidentStatusEnum::identified]); + $incident->components()->attach($component->id, ['component_status' => ComponentStatusEnum::major_outage]); + + CachetServer::tool(RecordIncidentUpdate::class, [ + 'incident_id' => $incident->id, + 'status' => IncidentStatusEnum::fixed->value, + 'message' => 'The problem is resolved.', + ])->assertOk(); + + CachetServer::tool(GetComponent::class, ['id' => $component->id]) + ->assertOk() + ->assertStructuredContent(fn (AssertableJson $json) => $json + ->where('data.status.name', 'operational') + ->where('data.latest_status.name', 'operational') + ->etc()); +}); From f7add753b2862a236433718304dcd4874b4c2c62 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:23:28 +0000 Subject: [PATCH 5/5] Relax MCP schedule datetime descriptions to match FlexibleDateTimeCast The schedule request data objects now cast datetimes with FlexibleDateTimeCast (#391), which accepts any format the "date" validation rule does, so the MCP schedule tool schemas no longer need to demand Y-m-d H:i:s. New tests prove ISO-8601 input works when creating a schedule and when completing one through an update. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0157S7dDNDwYuqWsqeg1DB94 --- .../ScheduleUpdates/RecordScheduleUpdate.php | 2 +- src/Mcp/Tools/Schedules/CreateSchedule.php | 4 +-- src/Mcp/Tools/Schedules/UpdateSchedule.php | 4 +-- tests/Feature/Mcp/Tools/ScheduleToolsTest.php | 33 +++++++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php b/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php index 0d5c8c5b..3947b0b0 100644 --- a/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php +++ b/src/Mcp/Tools/ScheduleUpdates/RecordScheduleUpdate.php @@ -30,7 +30,7 @@ public function schema(JsonSchema $schema): array return [ 'schedule_id' => $schema->integer()->required()->description('The schedule ID.'), 'message' => $schema->string()->required()->description('The update message, in Markdown.'), - 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s. Marks the schedule as complete.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, as an ISO-8601 or Y-m-d H:i:s datetime. Marks the schedule as complete.'), ]; } diff --git a/src/Mcp/Tools/Schedules/CreateSchedule.php b/src/Mcp/Tools/Schedules/CreateSchedule.php index 9f0cc7af..5fa787e4 100644 --- a/src/Mcp/Tools/Schedules/CreateSchedule.php +++ b/src/Mcp/Tools/Schedules/CreateSchedule.php @@ -30,8 +30,8 @@ public function schema(JsonSchema $schema): array return [ 'name' => $schema->string()->max(255)->required()->description('The name of the maintenance schedule.'), 'message' => $schema->string()->required()->description('The schedule message, in Markdown.'), - 'scheduled_at' => $schema->string()->required()->description('When the maintenance starts, formatted as Y-m-d H:i:s.'), - 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s. Leave empty for incomplete maintenance.'), + 'scheduled_at' => $schema->string()->required()->description('When the maintenance starts, as an ISO-8601 or Y-m-d H:i:s datetime.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, as an ISO-8601 or Y-m-d H:i:s datetime. Leave empty for incomplete maintenance.'), 'notifications' => $schema->boolean()->default(false)->description('Whether to notify verified subscribers.'), 'components' => $schema->array() ->items($schema->object([ diff --git a/src/Mcp/Tools/Schedules/UpdateSchedule.php b/src/Mcp/Tools/Schedules/UpdateSchedule.php index 7f54019f..5414e234 100644 --- a/src/Mcp/Tools/Schedules/UpdateSchedule.php +++ b/src/Mcp/Tools/Schedules/UpdateSchedule.php @@ -34,8 +34,8 @@ public function schema(JsonSchema $schema): array 'id' => $schema->integer()->required()->description('The schedule ID.'), 'name' => $schema->string()->max(255)->description('The name of the maintenance schedule.'), 'message' => $schema->string()->description('The schedule message, in Markdown.'), - 'scheduled_at' => $schema->string()->description('When the maintenance starts, formatted as Y-m-d H:i:s.'), - 'completed_at' => $schema->string()->description('When the maintenance finished, formatted as Y-m-d H:i:s.'), + 'scheduled_at' => $schema->string()->description('When the maintenance starts, as an ISO-8601 or Y-m-d H:i:s datetime.'), + 'completed_at' => $schema->string()->description('When the maintenance finished, as an ISO-8601 or Y-m-d H:i:s datetime.'), 'components' => $schema->array() ->items($schema->object([ 'id' => $schema->integer()->required()->description('The component ID.'), diff --git a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php index 74459b52..7802ff7b 100644 --- a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php +++ b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php @@ -142,3 +142,36 @@ expect($schedule->updates()->count())->toBe(0); }); + +it('accepts iso-8601 datetimes when creating a schedule', function () { + Sanctum::actingAs(User::factory()->create(), ['schedules.manage']); + + CachetServer::tool(CreateSchedule::class, [ + 'name' => 'Timezone Maintenance', + 'message' => 'Testing ISO-8601 input.', + 'scheduled_at' => now()->addDay()->toIso8601String(), + ])->assertOk(); + + expect(Schedule::query()->firstWhere('name', 'Timezone Maintenance')) + ->not->toBeNull() + ->scheduled_at->not->toBeNull(); +}); + +it('accepts iso-8601 datetimes when completing a schedule through an update', function () { + Sanctum::actingAs(User::factory()->create(), ['schedule-updates.manage']); + + $schedule = Schedule::factory()->create([ + 'scheduled_at' => now()->subHour(), + 'completed_at' => null, + ]); + + CachetServer::tool(RecordScheduleUpdate::class, [ + 'schedule_id' => $schedule->id, + 'message' => 'Maintenance is complete.', + 'completed_at' => now()->toIso8601String(), + ])->assertOk(); + + expect($schedule->fresh()) + ->completed_at->not->toBeNull() + ->status->toBe(ScheduleStatusEnum::complete); +});