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 69ad202a..d6028b1d 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 Cachet\Http\Middleware\ForceJsonResponse; use Illuminate\Routing\Middleware\SubstituteBindings; @@ -98,6 +100,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', ''), /* @@ -112,6 +129,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/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/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..6b490c3f --- /dev/null +++ b/src/Mcp/CachetServer.php @@ -0,0 +1,129 @@ +> + */ + 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..fcb21f22 --- /dev/null +++ b/src/Mcp/Concerns/PresentsResources.php @@ -0,0 +1,204 @@ + $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), + 'latest_status' => $this->presentEnum($component->latest_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/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/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..adb06e6d --- /dev/null +++ b/src/Mcp/Tools/ComponentGroups/ListComponentGroups.php @@ -0,0 +1,57 @@ + + */ + 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() + ->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)); + + 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..bf9a6bc3 --- /dev/null +++ b/src/Mcp/Tools/Components/GetComponent.php @@ -0,0 +1,44 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->required()->description('The component ID.'), + ]; + } + + public function handle(Request $request): Response|ResponseFactory + { + $component = $this->visibleComponents()->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..a5ad341b --- /dev/null +++ b/src/Mcp/Tools/Components/ListComponents.php @@ -0,0 +1,60 @@ + + */ + 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()->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), + ]; + } + + public function handle(Request $request): ResponseFactory + { + $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'))) + ->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)); + + 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..21d581a6 --- /dev/null +++ b/src/Mcp/Tools/Incidents/CreateIncident.php @@ -0,0 +1,83 @@ + + */ + 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 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 display for each while the incident is unresolved.'), + ]; + } + + /** + * 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']); + + 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..2b68e9d2 --- /dev/null +++ b/src/Mcp/Tools/Incidents/GetIncident.php @@ -0,0 +1,48 @@ + + */ + 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() + ->visible($this->isAuthenticated()) + ->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..c49dc37a --- /dev/null +++ b/src/Mcp/Tools/Incidents/ListIncidents.php @@ -0,0 +1,57 @@ + + */ + 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() + ->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() + ->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..15d8f34b --- /dev/null +++ b/src/Mcp/Tools/Metrics/GetMetric.php @@ -0,0 +1,48 @@ + + */ + 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() + ->visible($this->isAuthenticated()) + ->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..1508878c --- /dev/null +++ b/src/Mcp/Tools/Metrics/ListMetrics.php @@ -0,0 +1,52 @@ + + */ + 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() + ->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)); + + 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..3947b0b0 --- /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, as an ISO-8601 or Y-m-d H:i:s datetime. 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..5fa787e4 --- /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, 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([ + '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..5414e234 --- /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, 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.'), + '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 f193d60f..2e168aa4 100644 --- a/tests/Architecture/DataTest.php +++ b/tests/Architecture/DataTest.php @@ -22,6 +22,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..094e8755 --- /dev/null +++ b/tests/Feature/Mcp/Tools/ComponentToolsTest.php @@ -0,0 +1,255 @@ +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]); +}); + +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 new file mode 100644 index 00000000..655693d3 --- /dev/null +++ b/tests/Feature/Mcp/Tools/IncidentToolsTest.php @@ -0,0 +1,349 @@ +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(); +}); + +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'); +}); + +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()); +}); diff --git a/tests/Feature/Mcp/Tools/MetricToolsTest.php b/tests/Feature/Mcp/Tools/MetricToolsTest.php new file mode 100644 index 00000000..7545e4ad --- /dev/null +++ b/tests/Feature/Mcp/Tools/MetricToolsTest.php @@ -0,0 +1,145 @@ +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}]."]); +}); + +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."]); +}); diff --git a/tests/Feature/Mcp/Tools/ScheduleToolsTest.php b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php new file mode 100644 index 00000000..7802ff7b --- /dev/null +++ b/tests/Feature/Mcp/Tools/ScheduleToolsTest.php @@ -0,0 +1,177 @@ +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); +}); + +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); +}); 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(); +});