From eada5f503c76dfe4bd2542550b0e72b0ae3ccc85 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 14:41:02 +0100 Subject: [PATCH 01/45] Install laravel MCP --- composer.json | 1 + composer.lock | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d363d4519870..66c21ac6e877 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "laravel-notification-channels/microsoft-teams": "^1.2", "laravel/framework": "^12.0", "laravel/helpers": "^1.4", + "laravel/mcp": "^0.7.0", "laravel/passport": "^12.0", "laravel/slack-notification-channel": "^3.4", "laravel/socialite": "^5.6", diff --git a/composer.lock b/composer.lock index edc5657c78ec..e54ff4794e2c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6dbe361f8555681a027fca2d2c2e61fd", + "content-hash": "dfc5c42eac938218211469b8dab29892", "packages": [ { "name": "alek13/slack", @@ -2861,6 +2861,79 @@ }, "time": "2025-09-02T15:31:25+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-21T10:23:03+00:00" + }, { "name": "laravel/passport", "version": "v12.4.2", From 84fea969490030d058cd46e7cb8bc7762eab80a5 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 15:41:01 +0100 Subject: [PATCH 02/45] Added AssetBuilder to sequester scopes better MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This isn’t fully baked yet - it would touch way too much main code to flip it over just yet --- app/Models/Asset.php | 13 + app/Models/Builders/AssetBuilder.php | 700 +++++++++++++++++++++++++++ 2 files changed, 713 insertions(+) create mode 100644 app/Models/Builders/AssetBuilder.php diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 3d68d2136abf..07fb549c7e46 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -6,6 +6,7 @@ use App\Exceptions\CheckoutNotAllowed; use App\Helpers\Helper; use App\Http\Traits\UniqueUndeletedTrait; +use App\Models\Builders\AssetBuilder; use App\Models\Traits\Acceptable; use App\Models\Traits\CompanyableTrait; use App\Models\Traits\HasUploads; @@ -232,6 +233,18 @@ public function declinedCheckout(User $declinedBy, $signature) 'rtd_location' => 'defaultLoc', ]; + /** + * Create a new Eloquent query builder for the model. + * (We will be moving the query scopes below to the new builders eventually) + * + * @param \Illuminate\Database\Query\Builder $query + * @return AssetBuilder + */ + public function newEloquentBuilder($query) + { + return new AssetBuilder($query); + } + protected static function booted(): void { static::forceDeleted(function (Asset $asset) { diff --git a/app/Models/Builders/AssetBuilder.php b/app/Models/Builders/AssetBuilder.php new file mode 100644 index 000000000000..e9a7a2aa8628 --- /dev/null +++ b/app/Models/Builders/AssetBuilder.php @@ -0,0 +1,700 @@ +whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 1) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for searching location + * + * @return AssetBuilder Modified query builder + */ + public function byLocation($location) + { + return $this->where( + function ($query) use ($location) { + $query->whereHas( + 'assignedTo', function ($query) use ($location) { + $query->where( + [ + ['users.location_id', '=', $location->id], + ['assets.assigned_type', '=', User::class], + ] + )->orWhere( + [ + ['locations.id', '=', $location->id], + ['assets.assigned_type', '=', Location::class], + ] + )->orWhere( + [ + ['assets.rtd_location_id', '=', $location->id], + ['assets.assigned_type', '=', self::class], + ] + ); + } + )->orWhere( + function ($query) use ($location) { + $query->where('assets.rtd_location_id', '=', $location->id); + $query->whereNull('assets.assigned_to'); + } + ); + } + ); + } + + /** + * Query builder scope for RTD assets + * + * @return AssetBuilder Modified query builder + */ + public function rtd() + { + return $this->whereNull('assets.assigned_to') + ->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 1) + ->where('pending', '=', 0) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for Undeployable assets + * + * @return AssetBuilder Modified query builder + */ + public function undeployable() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 0) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for non-Archived assets + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @return AssetBuilder Modified query builder + */ + public function notArchived() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for Assets that are due for auditing, based on the assets.next_audit_date + * and settings.audit_warning_days. + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on assets that will need to be audited. + * + * Due for audit soon: + * next_audit_date greater than or equal to now (must be in the future) + * and (next_audit_date - threshold days) <= now () + * + * Example: + * next_audit_date = May 4, 2025 + * threshold for alerts = 30 days + * now = May 4, 2019 + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function dueForAudit($settings) + { + $interval = (int) $settings->audit_warning_days ?? 0; + $today = Carbon::now(); + $interval_date = $today->copy()->addDays($interval)->format('Y-m-d'); + + return $this->whereNotNull('assets.next_audit_date') + ->whereBetween('assets.next_audit_date', [$today->format('Y-m-d'), $interval_date]) + ->where('assets.archived', '=', 0) + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are OVERDUE for auditing, based on the assets.next_audit_date + * and settings.audit_warning_days. It checks to see if assets.next audit_date is before now + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on overdue assets. + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function overdueForAudit() + { + return $this->whereNotNull('assets.next_audit_date') + ->where('assets.next_audit_date', '<', Carbon::now()->format('Y-m-d')) + ->where('assets.archived', '=', 0) + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are due for auditing OR overdue, based on the assets.next_audit_date + * and settings.audit_warning_days. + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on assets that will need to be audited. + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function dueOrOverdueForAudit($settings) + { + + return $this->where( + function ($query) { + $query->OverdueForAudit(); + } + )->orWhere( + function ($query) use ($settings) { + $query->DueForAudit($settings); + } + ); + } + + /** + * Query builder scope for Assets that are DUE for checkin, based on the assets.expected_checkin + * and settings.audit_warning_days. It checks to see if assets.expected_checkin is now + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function dueForCheckin($settings) + { + $interval = (int) $settings->due_checkin_days ?? 0; + $today = Carbon::now(); + $interval_date = $today->copy()->addDays($interval)->format('Y-m-d'); + + return $this->whereNotNull('assets.expected_checkin') + ->whereBetween('assets.expected_checkin', [$today->format('Y-m-d'), $interval_date]) + ->where('assets.archived', '=', 0) + ->whereNotNull('assets.assigned_to') + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are overdue for checkin OR overdue + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function overdueForCheckin() + { + return $this->whereNotNull('assets.expected_checkin') + ->where('assets.expected_checkin', '<', Carbon::now()->format('Y-m-d')) + ->where('assets.archived', '=', 0) + ->whereNotNull('assets.assigned_to') + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are due for checkin OR overdue + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function dueOrOverdueForCheckin($settings) + { + return $this->where( + function ($query) { + $query->OverdueForCheckin(); + } + )->orWhere( + function ($query) use ($settings) { + $query->DueForCheckin($settings); + } + ); + } + + /** + * Query builder scope for Archived assets counting + * + * This is primarily used for the tab counters so that IF the admin + * has chosen to not display archived assets in their regular lists + * and views, it will return the correct number. + * + * @return AssetBuilder Modified query builder + */ + public function forShow() + { + + if (Setting::getSettings()->show_archived_in_list != 1) { + return $this->whereHas( + 'status', function ($query) { + $query->where('archived', '=', 0); + } + ); + } else { + return $this; + } + + } + + /** + * Query builder scope for Archived assets + * + * @return AssetBuilder Modified query builder + */ + public function archived() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 0) + ->where('archived', '=', 1); + } + ); + } + + /** + * Query builder scope for Deployed assets + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @return AssetBuilder Modified query builder + */ + public function deployed() + { + return $this->whereNotNull('assets.assigned_to'); + } + + /** + * Query builder scope for Requestable assets + * + * @return AssetBuilder Modified query builder + * + * @todo probably refactor? This is allowing table names for some reason + */ + public function requestable(): Builder + { + $table = $this->getModel()->getTable(); + + return Company::scopeCompanyables($this->where($table.'.requestable', '=', 1)) + ->whereHas( + 'status', function ($query) { + $query->where( + function ($query) { + $query->where('deployable', '=', 1) + ->where('archived', '=', 0); // you definitely can't request something that's archived + } + )->orWhere('pending', '=', 1); // we've decided that even though an asset may be 'pending', you can still request it + } + ); + } + + /** + * scopeInModelList + * Get all assets in the provided listing of model ids + * + * @return AssetBuilder + * + * @author Vincent Sposato + * + * @version v1.0 + */ + public function inModels(array $modelIdListing) + { + return $this->whereIn('assets.model_id', $modelIdListing); + } + + /** + * Query builder scope to get not-yet-accepted assets + * + * @return AssetBuilder Modified query builder + */ + public function unaccepted() + { + return $this->where('accepted', '=', 'pending'); + } + + /** + * Query builder scope to get rejected assets + * + * @return AssetBuilder Modified query builder + */ + public function rejected() + { + return $this->where('accepted', '=', 'rejected'); + } + + /** + * Query builder scope to get accepted assets + * + * @return AssetBuilder Modified query builder + */ + public function accepted() + { + return $this->where('accepted', '=', 'accepted'); + } + + /** + * Query builder scope to search on text for complex Bootstrap Tables API. + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function assignedSearch($search) + { + $search = explode(' OR ', $search); + + return $this->leftJoin( + 'users as assets_users', function ($leftJoin) { + $leftJoin->on('assets_users.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', User::class); + } + )->leftJoin( + 'locations as assets_locations', function ($leftJoin) { + $leftJoin->on('assets_locations.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', Location::class); + } + )->leftJoin( + 'assets as assigned_assets', function ($leftJoin) { + $leftJoin->on('assigned_assets.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', self::class); + } + )->where( + function ($query) use ($search) { + foreach ($search as $search) { + $query->whereHas( + 'model', function ($query) use ($search) { + $query->whereHas( + 'category', function ($query) use ($search) { + $query->where( + function ($query) use ($search) { + $query->where('categories.name', 'LIKE', '%'.$search.'%') + ->orWhere('models.name', 'LIKE', '%'.$search.'%') + ->orWhere('models.model_number', 'LIKE', '%'.$search.'%'); + } + ); + } + ); + } + )->orWhereHas( + 'model', function ($query) use ($search) { + $query->whereHas( + 'manufacturer', function ($query) use ($search) { + $query->where( + function ($query) use ($search) { + $query->where('manufacturers.name', 'LIKE', '%'.$search.'%'); + } + ); + } + ); + } + )->orWhere( + function ($query) use ($search) { + $query->where('assets_users.first_name', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.last_name', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.username', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.jobtitle', 'LIKE', '%'.$search.'%') + ->orWhereMultipleColumns( + [ + 'assets_users.first_name', + 'assets_users.last_name', + 'assets_users.jobtitle', + ], $search + ) + ->orWhere('assets_locations.name', 'LIKE', '%'.$search.'%') + ->orWhere('assigned_assets.name', 'LIKE', '%'.$search.'%'); + } + )->orWhere('assets.name', 'LIKE', '%'.$search.'%') + ->orWhere('assets.asset_tag', 'LIKE', '%'.$search.'%') + ->orWhere('assets.serial', 'LIKE', '%'.$search.'%') + ->orWhere('assets.order_number', 'LIKE', '%'.$search.'%') + ->orWhere('assets.notes', 'LIKE', '%'.$search.'%'); + } + + } + )->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug + } + + /** + * Query builder scope to search the department ID of users assigned to assets + * + * @return string | false + * @return AssetBuilder Modified query builder + * + * @author [A. Gianotto] [] + * + * @since [v5.0] + */ + public function inDepartment($search) + { + return $this->leftJoin( + 'users as assets_dept_users', function ($leftJoin) { + $leftJoin->on('assets_dept_users.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', User::class); + } + )->where( + function ($query) use ($search) { + $query->whereIn('assets_dept_users.department_id', $search); + + } + )->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug + } + + /** + * Query builder scope to order on model + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByModel($order) + { + return $this->join('models as asset_models', 'assets.model_id', '=', 'asset_models.id')->orderBy('asset_models.name', $order); + } + + /** + * Query builder scope to order on model number + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByModelNumber($order) + { + return $this->leftJoin('models as model_number_sort', 'assets.model_id', '=', 'model_number_sort.id')->orderBy('model_number_sort.model_number', $order); + } + + /** + * Query builder scope to order on created_by name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCreatedByName($order) + { + return $this->leftJoin('users as admin_sort', 'assets.created_by', '=', 'admin_sort.id')->select('assets.*')->orderBy('admin_sort.first_name', $order)->orderBy('admin_sort.last_name', $order); + } + + /** + * Query builder scope to order on assigned user + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByAssigned($order) + { + return $this->leftJoin('users as users_sort', 'assets.assigned_to', '=', 'users_sort.id')->select('assets.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order); + } + + /** + * Query builder scope to order on status + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByStatus($order) + { + return $this->join('status_labels as status_sort', 'assets.status_id', '=', 'status_sort.id')->orderBy('status_sort.name', $order); + } + + /** + * Query builder scope to order on company + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCompany($order) + { + return $this->leftJoin('companies as company_sort', 'assets.company_id', '=', 'company_sort.id')->orderBy('company_sort.name', $order); + } + + /** + * Query builder scope to return results of a category + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function inCategory($category_id) + { + return $this->join('models as category_models', 'assets.model_id', '=', 'category_models.id') + ->join('categories', 'category_models.category_id', '=', 'categories.id') + ->whereIn('category_models.category_id', (! is_array($category_id) ? explode(',', $category_id) : $category_id)); + // ->whereIn('category_models.category_id', $category_id); + } + + /** + * Query builder scope to return results of a manufacturer + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function byManufacturer($manufacturer_id) + { + return $this->join('models', 'assets.model_id', '=', 'models.id') + ->join('manufacturers', 'models.manufacturer_id', '=', 'manufacturers.id')->whereIn('models.manufacturer_id', (! is_array($manufacturer_id) ? explode(',', $manufacturer_id) : $manufacturer_id)); + } + + /** + * Query builder scope to order on category + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCategoryName($order) + { + return $this->join('models as order_model_category', 'assets.model_id', '=', 'order_model_category.id') + ->join('categories as category_order', 'order_model_category.category_id', '=', 'category_order.id') + ->orderBy('category_order.name', $order); + } + + /** + * Query builder scope to order on manufacturer + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByManufacturerName($query, $order) + { + return $this->join('models as order_asset_model', 'assets.model_id', '=', 'order_asset_model.id') + ->leftjoin('manufacturers as manufacturer_order', 'order_asset_model.manufacturer_id', '=', 'manufacturer_order.id') + ->orderBy('manufacturer_order.name', $order); + } + + /** + * Query builder scope to order on location + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByLocationName($query, $order) + { + return $this->leftJoin('locations as asset_locations', 'asset_locations.id', '=', 'assets.location_id')->orderBy('asset_locations.name', $order); + } + + /** + * Query builder scope to order on default + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByRTDLocationName($query, $order) + { + return $this->leftJoin('locations as rtd_asset_locations', 'rtd_asset_locations.id', '=', 'assets.rtd_location_id')->orderBy('rtd_asset_locations.name', $order); + } + + /** + * Query builder scope to order on supplier name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderBySupplierName($query, $order) + { + return $this->leftJoin('suppliers as suppliers_assets', 'assets.supplier_id', '=', 'suppliers_assets.id')->orderBy('suppliers_assets.name', $order); + } + + /** + * Query builder scope to order on supplier name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByJobTitle($query, $order) + { + return $this->leftJoin('users as users_sort', 'assets.assigned_to', '=', 'users_sort.id')->select('assets.*')->orderBy('users_sort.jobtitle', $order); + } + + /** + * Query builder scope to search on location ID + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function inLocation($query, $search) + { + return $this->where( + function ($query) use ($search) { + $query->whereHas( + 'location', function ($query) use ($search) { + $query->where('locations.id', '=', $search); + } + ); + } + ); + + } + + /** + * Query builder scope to search on depreciation name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function orderByDepreciationName($query, $search) + { + return $this->join('models', 'assets.model_id', '=', 'models.id') + ->join('depreciations', 'models.depreciation_id', '=', 'depreciations.id')->where('models.depreciation_id', '=', $search); + + } +} From 7d57ce4679730bab5060d26a50c7ec1c33e83edf Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 15:54:23 +0100 Subject: [PATCH 03/45] Added basic asset tools --- app/Mcp/Servers/SnipeMCPServer.php | 33 ++++++ app/Mcp/Tools/CheckinAssetTool.php | 119 +++++++++++++++++++++ app/Mcp/Tools/CheckoutAssetTool.php | 160 ++++++++++++++++++++++++++++ app/Mcp/Tools/ListAssetsTool.php | 136 +++++++++++++++++++++++ app/Mcp/Tools/ShowAssetTool.php | 116 ++++++++++++++++++++ 5 files changed, 564 insertions(+) create mode 100644 app/Mcp/Servers/SnipeMCPServer.php create mode 100644 app/Mcp/Tools/CheckinAssetTool.php create mode 100644 app/Mcp/Tools/CheckoutAssetTool.php create mode 100644 app/Mcp/Tools/ListAssetsTool.php create mode 100644 app/Mcp/Tools/ShowAssetTool.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php new file mode 100644 index 000000000000..a4b82f74b1b2 --- /dev/null +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -0,0 +1,33 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'id' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make( + Response::text('Asset not found') + )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + } + + $target = $asset->assignedTo; + + if (is_null($target)) { + return Response::make( + Response::text('Asset is not currently checked out') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Asset '.$asset->asset_tag.' is not currently checked out', + 'asset_tag' => $asset->asset_tag, + ]); + } + + $originalValues = $asset->getRawOriginal(); + $checkinAt = date('Y-m-d H:i:s'); + + $asset->expected_checkin = null; + $asset->last_checkin = now(); + $asset->assignedTo()->disassociate($asset); + $asset->accepted = null; + $asset->location_id = $asset->rtd_location_id; + + if ($asset->save()) { + event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->get('note'), $checkinAt, $originalValues)); + + return Response::make( + Response::text('Asset '.$asset->asset_tag.' checked in successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset checked in successfully', + 'asset_tag' => $asset->asset_tag, + 'model' => $asset->model?->name, + 'location' => $asset->location?->name, + ]); + } + + return Response::make( + Response::text('Checkin failed') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Checkin failed: '.$asset->getErrors()->first(), + 'asset_tag' => $asset->asset_tag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag')) + ->with('model', 'location') + ->first(); + } + + if ($request->filled('id')) { + return Asset::with('model', 'location')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('Asset tag of the asset to check in'), + 'id' => $schema->number() + ->description('Numeric ID of the asset to check in'), + 'note' => $schema->string() + ->description('Optional note to attach to this checkin'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->string()->description('True if the checkin succeeded'), + 'error' => $schema->string()->description('True if the checkin failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the checked-in asset'), + 'model' => $schema->string()->description('Model name of the checked-in asset'), + 'location' => $schema->string()->description('Location the asset returned to'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutAssetTool.php b/app/Mcp/Tools/CheckoutAssetTool.php new file mode 100644 index 000000000000..96532ad9ea71 --- /dev/null +++ b/app/Mcp/Tools/CheckoutAssetTool.php @@ -0,0 +1,160 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'id' => 'nullable|integer', + 'checkout_to_type' => 'required|string|in:user,location,asset', + 'assigned_user' => 'nullable|integer', + 'assigned_location' => 'nullable|integer', + 'assigned_asset' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + 'checkout_at' => 'nullable|date', + 'expected_checkin' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make( + Response::text('Asset not found') + )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + } + + if (! $asset->availableForCheckout()) { + return Response::make( + Response::text('Asset is not available for checkout') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Asset '.$asset->asset_tag.' is not available for checkout', + 'asset_tag' => $asset->asset_tag, + 'status' => $asset->status?->name, + ]); + } + + $checkoutType = $request->get('checkout_to_type'); + $target = null; + + if ($checkoutType === 'user') { + $target = User::find($request->get('assigned_user')); + if ($target) { + $asset->location_id = $target->location_id ?? $asset->location_id; + } + } elseif ($checkoutType === 'location') { + $target = Location::find($request->get('assigned_location')); + if ($target) { + $asset->location_id = $target->id; + } + } elseif ($checkoutType === 'asset') { + $target = Asset::where('id', '!=', $asset->id)->find($request->get('assigned_asset')); + if ($target) { + $asset->location_id = $target->location_id ?? $asset->location_id; + } + } + + if (! $target) { + return Response::make( + Response::text('Checkout target not found') + )->withStructuredContent([ + 'error' => true, + 'message' => 'The specified '.$checkoutType.' was not found', + ]); + } + + $checkoutAt = $request->filled('checkout_at') ? $request->get('checkout_at') : date('Y-m-d H:i:s'); + $expectedCheckin = $request->filled('expected_checkin') ? $request->get('expected_checkin') : null; + $note = $request->filled('note') ? $request->get('note') : null; + + if ($asset->checkOut($target, auth()->user(), $checkoutAt, $expectedCheckin, $note, $asset->name, $asset->location_id)) { + return Response::make( + Response::text('Asset '.$asset->asset_tag.' checked out successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset checked out successfully', + 'asset_tag' => $asset->asset_tag, + 'checked_out_to_type' => $checkoutType, + 'checked_out_to_id' => $target->id, + ]); + } + + return Response::make( + Response::text('Checkout failed') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Checkout failed', + 'asset_tag' => $asset->asset_tag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag')) + ->with('status') + ->first(); + } + + if ($request->filled('id')) { + return Asset::with('status')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('Asset tag of the asset to check out'), + 'id' => $schema->number() + ->description('Numeric ID of the asset to check out'), + 'checkout_to_type' => $schema->string() + ->description('What to check the asset out to: user, location, or asset') + ->required(), + 'assigned_user' => $schema->number() + ->description('ID of the user to check the asset out to (when checkout_to_type is user)'), + 'assigned_location' => $schema->number() + ->description('ID of the location to check the asset out to (when checkout_to_type is location)'), + 'assigned_asset' => $schema->number() + ->description('ID of the asset to check the asset out to (when checkout_to_type is asset)'), + 'note' => $schema->string() + ->description('Optional note to attach to this checkout'), + 'checkout_at' => $schema->string() + ->description('Checkout date/time (defaults to now, format: YYYY-MM-DD)'), + 'expected_checkin' => $schema->string() + ->description('Expected checkin date (format: YYYY-MM-DD)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->string()->description('True if the checkout succeeded'), + 'error' => $schema->string()->description('True if the checkout failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the checked-out asset'), + 'checked_out_to_type' => $schema->string()->description('Type of entity the asset was checked out to'), + 'checked_out_to_id' => $schema->number()->description('ID of the entity the asset was checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetsTool.php b/app/Mcp/Tools/ListAssetsTool.php new file mode 100644 index 000000000000..0b305f290ad3 --- /dev/null +++ b/app/Mcp/Tools/ListAssetsTool.php @@ -0,0 +1,136 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'status_type' => 'nullable|string|in:RTD,Deployed,Archived,Pending,Undeployable', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'model_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $assets = Asset::select('assets.*') + ->with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'company'); + + match ($request->filled('status_type') ? $request->get('status_type') : null) { + 'RTD' => $assets->rtd(), + 'Deployed' => $assets->deployed(), + 'Archived' => $assets->archived(), + 'Pending' => $assets->pending(), + 'Undeployable' => $assets->undeployable(), + default => $assets->notArchived(), + }; + + if ($request->filled('search')) { + $assets->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $assets->where('assets.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('location_id')) { + $assets->where('assets.location_id', '=', $request->get('location_id')); + } + + if ($request->filled('category_id')) { + $assets->inCategory($request->get('category_id')); + } + + if ($request->filled('model_id')) { + $assets->inModels([$request->get('model_id')]); + } + + if ($request->filled('manufacturer_id')) { + $assets->byManufacturer($request->get('manufacturer_id')); + } + + $assets->orderBy('assets.created_at', 'desc'); + + $total = $assets->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $assets->skip($offset)->take($limit)->get(); + + $assetsData = $results->map(fn (Asset $asset) => [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'status' => $asset->status?->name, + 'status_type' => $asset->status?->getStatuslabelType(), + 'model' => $asset->model?->name, + 'category' => $asset->model?->category?->name, + 'manufacturer' => $asset->model?->manufacturer?->name, + 'company' => $asset->company?->name, + 'location' => $asset->location?->name, + 'assigned_to_id' => $asset->assigned_to, + 'assigned_to_type' => $asset->assigned_type ? class_basename($asset->assigned_type) : null, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} assets, returning ".count($assetsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'assets' => $assetsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string() + ->description('Keyword to search across asset tag, serial, name, and model'), + 'status_type' => $schema->string() + ->description('Filter by status type: RTD (ready to deploy), Deployed, Archived, Pending, or Undeployable'), + 'company_id' => $schema->number() + ->description('Filter by company ID'), + 'location_id' => $schema->number() + ->description('Filter by location ID'), + 'category_id' => $schema->number() + ->description('Filter by category ID'), + 'model_id' => $schema->number() + ->description('Filter by model ID'), + 'manufacturer_id' => $schema->number() + ->description('Filter by manufacturer ID'), + 'limit' => $schema->number() + ->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number() + ->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching assets')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ShowAssetTool.php b/app/Mcp/Tools/ShowAssetTool.php new file mode 100644 index 000000000000..1fb6fb250728 --- /dev/null +++ b/app/Mcp/Tools/ShowAssetTool.php @@ -0,0 +1,116 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'id' => 'nullable|integer', + ]); + + $asset = null; + + if ($request->filled('asset_tag')) { + $asset = Asset::where('asset_tag', $request->get('asset_tag')) + ->with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser') + ->first(); + } elseif ($request->filled('id')) { + $asset = Asset::with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser') + ->find($request->get('id')); + } + + if (! $asset) { + return Response::make( + Response::error('Asset not found') + ); + } + + return Response::make( + Response::text('Asset '.$asset->asset_tag.' found') + )->withStructuredContent($this->formatAsset($asset)); + } + + private function formatAsset(Asset $asset): array + { + return [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'status' => $asset->status?->name, + 'status_type' => $asset->status?->getStatuslabelType(), + 'model' => $asset->model?->name, + 'model_number' => $asset->model?->model_number, + 'category' => $asset->model?->category?->name, + 'manufacturer' => $asset->model?->manufacturer?->name, + 'company' => $asset->company?->name, + 'location' => $asset->location?->name, + 'rtd_location' => $asset->defaultLoc?->name, + 'supplier' => $asset->supplier?->name, + 'assigned_to_id' => $asset->assigned_to, + 'assigned_to_type' => $asset->assigned_type ? class_basename($asset->assigned_type) : null, + 'notes' => $asset->notes, + 'order_number' => $asset->order_number, + 'purchase_date' => $asset->purchase_date?->format('Y-m-d'), + 'purchase_cost' => $asset->purchase_cost, + 'warranty_months' => $asset->warranty_months, + 'last_checkout' => $asset->last_checkout, + 'last_checkin' => $asset->last_checkin, + 'expected_checkin' => $asset->expected_checkin, + 'last_audit_date' => $asset->last_audit_date, + 'created_at' => $asset->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $asset->updated_at?->format('Y-m-d H:i:s'), + ]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('The asset tag of the asset to look up'), + 'id' => $schema->number() + ->description('The numeric ID of the asset to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric asset ID'), + 'asset_tag' => $schema->string()->description('Asset tag'), + 'name' => $schema->string()->description('Asset name'), + 'serial' => $schema->string()->description('Serial number'), + 'status' => $schema->string()->description('Status label name'), + 'status_type' => $schema->string()->description('Status type: deployable, pending, or archived'), + 'model' => $schema->string()->description('Asset model name'), + 'model_number' => $schema->string()->description('Model number'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'company' => $schema->string()->description('Company name'), + 'location' => $schema->string()->description('Current location name'), + 'rtd_location' => $schema->string()->description('Default return-to-deploy location name'), + 'assigned_to_id' => $schema->number()->description('ID of the entity this asset is currently assigned to'), + 'assigned_to_type' => $schema->string()->description('Type of entity assigned to: User, Asset, or Location'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->string()->description('Purchase cost'), + 'last_checkout' => $schema->string()->description('Date of last checkout'), + 'last_checkin' => $schema->string()->description('Date of last checkin'), + ]; + } +} From deb56f250f8e160fedae312708516a48103b6a94 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 15:54:30 +0100 Subject: [PATCH 04/45] Added routes file --- routes/ai.php | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 routes/ai.php diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 000000000000..7506c931242b --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,6 @@ + Date: Thu, 7 May 2026 15:54:37 +0100 Subject: [PATCH 05/45] Added tests --- tests/Feature/Mcp/CheckinAssetToolTest.php | 123 ++++++++++++++ tests/Feature/Mcp/CheckoutAssetToolTest.php | 168 ++++++++++++++++++++ tests/Feature/Mcp/ListAssetsToolTest.php | 147 +++++++++++++++++ tests/Feature/Mcp/ShowAssetToolTest.php | 112 +++++++++++++ 4 files changed, 550 insertions(+) create mode 100644 tests/Feature/Mcp/CheckinAssetToolTest.php create mode 100644 tests/Feature/Mcp/CheckoutAssetToolTest.php create mode 100644 tests/Feature/Mcp/ListAssetsToolTest.php create mode 100644 tests/Feature/Mcp/ShowAssetToolTest.php diff --git a/tests/Feature/Mcp/CheckinAssetToolTest.php b/tests/Feature/Mcp/CheckinAssetToolTest.php new file mode 100644 index 000000000000..821e24a8444d --- /dev/null +++ b/tests/Feature/Mcp/CheckinAssetToolTest.php @@ -0,0 +1,123 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinAssetTool)->handle(new Request($args)); + } + + public function test_checks_in_asset_by_asset_tag() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => null, + 'assigned_type' => null, + 'accepted' => null, + ]); + } + + public function test_checks_in_asset_by_numeric_id() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_returns_error_when_asset_not_checked_out() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_asset_location_resets_to_rtd_location_on_checkin() + { + $rtdLocation = Location::factory()->create(); + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create([ + 'rtd_location_id' => $rtdLocation->id, + ]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $rtdLocation->id, + ]); + } + + public function test_clears_expected_checkin_date_on_checkin() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create([ + 'expected_checkin' => now()->addDays(7), + ]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertNull($asset->fresh()->expected_checkin); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($asset) { + return $event->checkoutable->id === $asset->id; + }); + } + + public function test_response_includes_asset_tag_and_location() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertArrayHasKey('model', $content); + $this->assertArrayHasKey('location', $content); + } +} diff --git a/tests/Feature/Mcp/CheckoutAssetToolTest.php b/tests/Feature/Mcp/CheckoutAssetToolTest.php new file mode 100644 index 000000000000..b5f412a76ee2 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutAssetToolTest.php @@ -0,0 +1,168 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutAssetTool)->handle(new Request($args)); + } + + public function test_checks_out_asset_to_user_by_asset_tag() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + ]); + } + + public function test_checks_out_asset_to_user_by_numeric_id() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $asset->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_asset_to_location() + { + $asset = Asset::factory()->create(); + $location = Location::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'location', + 'assigned_location' => $location->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $location->id, + 'assigned_type' => Location::class, + ]); + } + + public function test_checks_out_asset_to_another_asset() + { + $asset = Asset::factory()->create(); + $target = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'asset', + 'assigned_asset' => $target->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $target->id, + 'assigned_type' => Asset::class, + ]); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $content = $this->handle([ + 'asset_tag' => 'DOES-NOT-EXIST', + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_returns_error_when_asset_already_checked_out() + { + $existingUser = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($existingUser)->create(); + $newUser = User::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $newUser->id, + ])->getStructuredContent(); + + $this->assertTrue($content['error']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $existingUser->id, + ]); + } + + public function test_returns_error_when_target_user_not_found() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => 99999, + ])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_returns_error_when_target_location_not_found() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'location', + 'assigned_location' => 99999, + ])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_response_includes_asset_tag_and_target_info() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals('user', $content['checked_out_to_type']); + $this->assertEquals($user->id, $content['checked_out_to_id']); + } +} diff --git a/tests/Feature/Mcp/ListAssetsToolTest.php b/tests/Feature/Mcp/ListAssetsToolTest.php new file mode 100644 index 000000000000..91c54f9eeea2 --- /dev/null +++ b/tests/Feature/Mcp/ListAssetsToolTest.php @@ -0,0 +1,147 @@ +handle(new Request($args)); + } + + public function test_returns_total_assets_and_pagination_metadata() + { + Asset::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('assets', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_asset_includes_key_fields() + { + Asset::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $asset = $content['assets'][0]; + $this->assertArrayHasKey('id', $asset); + $this->assertArrayHasKey('asset_tag', $asset); + $this->assertArrayHasKey('status', $asset); + $this->assertArrayHasKey('status_type', $asset); + $this->assertArrayHasKey('model', $asset); + $this->assertArrayHasKey('location', $asset); + } + + public function test_excludes_archived_assets_by_default() + { + $rtdAsset = Asset::factory()->create(); + $archivedAsset = Asset::factory()->create([ + 'status_id' => Statuslabel::factory()->archived()->create()->id, + ]); + + $content = $this->handle()->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($rtdAsset->id, $ids); + $this->assertNotContains($archivedAsset->id, $ids); + } + + public function test_rtd_filter_excludes_deployed_assets() + { + $rtdAsset = Asset::factory()->create(); + $deployedAsset = Asset::factory()->assignedToUser()->create(); + + $content = $this->handle(['status_type' => 'RTD'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($rtdAsset->id, $ids); + $this->assertNotContains($deployedAsset->id, $ids); + } + + public function test_deployed_filter_excludes_rtd_assets() + { + $user = User::factory()->create(); + $deployedAsset = Asset::factory()->assignedToUser($user)->create(); + $rtdAsset = Asset::factory()->create(); + + $content = $this->handle(['status_type' => 'Deployed'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($deployedAsset->id, $ids); + $this->assertNotContains($rtdAsset->id, $ids); + } + + public function test_archived_filter_returns_only_archived_assets() + { + $archivedStatus = Statuslabel::factory()->archived()->create(); + $archivedAsset = Asset::factory()->create(['status_id' => $archivedStatus->id]); + $rtdAsset = Asset::factory()->create(); + + $content = $this->handle(['status_type' => 'Archived'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($archivedAsset->id, $ids); + $this->assertNotContains($rtdAsset->id, $ids); + } + + public function test_limit_controls_number_of_results_returned() + { + Asset::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['assets']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_offset_skips_results_for_pagination() + { + Asset::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 4, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 4, 'offset' => 4])->getStructuredContent(); + + $ids1 = array_column($page1['assets'], 'id'); + $ids2 = array_column($page2['assets'], 'id'); + + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_search_matches_on_asset_tag() + { + $target = Asset::factory()->create(['asset_tag' => 'UNIQ-SEARCH-XYZ']); + Asset::factory()->create(['asset_tag' => 'COMPLETELY-DIFFERENT']); + + $content = $this->handle(['search' => 'UNIQ-SEARCH-XYZ'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($target->id, $ids); + } + + public function test_filters_by_location_id() + { + $location = Location::factory()->create(); + $matchingAsset = Asset::factory()->create(['location_id' => $location->id]); + $otherAsset = Asset::factory()->create(); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($matchingAsset->id, $ids); + $this->assertNotContains($otherAsset->id, $ids); + } +} diff --git a/tests/Feature/Mcp/ShowAssetToolTest.php b/tests/Feature/Mcp/ShowAssetToolTest.php new file mode 100644 index 000000000000..3bb8884f1752 --- /dev/null +++ b/tests/Feature/Mcp/ShowAssetToolTest.php @@ -0,0 +1,112 @@ +handle(new Request($args)); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $response = $this->handle(['asset_tag' => 'DOES-NOT-EXIST']); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_id_not_found() + { + $response = $this->handle(['id' => 99999]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_finds_asset_by_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-FIND-001']); + + $content = $this->handle(['asset_tag' => 'TAG-FIND-001'])->getStructuredContent(); + + $this->assertEquals('TAG-FIND-001', $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_finds_asset_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_returns_model_status_and_manufacturer_details() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertArrayHasKey('model', $content); + $this->assertArrayHasKey('model_number', $content); + $this->assertArrayHasKey('category', $content); + $this->assertArrayHasKey('manufacturer', $content); + $this->assertArrayHasKey('status', $content); + $this->assertArrayHasKey('status_type', $content); + } + + public function test_returns_date_and_cost_fields() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertArrayHasKey('purchase_date', $content); + $this->assertArrayHasKey('purchase_cost', $content); + $this->assertArrayHasKey('warranty_months', $content); + $this->assertArrayHasKey('created_at', $content); + $this->assertArrayHasKey('updated_at', $content); + } + + public function test_shows_assignment_info_when_checked_out_to_user() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($user->id, $content['assigned_to_id']); + $this->assertEquals('User', $content['assigned_to_type']); + } + + public function test_shows_assignment_info_when_checked_out_to_location() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->assignedToLocation($location)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($location->id, $content['assigned_to_id']); + $this->assertEquals('Location', $content['assigned_to_type']); + } + + public function test_assignment_info_is_null_when_not_checked_out() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertNull($content['assigned_to_id']); + $this->assertNull($content['assigned_to_type']); + } +} From b731ec6dd60d143bb06fc574b7130cee72b0785b Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 15:58:36 +0100 Subject: [PATCH 06/45] Added oauth routes to MCP --- routes/ai.php | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/ai.php b/routes/ai.php index 7506c931242b..e242b2197757 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -3,4 +3,5 @@ use App\Mcp\Servers\SnipeMCPServer; use Laravel\Mcp\Facades\Mcp; +Mcp::oauthRoutes(); Mcp::web('/mcp/snipe-it', SnipeMCPServer::class); From 490ce6fa5de22dc1dbf9ce589f9d8c56ca0947f4 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:00:46 +0100 Subject: [PATCH 07/45] Added passport oauth for mcp --- app/Providers/AppServiceProvider.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a6a4e7912780..602f21cf061a 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -29,6 +29,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Laravel\Passport\Passport; use Rollbar\Laravel\RollbarServiceProvider; /** @@ -63,6 +64,10 @@ public function boot(UrlGenerator $url) $url->forceScheme('https'); } + Passport::authorizationView(function ($parameters) { + return view('mcp.authorize', $parameters); + }); + // TODO - isn't it somehow 'gauche' to check the environment directly; shouldn't we be using config() somehow? if (! env('APP_ALLOW_INSECURE_HOSTS')) { // unless you set APP_ALLOW_INSECURE_HOSTS, you should PROHIBIT forging domain parts of URL via Host: headers $url_parts = parse_url(config('app.url')); From cc0169d2f7221a84c348dbde0d3e8195996d9111 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:01:13 +0100 Subject: [PATCH 08/45] Use auth:api on routes --- routes/ai.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/ai.php b/routes/ai.php index e242b2197757..87b621766859 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use Laravel\Mcp\Facades\Mcp; Mcp::oauthRoutes(); -Mcp::web('/mcp/snipe-it', SnipeMCPServer::class); +Mcp::web('/mcp/snipe-it', SnipeMCPServer::class)->middleware('auth:api'); From 0514901cbc21a80e26ed18d7fe6040fd7df46ada Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:01:27 +0100 Subject: [PATCH 09/45] Updated docs for laravel 12 --- .github/copilot-instructions.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b15fe63de014..0f711b41ffdc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,7 @@ # GitHub Copilot Custom Instructions for Snipe-IT -These instructions guide Copilot to generate code that aligns with modern Laravel 11 standards, PHP 8.2/8.4 features, software engineering principles, and industry best practices to improve software quality, maintainability, and security. +These instructions guide Copilot to generate code that aligns with modern Laravel 12 standards, PHP 8.2/8.4 features, +software engineering principles, and industry best practices to improve software quality, maintainability, and security. ## ✅ General Coding Standards @@ -22,7 +23,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave - Adopt **final classes** where extension is not intended. - Use **Named Arguments** for improved clarity when calling functions with multiple parameters. -## ✅ Laravel 11 Project Structure & Conventions +## ✅ Laravel 12 Project Structure & Conventions - Follow the official Laravel project structure: - `app/Http/Controllers` - Controllers @@ -32,6 +33,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave - `app/Enums` - Enums - `app/Actions` - Single-responsibility action classes - `app/Policies` - Authorization logic + - `app/Models/Builders` - Query scoping logic - Controllers must: - Use dependency injection. From 2f3df9a085b62d6498ebe2260c44ad424caa3a26 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:01:46 +0100 Subject: [PATCH 10/45] Allow lookup by serial number --- app/Mcp/Tools/ShowAssetTool.php | 13 ++++++++----- tests/Feature/Mcp/ShowAssetToolTest.php | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/app/Mcp/Tools/ShowAssetTool.php b/app/Mcp/Tools/ShowAssetTool.php index 1fb6fb250728..6acc26439c05 100644 --- a/app/Mcp/Tools/ShowAssetTool.php +++ b/app/Mcp/Tools/ShowAssetTool.php @@ -21,18 +21,19 @@ public function handle(Request $request): ResponseFactory { $request->validate([ 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', 'id' => 'nullable|integer', ]); + $with = ['status', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser']; $asset = null; if ($request->filled('asset_tag')) { - $asset = Asset::where('asset_tag', $request->get('asset_tag')) - ->with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser') - ->first(); + $asset = Asset::where('asset_tag', $request->get('asset_tag'))->with($with)->first(); + } elseif ($request->filled('serial')) { + $asset = Asset::where('serial', $request->get('serial'))->with($with)->first(); } elseif ($request->filled('id')) { - $asset = Asset::with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser') - ->find($request->get('id')); + $asset = Asset::with($with)->find($request->get('id')); } if (! $asset) { @@ -84,6 +85,8 @@ public function schema(JsonSchema $schema): array return [ 'asset_tag' => $schema->string() ->description('The asset tag of the asset to look up'), + 'serial' => $schema->string() + ->description('The serial number of the asset to look up'), 'id' => $schema->number() ->description('The numeric ID of the asset to look up'), ]; diff --git a/tests/Feature/Mcp/ShowAssetToolTest.php b/tests/Feature/Mcp/ShowAssetToolTest.php index 3bb8884f1752..06ca1e2763b9 100644 --- a/tests/Feature/Mcp/ShowAssetToolTest.php +++ b/tests/Feature/Mcp/ShowAssetToolTest.php @@ -41,6 +41,23 @@ public function test_finds_asset_by_asset_tag() $this->assertEquals($asset->id, $content['id']); } + public function test_finds_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-FIND-001']); + + $content = $this->handle(['serial' => 'SN-FIND-001'])->getStructuredContent(); + + $this->assertEquals('SN-FIND-001', $content['serial']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_returns_error_when_serial_not_found() + { + $response = $this->handle(['serial' => 'DOES-NOT-EXIST']); + + $this->assertTrue($response->responses()->first()->isError()); + } + public function test_finds_asset_by_numeric_id() { $asset = Asset::factory()->create(); From 656dae04a71d52a8d2ac56d4e9662bc925e37b63 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:09:21 +0100 Subject: [PATCH 11/45] Added views --- resources/views/mcp/authorize.blade.php | 180 ++++++++++++++++++ .../views/vendor/mcp/components/app.blade.php | 21 ++ 2 files changed, 201 insertions(+) create mode 100644 resources/views/mcp/authorize.blade.php create mode 100644 resources/views/vendor/mcp/components/app.blade.php diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php new file mode 100644 index 000000000000..7d14787490d4 --- /dev/null +++ b/resources/views/mcp/authorize.blade.php @@ -0,0 +1,180 @@ + + ($appearance ?? 'system') == 'dark'])> + + + + + {{-- Inline script to detect system dark mode preference and apply it immediately --}} + + + + + Authorize Application - {{ config('app.name', 'MCP Server') }} + + + + + + + + + + + + @vite(['resources/css/app.css']) + + +
+
+ +
+ +
+
+ + + + +
+ +

+ Authorize {{ $client->name }} +

+ +

+ This application will be able to:
Use available MCP functionality. +

+
+ + +
+ +
+

Logged in as:

+

{{ $user->email }}

+
+ + + @if(count($scopes) > 0) +
+

Permissions:

+ +
    + @foreach($scopes as $scope) +
  • +
    +
    +
    + + {{ $scope->description }} + +
  • + @endforeach +
+
+ @endif +
+ + +
+ +
+ @csrf + @method('DELETE') + + + + +
+ + +
+ @csrf + + + + +
+
+
+
+
+ + + + diff --git a/resources/views/vendor/mcp/components/app.blade.php b/resources/views/vendor/mcp/components/app.blade.php new file mode 100644 index 000000000000..48a0874c958f --- /dev/null +++ b/resources/views/vendor/mcp/components/app.blade.php @@ -0,0 +1,21 @@ +@props(['title' => null]) +@php + $mcpSdk = app('mcp.sdk'); + $libraryScripts = app()->bound('mcp.library_scripts') ? app('mcp.library_scripts') : ''; +@endphp + + + + + + @if($title) + {{ $title }} + @endif + + {!! $libraryScripts !!} + {{ $head ?? '' }} + + + {{ $slot }} + + From 6a47b4e6a767a1f69e326675faa9ff43d146a65a Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:23:08 +0100 Subject: [PATCH 12/45] More tests --- tests/Feature/Mcp/AuditAssetToolTest.php | 140 ++++++++++++ tests/Feature/Mcp/DeleteAssetToolTest.php | 100 +++++++++ tests/Feature/Mcp/FmcsCompanyScopingTest.php | 211 +++++++++++++++++++ tests/Feature/Mcp/UpdateAssetToolTest.php | 135 ++++++++++++ 4 files changed, 586 insertions(+) create mode 100644 tests/Feature/Mcp/AuditAssetToolTest.php create mode 100644 tests/Feature/Mcp/DeleteAssetToolTest.php create mode 100644 tests/Feature/Mcp/FmcsCompanyScopingTest.php create mode 100644 tests/Feature/Mcp/UpdateAssetToolTest.php diff --git a/tests/Feature/Mcp/AuditAssetToolTest.php b/tests/Feature/Mcp/AuditAssetToolTest.php new file mode 100644 index 000000000000..e2cf06d263e4 --- /dev/null +++ b/tests/Feature/Mcp/AuditAssetToolTest.php @@ -0,0 +1,140 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new AuditAssetTool)->handle(new Request($args)); + } + + public function test_records_audit_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_records_audit_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_records_audit_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-AUDIT-001']); + + $content = $this->handle(['serial' => 'SN-AUDIT-001'])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_not_found() + { + $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_sets_last_audit_date_to_now() + { + $asset = Asset::factory()->create(['last_audit_date' => null]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertNotNull($asset->fresh()->last_audit_date); + } + + public function test_respects_explicit_next_audit_date() + { + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'next_audit_date' => '2027-01-15', + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'next_audit_date' => '2027-01-15', + ]); + } + + public function test_updates_location_when_provided() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $location->id, + ]); + } + + public function test_does_not_change_location_when_not_provided() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->create(['location_id' => $location->id]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $location->id, + ]); + } + + public function test_response_includes_audit_dates_and_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertArrayHasKey('last_audit_date', $content); + $this->assertArrayHasKey('next_audit_date', $content); + $this->assertNotNull($content['last_audit_date']); + } + + public function test_creates_audit_log_entry() + { + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'note' => 'MCP audit note', + ]); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'audit', + ]); + } +} diff --git a/tests/Feature/Mcp/DeleteAssetToolTest.php b/tests/Feature/Mcp/DeleteAssetToolTest.php new file mode 100644 index 000000000000..4cd384b82940 --- /dev/null +++ b/tests/Feature/Mcp/DeleteAssetToolTest.php @@ -0,0 +1,100 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteAssetTool)->handle(new Request($args)); + } + + public function test_deletes_asset_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_asset_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-DELETE-001']); + + $content = $this->handle(['serial' => 'SN-DELETE-001'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_returns_error_when_asset_not_found() + { + $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_checks_in_asset_before_deleting_when_checked_out() + { + Event::fake([CheckoutableCheckedIn::class]); + + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($asset) { + return $event->checkoutable->id === $asset->id; + }); + + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_unassigned_asset_without_firing_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $asset = Asset::factory()->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertNotDispatched(CheckoutableCheckedIn::class); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_response_includes_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-DEL-001']); + + $content = $this->handle(['asset_tag' => 'TAG-DEL-001'])->getStructuredContent(); + + $this->assertEquals('TAG-DEL-001', $content['asset_tag']); + } +} diff --git a/tests/Feature/Mcp/FmcsCompanyScopingTest.php b/tests/Feature/Mcp/FmcsCompanyScopingTest.php new file mode 100644 index 000000000000..2f920e60293b --- /dev/null +++ b/tests/Feature/Mcp/FmcsCompanyScopingTest.php @@ -0,0 +1,211 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->assetA = Asset::factory()->for($this->companyA)->create(); + $this->assetB = Asset::factory()->for($this->companyB)->create(); + + $this->userInCompanyA = $this->companyA->users()->save(User::factory()->make()); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateAssetTool --- + + public function test_update_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'name' => 'Should Not Apply', + ]))->getStructuredContent(); + + $this->assertTrue($content['error']); + $this->assertDatabaseMissing('assets', ['id' => $this->assetB->id, 'name' => 'Should Not Apply']); + } + + public function test_update_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + 'name' => 'Updated Name', + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetA->id, 'name' => 'Updated Name']); + } + + // --- DeleteAssetTool --- + + public function test_delete_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['error']); + $this->assertNotSoftDeleted($this->assetB); + } + + public function test_delete_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->assetA); + } + + // --- AuditAssetTool --- + + public function test_audit_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + (new AuditAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ])); + + $this->assertNull($this->assetB->fresh()->last_audit_date); + } + + public function test_audit_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new AuditAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotNull($this->assetA->fresh()->last_audit_date); + } + + // --- CheckinAssetTool --- + + public function test_checkin_blocked_for_cross_company_asset() + { + $user = User::factory()->create(); + $checkedOutAsset = Asset::factory()->for($this->companyB)->assignedToUser($user)->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckinAssetTool)->handle(new Request([ + 'asset_tag' => $checkedOutAsset->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['error']); + $this->assertDatabaseHas('assets', ['id' => $checkedOutAsset->id, 'assigned_to' => $user->id]); + } + + public function test_checkin_allowed_for_same_company_asset() + { + $user = User::factory()->create(); + $asset = Asset::factory()->for($this->companyA)->assignedToUser($user)->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckinAssetTool)->handle(new Request([ + 'asset_tag' => $asset->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'assigned_to' => null]); + } + + // --- CheckoutAssetTool --- + + public function test_checkout_blocked_for_cross_company_asset() + { + $user = User::factory()->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['error']); + $this->assertDatabaseHas('assets', ['id' => $this->assetB->id, 'assigned_to' => null]); + } + + public function test_checkout_allowed_for_same_company_asset() + { + $user = User::factory()->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetA->id, 'assigned_to' => $user->id]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_asset_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'name' => 'Superuser Override', + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetB->id, 'name' => 'Superuser Override']); + } + + public function test_superuser_can_delete_asset_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->assetB); + } +} diff --git a/tests/Feature/Mcp/UpdateAssetToolTest.php b/tests/Feature/Mcp/UpdateAssetToolTest.php new file mode 100644 index 000000000000..99d78e345973 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAssetToolTest.php @@ -0,0 +1,135 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateAssetTool)->handle(new Request($args)); + } + + public function test_updates_name_by_asset_tag() + { + $asset = Asset::factory()->create(['name' => 'Old Name']); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'New Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'New Name']); + } + + public function test_updates_asset_by_numeric_id() + { + $asset = Asset::factory()->create(['name' => 'Old Name']); + + $content = $this->handle([ + 'id' => $asset->id, + 'name' => 'Updated by ID', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'Updated by ID']); + } + + public function test_updates_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-UPDATE-001', 'name' => 'Old Name']); + + $content = $this->handle([ + 'serial' => 'SN-UPDATE-001', + 'name' => 'Updated by Serial', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'Updated by Serial']); + } + + public function test_updates_multiple_fields_at_once() + { + $asset = Asset::factory()->create(); + $location = Location::factory()->create(); + $status = Statuslabel::factory()->rtd()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'notes' => 'MCP note', + 'location_id' => $location->id, + 'status_id' => $status->id, + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'notes' => 'MCP note', + 'location_id' => $location->id, + 'status_id' => $status->id, + ]); + } + + public function test_renames_asset_tag_via_new_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-OLD-001']); + + $content = $this->handle([ + 'asset_tag' => 'TAG-OLD-001', + 'new_asset_tag' => 'TAG-NEW-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('TAG-NEW-001', $content['asset_tag']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'asset_tag' => 'TAG-NEW-001']); + } + + public function test_updates_serial_via_new_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-OLD-001']); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'new_serial' => 'SN-NEW-001', + ]); + + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'serial' => 'SN-NEW-001']); + } + + public function test_returns_error_when_asset_not_found() + { + $content = $this->handle([ + 'asset_tag' => 'DOES-NOT-EXIST', + 'name' => 'Anything', + ])->getStructuredContent(); + + $this->assertTrue($content['error']); + } + + public function test_response_includes_asset_tag_and_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'Response Check', + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } +} From 51bdc3b020ac0c83567db7139d2d0409fc6583e2 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:23:34 +0100 Subject: [PATCH 13/45] Added audit, delete and update tools --- app/Mcp/Servers/SnipeMCPServer.php | 6 ++ app/Mcp/Tools/AuditAssetTool.php | 123 ++++++++++++++++++++++++ app/Mcp/Tools/UpdateAssetTool.php | 148 +++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 app/Mcp/Tools/AuditAssetTool.php create mode 100644 app/Mcp/Tools/UpdateAssetTool.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index a4b82f74b1b2..afd0f67bc84a 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -2,10 +2,13 @@ namespace App\Mcp\Servers; +use App\Mcp\Tools\AuditAssetTool; use App\Mcp\Tools\CheckinAssetTool; use App\Mcp\Tools\CheckoutAssetTool; +use App\Mcp\Tools\DeleteAssetTool; use App\Mcp\Tools\ListAssetsTool; use App\Mcp\Tools\ShowAssetTool; +use App\Mcp\Tools\UpdateAssetTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; use Laravel\Mcp\Server\Attributes\Name; @@ -21,6 +24,9 @@ class SnipeMCPServer extends Server ListAssetsTool::class, CheckoutAssetTool::class, CheckinAssetTool::class, + UpdateAssetTool::class, + DeleteAssetTool::class, + AuditAssetTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/AuditAssetTool.php b/app/Mcp/Tools/AuditAssetTool.php new file mode 100644 index 000000000000..c40f440a77e2 --- /dev/null +++ b/app/Mcp/Tools/AuditAssetTool.php @@ -0,0 +1,123 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + 'location_id' => 'nullable|integer|exists:locations,id', + 'next_audit_date' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make( + Response::text('Asset not found') + )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + } + + $originalValues = $asset->getRawOriginal(); + $settings = Setting::getSettings(); + + $asset->last_audit_date = date('Y-m-d H:i:s'); + + if ($request->filled('next_audit_date')) { + $asset->next_audit_date = $request->get('next_audit_date'); + } elseif (! is_null($settings->audit_interval)) { + $asset->next_audit_date = Carbon::now()->addMonths($settings->audit_interval)->toDateString(); + } + + if ($request->filled('location_id')) { + $asset->location_id = $request->get('location_id'); + } + + // Bypass the observer to avoid logging a spurious asset-update entry + // alongside the audit log entry created by logAudit() below + $asset->unsetEventDispatcher(); + + if ($asset->isValid() && $asset->save()) { + $asset->logAudit($request->get('note'), $request->get('location_id'), null, $originalValues); + + return Response::make( + Response::text('Audit recorded for asset '.$asset->asset_tag) + )->withStructuredContent([ + 'success' => true, + 'message' => 'Audit recorded successfully', + 'asset_tag' => $asset->asset_tag, + 'last_audit_date' => $asset->last_audit_date, + 'next_audit_date' => $asset->next_audit_date, + 'location' => $asset->location?->name, + ]); + } + + return Response::make( + Response::text('Audit failed') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Audit failed: '.$asset->getErrors()->first(), + 'asset_tag' => $asset->asset_tag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->with('location')->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->with('location')->first(); + } + if ($request->filled('id')) { + return Asset::with('location')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset to audit'), + 'serial' => $schema->string()->description('Serial number of the asset to audit'), + 'id' => $schema->number()->description('Numeric ID of the asset to audit'), + 'note' => $schema->string()->description('Optional audit note'), + 'location_id' => $schema->number()->description('Location ID where the asset was found (also updates the asset location)'), + 'next_audit_date' => $schema->string()->description('Override the next audit date (YYYY-MM-DD); defaults to now plus the audit_interval from settings'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the audit succeeded'), + 'error' => $schema->boolean()->description('True if the audit failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the audited asset'), + 'last_audit_date' => $schema->string()->description('Timestamp of the audit just recorded'), + 'next_audit_date' => $schema->string()->description('Date of the next scheduled audit'), + 'location' => $schema->string()->description('Location name where the asset was found'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAssetTool.php b/app/Mcp/Tools/UpdateAssetTool.php new file mode 100644 index 000000000000..43b3761ea6ee --- /dev/null +++ b/app/Mcp/Tools/UpdateAssetTool.php @@ -0,0 +1,148 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_asset_tag' => 'nullable|string|max:255', + 'new_serial' => 'nullable|string|max:255', + 'status_id' => 'nullable|integer|exists:status_labels,id', + 'model_id' => 'nullable|integer|exists:models,id', + 'notes' => 'nullable|string|max:65535', + 'order_number' => 'nullable|string|max:255', + 'purchase_date' => 'nullable|date', + 'purchase_cost' => 'nullable|numeric', + 'warranty_months' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'rtd_location_id' => 'nullable|integer|exists:locations,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'requestable' => 'nullable|boolean', + 'byod' => 'nullable|boolean', + 'asset_eol_date' => 'nullable|date', + 'expected_checkin' => 'nullable|date', + 'next_audit_date' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make( + Response::text('Asset not found') + )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + } + + $updatable = [ + 'name', 'status_id', 'model_id', 'notes', 'order_number', + 'purchase_date', 'purchase_cost', 'warranty_months', + 'location_id', 'rtd_location_id', 'supplier_id', + 'requestable', 'byod', 'asset_eol_date', 'expected_checkin', 'next_audit_date', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $asset->{$field} = $request->get($field); + } + } + + // new_asset_tag / new_serial let callers change the identifiers without + // conflicting with the asset_tag/serial used to look up the asset + if ($request->filled('new_asset_tag')) { + $asset->asset_tag = $request->get('new_asset_tag'); + } + if ($request->filled('new_serial')) { + $asset->serial = $request->get('new_serial'); + } + + if ($asset->save()) { + return Response::make( + Response::text('Asset '.$asset->asset_tag.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset updated successfully', + 'asset_tag' => $asset->asset_tag, + 'id' => $asset->id, + ]); + } + + return Response::make( + Response::text('Update failed') + )->withStructuredContent([ + 'error' => true, + 'message' => 'Update failed: '.$asset->getErrors()->first(), + 'asset_tag' => $asset->asset_tag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag to identify the asset'), + 'serial' => $schema->string()->description('Serial number to identify the asset'), + 'id' => $schema->number()->description('Numeric ID to identify the asset'), + 'name' => $schema->string()->description('New display name'), + 'new_asset_tag' => $schema->string()->description('New asset tag (renames the asset tag itself)'), + 'new_serial' => $schema->string()->description('New serial number'), + 'status_id' => $schema->number()->description('Status label ID'), + 'model_id' => $schema->number()->description('Model ID'), + 'notes' => $schema->string()->description('Notes'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'warranty_months' => $schema->number()->description('Warranty length in months'), + 'location_id' => $schema->number()->description('Current location ID'), + 'rtd_location_id' => $schema->number()->description('Default RTD location ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'requestable' => $schema->boolean()->description('Whether the asset is user-requestable'), + 'byod' => $schema->boolean()->description('Bring-your-own-device flag'), + 'asset_eol_date' => $schema->string()->description('Asset end-of-life date (YYYY-MM-DD)'), + 'expected_checkin' => $schema->string()->description('Expected check-in date (YYYY-MM-DD)'), + 'next_audit_date' => $schema->string()->description('Next scheduled audit date (YYYY-MM-DD)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'error' => $schema->boolean()->description('True if the update failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the updated asset'), + 'id' => $schema->number()->description('Numeric ID of the updated asset'), + ]; + } +} From d9617143587971194b571a05a3f7e12f3278e3c9 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:34:38 +0100 Subject: [PATCH 14/45] Updated response --- app/Mcp/Tools/AuditAssetTool.php | 12 +--- app/Mcp/Tools/CheckinAssetTool.php | 20 +------ app/Mcp/Tools/CheckoutAssetTool.php | 28 ++------- app/Mcp/Tools/DeleteAssetTool.php | 89 +++++++++++++++++++++++++++++ app/Mcp/Tools/UpdateAssetTool.php | 12 +--- 5 files changed, 100 insertions(+), 61 deletions(-) create mode 100644 app/Mcp/Tools/DeleteAssetTool.php diff --git a/app/Mcp/Tools/AuditAssetTool.php b/app/Mcp/Tools/AuditAssetTool.php index c40f440a77e2..6b01b1f458f1 100644 --- a/app/Mcp/Tools/AuditAssetTool.php +++ b/app/Mcp/Tools/AuditAssetTool.php @@ -33,9 +33,7 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make( - Response::text('Asset not found') - )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + return Response::make(Response::error('Asset not found')); } $originalValues = $asset->getRawOriginal(); @@ -72,13 +70,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make( - Response::text('Audit failed') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Audit failed: '.$asset->getErrors()->first(), - 'asset_tag' => $asset->asset_tag, - ]); + return Response::make(Response::error('Audit failed: '.$asset->getErrors()->first())); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/CheckinAssetTool.php b/app/Mcp/Tools/CheckinAssetTool.php index 5f62a7ca486d..3fbe554f56ec 100644 --- a/app/Mcp/Tools/CheckinAssetTool.php +++ b/app/Mcp/Tools/CheckinAssetTool.php @@ -29,21 +29,13 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make( - Response::text('Asset not found') - )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + return Response::make(Response::error('Asset not found')); } $target = $asset->assignedTo; if (is_null($target)) { - return Response::make( - Response::text('Asset is not currently checked out') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Asset '.$asset->asset_tag.' is not currently checked out', - 'asset_tag' => $asset->asset_tag, - ]); + return Response::make(Response::error('Asset '.$asset->asset_tag.' is not currently checked out')); } $originalValues = $asset->getRawOriginal(); @@ -69,13 +61,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make( - Response::text('Checkin failed') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Checkin failed: '.$asset->getErrors()->first(), - 'asset_tag' => $asset->asset_tag, - ]); + return Response::make(Response::error('Checkin failed: '.$asset->getErrors()->first())); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/CheckoutAssetTool.php b/app/Mcp/Tools/CheckoutAssetTool.php index 96532ad9ea71..8d9828e504d6 100644 --- a/app/Mcp/Tools/CheckoutAssetTool.php +++ b/app/Mcp/Tools/CheckoutAssetTool.php @@ -36,20 +36,11 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make( - Response::text('Asset not found') - )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + return Response::make(Response::error('Asset not found')); } if (! $asset->availableForCheckout()) { - return Response::make( - Response::text('Asset is not available for checkout') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Asset '.$asset->asset_tag.' is not available for checkout', - 'asset_tag' => $asset->asset_tag, - 'status' => $asset->status?->name, - ]); + return Response::make(Response::error('Asset '.$asset->asset_tag.' is not available for checkout')); } $checkoutType = $request->get('checkout_to_type'); @@ -73,12 +64,7 @@ public function handle(Request $request): ResponseFactory } if (! $target) { - return Response::make( - Response::text('Checkout target not found') - )->withStructuredContent([ - 'error' => true, - 'message' => 'The specified '.$checkoutType.' was not found', - ]); + return Response::make(Response::error('The specified '.$checkoutType.' was not found')); } $checkoutAt = $request->filled('checkout_at') ? $request->get('checkout_at') : date('Y-m-d H:i:s'); @@ -97,13 +83,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make( - Response::text('Checkout failed') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Checkout failed', - 'asset_tag' => $asset->asset_tag, - ]); + return Response::make(Response::error('Checkout failed')); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/DeleteAssetTool.php b/app/Mcp/Tools/DeleteAssetTool.php new file mode 100644 index 000000000000..b9ee1e4433b6 --- /dev/null +++ b/app/Mcp/Tools/DeleteAssetTool.php @@ -0,0 +1,89 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error('Asset not found')); + } + + $assetTag = $asset->asset_tag; + + if ($asset->assignedTo) { + $target = $asset->assignedTo; + $originalValues = $asset->getRawOriginal(); + event(new CheckoutableCheckedIn($asset, $target, auth()->user(), 'Checked in on delete', date('Y-m-d H:i:s'), $originalValues)); + DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null]); + } + + $asset->delete(); + + return Response::make( + Response::text('Asset '.$assetTag.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset deleted successfully', + 'asset_tag' => $assetTag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset to delete'), + 'serial' => $schema->string()->description('Serial number of the asset to delete'), + 'id' => $schema->number()->description('Numeric ID of the asset to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'error' => $schema->boolean()->description('True if the deletion failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the deleted asset'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAssetTool.php b/app/Mcp/Tools/UpdateAssetTool.php index 43b3761ea6ee..024fc81e76ad 100644 --- a/app/Mcp/Tools/UpdateAssetTool.php +++ b/app/Mcp/Tools/UpdateAssetTool.php @@ -46,9 +46,7 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make( - Response::text('Asset not found') - )->withStructuredContent(['error' => true, 'message' => 'Asset not found']); + return Response::make(Response::error('Asset not found')); } $updatable = [ @@ -84,13 +82,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make( - Response::text('Update failed') - )->withStructuredContent([ - 'error' => true, - 'message' => 'Update failed: '.$asset->getErrors()->first(), - 'asset_tag' => $asset->asset_tag, - ]); + return Response::make(Response::error('Update failed: '.$asset->getErrors()->first())); } private function resolveAsset(Request $request): ?Asset From 0eec6e3688f85876523b09215dd8de71a11cf871 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:36:07 +0100 Subject: [PATCH 15/45] Fixed tests --- tests/Feature/Mcp/AuditAssetToolTest.php | 22 +++++------ tests/Feature/Mcp/CheckinAssetToolTest.php | 8 +--- tests/Feature/Mcp/CheckoutAssetToolTest.php | 23 ++++-------- tests/Feature/Mcp/DeleteAssetToolTest.php | 6 +-- tests/Feature/Mcp/FmcsCompanyScopingTest.php | 39 +++++++++++--------- tests/Feature/Mcp/UpdateAssetToolTest.php | 36 +++++++++--------- 6 files changed, 61 insertions(+), 73 deletions(-) diff --git a/tests/Feature/Mcp/AuditAssetToolTest.php b/tests/Feature/Mcp/AuditAssetToolTest.php index e2cf06d263e4..c16d67d3dc80 100644 --- a/tests/Feature/Mcp/AuditAssetToolTest.php +++ b/tests/Feature/Mcp/AuditAssetToolTest.php @@ -52,9 +52,7 @@ public function test_records_audit_by_serial() public function test_returns_error_when_asset_not_found() { - $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); - - $this->assertTrue($content['error']); + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); } public function test_sets_last_audit_date_to_now() @@ -71,12 +69,12 @@ public function test_respects_explicit_next_audit_date() $asset = Asset::factory()->create(); $this->handle([ - 'asset_tag' => $asset->asset_tag, + 'asset_tag' => $asset->asset_tag, 'next_audit_date' => '2027-01-15', ]); $this->assertDatabaseHas('assets', [ - 'id' => $asset->id, + 'id' => $asset->id, 'next_audit_date' => '2027-01-15', ]); } @@ -84,15 +82,15 @@ public function test_respects_explicit_next_audit_date() public function test_updates_location_when_provided() { $location = Location::factory()->create(); - $asset = Asset::factory()->create(); + $asset = Asset::factory()->create(); $this->handle([ - 'asset_tag' => $asset->asset_tag, + 'asset_tag' => $asset->asset_tag, 'location_id' => $location->id, ]); $this->assertDatabaseHas('assets', [ - 'id' => $asset->id, + 'id' => $asset->id, 'location_id' => $location->id, ]); } @@ -100,12 +98,12 @@ public function test_updates_location_when_provided() public function test_does_not_change_location_when_not_provided() { $location = Location::factory()->create(); - $asset = Asset::factory()->create(['location_id' => $location->id]); + $asset = Asset::factory()->create(['location_id' => $location->id]); $this->handle(['asset_tag' => $asset->asset_tag]); $this->assertDatabaseHas('assets', [ - 'id' => $asset->id, + 'id' => $asset->id, 'location_id' => $location->id, ]); } @@ -128,12 +126,12 @@ public function test_creates_audit_log_entry() $this->handle([ 'asset_tag' => $asset->asset_tag, - 'note' => 'MCP audit note', + 'note' => 'MCP audit note', ]); $this->assertDatabaseHas('action_logs', [ 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_id' => $asset->id, 'action_type' => 'audit', ]); } diff --git a/tests/Feature/Mcp/CheckinAssetToolTest.php b/tests/Feature/Mcp/CheckinAssetToolTest.php index 821e24a8444d..f48dd00155ad 100644 --- a/tests/Feature/Mcp/CheckinAssetToolTest.php +++ b/tests/Feature/Mcp/CheckinAssetToolTest.php @@ -53,18 +53,14 @@ public function test_checks_in_asset_by_numeric_id() public function test_returns_error_when_asset_tag_not_found() { - $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); - - $this->assertTrue($content['error']); + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); } public function test_returns_error_when_asset_not_checked_out() { $asset = Asset::factory()->create(); - $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); - - $this->assertTrue($content['error']); + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); } public function test_asset_location_resets_to_rtd_location_on_checkin() diff --git a/tests/Feature/Mcp/CheckoutAssetToolTest.php b/tests/Feature/Mcp/CheckoutAssetToolTest.php index b5f412a76ee2..346f55488599 100644 --- a/tests/Feature/Mcp/CheckoutAssetToolTest.php +++ b/tests/Feature/Mcp/CheckoutAssetToolTest.php @@ -96,13 +96,11 @@ public function test_checks_out_asset_to_another_asset() public function test_returns_error_when_asset_tag_not_found() { - $content = $this->handle([ + $this->assertTrue($this->handle([ 'asset_tag' => 'DOES-NOT-EXIST', 'checkout_to_type' => 'user', 'assigned_user' => User::factory()->create()->id, - ])->getStructuredContent(); - - $this->assertTrue($content['error']); + ])->responses()->first()->isError()); } public function test_returns_error_when_asset_already_checked_out() @@ -111,13 +109,12 @@ public function test_returns_error_when_asset_already_checked_out() $asset = Asset::factory()->assignedToUser($existingUser)->create(); $newUser = User::factory()->create(); - $content = $this->handle([ + $this->assertTrue($this->handle([ 'asset_tag' => $asset->asset_tag, 'checkout_to_type' => 'user', 'assigned_user' => $newUser->id, - ])->getStructuredContent(); + ])->responses()->first()->isError()); - $this->assertTrue($content['error']); $this->assertDatabaseHas('assets', [ 'id' => $asset->id, 'assigned_to' => $existingUser->id, @@ -128,26 +125,22 @@ public function test_returns_error_when_target_user_not_found() { $asset = Asset::factory()->create(); - $content = $this->handle([ + $this->assertTrue($this->handle([ 'asset_tag' => $asset->asset_tag, 'checkout_to_type' => 'user', 'assigned_user' => 99999, - ])->getStructuredContent(); - - $this->assertTrue($content['error']); + ])->responses()->first()->isError()); } public function test_returns_error_when_target_location_not_found() { $asset = Asset::factory()->create(); - $content = $this->handle([ + $this->assertTrue($this->handle([ 'asset_tag' => $asset->asset_tag, 'checkout_to_type' => 'location', 'assigned_location' => 99999, - ])->getStructuredContent(); - - $this->assertTrue($content['error']); + ])->responses()->first()->isError()); } public function test_response_includes_asset_tag_and_target_info() diff --git a/tests/Feature/Mcp/DeleteAssetToolTest.php b/tests/Feature/Mcp/DeleteAssetToolTest.php index 4cd384b82940..f8ca75f43829 100644 --- a/tests/Feature/Mcp/DeleteAssetToolTest.php +++ b/tests/Feature/Mcp/DeleteAssetToolTest.php @@ -56,16 +56,14 @@ public function test_deletes_asset_by_serial() public function test_returns_error_when_asset_not_found() { - $content = $this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->getStructuredContent(); - - $this->assertTrue($content['error']); + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); } public function test_checks_in_asset_before_deleting_when_checked_out() { Event::fake([CheckoutableCheckedIn::class]); - $user = User::factory()->create(); + $user = User::factory()->create(); $asset = Asset::factory()->assignedToUser($user)->create(); $this->handle(['asset_tag' => $asset->asset_tag]); diff --git a/tests/Feature/Mcp/FmcsCompanyScopingTest.php b/tests/Feature/Mcp/FmcsCompanyScopingTest.php index 2f920e60293b..3a7630c803ed 100644 --- a/tests/Feature/Mcp/FmcsCompanyScopingTest.php +++ b/tests/Feature/Mcp/FmcsCompanyScopingTest.php @@ -16,10 +16,15 @@ class FmcsCompanyScopingTest extends TestCase { private Company $companyA; + private Company $companyB; + private Asset $assetA; + private Asset $assetB; + private User $userInCompanyA; + private User $superUser; protected function setUp(): void @@ -43,12 +48,12 @@ public function test_update_blocked_for_cross_company_asset() { $this->actingAs($this->userInCompanyA); - $content = (new UpdateAssetTool)->handle(new Request([ + $response = (new UpdateAssetTool)->handle(new Request([ 'asset_tag' => $this->assetB->asset_tag, 'name' => 'Should Not Apply', - ]))->getStructuredContent(); + ])); - $this->assertTrue($content['error']); + $this->assertTrue($response->responses()->first()->isError()); $this->assertDatabaseMissing('assets', ['id' => $this->assetB->id, 'name' => 'Should Not Apply']); } @@ -71,11 +76,11 @@ public function test_delete_blocked_for_cross_company_asset() { $this->actingAs($this->userInCompanyA); - $content = (new DeleteAssetTool)->handle(new Request([ + $response = (new DeleteAssetTool)->handle(new Request([ 'asset_tag' => $this->assetB->asset_tag, - ]))->getStructuredContent(); + ])); - $this->assertTrue($content['error']); + $this->assertTrue($response->responses()->first()->isError()); $this->assertNotSoftDeleted($this->assetB); } @@ -120,23 +125,23 @@ public function test_audit_allowed_for_same_company_asset() public function test_checkin_blocked_for_cross_company_asset() { - $user = User::factory()->create(); + $user = $this->companyB->users()->save(User::factory()->make()); $checkedOutAsset = Asset::factory()->for($this->companyB)->assignedToUser($user)->create(); $this->actingAs($this->userInCompanyA); - $content = (new CheckinAssetTool)->handle(new Request([ + $response = (new CheckinAssetTool)->handle(new Request([ 'asset_tag' => $checkedOutAsset->asset_tag, - ]))->getStructuredContent(); + ])); - $this->assertTrue($content['error']); + $this->assertTrue($response->responses()->first()->isError()); $this->assertDatabaseHas('assets', ['id' => $checkedOutAsset->id, 'assigned_to' => $user->id]); } public function test_checkin_allowed_for_same_company_asset() { - $user = User::factory()->create(); - $asset = Asset::factory()->for($this->companyA)->assignedToUser($user)->create(); + $user = $this->companyA->users()->save(User::factory()->make()); + $asset = Asset::factory()->for($this->companyA)->assignedToUser($user)->create(); $this->actingAs($this->userInCompanyA); @@ -152,23 +157,23 @@ public function test_checkin_allowed_for_same_company_asset() public function test_checkout_blocked_for_cross_company_asset() { - $user = User::factory()->create(); + $user = $this->companyA->users()->save(User::factory()->make()); $this->actingAs($this->userInCompanyA); - $content = (new CheckoutAssetTool)->handle(new Request([ + $response = (new CheckoutAssetTool)->handle(new Request([ 'asset_tag' => $this->assetB->asset_tag, 'checkout_to_type' => 'user', 'assigned_user' => $user->id, - ]))->getStructuredContent(); + ])); - $this->assertTrue($content['error']); + $this->assertTrue($response->responses()->first()->isError()); $this->assertDatabaseHas('assets', ['id' => $this->assetB->id, 'assigned_to' => null]); } public function test_checkout_allowed_for_same_company_asset() { - $user = User::factory()->create(); + $user = $this->companyA->users()->save(User::factory()->make()); $this->actingAs($this->userInCompanyA); diff --git a/tests/Feature/Mcp/UpdateAssetToolTest.php b/tests/Feature/Mcp/UpdateAssetToolTest.php index 99d78e345973..6c51541bb5d3 100644 --- a/tests/Feature/Mcp/UpdateAssetToolTest.php +++ b/tests/Feature/Mcp/UpdateAssetToolTest.php @@ -30,7 +30,7 @@ public function test_updates_name_by_asset_tag() $content = $this->handle([ 'asset_tag' => $asset->asset_tag, - 'name' => 'New Name', + 'name' => 'New Name', ])->getStructuredContent(); $this->assertTrue($content['success']); @@ -42,7 +42,7 @@ public function test_updates_asset_by_numeric_id() $asset = Asset::factory()->create(['name' => 'Old Name']); $content = $this->handle([ - 'id' => $asset->id, + 'id' => $asset->id, 'name' => 'Updated by ID', ])->getStructuredContent(); @@ -56,7 +56,7 @@ public function test_updates_asset_by_serial() $content = $this->handle([ 'serial' => 'SN-UPDATE-001', - 'name' => 'Updated by Serial', + 'name' => 'Updated by Serial', ])->getStructuredContent(); $this->assertTrue($content['success']); @@ -65,22 +65,22 @@ public function test_updates_asset_by_serial() public function test_updates_multiple_fields_at_once() { - $asset = Asset::factory()->create(); + $asset = Asset::factory()->create(); $location = Location::factory()->create(); - $status = Statuslabel::factory()->rtd()->create(); + $status = Statuslabel::factory()->rtd()->create(); $this->handle([ - 'asset_tag' => $asset->asset_tag, - 'notes' => 'MCP note', + 'asset_tag' => $asset->asset_tag, + 'notes' => 'MCP note', 'location_id' => $location->id, - 'status_id' => $status->id, + 'status_id' => $status->id, ]); $this->assertDatabaseHas('assets', [ - 'id' => $asset->id, - 'notes' => 'MCP note', + 'id' => $asset->id, + 'notes' => 'MCP note', 'location_id' => $location->id, - 'status_id' => $status->id, + 'status_id' => $status->id, ]); } @@ -89,7 +89,7 @@ public function test_renames_asset_tag_via_new_asset_tag() $asset = Asset::factory()->create(['asset_tag' => 'TAG-OLD-001']); $content = $this->handle([ - 'asset_tag' => 'TAG-OLD-001', + 'asset_tag' => 'TAG-OLD-001', 'new_asset_tag' => 'TAG-NEW-001', ])->getStructuredContent(); @@ -103,7 +103,7 @@ public function test_updates_serial_via_new_serial() $asset = Asset::factory()->create(['serial' => 'SN-OLD-001']); $this->handle([ - 'asset_tag' => $asset->asset_tag, + 'asset_tag' => $asset->asset_tag, 'new_serial' => 'SN-NEW-001', ]); @@ -112,12 +112,10 @@ public function test_updates_serial_via_new_serial() public function test_returns_error_when_asset_not_found() { - $content = $this->handle([ + $this->assertTrue($this->handle([ 'asset_tag' => 'DOES-NOT-EXIST', - 'name' => 'Anything', - ])->getStructuredContent(); - - $this->assertTrue($content['error']); + 'name' => 'Anything', + ])->responses()->first()->isError()); } public function test_response_includes_asset_tag_and_id() @@ -126,7 +124,7 @@ public function test_response_includes_asset_tag_and_id() $content = $this->handle([ 'asset_tag' => $asset->asset_tag, - 'name' => 'Response Check', + 'name' => 'Response Check', ])->getStructuredContent(); $this->assertEquals($asset->asset_tag, $content['asset_tag']); From 7636c2436ce893516073183c7c8c071ad582bd2b Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 16:36:39 +0100 Subject: [PATCH 16/45] TEMPORARILY remove api auth from MCP routes - this is breaking the inspector for me --- routes/ai.php | 2 +- tests/Feature/Mcp/FmcsCompanyScopingTest.php | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/routes/ai.php b/routes/ai.php index 87b621766859..e242b2197757 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use Laravel\Mcp\Facades\Mcp; Mcp::oauthRoutes(); -Mcp::web('/mcp/snipe-it', SnipeMCPServer::class)->middleware('auth:api'); +Mcp::web('/mcp/snipe-it', SnipeMCPServer::class); diff --git a/tests/Feature/Mcp/FmcsCompanyScopingTest.php b/tests/Feature/Mcp/FmcsCompanyScopingTest.php index 3a7630c803ed..079eccb68180 100644 --- a/tests/Feature/Mcp/FmcsCompanyScopingTest.php +++ b/tests/Feature/Mcp/FmcsCompanyScopingTest.php @@ -37,7 +37,7 @@ protected function setUp(): void $this->assetB = Asset::factory()->for($this->companyB)->create(); $this->userInCompanyA = $this->companyA->users()->save(User::factory()->make()); - $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); $this->settings->enableMultipleFullCompanySupport(); } @@ -50,7 +50,7 @@ public function test_update_blocked_for_cross_company_asset() $response = (new UpdateAssetTool)->handle(new Request([ 'asset_tag' => $this->assetB->asset_tag, - 'name' => 'Should Not Apply', + 'name' => 'Should Not Apply', ])); $this->assertTrue($response->responses()->first()->isError()); @@ -63,7 +63,7 @@ public function test_update_allowed_for_same_company_asset() $content = (new UpdateAssetTool)->handle(new Request([ 'asset_tag' => $this->assetA->asset_tag, - 'name' => 'Updated Name', + 'name' => 'Updated Name', ]))->getStructuredContent(); $this->assertTrue($content['success']); @@ -125,7 +125,7 @@ public function test_audit_allowed_for_same_company_asset() public function test_checkin_blocked_for_cross_company_asset() { - $user = $this->companyB->users()->save(User::factory()->make()); + $user = $this->companyB->users()->save(User::factory()->make()); $checkedOutAsset = Asset::factory()->for($this->companyB)->assignedToUser($user)->create(); $this->actingAs($this->userInCompanyA); @@ -140,7 +140,7 @@ public function test_checkin_blocked_for_cross_company_asset() public function test_checkin_allowed_for_same_company_asset() { - $user = $this->companyA->users()->save(User::factory()->make()); + $user = $this->companyA->users()->save(User::factory()->make()); $asset = Asset::factory()->for($this->companyA)->assignedToUser($user)->create(); $this->actingAs($this->userInCompanyA); @@ -162,9 +162,9 @@ public function test_checkout_blocked_for_cross_company_asset() $this->actingAs($this->userInCompanyA); $response = (new CheckoutAssetTool)->handle(new Request([ - 'asset_tag' => $this->assetB->asset_tag, + 'asset_tag' => $this->assetB->asset_tag, 'checkout_to_type' => 'user', - 'assigned_user' => $user->id, + 'assigned_user' => $user->id, ])); $this->assertTrue($response->responses()->first()->isError()); @@ -178,9 +178,9 @@ public function test_checkout_allowed_for_same_company_asset() $this->actingAs($this->userInCompanyA); $content = (new CheckoutAssetTool)->handle(new Request([ - 'asset_tag' => $this->assetA->asset_tag, + 'asset_tag' => $this->assetA->asset_tag, 'checkout_to_type' => 'user', - 'assigned_user' => $user->id, + 'assigned_user' => $user->id, ]))->getStructuredContent(); $this->assertTrue($content['success']); @@ -195,7 +195,7 @@ public function test_superuser_can_update_asset_in_any_company() $content = (new UpdateAssetTool)->handle(new Request([ 'asset_tag' => $this->assetB->asset_tag, - 'name' => 'Superuser Override', + 'name' => 'Superuser Override', ]))->getStructuredContent(); $this->assertTrue($content['success']); From b74e79b8140369fcf56355a3dad298f3b282c8da Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 17:27:20 +0100 Subject: [PATCH 17/45] Added user create, show, list, delete --- app/Mcp/Servers/SnipeMCPServer.php | 10 + app/Mcp/Tools/CreateUserTool.php | 134 ++++++++++++++ app/Mcp/Tools/DeleteUserTool.php | 89 +++++++++ app/Mcp/Tools/ListUsersTool.php | 110 +++++++++++ app/Mcp/Tools/ShowUserTool.php | 111 +++++++++++ app/Mcp/Tools/UpdateUserTool.php | 178 ++++++++++++++++++ tests/Feature/Mcp/CreateUserToolTest.php | 160 ++++++++++++++++ tests/Feature/Mcp/DeleteUserToolTest.php | 89 +++++++++ tests/Feature/Mcp/ListUsersToolTest.php | 132 +++++++++++++ tests/Feature/Mcp/ShowUserToolTest.php | 105 +++++++++++ tests/Feature/Mcp/UpdateUserToolTest.php | 225 +++++++++++++++++++++++ 11 files changed, 1343 insertions(+) create mode 100644 app/Mcp/Tools/CreateUserTool.php create mode 100644 app/Mcp/Tools/DeleteUserTool.php create mode 100644 app/Mcp/Tools/ListUsersTool.php create mode 100644 app/Mcp/Tools/ShowUserTool.php create mode 100644 app/Mcp/Tools/UpdateUserTool.php create mode 100644 tests/Feature/Mcp/CreateUserToolTest.php create mode 100644 tests/Feature/Mcp/DeleteUserToolTest.php create mode 100644 tests/Feature/Mcp/ListUsersToolTest.php create mode 100644 tests/Feature/Mcp/ShowUserToolTest.php create mode 100644 tests/Feature/Mcp/UpdateUserToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index afd0f67bc84a..57b2c6847fde 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -5,10 +5,15 @@ use App\Mcp\Tools\AuditAssetTool; use App\Mcp\Tools\CheckinAssetTool; use App\Mcp\Tools\CheckoutAssetTool; +use App\Mcp\Tools\CreateUserTool; use App\Mcp\Tools\DeleteAssetTool; +use App\Mcp\Tools\DeleteUserTool; use App\Mcp\Tools\ListAssetsTool; +use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\ShowAssetTool; +use App\Mcp\Tools\ShowUserTool; use App\Mcp\Tools\UpdateAssetTool; +use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; use Laravel\Mcp\Server\Attributes\Name; @@ -27,6 +32,11 @@ class SnipeMCPServer extends Server UpdateAssetTool::class, DeleteAssetTool::class, AuditAssetTool::class, + ListUsersTool::class, + ShowUserTool::class, + CreateUserTool::class, + UpdateUserTool::class, + DeleteUserTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php new file mode 100644 index 000000000000..c12f8ce8770b --- /dev/null +++ b/app/Mcp/Tools/CreateUserTool.php @@ -0,0 +1,134 @@ +validate([ + 'first_name' => 'required|string|max:191', + 'last_name' => 'nullable|string|max:191', + 'username' => 'required|string|max:191', + 'email' => 'nullable|email|max:191', + 'password' => 'nullable|string|min:8', + 'employee_num' => 'nullable|string|max:191', + 'jobtitle' => 'nullable|string|max:191', + 'phone' => 'nullable|string|max:35', + 'mobile' => 'nullable|string|max:35', + 'company_id' => 'nullable|integer|exists:companies,id', + 'department_id' => 'nullable|integer|exists:departments,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'activated' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'vip' => 'nullable|boolean', + 'remote' => 'nullable|boolean', + 'website' => 'nullable|url|max:191', + 'address' => 'nullable|string|max:191', + 'city' => 'nullable|string|max:191', + 'state' => 'nullable|string|max:191', + 'country' => 'nullable|string|max:191', + 'zip' => 'nullable|string|max:10', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $user = new User; + $user->fill($request->only([ + 'first_name', 'last_name', 'username', 'email', 'employee_num', + 'jobtitle', 'phone', 'mobile', 'department_id', 'location_id', + 'manager_id', 'notes', 'start_date', 'end_date', 'vip', 'remote', + 'website', 'address', 'city', 'state', 'country', 'zip', + ])); + + $user->activated = $request->filled('activated') ? (bool) $request->get('activated') : true; + $user->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $user->created_by = auth()->id(); + + if ($request->filled('password')) { + $user->password = bcrypt($request->get('password')); + } else { + $user->password = $user->noPassword(); + } + + if ($user->save()) { + return Response::make( + Response::text('User '.$user->username.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'User created successfully', + 'id' => $user->id, + 'username' => $user->username, + 'email' => $user->email, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + ]); + } + + return Response::make(Response::error('Create failed: '.$user->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'first_name' => $schema->string()->description('First name (required)'), + 'last_name' => $schema->string()->description('Last name'), + 'username' => $schema->string()->description('Username (required, must be unique)'), + 'email' => $schema->string()->description('Email address'), + 'password' => $schema->string()->description('Password (min 8 characters; if omitted, account will have no password set)'), + 'employee_num' => $schema->string()->description('Employee number'), + 'jobtitle' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'mobile' => $schema->string()->description('Mobile number'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'department_id' => $schema->number()->description('Department ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + 'activated' => $schema->boolean()->description('Whether the account is active (default: true)'), + 'notes' => $schema->string()->description('Notes'), + 'start_date' => $schema->string()->description('Employment start date (YYYY-MM-DD)'), + 'end_date' => $schema->string()->description('Employment end date (YYYY-MM-DD)'), + 'vip' => $schema->boolean()->description('Mark user as VIP'), + 'remote' => $schema->boolean()->description('Mark user as remote'), + 'website' => $schema->string()->description('Website URL'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State/province'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal/ZIP code'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the user was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new user'), + 'username' => $schema->string()->description('Username of the new user'), + 'email' => $schema->string()->description('Email of the new user'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteUserTool.php b/app/Mcp/Tools/DeleteUserTool.php new file mode 100644 index 000000000000..1eca3aea847b --- /dev/null +++ b/app/Mcp/Tools/DeleteUserTool.php @@ -0,0 +1,89 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + if ($user->id === auth()->id()) { + return Response::make(Response::error('You cannot delete your own account')); + } + + if ($user->allAssignedCount() > 0) { + return Response::make(Response::error('User has assigned items and cannot be deleted. Check in all items first.')); + } + + $username = $user->username; + + if ($user->delete()) { + return Response::make( + Response::text('User '.$username.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'User deleted successfully', + 'username' => $username, + ]); + } + + return Response::make(Response::error('Delete failed: '.$user->getErrors()->first())); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID to delete'), + 'username' => $schema->string()->description('Username of the user to delete'), + 'email' => $schema->string()->description('Email address of the user to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'username' => $schema->string()->description('Username of the deleted user'), + ]; + } +} diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php new file mode 100644 index 000000000000..a9c43f0929b5 --- /dev/null +++ b/app/Mcp/Tools/ListUsersTool.php @@ -0,0 +1,110 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'department_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'activated' => 'nullable|boolean', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $users = User::with('company', 'department', 'userloc', 'manager') + ->withCount(['assets as assets_count', 'licenses as licenses_count']); + + if ($request->filled('search')) { + $users->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $users->where('users.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('department_id')) { + $users->where('users.department_id', '=', $request->get('department_id')); + } + + if ($request->filled('location_id')) { + $users->where('users.location_id', '=', $request->get('location_id')); + } + + if ($request->has('activated')) { + $users->where('users.activated', '=', $request->get('activated')); + } + + $users->orderBy('users.last_name', 'asc')->orderBy('users.first_name', 'asc'); + + $total = $users->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $users->skip($offset)->take($limit)->get(); + + $usersData = $results->map(fn (User $user) => [ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'username' => $user->username, + 'email' => $user->email, + 'jobtitle' => $user->jobtitle, + 'company' => $user->company?->name, + 'department' => $user->department?->name, + 'location' => $user->userloc?->name, + 'manager' => $user->manager ? trim($user->manager->first_name.' '.$user->manager->last_name) : null, + 'activated' => (bool) $user->activated, + 'assets_count' => $user->assets_count, + 'licenses_count' => $user->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} users, returning ".count($usersData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'users' => $usersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across name, username, email, and employee number'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'department_id' => $schema->number()->description('Filter by department ID'), + 'location_id' => $schema->number()->description('Filter by location ID'), + 'activated' => $schema->boolean()->description('Filter by account activated status'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching users')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ShowUserTool.php b/app/Mcp/Tools/ShowUserTool.php new file mode 100644 index 000000000000..ffa68a56255a --- /dev/null +++ b/app/Mcp/Tools/ShowUserTool.php @@ -0,0 +1,111 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $with = ['company', 'department', 'userloc', 'manager', 'groups']; + + if ($request->filled('id')) { + $user = User::with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->find($request->get('id')); + } elseif ($request->filled('username')) { + $user = User::where('username', $request->get('username'))->with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->first(); + } elseif ($request->filled('email')) { + $user = User::where('email', $request->get('email'))->with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->first(); + } else { + return Response::make(Response::error('Please provide an id, username, or email')); + } + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + return Response::make( + Response::text('User '.$user->username.' found') + )->withStructuredContent([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'username' => $user->username, + 'email' => $user->email, + 'employee_num' => $user->employee_num, + 'jobtitle' => $user->jobtitle, + 'phone' => $user->phone, + 'mobile' => $user->mobile, + 'company' => $user->company?->name, + 'company_id' => $user->company_id, + 'department' => $user->department?->name, + 'department_id' => $user->department_id, + 'location' => $user->userloc?->name, + 'location_id' => $user->location_id, + 'manager' => $user->manager ? trim($user->manager->first_name.' '.$user->manager->last_name) : null, + 'manager_id' => $user->manager_id, + 'activated' => (bool) $user->activated, + 'notes' => $user->notes, + 'start_date' => $user->start_date?->toDateString(), + 'end_date' => $user->end_date?->toDateString(), + 'vip' => (bool) $user->vip, + 'remote' => (bool) $user->remote, + 'website' => $user->website, + 'address' => $user->address, + 'city' => $user->city, + 'state' => $user->state, + 'country' => $user->country, + 'zip' => $user->zip, + 'assets_count' => $user->assets_count, + 'licenses_count' => $user->licenses_count, + 'accessories_count' => $user->accessories_count, + 'consumables_count' => $user->consumables_count, + 'last_login' => $user->last_login?->toDateTimeString(), + 'created_at' => $user->created_at?->toDateTimeString(), + 'updated_at' => $user->updated_at?->toDateTimeString(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID'), + 'username' => $schema->string()->description('Username to look up'), + 'email' => $schema->string()->description('Email address to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID')->required(), + 'username' => $schema->string()->description('Username')->required(), + 'email' => $schema->string()->description('Email address'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php new file mode 100644 index 000000000000..e2011d23bd7f --- /dev/null +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -0,0 +1,178 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + 'new_username' => 'nullable|string|max:191', + 'new_email' => 'nullable|email|max:191', + 'first_name' => 'nullable|string|max:191', + 'last_name' => 'nullable|string|max:191', + 'password' => 'nullable|string|min:8', + 'employee_num' => 'nullable|string|max:191', + 'jobtitle' => 'nullable|string|max:191', + 'phone' => 'nullable|string|max:35', + 'mobile' => 'nullable|string|max:35', + 'company_id' => 'nullable|integer|exists:companies,id', + 'department_id' => 'nullable|integer|exists:departments,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'activated' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'vip' => 'nullable|boolean', + 'remote' => 'nullable|boolean', + 'website' => 'nullable|url|max:191', + 'address' => 'nullable|string|max:191', + 'city' => 'nullable|string|max:191', + 'state' => 'nullable|string|max:191', + 'country' => 'nullable|string|max:191', + 'zip' => 'nullable|string|max:10', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + $updatable = [ + 'first_name', 'last_name', 'employee_num', 'jobtitle', + 'phone', 'mobile', 'department_id', 'location_id', 'manager_id', + 'notes', 'start_date', 'end_date', + 'vip', 'remote', 'website', 'address', 'city', 'state', 'country', 'zip', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $user->{$field} = $request->get($field); + } + } + + // Sensitive auth fields require elevated permission over the target user + $canEditAuthFields = Gate::allows('canEditAuthFields', $user); + + if ($request->filled('new_username') || $request->filled('new_email') || + $request->filled('password') || $request->has('activated')) { + if (! $canEditAuthFields) { + return Response::make(Response::error('You do not have permission to edit auth fields (username, email, password, activated) for this user')); + } + + if ($request->filled('new_username')) { + $user->username = $request->get('new_username'); + } + + if ($request->filled('new_email')) { + $user->email = $request->get('new_email'); + } + + if ($request->filled('password')) { + $user->password = bcrypt($request->get('password')); + } + + if ($request->has('activated')) { + $user->activated = $request->get('activated'); + } + } + + if ($request->filled('company_id')) { + $user->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($user->save()) { + return Response::make( + Response::text('User '.$user->username.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'User updated successfully', + 'id' => $user->id, + 'username' => $user->username, + 'email' => $user->email, + ]); + } + + return Response::make(Response::error('Update failed: '.$user->getErrors()->first())); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID to identify the user'), + 'username' => $schema->string()->description('Username to identify the user'), + 'email' => $schema->string()->description('Email address to identify the user'), + 'new_username' => $schema->string()->description('New username (renames the username itself)'), + 'new_email' => $schema->string()->description('New email address'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'password' => $schema->string()->description('New password (min 8 characters)'), + 'employee_num' => $schema->string()->description('Employee number'), + 'jobtitle' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'mobile' => $schema->string()->description('Mobile number'), + 'company_id' => $schema->number()->description('Company ID'), + 'department_id' => $schema->number()->description('Department ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + 'activated' => $schema->boolean()->description('Whether the account is active'), + 'notes' => $schema->string()->description('Notes'), + 'start_date' => $schema->string()->description('Employment start date (YYYY-MM-DD)'), + 'end_date' => $schema->string()->description('Employment end date (YYYY-MM-DD)'), + 'vip' => $schema->boolean()->description('Mark user as VIP'), + 'remote' => $schema->boolean()->description('Mark user as remote'), + 'website' => $schema->string()->description('Website URL'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State/province'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal/ZIP code'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated user'), + 'username' => $schema->string()->description('Username of the updated user'), + 'email' => $schema->string()->description('Email of the updated user'), + ]; + } +} diff --git a/tests/Feature/Mcp/CreateUserToolTest.php b/tests/Feature/Mcp/CreateUserToolTest.php new file mode 100644 index 000000000000..b3b0512c4014 --- /dev/null +++ b/tests/Feature/Mcp/CreateUserToolTest.php @@ -0,0 +1,160 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateUserTool)->handle(new Request($args)); + } + + public function test_creates_user_with_required_fields() + { + $content = $this->handle([ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'username' => 'jane.doe.mcp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['username' => 'jane.doe.mcp', 'first_name' => 'Jane']); + } + + public function test_response_includes_new_user_id_and_username() + { + $content = $this->handle([ + 'first_name' => 'Test', + 'username' => 'new.user.mcp', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('username', $content); + $this->assertEquals('new.user.mcp', $content['username']); + $this->assertIsInt($content['id']); + } + + public function test_hashes_password_when_provided() + { + $this->handle([ + 'first_name' => 'Secure', + 'username' => 'secure.user.mcp', + 'password' => 'secret1234', + ]); + + $user = User::where('username', 'secure.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertTrue(Hash::check('secret1234', $user->password)); + } + + public function test_creates_user_with_no_password_when_omitted() + { + $this->handle([ + 'first_name' => 'Nopass', + 'username' => 'nopass.user.mcp', + ]); + + $user = User::where('username', 'nopass.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertFalse(Hash::check('', $user->password)); + } + + public function test_creates_user_with_email_and_contact_fields() + { + $this->handle([ + 'first_name' => 'Contact', + 'username' => 'contact.user.mcp', + 'email' => 'contact@example.com', + 'phone' => '555-1234', + 'jobtitle' => 'Engineer', + ]); + + $this->assertDatabaseHas('users', [ + 'username' => 'contact.user.mcp', + 'email' => 'contact@example.com', + 'phone' => '555-1234', + 'jobtitle' => 'Engineer', + ]); + } + + public function test_creates_user_with_department_and_location() + { + $department = Department::factory()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'first_name' => 'Located', + 'username' => 'located.user.mcp', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('users', [ + 'username' => 'located.user.mcp', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + } + + public function test_returns_error_when_username_missing() + { + $this->assertTrue($this->handle([ + 'first_name' => 'No Username', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_first_name_missing() + { + $this->assertTrue($this->handle([ + 'username' => 'no.firstname.mcp', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_username_already_exists() + { + User::factory()->create(['username' => 'duplicate.mcp']); + + $response = $this->handle([ + 'first_name' => 'Dupe', + 'username' => 'duplicate.mcp', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_activates_user_by_default() + { + $this->handle([ + 'first_name' => 'Active', + 'username' => 'default.active.mcp', + ]); + + $this->assertDatabaseHas('users', ['username' => 'default.active.mcp', 'activated' => 1]); + } + + public function test_can_create_deactivated_user() + { + $this->handle([ + 'first_name' => 'Inactive', + 'username' => 'inactive.user.mcp', + 'activated' => false, + ]); + + $this->assertDatabaseHas('users', ['username' => 'inactive.user.mcp', 'activated' => 0]); + } +} diff --git a/tests/Feature/Mcp/DeleteUserToolTest.php b/tests/Feature/Mcp/DeleteUserToolTest.php new file mode 100644 index 000000000000..64a9e8adfd7d --- /dev/null +++ b/tests/Feature/Mcp/DeleteUserToolTest.php @@ -0,0 +1,89 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteUserTool)->handle(new Request($args)); + } + + public function test_deletes_user_by_id() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_deletes_user_by_username() + { + $user = User::factory()->create(['username' => 'delete.by.username']); + + $content = $this->handle(['username' => 'delete.by.username'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_deletes_user_by_email() + { + $user = User::factory()->create(['email' => 'delete.by@example.com']); + + $content = $this->handle(['email' => 'delete.by@example.com'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_response_includes_username() + { + $user = User::factory()->create(['username' => 'response.check.mcp']); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals('response.check.mcp', $content['username']); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_deleting_own_account() + { + $authUser = auth()->user(); + + $response = $this->handle(['id' => $authUser->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $authUser->id]); + } + + public function test_returns_error_when_user_has_assigned_assets() + { + $user = User::factory()->create(); + Asset::factory()->assignedToUser($user)->create(); + + $response = $this->handle(['id' => $user->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } +} diff --git a/tests/Feature/Mcp/ListUsersToolTest.php b/tests/Feature/Mcp/ListUsersToolTest.php new file mode 100644 index 000000000000..3c337bbcd934 --- /dev/null +++ b/tests/Feature/Mcp/ListUsersToolTest.php @@ -0,0 +1,132 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListUsersTool)->handle(new Request($args)); + } + + public function test_returns_list_of_users() + { + User::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('users', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_user_records_contain_expected_fields() + { + User::factory()->create(); + + $content = $this->handle(['limit' => 1])->getStructuredContent(); + + $user = $content['users'][0]; + $this->assertArrayHasKey('id', $user); + $this->assertArrayHasKey('username', $user); + $this->assertArrayHasKey('first_name', $user); + $this->assertArrayHasKey('last_name', $user); + $this->assertArrayHasKey('email', $user); + $this->assertArrayHasKey('activated', $user); + $this->assertArrayHasKey('assets_count', $user); + } + + public function test_filters_by_company_id() + { + $company = Company::factory()->create(); + $user = $company->users()->save(User::factory()->make()); + + $content = $this->handle(['company_id' => $company->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + foreach ($content['users'] as $u) { + $this->assertEquals($company->name, $u['company']); + } + } + + public function test_filters_by_department_id() + { + $department = Department::factory()->create(); + $user = User::factory()->create(['department_id' => $department->id]); + + $content = $this->handle(['department_id' => $department->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + } + + public function test_filters_by_location_id() + { + $location = Location::factory()->create(); + $user = User::factory()->create(['location_id' => $location->id]); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + } + + public function test_filters_by_activated_status() + { + $active = User::factory()->create(['activated' => true]); + $inactive = User::factory()->create(['activated' => false]); + + $content = $this->handle(['activated' => false])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($inactive->id, $ids); + $this->assertNotContains($active->id, $ids); + } + + public function test_search_filters_by_name() + { + $target = User::factory()->create(['first_name' => 'Uniquefirstname', 'last_name' => 'McTest']); + + $content = $this->handle(['search' => 'Uniquefirstname'])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($target->id, $ids); + } + + public function test_pagination_limit_and_offset() + { + User::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 3, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 3, 'offset' => 3])->getStructuredContent(); + + $this->assertCount(3, $page1['users']); + $this->assertCount(3, $page2['users']); + $this->assertNotEquals($page1['users'][0]['id'], $page2['users'][0]['id']); + } + + public function test_response_contains_pagination_meta() + { + $content = $this->handle(['limit' => 5, 'offset' => 0])->getStructuredContent(); + + $this->assertEquals(5, $content['limit']); + $this->assertEquals(0, $content['offset']); + $this->assertArrayHasKey('total', $content); + } +} diff --git a/tests/Feature/Mcp/ShowUserToolTest.php b/tests/Feature/Mcp/ShowUserToolTest.php new file mode 100644 index 000000000000..54d148598100 --- /dev/null +++ b/tests/Feature/Mcp/ShowUserToolTest.php @@ -0,0 +1,105 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ShowUserTool)->handle(new Request($args)); + } + + public function test_finds_user_by_id() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + public function test_finds_user_by_username() + { + $user = User::factory()->create(['username' => 'test.user.mcp']); + + $content = $this->handle(['username' => 'test.user.mcp'])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals('test.user.mcp', $content['username']); + } + + public function test_finds_user_by_email() + { + $user = User::factory()->create(['email' => 'mcp.test@example.com']); + + $content = $this->handle(['email' => 'mcp.test@example.com'])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals('mcp.test@example.com', $content['email']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_id() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_username() + { + $this->assertTrue($this->handle(['username' => 'no.such.user'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_email() + { + $this->assertTrue($this->handle(['email' => 'nobody@nowhere.invalid'])->responses()->first()->isError()); + } + + public function test_response_includes_expected_fields() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + foreach (['id', 'username', 'email', 'first_name', 'last_name', 'activated', 'assets_count', 'licenses_count'] as $field) { + $this->assertArrayHasKey($field, $content); + } + } + + public function test_response_includes_asset_and_license_counts() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertIsInt($content['assets_count']); + $this->assertIsInt($content['licenses_count']); + } + + public function test_response_includes_company_and_department() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertArrayHasKey('company', $content); + $this->assertArrayHasKey('department', $content); + $this->assertArrayHasKey('location', $content); + } +} diff --git a/tests/Feature/Mcp/UpdateUserToolTest.php b/tests/Feature/Mcp/UpdateUserToolTest.php new file mode 100644 index 000000000000..42ff22f57927 --- /dev/null +++ b/tests/Feature/Mcp/UpdateUserToolTest.php @@ -0,0 +1,225 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateUserTool)->handle(new Request($args)); + } + + public function test_updates_user_by_id() + { + $user = User::factory()->create(['first_name' => 'Old']); + + $content = $this->handle([ + 'id' => $user->id, + 'first_name' => 'New', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'first_name' => 'New']); + } + + public function test_updates_user_by_username() + { + $user = User::factory()->create(['username' => 'lookup.by.username', 'jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'username' => 'lookup.by.username', + 'jobtitle' => 'New Title', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'jobtitle' => 'New Title']); + } + + public function test_updates_user_by_email() + { + $user = User::factory()->create(['email' => 'update.by@example.com', 'jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'email' => 'update.by@example.com', + 'jobtitle' => 'Senior Engineer', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'jobtitle' => 'Senior Engineer']); + } + + public function test_renames_username_via_new_username() + { + $user = User::factory()->create(['username' => 'old.username.mcp']); + + $content = $this->handle([ + 'username' => 'old.username.mcp', + 'new_username' => 'new.username.mcp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('new.username.mcp', $content['username']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'username' => 'new.username.mcp']); + } + + public function test_updates_email_via_new_email() + { + $user = User::factory()->create(['email' => 'before@example.com']); + + $this->handle([ + 'id' => $user->id, + 'new_email' => 'after@example.com', + ]); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); + } + + public function test_updates_password() + { + $user = User::factory()->create(); + + $this->handle([ + 'id' => $user->id, + 'password' => 'newpassword99', + ]); + + $this->assertTrue(Hash::check('newpassword99', $user->fresh()->password)); + } + + public function test_updates_multiple_fields_at_once() + { + $user = User::factory()->create(); + $department = Department::factory()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'id' => $user->id, + 'jobtitle' => 'Team Lead', + 'phone' => '555-9999', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'jobtitle' => 'Team Lead', + 'phone' => '555-9999', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'first_name' => 'Ghost', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'first_name' => 'No Identifier', + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_username() + { + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $user->id, + 'first_name' => 'Updated', + ])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + // --- canEditAuthFields permission gate --- + + public function test_non_admin_cannot_change_auth_fields_of_admin() + { + // Acting user is a plain user (set in setUp); target is an admin + $admin = User::factory()->admin()->create(); + + $response = $this->handle([ + 'id' => $admin->id, + 'password' => 'shouldnotwork1', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertFalse(Hash::check('shouldnotwork1', $admin->fresh()->password)); + } + + public function test_non_admin_cannot_change_auth_fields_of_superuser() + { + $superuser = User::factory()->superuser()->create(); + + $response = $this->handle([ + 'id' => $superuser->id, + 'new_email' => 'hacked@example.com', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['id' => $superuser->id, 'email' => 'hacked@example.com']); + } + + public function test_admin_cannot_change_auth_fields_of_superuser() + { + $this->actingAs(User::factory()->admin()->create()); + $superuser = User::factory()->superuser()->create(); + + $response = $this->handle([ + 'id' => $superuser->id, + 'new_username' => 'hijacked', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['id' => $superuser->id, 'username' => 'hijacked']); + } + + public function test_superuser_can_change_auth_fields_of_any_user() + { + $this->actingAs(User::factory()->superuser()->create()); + $target = User::factory()->admin()->create(); + + $content = $this->handle([ + 'id' => $target->id, + 'new_email' => 'superchanged@example.com', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $target->id, 'email' => 'superchanged@example.com']); + } + + public function test_non_auth_fields_are_still_updated_even_without_canEditAuthFields() + { + // A non-admin updating an admin's non-sensitive fields (jobtitle etc.) should still work + $admin = User::factory()->admin()->create(['jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'id' => $admin->id, + 'jobtitle' => 'New Title', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $admin->id, 'jobtitle' => 'New Title']); + } +} From 9aa5ba5cd0cb5403db175a42efd574d43b35646f Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 17:38:03 +0100 Subject: [PATCH 18/45] MCP for accessories management --- app/Mcp/Servers/SnipeMCPServer.php | 10 ++ app/Mcp/Tools/CheckinAccessoryTool.php | 78 ++++++++ app/Mcp/Tools/CheckoutAccessoryTool.php | 129 ++++++++++++++ app/Mcp/Tools/CreateAccessoryTool.php | 102 +++++++++++ app/Mcp/Tools/CreateUserTool.php | 52 +++--- app/Mcp/Tools/DeleteAccessoryTool.php | 78 ++++++++ app/Mcp/Tools/UpdateAccessoryTool.php | 125 +++++++++++++ .../Feature/Mcp/CheckinAccessoryToolTest.php | 96 ++++++++++ .../Feature/Mcp/CheckoutAccessoryToolTest.php | 166 ++++++++++++++++++ tests/Feature/Mcp/CreateAccessoryToolTest.php | 105 +++++++++++ tests/Feature/Mcp/DeleteAccessoryToolTest.php | 69 ++++++++ tests/Feature/Mcp/UpdateAccessoryToolTest.php | 118 +++++++++++++ tests/Feature/Mcp/UpdateUserToolTest.php | 2 +- 13 files changed, 1103 insertions(+), 27 deletions(-) create mode 100644 app/Mcp/Tools/CheckinAccessoryTool.php create mode 100644 app/Mcp/Tools/CheckoutAccessoryTool.php create mode 100644 app/Mcp/Tools/CreateAccessoryTool.php create mode 100644 app/Mcp/Tools/DeleteAccessoryTool.php create mode 100644 app/Mcp/Tools/UpdateAccessoryTool.php create mode 100644 tests/Feature/Mcp/CheckinAccessoryToolTest.php create mode 100644 tests/Feature/Mcp/CheckoutAccessoryToolTest.php create mode 100644 tests/Feature/Mcp/CreateAccessoryToolTest.php create mode 100644 tests/Feature/Mcp/DeleteAccessoryToolTest.php create mode 100644 tests/Feature/Mcp/UpdateAccessoryToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 57b2c6847fde..3baa4ad5a101 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -3,15 +3,20 @@ namespace App\Mcp\Servers; use App\Mcp\Tools\AuditAssetTool; +use App\Mcp\Tools\CheckinAccessoryTool; use App\Mcp\Tools\CheckinAssetTool; +use App\Mcp\Tools\CheckoutAccessoryTool; use App\Mcp\Tools\CheckoutAssetTool; +use App\Mcp\Tools\CreateAccessoryTool; use App\Mcp\Tools\CreateUserTool; +use App\Mcp\Tools\DeleteAccessoryTool; use App\Mcp\Tools\DeleteAssetTool; use App\Mcp\Tools\DeleteUserTool; use App\Mcp\Tools\ListAssetsTool; use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\ShowAssetTool; use App\Mcp\Tools\ShowUserTool; +use App\Mcp\Tools\UpdateAccessoryTool; use App\Mcp\Tools\UpdateAssetTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; @@ -37,6 +42,11 @@ class SnipeMCPServer extends Server CreateUserTool::class, UpdateUserTool::class, DeleteUserTool::class, + CreateAccessoryTool::class, + UpdateAccessoryTool::class, + DeleteAccessoryTool::class, + CheckoutAccessoryTool::class, + CheckinAccessoryTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/CheckinAccessoryTool.php b/app/Mcp/Tools/CheckinAccessoryTool.php new file mode 100644 index 000000000000..c9cfd25026a8 --- /dev/null +++ b/app/Mcp/Tools/CheckinAccessoryTool.php @@ -0,0 +1,78 @@ +validate([ + 'checkout_id' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $checkout = AccessoryCheckout::find($request->get('checkout_id')); + + if (! $checkout) { + return Response::make(Response::error('Accessory checkout record not found')); + } + + $accessory = Accessory::find($checkout->accessory_id); + + if (! $accessory) { + return Response::make(Response::error('Accessory not found')); + } + + $target = $checkout->assigned_type && $checkout->assigned_to + ? $checkout->assigned_type::find($checkout->assigned_to) + : null; + + $accessory->logCheckin($target, $request->get('note')); + + if ($checkout->delete()) { + return Response::make( + Response::text('Accessory '.$accessory->name.' checked in successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Accessory checked in successfully', + 'accessory_id' => $accessory->id, + 'accessory_name' => $accessory->name, + ]); + } + + return Response::make(Response::error('Checkin failed')); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'checkout_id' => $schema->number()->description('ID of the checkout record to check in (returned by checkout_accessory)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'accessory_id' => $schema->number()->description('Numeric ID of the accessory'), + 'accessory_name' => $schema->string()->description('Name of the accessory'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutAccessoryTool.php b/app/Mcp/Tools/CheckoutAccessoryTool.php new file mode 100644 index 000000000000..099fda2dbea2 --- /dev/null +++ b/app/Mcp/Tools/CheckoutAccessoryTool.php @@ -0,0 +1,129 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'checkout_to_type' => 'required|in:user,location,asset', + 'assigned_user' => 'nullable|integer', + 'assigned_location' => 'nullable|integer', + 'assigned_asset' => 'nullable|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error('Accessory not found')); + } + + if ($accessory->numRemaining() < 1) { + return Response::make(Response::error('No units of this accessory are available for checkout')); + } + + $checkoutType = $request->get('checkout_to_type'); + + $target = match ($checkoutType) { + 'user' => User::find($request->get('assigned_user')), + 'location' => Location::find($request->get('assigned_location')), + 'asset' => Asset::find($request->get('assigned_asset')), + }; + + if (! $target) { + return Response::make(Response::error('The specified '.$checkoutType.' was not found')); + } + + $checkout = new AccessoryCheckout([ + 'accessory_id' => $accessory->id, + 'created_at' => Carbon::now(), + 'assigned_to' => $target->id, + 'assigned_type' => $target::class, + 'note' => $request->get('note'), + ]); + $checkout->created_by = auth()->id(); + $checkout->save(); + + event(new CheckoutableCheckedOut( + $accessory, + $target, + auth()->user(), + $request->get('note'), + [], + 1, + )); + + return Response::make( + Response::text('Accessory '.$accessory->name.' checked out successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Accessory checked out successfully', + 'accessory_id' => $accessory->id, + 'accessory_name' => $accessory->name, + 'checkout_id' => $checkout->id, + 'checked_out_to_type' => $checkoutType, + 'checked_out_to_id' => $target->id, + ]); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::withCount('checkouts as checkouts_count')->find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::withCount('checkouts as checkouts_count')->where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the accessory to check out'), + 'name' => $schema->string()->description('Name of the accessory to check out'), + 'checkout_to_type' => $schema->string()->description('Target type: user, location, or asset (required)'), + 'assigned_user' => $schema->number()->description('User ID to check out to'), + 'assigned_location' => $schema->number()->description('Location ID to check out to'), + 'assigned_asset' => $schema->number()->description('Asset ID to check out to'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'accessory_id' => $schema->number()->description('Numeric ID of the accessory'), + 'accessory_name' => $schema->string()->description('Name of the accessory'), + 'checkout_id' => $schema->number()->description('ID of the checkout record (use this for checkin)'), + 'checked_out_to_type' => $schema->string()->description('Type of target: user, location, or asset'), + 'checked_out_to_id' => $schema->number()->description('ID of the target'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAccessoryTool.php b/app/Mcp/Tools/CreateAccessoryTool.php new file mode 100644 index 000000000000..46cd18d00b0d --- /dev/null +++ b/app/Mcp/Tools/CreateAccessoryTool.php @@ -0,0 +1,102 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_id' => 'required|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:0', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $accessory = new Accessory; + $accessory->fill($request->only([ + 'name', 'category_id', 'qty', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ])); + + $accessory->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $accessory->created_by = auth()->id(); + + if ($accessory->save()) { + return Response::make( + Response::text('Accessory '.$accessory->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Accessory created successfully', + 'id' => $accessory->id, + 'name' => $accessory->name, + 'qty' => $accessory->qty, + 'category_id' => $accessory->category_id, + ]); + } + + return Response::make(Response::error('Create failed: '.$accessory->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Accessory name (required)'), + 'category_id' => $schema->number()->description('Category ID — must be an accessory category (required)'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'requestable' => $schema->boolean()->description('Whether users can request this accessory'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the accessory was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new accessory'), + 'name' => $schema->string()->description('Name of the new accessory'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php index c12f8ce8770b..f545f4681ec0 100644 --- a/app/Mcp/Tools/CreateUserTool.php +++ b/app/Mcp/Tools/CreateUserTool.php @@ -23,32 +23,32 @@ public function handle(Request $request): ResponseFactory { try { $request->validate([ - 'first_name' => 'required|string|max:191', - 'last_name' => 'nullable|string|max:191', - 'username' => 'required|string|max:191', - 'email' => 'nullable|email|max:191', - 'password' => 'nullable|string|min:8', - 'employee_num' => 'nullable|string|max:191', - 'jobtitle' => 'nullable|string|max:191', - 'phone' => 'nullable|string|max:35', - 'mobile' => 'nullable|string|max:35', - 'company_id' => 'nullable|integer|exists:companies,id', - 'department_id' => 'nullable|integer|exists:departments,id', - 'location_id' => 'nullable|integer|exists:locations,id', - 'manager_id' => 'nullable|integer|exists:users,id', - 'activated' => 'nullable|boolean', - 'notes' => 'nullable|string', - 'start_date' => 'nullable|date_format:Y-m-d', - 'end_date' => 'nullable|date_format:Y-m-d', - 'vip' => 'nullable|boolean', - 'remote' => 'nullable|boolean', - 'website' => 'nullable|url|max:191', - 'address' => 'nullable|string|max:191', - 'city' => 'nullable|string|max:191', - 'state' => 'nullable|string|max:191', - 'country' => 'nullable|string|max:191', - 'zip' => 'nullable|string|max:10', - ]); + 'first_name' => 'required|string|max:191', + 'last_name' => 'nullable|string|max:191', + 'username' => 'required|string|max:191', + 'email' => 'nullable|email|max:191', + 'password' => 'nullable|string|min:8', + 'employee_num' => 'nullable|string|max:191', + 'jobtitle' => 'nullable|string|max:191', + 'phone' => 'nullable|string|max:35', + 'mobile' => 'nullable|string|max:35', + 'company_id' => 'nullable|integer|exists:companies,id', + 'department_id' => 'nullable|integer|exists:departments,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'activated' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'vip' => 'nullable|boolean', + 'remote' => 'nullable|boolean', + 'website' => 'nullable|url|max:191', + 'address' => 'nullable|string|max:191', + 'city' => 'nullable|string|max:191', + 'state' => 'nullable|string|max:191', + 'country' => 'nullable|string|max:191', + 'zip' => 'nullable|string|max:10', + ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); } diff --git a/app/Mcp/Tools/DeleteAccessoryTool.php b/app/Mcp/Tools/DeleteAccessoryTool.php new file mode 100644 index 000000000000..4f9e50e6a3c2 --- /dev/null +++ b/app/Mcp/Tools/DeleteAccessoryTool.php @@ -0,0 +1,78 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error('Accessory not found')); + } + + if ($accessory->numCheckedOut() > 0) { + return Response::make(Response::error('Accessory has units checked out and cannot be deleted. Check them in first.')); + } + + $name = $accessory->name; + + $accessory->delete(); + + return Response::make( + Response::text('Accessory '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Accessory deleted successfully', + 'name' => $name, + ]); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::withCount('checkouts as checkouts_count')->find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::withCount('checkouts as checkouts_count')->where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the accessory to delete'), + 'name' => $schema->string()->description('Name of the accessory to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted accessory'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAccessoryTool.php b/app/Mcp/Tools/UpdateAccessoryTool.php new file mode 100644 index 000000000000..33fe9a32403b --- /dev/null +++ b/app/Mcp/Tools/UpdateAccessoryTool.php @@ -0,0 +1,125 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:0', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error('Accessory not found')); + } + + $updatable = [ + 'category_id', 'qty', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $accessory->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $accessory->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $accessory->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($accessory->save()) { + return Response::make( + Response::text('Accessory '.$accessory->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Accessory updated successfully', + 'id' => $accessory->id, + 'name' => $accessory->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$accessory->getErrors()->first())); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the accessory'), + 'name' => $schema->string()->description('Name to identify the accessory'), + 'new_name' => $schema->string()->description('New name (renames the accessory)'), + 'category_id' => $schema->number()->description('Category ID'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'requestable' => $schema->boolean()->description('Whether users can request this accessory'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the accessory'), + 'name' => $schema->string()->description('Name of the accessory'), + ]; + } +} diff --git a/tests/Feature/Mcp/CheckinAccessoryToolTest.php b/tests/Feature/Mcp/CheckinAccessoryToolTest.php new file mode 100644 index 000000000000..560ed04ae5ba --- /dev/null +++ b/tests/Feature/Mcp/CheckinAccessoryToolTest.php @@ -0,0 +1,96 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinAccessoryTool)->handle(new Request($args)); + } + + private function checkoutToUser(Accessory $accessory, User $user): AccessoryCheckout + { + $response = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])); + + $checkoutId = $response->getStructuredContent()['checkout_id']; + + return AccessoryCheckout::find($checkoutId); + } + + public function test_checks_in_accessory_by_checkout_id() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); + } + + public function test_response_includes_accessory_name() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['name' => 'Checkin Test Accessory', 'qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertEquals('Checkin Test Accessory', $content['accessory_name']); + $this->assertEquals($accessory->id, $content['accessory_id']); + } + + public function test_returns_error_when_checkout_not_found() + { + $this->assertTrue($this->handle(['checkout_id' => 999999])->responses()->first()->isError()); + } + + public function test_creates_checkin_action_log_entry() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $this->handle(['checkout_id' => $checkout->id]); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Accessory::class, + 'item_id' => $accessory->id, + 'action_type' => 'checkin from', + ]); + } + + public function test_checkout_record_from_factory_can_be_checked_in() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + + $checkout = AccessoryCheckout::where('accessory_id', $accessory->id)->first(); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); + } +} diff --git a/tests/Feature/Mcp/CheckoutAccessoryToolTest.php b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php new file mode 100644 index 000000000000..c304be37b96b --- /dev/null +++ b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php @@ -0,0 +1,166 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutAccessoryTool)->handle(new Request($args)); + } + + public function test_checks_out_accessory_to_user_by_id() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + ]); + } + + public function test_checks_out_accessory_to_user_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Named Checkout Accessory', 'qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout Accessory', + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_accessory_to_location() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'location', + 'assigned_location' => $location->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $location->id, + 'assigned_type' => Location::class, + ]); + } + + public function test_checks_out_accessory_to_asset() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'asset', + 'assigned_asset' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $asset->id, + 'assigned_type' => Asset::class, + ]); + } + + public function test_response_includes_checkout_id_for_checkin() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('checkout_id', $content); + $this->assertIsInt($content['checkout_id']); + } + + public function test_fires_checkout_event() + { + Event::fake([CheckoutableCheckedOut::class]); + + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]); + + Event::assertDispatched(CheckoutableCheckedOut::class, function (CheckoutableCheckedOut $event) use ($accessory) { + return $event->checkoutable->id === $accessory->id; + }); + } + + public function test_returns_error_when_no_units_remaining() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->withoutItemsRemaining()->create(); + + $response = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_accessory_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_target_user_not_found() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => 999999, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateAccessoryToolTest.php b/tests/Feature/Mcp/CreateAccessoryToolTest.php new file mode 100644 index 000000000000..673912050a37 --- /dev/null +++ b/tests/Feature/Mcp/CreateAccessoryToolTest.php @@ -0,0 +1,105 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateAccessoryTool)->handle(new Request($args)); + } + + public function test_creates_accessory_with_required_fields() + { + $category = Category::factory()->forAccessories()->create(); + + $content = $this->handle([ + 'name' => 'Test Keyboard', + 'category_id' => $category->id, + 'qty' => 5, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['name' => 'Test Keyboard', 'qty' => 5]); + } + + public function test_response_includes_id_name_and_qty() + { + $category = Category::factory()->forAccessories()->create(); + + $content = $this->handle([ + 'name' => 'Response Check Accessory', + 'category_id' => $category->id, + 'qty' => 3, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Accessory', $content['name']); + $this->assertEquals(3, $content['qty']); + } + + public function test_creates_accessory_with_optional_fields() + { + $category = Category::factory()->forAccessories()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Accessory', + 'category_id' => $category->id, + 'qty' => 10, + 'model_number' => 'MN-1234', + 'location_id' => $location->id, + 'order_number' => 'ORD-001', + 'purchase_cost' => 29.99, + 'purchase_date' => '2024-01-15', + 'min_amt' => 2, + 'notes' => 'Test notes', + ]); + + $this->assertDatabaseHas('accessories', [ + 'name' => 'Full Accessory', + 'model_number' => 'MN-1234', + 'location_id' => $location->id, + 'order_number' => 'ORD-001', + 'min_amt' => 2, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->forAccessories()->create(); + + $this->assertTrue($this->handle([ + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'category_id' => 999999, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/DeleteAccessoryToolTest.php b/tests/Feature/Mcp/DeleteAccessoryToolTest.php new file mode 100644 index 000000000000..60e9d5bb298a --- /dev/null +++ b/tests/Feature/Mcp/DeleteAccessoryToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteAccessoryTool)->handle(new Request($args)); + } + + public function test_deletes_accessory_by_id() + { + $accessory = Accessory::factory()->create(); + + $content = $this->handle(['id' => $accessory->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('accessories', ['id' => $accessory->id]); + } + + public function test_deletes_accessory_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Delete By Name']); + + $content = $this->handle(['name' => 'Delete By Name'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('accessories', ['id' => $accessory->id]); + } + + public function test_response_includes_name() + { + $accessory = Accessory::factory()->create(['name' => 'Named Accessory']); + + $content = $this->handle(['id' => $accessory->id])->getStructuredContent(); + + $this->assertEquals('Named Accessory', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_accessory_has_checkouts() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + + $response = $this->handle(['id' => $accessory->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('accessories', ['id' => $accessory->id]); + } +} diff --git a/tests/Feature/Mcp/UpdateAccessoryToolTest.php b/tests/Feature/Mcp/UpdateAccessoryToolTest.php new file mode 100644 index 000000000000..11636e1a2219 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAccessoryToolTest.php @@ -0,0 +1,118 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateAccessoryTool)->handle(new Request($args)); + } + + public function test_updates_accessory_by_id() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $accessory->id, + 'qty' => 10, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'qty' => 10]); + } + + public function test_updates_accessory_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Lookup By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Lookup By Name', + 'qty' => 8, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'qty' => 8]); + } + + public function test_renames_accessory_via_new_name() + { + $accessory = Accessory::factory()->create(['name' => 'Old Accessory Name']); + + $content = $this->handle([ + 'id' => $accessory->id, + 'new_name' => 'New Accessory Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Accessory Name', $content['name']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'name' => 'New Accessory Name']); + } + + public function test_updates_multiple_fields_at_once() + { + $accessory = Accessory::factory()->create(); + $location = Location::factory()->create(); + $category = Category::factory()->forAccessories()->create(); + + $this->handle([ + 'id' => $accessory->id, + 'qty' => 20, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-999', + 'notes' => 'Updated note', + ]); + + $this->assertDatabaseHas('accessories', [ + 'id' => $accessory->id, + 'qty' => 20, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_name() + { + $accessory = Accessory::factory()->create(['name' => 'Response Accessory']); + + $content = $this->handle([ + 'id' => $accessory->id, + 'qty' => 7, + ])->getStructuredContent(); + + $this->assertEquals($accessory->id, $content['id']); + $this->assertEquals('Response Accessory', $content['name']); + } +} diff --git a/tests/Feature/Mcp/UpdateUserToolTest.php b/tests/Feature/Mcp/UpdateUserToolTest.php index 42ff22f57927..a58862d6c142 100644 --- a/tests/Feature/Mcp/UpdateUserToolTest.php +++ b/tests/Feature/Mcp/UpdateUserToolTest.php @@ -209,7 +209,7 @@ public function test_superuser_can_change_auth_fields_of_any_user() $this->assertDatabaseHas('users', ['id' => $target->id, 'email' => 'superchanged@example.com']); } - public function test_non_auth_fields_are_still_updated_even_without_canEditAuthFields() + public function test_non_auth_fields_are_still_updated_even_without_can_edit_auth_fields() { // A non-admin updating an admin's non-sensitive fields (jobtitle etc.) should still work $admin = User::factory()->admin()->create(['jobtitle' => 'Old Title']); From 6b2f2d68b7c9bbaa6d6fcd89d333cab737fc3c59 Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 17:45:29 +0100 Subject: [PATCH 19/45] Add/delete/checkout/checkin/edit MCP tools for Components --- app/Mcp/Servers/SnipeMCPServer.php | 10 ++ app/Mcp/Tools/CheckinAccessoryTool.php | 1 - app/Mcp/Tools/CheckinComponentTool.php | 97 ++++++++++++ app/Mcp/Tools/CheckoutComponentTool.php | 116 ++++++++++++++ app/Mcp/Tools/CreateComponentTool.php | 102 +++++++++++++ app/Mcp/Tools/DeleteComponentTool.php | 78 ++++++++++ app/Mcp/Tools/UpdateComponentTool.php | 125 +++++++++++++++ .../Feature/Mcp/CheckinComponentToolTest.php | 144 ++++++++++++++++++ .../Feature/Mcp/CheckoutComponentToolTest.php | 137 +++++++++++++++++ tests/Feature/Mcp/CreateComponentToolTest.php | 121 +++++++++++++++ tests/Feature/Mcp/DeleteComponentToolTest.php | 70 +++++++++ tests/Feature/Mcp/UpdateComponentToolTest.php | 118 ++++++++++++++ 12 files changed, 1118 insertions(+), 1 deletion(-) create mode 100644 app/Mcp/Tools/CheckinComponentTool.php create mode 100644 app/Mcp/Tools/CheckoutComponentTool.php create mode 100644 app/Mcp/Tools/CreateComponentTool.php create mode 100644 app/Mcp/Tools/DeleteComponentTool.php create mode 100644 app/Mcp/Tools/UpdateComponentTool.php create mode 100644 tests/Feature/Mcp/CheckinComponentToolTest.php create mode 100644 tests/Feature/Mcp/CheckoutComponentToolTest.php create mode 100644 tests/Feature/Mcp/CreateComponentToolTest.php create mode 100644 tests/Feature/Mcp/DeleteComponentToolTest.php create mode 100644 tests/Feature/Mcp/UpdateComponentToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 3baa4ad5a101..f8a29d1e3fe7 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -5,12 +5,16 @@ use App\Mcp\Tools\AuditAssetTool; use App\Mcp\Tools\CheckinAccessoryTool; use App\Mcp\Tools\CheckinAssetTool; +use App\Mcp\Tools\CheckinComponentTool; use App\Mcp\Tools\CheckoutAccessoryTool; use App\Mcp\Tools\CheckoutAssetTool; +use App\Mcp\Tools\CheckoutComponentTool; use App\Mcp\Tools\CreateAccessoryTool; +use App\Mcp\Tools\CreateComponentTool; use App\Mcp\Tools\CreateUserTool; use App\Mcp\Tools\DeleteAccessoryTool; use App\Mcp\Tools\DeleteAssetTool; +use App\Mcp\Tools\DeleteComponentTool; use App\Mcp\Tools\DeleteUserTool; use App\Mcp\Tools\ListAssetsTool; use App\Mcp\Tools\ListUsersTool; @@ -18,6 +22,7 @@ use App\Mcp\Tools\ShowUserTool; use App\Mcp\Tools\UpdateAccessoryTool; use App\Mcp\Tools\UpdateAssetTool; +use App\Mcp\Tools\UpdateComponentTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; @@ -47,6 +52,11 @@ class SnipeMCPServer extends Server DeleteAccessoryTool::class, CheckoutAccessoryTool::class, CheckinAccessoryTool::class, + CreateComponentTool::class, + UpdateComponentTool::class, + DeleteComponentTool::class, + CheckoutComponentTool::class, + CheckinComponentTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/CheckinAccessoryTool.php b/app/Mcp/Tools/CheckinAccessoryTool.php index c9cfd25026a8..74508e6e6e59 100644 --- a/app/Mcp/Tools/CheckinAccessoryTool.php +++ b/app/Mcp/Tools/CheckinAccessoryTool.php @@ -4,7 +4,6 @@ use App\Models\Accessory; use App\Models\AccessoryCheckout; -use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Laravel\Mcp\Request; use Laravel\Mcp\Response; diff --git a/app/Mcp/Tools/CheckinComponentTool.php b/app/Mcp/Tools/CheckinComponentTool.php new file mode 100644 index 000000000000..91044cb70cfe --- /dev/null +++ b/app/Mcp/Tools/CheckinComponentTool.php @@ -0,0 +1,97 @@ +validate([ + 'component_asset_id' => 'required|integer', + 'checkin_qty' => 'nullable|integer|min:1', + 'note' => 'nullable|string|max:65535', + ]); + + $componentAsset = DB::table('components_assets')->find($request->get('component_asset_id')); + + if (! $componentAsset) { + return Response::make(Response::error('Component checkout record not found')); + } + + $component = Component::find($componentAsset->component_id); + + if (! $component) { + return Response::make(Response::error('Component not found')); + } + + $maxCheckin = $componentAsset->assigned_qty ?? 1; + $checkinQty = (int) $request->get('checkin_qty', $maxCheckin); + + if ($checkinQty > $maxCheckin) { + return Response::make(Response::error( + 'Checkin quantity ('.$checkinQty.') exceeds assigned quantity ('.$maxCheckin.')' + )); + } + + $remaining = $maxCheckin - $checkinQty; + + if ($remaining === 0) { + DB::table('components_assets')->where('id', $componentAsset->id)->delete(); + } else { + DB::table('components_assets')->where('id', $componentAsset->id)->update(['assigned_qty' => $remaining]); + } + + $asset = Asset::find($componentAsset->asset_id); + + event(new CheckoutableCheckedIn($component, $asset, auth()->user(), $request->get('note'), Carbon::now())); + + return Response::make( + Response::text('Component '.$component->name.' checked in successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Component checked in successfully', + 'component_id' => $component->id, + 'component_name' => $component->name, + 'checkin_qty' => $checkinQty, + 'qty_still_checked_out' => $remaining, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'component_asset_id' => $schema->number()->description('ID of the checkout record to check in (returned by checkout_component)'), + 'checkin_qty' => $schema->number()->description('Number of units to check in (default: all assigned units)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'component_id' => $schema->number()->description('Numeric ID of the component'), + 'component_name' => $schema->string()->description('Name of the component'), + 'checkin_qty' => $schema->number()->description('Number of units checked in'), + 'qty_still_checked_out' => $schema->number()->description('Units remaining checked out on this record (0 means fully returned)'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutComponentTool.php b/app/Mcp/Tools/CheckoutComponentTool.php new file mode 100644 index 000000000000..2f0feda9a291 --- /dev/null +++ b/app/Mcp/Tools/CheckoutComponentTool.php @@ -0,0 +1,116 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + 'asset_id' => 'required|integer|exists:assets,id', + 'assigned_qty' => 'nullable|integer|min:1', + 'note' => 'nullable|string|max:65535', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error('Component not found')); + } + + $qty = (int) $request->get('assigned_qty', 1); + + if ($component->numRemaining() < $qty) { + return Response::make(Response::error( + 'Not enough units available. Requested: '.$qty.', remaining: '.$component->numRemaining() + )); + } + + $asset = Asset::find($request->get('asset_id')); + + $component->assets()->attach($component->id, [ + 'component_id' => $component->id, + 'created_at' => Carbon::now(), + 'assigned_qty' => $qty, + 'created_by' => auth()->id(), + 'asset_id' => $asset->id, + 'note' => $request->get('note'), + ]); + + $pivotId = $component->assets()->wherePivot('asset_id', $asset->id)->latest('components_assets.created_at')->first()?->pivot->id; + + $component->logCheckout($request->get('note'), $asset, null, [], $qty); + + return Response::make( + Response::text('Component '.$component->name.' checked out to asset '.$asset->asset_tag) + )->withStructuredContent([ + 'success' => true, + 'message' => 'Component checked out successfully', + 'component_id' => $component->id, + 'component_name' => $component->name, + 'asset_id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'assigned_qty' => $qty, + 'component_asset_id' => $pivotId, + ]); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the component to check out'), + 'name' => $schema->string()->description('Name of the component to check out'), + 'asset_id' => $schema->number()->description('Asset ID to check the component out to (required)'), + 'assigned_qty' => $schema->number()->description('Number of units to check out (default: 1)'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'component_id' => $schema->number()->description('Numeric ID of the component'), + 'component_name' => $schema->string()->description('Name of the component'), + 'asset_id' => $schema->number()->description('ID of the asset checked out to'), + 'asset_tag' => $schema->string()->description('Asset tag of the asset checked out to'), + 'assigned_qty' => $schema->number()->description('Number of units checked out'), + 'component_asset_id' => $schema->number()->description('ID of the checkout record (use this for checkin)'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateComponentTool.php b/app/Mcp/Tools/CreateComponentTool.php new file mode 100644 index 000000000000..d97e62df826f --- /dev/null +++ b/app/Mcp/Tools/CreateComponentTool.php @@ -0,0 +1,102 @@ +validate([ + 'name' => 'required|string|max:191', + 'category_id' => 'required|integer|exists:categories,id', + 'qty' => 'required|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $component = new Component; + $component->fill($request->only([ + 'name', 'category_id', 'qty', 'serial', 'model_number', + 'manufacturer_id', 'supplier_id', 'location_id', + 'order_number', 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ])); + + $component->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $component->created_by = auth()->id(); + + if ($component->save()) { + return Response::make( + Response::text('Component '.$component->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Component created successfully', + 'id' => $component->id, + 'name' => $component->name, + 'qty' => $component->qty, + 'category_id' => $component->category_id, + ]); + } + + return Response::make(Response::error('Create failed: '.$component->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Component name (required)'), + 'category_id' => $schema->number()->description('Category ID — must be a component category (required)'), + 'qty' => $schema->number()->description('Total quantity in stock (required, min 1)'), + 'serial' => $schema->string()->description('Serial number'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the component was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new component'), + 'name' => $schema->string()->description('Name of the new component'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteComponentTool.php b/app/Mcp/Tools/DeleteComponentTool.php new file mode 100644 index 000000000000..42bcf843f019 --- /dev/null +++ b/app/Mcp/Tools/DeleteComponentTool.php @@ -0,0 +1,78 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + ]); + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error('Component not found')); + } + + if ($component->numCheckedOut() > 0) { + return Response::make(Response::error('Component has units checked out and cannot be deleted. Check them in first.')); + } + + $name = $component->name; + + $component->delete(); + + return Response::make( + Response::text('Component '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Component deleted successfully', + 'name' => $name, + ]); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the component to delete'), + 'name' => $schema->string()->description('Name of the component to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted component'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateComponentTool.php b/app/Mcp/Tools/UpdateComponentTool.php new file mode 100644 index 000000000000..feb0916866b7 --- /dev/null +++ b/app/Mcp/Tools/UpdateComponentTool.php @@ -0,0 +1,125 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + 'new_name' => 'nullable|string|max:191', + 'category_id' => 'nullable|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error('Component not found')); + } + + $updatable = [ + 'category_id', 'qty', 'serial', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', + 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $component->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $component->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $component->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($component->save()) { + return Response::make( + Response::text('Component '.$component->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Component updated successfully', + 'id' => $component->id, + 'name' => $component->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$component->getErrors()->first())); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the component'), + 'name' => $schema->string()->description('Name to identify the component'), + 'new_name' => $schema->string()->description('New name (renames the component)'), + 'category_id' => $schema->number()->description('Category ID'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'serial' => $schema->string()->description('Serial number'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the component'), + 'name' => $schema->string()->description('Name of the component'), + ]; + } +} diff --git a/tests/Feature/Mcp/CheckinComponentToolTest.php b/tests/Feature/Mcp/CheckinComponentToolTest.php new file mode 100644 index 000000000000..a6b388f0a3a1 --- /dev/null +++ b/tests/Feature/Mcp/CheckinComponentToolTest.php @@ -0,0 +1,144 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinComponentTool)->handle(new Request($args)); + } + + private function checkoutToAsset(Component $component, Asset $asset, int $qty = 1): int + { + $response = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => $qty, + ])); + + return $response->getStructuredContent()['component_asset_id']; + } + + public function test_checks_in_full_quantity() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $content = $this->handle(['component_asset_id' => $componentAssetId])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(0, $content['qty_still_checked_out']); + $this->assertDatabaseMissing('components_assets', ['id' => $componentAssetId]); + } + + public function test_partial_checkin_leaves_remainder_in_record() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 4); + + $content = $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 2, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(2, $content['checkin_qty']); + $this->assertEquals(2, $content['qty_still_checked_out']); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId, 'assigned_qty' => 2]); + } + + public function test_full_checkin_deletes_pivot_record() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 3); + + $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 3, + ]); + + $this->assertDatabaseMissing('components_assets', ['id' => $componentAssetId]); + } + + public function test_response_includes_component_name_and_counts() + { + $component = Component::factory()->create(['name' => 'Checkin Test Component', 'qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $content = $this->handle(['component_asset_id' => $componentAssetId])->getStructuredContent(); + + $this->assertEquals('Checkin Test Component', $content['component_name']); + $this->assertEquals($component->id, $content['component_id']); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $this->handle(['component_asset_id' => $componentAssetId]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($component) { + return $event->checkoutable->id === $component->id; + }); + } + + public function test_returns_error_when_checkout_record_not_found() + { + $this->assertTrue($this->handle(['component_asset_id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_checkin_qty_exceeds_assigned_qty() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 2); + + $response = $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 5, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId, 'assigned_qty' => 2]); + } + + public function test_factory_checkout_record_can_be_checked_in() + { + $asset = Asset::factory()->create(); + $component = Component::factory()->checkedOutToAsset($asset)->create(); + + $record = DB::table('components_assets')->where('component_id', $component->id)->first(); + + $content = $this->handle(['component_asset_id' => $record->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('components_assets', ['id' => $record->id]); + } +} diff --git a/tests/Feature/Mcp/CheckoutComponentToolTest.php b/tests/Feature/Mcp/CheckoutComponentToolTest.php new file mode 100644 index 000000000000..5826f9237f9d --- /dev/null +++ b/tests/Feature/Mcp/CheckoutComponentToolTest.php @@ -0,0 +1,137 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutComponentTool)->handle(new Request($args)); + } + + public function test_checks_out_component_to_asset_by_id() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components_assets', [ + 'component_id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 1, + ]); + } + + public function test_checks_out_component_to_asset_by_name() + { + $component = Component::factory()->create(['name' => 'Named Checkout Component', 'qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout Component', + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_specified_quantity() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 3, + ]); + + $this->assertDatabaseHas('components_assets', [ + 'component_id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 3, + ]); + } + + public function test_response_includes_component_asset_id_for_checkin() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('component_asset_id', $content); + $this->assertIsInt($content['component_asset_id']); + $this->assertEquals(1, $content['assigned_qty']); + } + + public function test_response_includes_asset_tag() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_returns_error_when_not_enough_units_remaining() + { + $component = Component::factory()->create(['qty' => 2]); + $asset = Asset::factory()->create(); + + $response = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 5, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_component_not_found() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => 999999, + 'asset_id' => $asset->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_found() + { + $component = Component::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'asset_id' => 999999, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateComponentToolTest.php b/tests/Feature/Mcp/CreateComponentToolTest.php new file mode 100644 index 000000000000..503e50ad2933 --- /dev/null +++ b/tests/Feature/Mcp/CreateComponentToolTest.php @@ -0,0 +1,121 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateComponentTool)->handle(new Request($args)); + } + + public function test_creates_component_with_required_fields() + { + $category = Category::factory()->forComponents()->create(); + + $content = $this->handle([ + 'name' => 'Test RAM', + 'category_id' => $category->id, + 'qty' => 10, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['name' => 'Test RAM', 'qty' => 10]); + } + + public function test_response_includes_id_name_qty_and_category() + { + $category = Category::factory()->forComponents()->create(); + + $content = $this->handle([ + 'name' => 'Response Check Component', + 'category_id' => $category->id, + 'qty' => 5, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Component', $content['name']); + $this->assertEquals(5, $content['qty']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_creates_component_with_optional_fields() + { + $category = Category::factory()->forComponents()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Component', + 'category_id' => $category->id, + 'qty' => 20, + 'serial' => 'SN-COMP-001', + 'model_number' => 'MN-5678', + 'location_id' => $location->id, + 'order_number' => 'ORD-COMP-001', + 'purchase_cost' => 49.99, + 'purchase_date' => '2024-03-01', + 'min_amt' => 3, + 'notes' => 'Component notes', + ]); + + $this->assertDatabaseHas('components', [ + 'name' => 'Full Component', + 'serial' => 'SN-COMP-001', + 'model_number' => 'MN-5678', + 'location_id' => $location->id, + 'order_number' => 'ORD-COMP-001', + 'min_amt' => 3, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->forComponents()->create(); + + $this->assertTrue($this->handle([ + 'category_id' => $category->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_qty_missing() + { + $category = Category::factory()->forComponents()->create(); + + $this->assertTrue($this->handle([ + 'name' => 'No Qty', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'category_id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/DeleteComponentToolTest.php b/tests/Feature/Mcp/DeleteComponentToolTest.php new file mode 100644 index 000000000000..8129e255715e --- /dev/null +++ b/tests/Feature/Mcp/DeleteComponentToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteComponentTool)->handle(new Request($args)); + } + + public function test_deletes_component_by_id() + { + $component = Component::factory()->create(); + + $content = $this->handle(['id' => $component->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('components', ['id' => $component->id]); + } + + public function test_deletes_component_by_name() + { + $component = Component::factory()->create(['name' => 'Delete By Name Component']); + + $content = $this->handle(['name' => 'Delete By Name Component'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('components', ['id' => $component->id]); + } + + public function test_response_includes_name() + { + $component = Component::factory()->create(['name' => 'Named Component']); + + $content = $this->handle(['id' => $component->id])->getStructuredContent(); + + $this->assertEquals('Named Component', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_component_has_checkouts() + { + $asset = Asset::factory()->create(); + $component = Component::factory()->checkedOutToAsset($asset)->create(); + + $response = $this->handle(['id' => $component->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('components', ['id' => $component->id]); + } +} diff --git a/tests/Feature/Mcp/UpdateComponentToolTest.php b/tests/Feature/Mcp/UpdateComponentToolTest.php new file mode 100644 index 000000000000..160629a3a4d4 --- /dev/null +++ b/tests/Feature/Mcp/UpdateComponentToolTest.php @@ -0,0 +1,118 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateComponentTool)->handle(new Request($args)); + } + + public function test_updates_component_by_id() + { + $component = Component::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $component->id, + 'qty' => 15, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'qty' => 15]); + } + + public function test_updates_component_by_name() + { + $component = Component::factory()->create(['name' => 'Lookup By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Lookup By Name', + 'qty' => 9, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'qty' => 9]); + } + + public function test_renames_component_via_new_name() + { + $component = Component::factory()->create(['name' => 'Old Component Name']); + + $content = $this->handle([ + 'id' => $component->id, + 'new_name' => 'New Component Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Component Name', $content['name']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'name' => 'New Component Name']); + } + + public function test_updates_multiple_fields_at_once() + { + $component = Component::factory()->create(); + $location = Location::factory()->create(); + $category = Category::factory()->forComponents()->create(); + + $this->handle([ + 'id' => $component->id, + 'qty' => 25, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-COMP-999', + 'notes' => 'Updated component note', + ]); + + $this->assertDatabaseHas('components', [ + 'id' => $component->id, + 'qty' => 25, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-COMP-999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_name() + { + $component = Component::factory()->create(['name' => 'Response Component']); + + $content = $this->handle([ + 'id' => $component->id, + 'qty' => 8, + ])->getStructuredContent(); + + $this->assertEquals($component->id, $content['id']); + $this->assertEquals('Response Component', $content['name']); + } +} From dc9f0104f61522722de8ec84680195413440cb3e Mon Sep 17 00:00:00 2001 From: snipe Date: Thu, 7 May 2026 22:40:23 +0100 Subject: [PATCH 20/45] Gate checks and accessory scoping --- app/Mcp/Tools/AuditAssetTool.php | 5 + app/Mcp/Tools/CheckinAccessoryTool.php | 5 + app/Mcp/Tools/CheckinAssetTool.php | 5 + app/Mcp/Tools/CheckinComponentTool.php | 5 + app/Mcp/Tools/CheckoutAccessoryTool.php | 5 + app/Mcp/Tools/CheckoutAssetTool.php | 5 + app/Mcp/Tools/CheckoutComponentTool.php | 5 + app/Mcp/Tools/CreateAccessoryTool.php | 5 + app/Mcp/Tools/CreateComponentTool.php | 5 + app/Mcp/Tools/CreateUserTool.php | 5 + app/Mcp/Tools/DeleteAccessoryTool.php | 5 + app/Mcp/Tools/DeleteAssetTool.php | 5 + app/Mcp/Tools/DeleteComponentTool.php | 5 + app/Mcp/Tools/DeleteUserTool.php | 5 + app/Mcp/Tools/ListAssetsTool.php | 5 + app/Mcp/Tools/ListUsersTool.php | 5 + app/Mcp/Tools/ShowAssetTool.php | 5 + app/Mcp/Tools/ShowUserTool.php | 5 + app/Mcp/Tools/UpdateAccessoryTool.php | 5 + app/Mcp/Tools/UpdateAssetTool.php | 5 + app/Mcp/Tools/UpdateComponentTool.php | 5 + app/Mcp/Tools/UpdateUserTool.php | 4 + tests/Feature/Mcp/AuditAssetToolTest.php | 13 +- .../Feature/Mcp/CheckinAccessoryToolTest.php | 14 +- tests/Feature/Mcp/CheckinAssetToolTest.php | 12 +- .../Feature/Mcp/CheckinComponentToolTest.php | 14 +- .../Feature/Mcp/CheckoutAccessoryToolTest.php | 15 +- tests/Feature/Mcp/CheckoutAssetToolTest.php | 15 +- .../Feature/Mcp/CheckoutComponentToolTest.php | 14 +- tests/Feature/Mcp/CreateAccessoryToolTest.php | 13 +- tests/Feature/Mcp/CreateComponentToolTest.php | 14 +- tests/Feature/Mcp/CreateUserToolTest.php | 13 +- tests/Feature/Mcp/DeleteAccessoryToolTest.php | 11 +- tests/Feature/Mcp/DeleteAssetToolTest.php | 11 +- tests/Feature/Mcp/DeleteComponentToolTest.php | 11 +- tests/Feature/Mcp/DeleteUserToolTest.php | 11 +- .../Feature/Mcp/FmcsAccessoryScopingTest.php | 184 +++++++++++++++++ tests/Feature/Mcp/FmcsCompanyScopingTest.php | 4 +- .../Feature/Mcp/FmcsComponentScopingTest.php | 185 ++++++++++++++++++ tests/Feature/Mcp/ListAssetsToolTest.php | 13 ++ tests/Feature/Mcp/ListUsersToolTest.php | 9 +- tests/Feature/Mcp/ShowAssetToolTest.php | 14 ++ tests/Feature/Mcp/ShowUserToolTest.php | 10 +- tests/Feature/Mcp/UpdateAccessoryToolTest.php | 13 +- tests/Feature/Mcp/UpdateAssetToolTest.php | 13 +- tests/Feature/Mcp/UpdateComponentToolTest.php | 13 +- tests/Feature/Mcp/UpdateUserToolTest.php | 13 +- 47 files changed, 740 insertions(+), 21 deletions(-) create mode 100644 tests/Feature/Mcp/FmcsAccessoryScopingTest.php create mode 100644 tests/Feature/Mcp/FmcsComponentScopingTest.php diff --git a/app/Mcp/Tools/AuditAssetTool.php b/app/Mcp/Tools/AuditAssetTool.php index 6b01b1f458f1..c0004179cf2a 100644 --- a/app/Mcp/Tools/AuditAssetTool.php +++ b/app/Mcp/Tools/AuditAssetTool.php @@ -6,6 +6,7 @@ use App\Models\Setting; use Carbon\Carbon; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -36,6 +37,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Asset not found')); } + if (! Gate::allows('audit', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + $originalValues = $asset->getRawOriginal(); $settings = Setting::getSettings(); diff --git a/app/Mcp/Tools/CheckinAccessoryTool.php b/app/Mcp/Tools/CheckinAccessoryTool.php index 74508e6e6e59..32aa0189e1ab 100644 --- a/app/Mcp/Tools/CheckinAccessoryTool.php +++ b/app/Mcp/Tools/CheckinAccessoryTool.php @@ -5,6 +5,7 @@ use App\Models\Accessory; use App\Models\AccessoryCheckout; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -37,6 +38,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Accessory not found')); } + if (! Gate::allows('checkin', $accessory)) { + return Response::make(Response::error('Unauthorized')); + } + $target = $checkout->assigned_type && $checkout->assigned_to ? $checkout->assigned_type::find($checkout->assigned_to) : null; diff --git a/app/Mcp/Tools/CheckinAssetTool.php b/app/Mcp/Tools/CheckinAssetTool.php index 3fbe554f56ec..bc94c9960cb1 100644 --- a/app/Mcp/Tools/CheckinAssetTool.php +++ b/app/Mcp/Tools/CheckinAssetTool.php @@ -5,6 +5,7 @@ use App\Events\CheckoutableCheckedIn; use App\Models\Asset; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -32,6 +33,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Asset not found')); } + if (! Gate::allows('checkin', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + $target = $asset->assignedTo; if (is_null($target)) { diff --git a/app/Mcp/Tools/CheckinComponentTool.php b/app/Mcp/Tools/CheckinComponentTool.php index 91044cb70cfe..afb040c17266 100644 --- a/app/Mcp/Tools/CheckinComponentTool.php +++ b/app/Mcp/Tools/CheckinComponentTool.php @@ -8,6 +8,7 @@ use Carbon\Carbon; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -41,6 +42,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Component not found')); } + if (! Gate::allows('checkin', $component)) { + return Response::make(Response::error('Unauthorized')); + } + $maxCheckin = $componentAsset->assigned_qty ?? 1; $checkinQty = (int) $request->get('checkin_qty', $maxCheckin); diff --git a/app/Mcp/Tools/CheckoutAccessoryTool.php b/app/Mcp/Tools/CheckoutAccessoryTool.php index 099fda2dbea2..0fc9078abeb8 100644 --- a/app/Mcp/Tools/CheckoutAccessoryTool.php +++ b/app/Mcp/Tools/CheckoutAccessoryTool.php @@ -10,6 +10,7 @@ use App\Models\User; use Carbon\Carbon; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -41,6 +42,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Accessory not found')); } + if (! Gate::allows('checkout', $accessory)) { + return Response::make(Response::error('Unauthorized')); + } + if ($accessory->numRemaining() < 1) { return Response::make(Response::error('No units of this accessory are available for checkout')); } diff --git a/app/Mcp/Tools/CheckoutAssetTool.php b/app/Mcp/Tools/CheckoutAssetTool.php index 8d9828e504d6..8b33da0968b7 100644 --- a/app/Mcp/Tools/CheckoutAssetTool.php +++ b/app/Mcp/Tools/CheckoutAssetTool.php @@ -6,6 +6,7 @@ use App\Models\Location; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -39,6 +40,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Asset not found')); } + if (! Gate::allows('checkout', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + if (! $asset->availableForCheckout()) { return Response::make(Response::error('Asset '.$asset->asset_tag.' is not available for checkout')); } diff --git a/app/Mcp/Tools/CheckoutComponentTool.php b/app/Mcp/Tools/CheckoutComponentTool.php index 2f0feda9a291..404865e12c3a 100644 --- a/app/Mcp/Tools/CheckoutComponentTool.php +++ b/app/Mcp/Tools/CheckoutComponentTool.php @@ -6,6 +6,7 @@ use App\Models\Component; use Carbon\Carbon; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -40,6 +41,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Component not found')); } + if (! Gate::allows('checkout', $component)) { + return Response::make(Response::error('Unauthorized')); + } + $qty = (int) $request->get('assigned_qty', 1); if ($component->numRemaining() < $qty) { diff --git a/app/Mcp/Tools/CreateAccessoryTool.php b/app/Mcp/Tools/CreateAccessoryTool.php index 46cd18d00b0d..5e4ae6025fe7 100644 --- a/app/Mcp/Tools/CreateAccessoryTool.php +++ b/app/Mcp/Tools/CreateAccessoryTool.php @@ -5,6 +5,7 @@ use App\Models\Accessory; use App\Models\Company; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -21,6 +22,10 @@ class CreateAccessoryTool extends Tool { public function handle(Request $request): ResponseFactory { + if (! Gate::allows('create', Accessory::class)) { + return Response::make(Response::error('Unauthorized')); + } + try { $request->validate([ 'name' => 'required|string|max:255', diff --git a/app/Mcp/Tools/CreateComponentTool.php b/app/Mcp/Tools/CreateComponentTool.php index d97e62df826f..23db2ba970b5 100644 --- a/app/Mcp/Tools/CreateComponentTool.php +++ b/app/Mcp/Tools/CreateComponentTool.php @@ -5,6 +5,7 @@ use App\Models\Company; use App\Models\Component; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -21,6 +22,10 @@ class CreateComponentTool extends Tool { public function handle(Request $request): ResponseFactory { + if (! Gate::allows('create', Component::class)) { + return Response::make(Response::error('Unauthorized')); + } + try { $request->validate([ 'name' => 'required|string|max:191', diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php index f545f4681ec0..8a06d2a45a3f 100644 --- a/app/Mcp/Tools/CreateUserTool.php +++ b/app/Mcp/Tools/CreateUserTool.php @@ -5,6 +5,7 @@ use App\Models\Company; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Illuminate\Validation\ValidationException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -21,6 +22,10 @@ class CreateUserTool extends Tool { public function handle(Request $request): ResponseFactory { + if (! Gate::allows('create', User::class)) { + return Response::make(Response::error('Unauthorized')); + } + try { $request->validate([ 'first_name' => 'required|string|max:191', diff --git a/app/Mcp/Tools/DeleteAccessoryTool.php b/app/Mcp/Tools/DeleteAccessoryTool.php index 4f9e50e6a3c2..007ab3a43ddf 100644 --- a/app/Mcp/Tools/DeleteAccessoryTool.php +++ b/app/Mcp/Tools/DeleteAccessoryTool.php @@ -4,6 +4,7 @@ use App\Models\Accessory; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -30,6 +31,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Accessory not found')); } + if (! Gate::allows('delete', $accessory)) { + return Response::make(Response::error('Unauthorized')); + } + if ($accessory->numCheckedOut() > 0) { return Response::make(Response::error('Accessory has units checked out and cannot be deleted. Check them in first.')); } diff --git a/app/Mcp/Tools/DeleteAssetTool.php b/app/Mcp/Tools/DeleteAssetTool.php index b9ee1e4433b6..3d51cf3ec0f8 100644 --- a/app/Mcp/Tools/DeleteAssetTool.php +++ b/app/Mcp/Tools/DeleteAssetTool.php @@ -6,6 +6,7 @@ use App\Models\Asset; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -33,6 +34,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Asset not found')); } + if (! Gate::allows('delete', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + $assetTag = $asset->asset_tag; if ($asset->assignedTo) { diff --git a/app/Mcp/Tools/DeleteComponentTool.php b/app/Mcp/Tools/DeleteComponentTool.php index 42bcf843f019..1190b68e1efd 100644 --- a/app/Mcp/Tools/DeleteComponentTool.php +++ b/app/Mcp/Tools/DeleteComponentTool.php @@ -4,6 +4,7 @@ use App\Models\Component; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -30,6 +31,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Component not found')); } + if (! Gate::allows('delete', $component)) { + return Response::make(Response::error('Unauthorized')); + } + if ($component->numCheckedOut() > 0) { return Response::make(Response::error('Component has units checked out and cannot be deleted. Check them in first.')); } diff --git a/app/Mcp/Tools/DeleteUserTool.php b/app/Mcp/Tools/DeleteUserTool.php index 1eca3aea847b..df4ac8760719 100644 --- a/app/Mcp/Tools/DeleteUserTool.php +++ b/app/Mcp/Tools/DeleteUserTool.php @@ -4,6 +4,7 @@ use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -31,6 +32,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('User not found')); } + if (! Gate::allows('delete', $user)) { + return Response::make(Response::error('Unauthorized')); + } + if ($user->id === auth()->id()) { return Response::make(Response::error('You cannot delete your own account')); } diff --git a/app/Mcp/Tools/ListAssetsTool.php b/app/Mcp/Tools/ListAssetsTool.php index 0b305f290ad3..c2becca4f1e9 100644 --- a/app/Mcp/Tools/ListAssetsTool.php +++ b/app/Mcp/Tools/ListAssetsTool.php @@ -4,6 +4,7 @@ use App\Models\Asset; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -19,6 +20,10 @@ class ListAssetsTool extends Tool { public function handle(Request $request): ResponseFactory { + if (! Gate::allows('index', Asset::class)) { + return Response::make(Response::error('Unauthorized')); + } + $request->validate([ 'search' => 'nullable|string|max:255', 'status_type' => 'nullable|string|in:RTD,Deployed,Archived,Pending,Undeployable', diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php index a9c43f0929b5..b91b3de86397 100644 --- a/app/Mcp/Tools/ListUsersTool.php +++ b/app/Mcp/Tools/ListUsersTool.php @@ -4,6 +4,7 @@ use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -19,6 +20,10 @@ class ListUsersTool extends Tool { public function handle(Request $request): ResponseFactory { + if (! Gate::allows('index', User::class)) { + return Response::make(Response::error('Unauthorized')); + } + $request->validate([ 'search' => 'nullable|string|max:255', 'company_id' => 'nullable|integer', diff --git a/app/Mcp/Tools/ShowAssetTool.php b/app/Mcp/Tools/ShowAssetTool.php index 6acc26439c05..c7be342886df 100644 --- a/app/Mcp/Tools/ShowAssetTool.php +++ b/app/Mcp/Tools/ShowAssetTool.php @@ -4,6 +4,7 @@ use App\Models\Asset; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -42,6 +43,10 @@ public function handle(Request $request): ResponseFactory ); } + if (! Gate::allows('view', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + return Response::make( Response::text('Asset '.$asset->asset_tag.' found') )->withStructuredContent($this->formatAsset($asset)); diff --git a/app/Mcp/Tools/ShowUserTool.php b/app/Mcp/Tools/ShowUserTool.php index ffa68a56255a..1758aad24adf 100644 --- a/app/Mcp/Tools/ShowUserTool.php +++ b/app/Mcp/Tools/ShowUserTool.php @@ -4,6 +4,7 @@ use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -47,6 +48,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('User not found')); } + if (! Gate::allows('view', $user)) { + return Response::make(Response::error('Unauthorized')); + } + return Response::make( Response::text('User '.$user->username.' found') )->withStructuredContent([ diff --git a/app/Mcp/Tools/UpdateAccessoryTool.php b/app/Mcp/Tools/UpdateAccessoryTool.php index 33fe9a32403b..b941f39ee5f8 100644 --- a/app/Mcp/Tools/UpdateAccessoryTool.php +++ b/app/Mcp/Tools/UpdateAccessoryTool.php @@ -5,6 +5,7 @@ use App\Models\Accessory; use App\Models\Company; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -45,6 +46,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Accessory not found')); } + if (! Gate::allows('update', $accessory)) { + return Response::make(Response::error('Unauthorized')); + } + $updatable = [ 'category_id', 'qty', 'model_number', 'manufacturer_id', 'supplier_id', 'location_id', 'order_number', 'purchase_cost', diff --git a/app/Mcp/Tools/UpdateAssetTool.php b/app/Mcp/Tools/UpdateAssetTool.php index 024fc81e76ad..094b9c2f41ee 100644 --- a/app/Mcp/Tools/UpdateAssetTool.php +++ b/app/Mcp/Tools/UpdateAssetTool.php @@ -4,6 +4,7 @@ use App\Models\Asset; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -49,6 +50,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Asset not found')); } + if (! Gate::allows('update', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + $updatable = [ 'name', 'status_id', 'model_id', 'notes', 'order_number', 'purchase_date', 'purchase_cost', 'warranty_months', diff --git a/app/Mcp/Tools/UpdateComponentTool.php b/app/Mcp/Tools/UpdateComponentTool.php index feb0916866b7..33bb11c43bcf 100644 --- a/app/Mcp/Tools/UpdateComponentTool.php +++ b/app/Mcp/Tools/UpdateComponentTool.php @@ -5,6 +5,7 @@ use App\Models\Company; use App\Models\Component; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Support\Facades\Gate; use Laravel\Mcp\Request; use Laravel\Mcp\Response; use Laravel\Mcp\ResponseFactory; @@ -45,6 +46,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('Component not found')); } + if (! Gate::allows('update', $component)) { + return Response::make(Response::error('Unauthorized')); + } + $updatable = [ 'category_id', 'qty', 'serial', 'model_number', 'manufacturer_id', 'supplier_id', 'location_id', 'order_number', diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php index e2011d23bd7f..1115264c3227 100644 --- a/app/Mcp/Tools/UpdateUserTool.php +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -58,6 +58,10 @@ public function handle(Request $request): ResponseFactory return Response::make(Response::error('User not found')); } + if (! Gate::allows('update', $user)) { + return Response::make(Response::error('Unauthorized')); + } + $updatable = [ 'first_name', 'last_name', 'employee_num', 'jobtitle', 'phone', 'mobile', 'department_id', 'location_id', 'manager_id', diff --git a/tests/Feature/Mcp/AuditAssetToolTest.php b/tests/Feature/Mcp/AuditAssetToolTest.php index c16d67d3dc80..d58b7465eb14 100644 --- a/tests/Feature/Mcp/AuditAssetToolTest.php +++ b/tests/Feature/Mcp/AuditAssetToolTest.php @@ -15,7 +15,7 @@ class AuditAssetToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->auditAssets()->create()); } private function handle(array $args): ResponseFactory @@ -135,4 +135,15 @@ public function test_creates_audit_log_entry() 'action_type' => 'audit', ]); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $response = $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNull($asset->fresh()->last_audit_date); + } } diff --git a/tests/Feature/Mcp/CheckinAccessoryToolTest.php b/tests/Feature/Mcp/CheckinAccessoryToolTest.php index 560ed04ae5ba..d991c3b72698 100644 --- a/tests/Feature/Mcp/CheckinAccessoryToolTest.php +++ b/tests/Feature/Mcp/CheckinAccessoryToolTest.php @@ -16,7 +16,7 @@ class CheckinAccessoryToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkoutAccessories()->checkinAccessories()->create()); } private function handle(array $args): ResponseFactory @@ -93,4 +93,16 @@ public function test_checkout_record_from_factory_can_be_checked_in() $this->assertTrue($content['success']); $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); } + + public function test_returns_error_when_user_lacks_permission() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + $checkout = AccessoryCheckout::where('accessory_id', $accessory->id)->first(); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['checkout_id' => $checkout->id])->responses()->first()->isError()); + $this->assertDatabaseHas('accessories_checkout', ['id' => $checkout->id]); + } } diff --git a/tests/Feature/Mcp/CheckinAssetToolTest.php b/tests/Feature/Mcp/CheckinAssetToolTest.php index f48dd00155ad..4e4d34ab1287 100644 --- a/tests/Feature/Mcp/CheckinAssetToolTest.php +++ b/tests/Feature/Mcp/CheckinAssetToolTest.php @@ -17,7 +17,7 @@ class CheckinAssetToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkinAssets()->create()); } private function handle(array $args): ResponseFactory @@ -116,4 +116,14 @@ public function test_response_includes_asset_tag_and_location() $this->assertArrayHasKey('model', $content); $this->assertArrayHasKey('location', $content); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'assigned_to' => $user->id]); + } } diff --git a/tests/Feature/Mcp/CheckinComponentToolTest.php b/tests/Feature/Mcp/CheckinComponentToolTest.php index a6b388f0a3a1..20d93a4a513a 100644 --- a/tests/Feature/Mcp/CheckinComponentToolTest.php +++ b/tests/Feature/Mcp/CheckinComponentToolTest.php @@ -19,7 +19,7 @@ class CheckinComponentToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkoutComponents()->checkinComponents()->create()); } private function handle(array $args): ResponseFactory @@ -141,4 +141,16 @@ public function test_factory_checkout_record_can_be_checked_in() $this->assertTrue($content['success']); $this->assertDatabaseMissing('components_assets', ['id' => $record->id]); } + + public function test_returns_error_when_user_lacks_permission() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['component_asset_id' => $componentAssetId])->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId]); + } } diff --git a/tests/Feature/Mcp/CheckoutAccessoryToolTest.php b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php index c304be37b96b..ec8789b8edfa 100644 --- a/tests/Feature/Mcp/CheckoutAccessoryToolTest.php +++ b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php @@ -18,7 +18,7 @@ class CheckoutAccessoryToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkoutAccessories()->create()); } private function handle(array $args): ResponseFactory @@ -163,4 +163,17 @@ public function test_returns_error_when_target_user_not_found() 'assigned_user' => 999999, ])->responses()->first()->isError()); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CheckoutAssetToolTest.php b/tests/Feature/Mcp/CheckoutAssetToolTest.php index 346f55488599..4e53b662d409 100644 --- a/tests/Feature/Mcp/CheckoutAssetToolTest.php +++ b/tests/Feature/Mcp/CheckoutAssetToolTest.php @@ -15,7 +15,7 @@ class CheckoutAssetToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkoutAssets()->create()); } private function handle(array $args): ResponseFactory @@ -158,4 +158,17 @@ public function test_response_includes_asset_tag_and_target_info() $this->assertEquals('user', $content['checked_out_to_type']); $this->assertEquals($user->id, $content['checked_out_to_id']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CheckoutComponentToolTest.php b/tests/Feature/Mcp/CheckoutComponentToolTest.php index 5826f9237f9d..5956f626fa87 100644 --- a/tests/Feature/Mcp/CheckoutComponentToolTest.php +++ b/tests/Feature/Mcp/CheckoutComponentToolTest.php @@ -15,7 +15,7 @@ class CheckoutComponentToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->checkoutComponents()->create()); } private function handle(array $args): ResponseFactory @@ -134,4 +134,16 @@ public function test_returns_error_when_asset_not_found() 'asset_id' => 999999, ])->responses()->first()->isError()); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CreateAccessoryToolTest.php b/tests/Feature/Mcp/CreateAccessoryToolTest.php index 673912050a37..8b865ef8aca7 100644 --- a/tests/Feature/Mcp/CreateAccessoryToolTest.php +++ b/tests/Feature/Mcp/CreateAccessoryToolTest.php @@ -15,7 +15,7 @@ class CreateAccessoryToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->createAccessories()->create()); } private function handle(array $args): ResponseFactory @@ -102,4 +102,15 @@ public function test_returns_error_when_category_does_not_exist() 'category_id' => 999999, ])->responses()->first()->isError()); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'accessory']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Accessory', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CreateComponentToolTest.php b/tests/Feature/Mcp/CreateComponentToolTest.php index 503e50ad2933..58f5a6c85eda 100644 --- a/tests/Feature/Mcp/CreateComponentToolTest.php +++ b/tests/Feature/Mcp/CreateComponentToolTest.php @@ -15,7 +15,7 @@ class CreateComponentToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->createComponents()->create()); } private function handle(array $args): ResponseFactory @@ -118,4 +118,16 @@ public function test_returns_error_when_category_does_not_exist() 'qty' => 5, ])->responses()->first()->isError()); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'component']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Component', + 'category_id' => $category->id, + 'qty' => 1, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CreateUserToolTest.php b/tests/Feature/Mcp/CreateUserToolTest.php index b3b0512c4014..ea7dd6c728be 100644 --- a/tests/Feature/Mcp/CreateUserToolTest.php +++ b/tests/Feature/Mcp/CreateUserToolTest.php @@ -16,7 +16,7 @@ class CreateUserToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->createUsers()->create()); } private function handle(array $args): ResponseFactory @@ -157,4 +157,15 @@ public function test_can_create_deactivated_user() $this->assertDatabaseHas('users', ['username' => 'inactive.user.mcp', 'activated' => 0]); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'first_name' => 'Blocked', + 'username' => 'blocked.user.mcp', + ])->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['username' => 'blocked.user.mcp']); + } } diff --git a/tests/Feature/Mcp/DeleteAccessoryToolTest.php b/tests/Feature/Mcp/DeleteAccessoryToolTest.php index 60e9d5bb298a..d78a8c5e966e 100644 --- a/tests/Feature/Mcp/DeleteAccessoryToolTest.php +++ b/tests/Feature/Mcp/DeleteAccessoryToolTest.php @@ -14,7 +14,7 @@ class DeleteAccessoryToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->deleteAccessories()->create()); } private function handle(array $args): ResponseFactory @@ -66,4 +66,13 @@ public function test_returns_error_when_accessory_has_checkouts() $this->assertTrue($response->responses()->first()->isError()); $this->assertNotSoftDeleted('accessories', ['id' => $accessory->id]); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(); + + $this->assertTrue($this->handle(['id' => $accessory->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('accessories', ['id' => $accessory->id]); + } } diff --git a/tests/Feature/Mcp/DeleteAssetToolTest.php b/tests/Feature/Mcp/DeleteAssetToolTest.php index f8ca75f43829..347e6c829392 100644 --- a/tests/Feature/Mcp/DeleteAssetToolTest.php +++ b/tests/Feature/Mcp/DeleteAssetToolTest.php @@ -16,7 +16,7 @@ class DeleteAssetToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->deleteAssets()->create()); } private function handle(array $args): ResponseFactory @@ -95,4 +95,13 @@ public function test_response_includes_asset_tag() $this->assertEquals('TAG-DEL-001', $content['asset_tag']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + $this->assertNotSoftDeleted($asset); + } } diff --git a/tests/Feature/Mcp/DeleteComponentToolTest.php b/tests/Feature/Mcp/DeleteComponentToolTest.php index 8129e255715e..12ff32e9a1ef 100644 --- a/tests/Feature/Mcp/DeleteComponentToolTest.php +++ b/tests/Feature/Mcp/DeleteComponentToolTest.php @@ -15,7 +15,7 @@ class DeleteComponentToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->deleteComponents()->create()); } private function handle(array $args): ResponseFactory @@ -67,4 +67,13 @@ public function test_returns_error_when_component_has_checkouts() $this->assertTrue($response->responses()->first()->isError()); $this->assertNotSoftDeleted('components', ['id' => $component->id]); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(); + + $this->assertTrue($this->handle(['id' => $component->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('components', ['id' => $component->id]); + } } diff --git a/tests/Feature/Mcp/DeleteUserToolTest.php b/tests/Feature/Mcp/DeleteUserToolTest.php index 64a9e8adfd7d..de94e7789423 100644 --- a/tests/Feature/Mcp/DeleteUserToolTest.php +++ b/tests/Feature/Mcp/DeleteUserToolTest.php @@ -14,7 +14,7 @@ class DeleteUserToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->deleteUsers()->create()); } private function handle(array $args): ResponseFactory @@ -86,4 +86,13 @@ public function test_returns_error_when_user_has_assigned_assets() $this->assertTrue($response->responses()->first()->isError()); $this->assertNotSoftDeleted('users', ['id' => $user->id]); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } } diff --git a/tests/Feature/Mcp/FmcsAccessoryScopingTest.php b/tests/Feature/Mcp/FmcsAccessoryScopingTest.php new file mode 100644 index 000000000000..6e59b1fc9918 --- /dev/null +++ b/tests/Feature/Mcp/FmcsAccessoryScopingTest.php @@ -0,0 +1,184 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->accessoryA = Accessory::factory()->for($this->companyA)->create(['qty' => 5]); + $this->accessoryB = Accessory::factory()->for($this->companyB)->create(['qty' => 5]); + + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editAccessories()->deleteAccessories()->checkoutAccessories()->checkinAccessories()->make() + ); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateAccessoryTool --- + + public function test_update_blocked_for_cross_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $response = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'qty' => 99, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('accessories', ['id' => $this->accessoryB->id, 'qty' => 99]); + } + + public function test_update_allowed_for_same_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + 'qty' => 10, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $this->accessoryA->id, 'qty' => 10]); + } + + // --- DeleteAccessoryTool --- + + public function test_delete_blocked_for_cross_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $response = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted($this->accessoryB); + } + + public function test_delete_allowed_for_same_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->accessoryA); + } + + // --- CheckoutAccessoryTool --- + + public function test_checkout_blocked_for_cross_company_accessory() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('accessories_checkout', ['accessory_id' => $this->accessoryB->id]); + } + + public function test_checkout_allowed_for_same_company_accessory() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', ['accessory_id' => $this->accessoryA->id]); + } + + // --- CheckinAccessoryTool --- + + public function test_checkin_blocked_for_cross_company_accessory() + { + $user = $this->companyB->users()->save(User::factory()->make()); + $this->actingAs($this->userInCompanyA); + + // Checkout accessory B as superuser (different company), then try to check it in as userInCompanyA + $checkoutId = AccessoryCheckout::create([ + 'accessory_id' => $this->accessoryB->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + 'created_by' => $this->superUser->id, + ])->id; + + $response = (new CheckinAccessoryTool)->handle(new Request([ + 'checkout_id' => $checkoutId, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('accessories_checkout', ['id' => $checkoutId]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_accessory_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'qty' => 20, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $this->accessoryB->id, 'qty' => 20]); + } + + public function test_superuser_can_delete_accessory_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->accessoryB); + } +} diff --git a/tests/Feature/Mcp/FmcsCompanyScopingTest.php b/tests/Feature/Mcp/FmcsCompanyScopingTest.php index 079eccb68180..1f3853544b85 100644 --- a/tests/Feature/Mcp/FmcsCompanyScopingTest.php +++ b/tests/Feature/Mcp/FmcsCompanyScopingTest.php @@ -36,7 +36,9 @@ protected function setUp(): void $this->assetA = Asset::factory()->for($this->companyA)->create(); $this->assetB = Asset::factory()->for($this->companyB)->create(); - $this->userInCompanyA = $this->companyA->users()->save(User::factory()->make()); + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editAssets()->deleteAssets()->auditAssets()->checkinAssets()->checkoutAssets()->make() + ); $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); $this->settings->enableMultipleFullCompanySupport(); diff --git a/tests/Feature/Mcp/FmcsComponentScopingTest.php b/tests/Feature/Mcp/FmcsComponentScopingTest.php new file mode 100644 index 000000000000..9ad983533431 --- /dev/null +++ b/tests/Feature/Mcp/FmcsComponentScopingTest.php @@ -0,0 +1,185 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->componentA = Component::factory()->for($this->companyA)->create(['qty' => 5]); + $this->componentB = Component::factory()->for($this->companyB)->create(['qty' => 5]); + + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editComponents()->deleteComponents()->checkoutComponents()->checkinComponents()->make() + ); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateComponentTool --- + + public function test_update_blocked_for_cross_company_component() + { + $this->actingAs($this->userInCompanyA); + + $response = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'qty' => 99, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('components', ['id' => $this->componentB->id, 'qty' => 99]); + } + + public function test_update_allowed_for_same_company_component() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + 'qty' => 10, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $this->componentA->id, 'qty' => 10]); + } + + // --- DeleteComponentTool --- + + public function test_delete_blocked_for_cross_company_component() + { + $this->actingAs($this->userInCompanyA); + + $response = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted($this->componentB); + } + + public function test_delete_allowed_for_same_company_component() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->componentA); + } + + // --- CheckoutComponentTool --- + + public function test_checkout_blocked_for_cross_company_component() + { + $asset = Asset::factory()->for($this->companyA)->create(); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'asset_id' => $asset->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('components_assets', ['component_id' => $this->componentB->id]); + } + + public function test_checkout_allowed_for_same_company_component() + { + $asset = Asset::factory()->for($this->companyA)->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + 'asset_id' => $asset->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components_assets', ['component_id' => $this->componentA->id, 'asset_id' => $asset->id]); + } + + // --- CheckinComponentTool --- + + public function test_checkin_blocked_for_cross_company_component() + { + $asset = Asset::factory()->for($this->companyB)->create(); + + // Checkout component B to asset B as superuser + $componentAssetId = DB::table('components_assets')->insertGetId([ + 'component_id' => $this->componentB->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 1, + 'created_by' => $this->superUser->id, + 'created_at' => now(), + ]); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckinComponentTool)->handle(new Request([ + 'component_asset_id' => $componentAssetId, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_component_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'qty' => 20, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $this->componentB->id, 'qty' => 20]); + } + + public function test_superuser_can_delete_component_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->componentB); + } +} diff --git a/tests/Feature/Mcp/ListAssetsToolTest.php b/tests/Feature/Mcp/ListAssetsToolTest.php index 91c54f9eeea2..1142479ed4e5 100644 --- a/tests/Feature/Mcp/ListAssetsToolTest.php +++ b/tests/Feature/Mcp/ListAssetsToolTest.php @@ -13,6 +13,12 @@ class ListAssetsToolTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->actingAs(User::factory()->viewAssets()->create()); + } + private function handle(array $args = []): ResponseFactory { return (new ListAssetsTool)->handle(new Request($args)); @@ -144,4 +150,11 @@ public function test_filters_by_location_id() $this->assertContains($matchingAsset->id, $ids); $this->assertNotContains($otherAsset->id, $ids); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/ListUsersToolTest.php b/tests/Feature/Mcp/ListUsersToolTest.php index 3c337bbcd934..36c56080cc3e 100644 --- a/tests/Feature/Mcp/ListUsersToolTest.php +++ b/tests/Feature/Mcp/ListUsersToolTest.php @@ -16,7 +16,7 @@ class ListUsersToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->viewUsers()->create()); } private function handle(array $args = []): ResponseFactory @@ -129,4 +129,11 @@ public function test_response_contains_pagination_meta() $this->assertEquals(0, $content['offset']); $this->assertArrayHasKey('total', $content); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/ShowAssetToolTest.php b/tests/Feature/Mcp/ShowAssetToolTest.php index 06ca1e2763b9..a14300eeec58 100644 --- a/tests/Feature/Mcp/ShowAssetToolTest.php +++ b/tests/Feature/Mcp/ShowAssetToolTest.php @@ -12,6 +12,12 @@ class ShowAssetToolTest extends TestCase { + protected function setUp(): void + { + parent::setUp(); + $this->actingAs(User::factory()->viewAssets()->create()); + } + private function handle(array $args): ResponseFactory { return (new ShowAssetTool)->handle(new Request($args)); @@ -126,4 +132,12 @@ public function test_assignment_info_is_null_when_not_checked_out() $this->assertNull($content['assigned_to_id']); $this->assertNull($content['assigned_to_type']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/ShowUserToolTest.php b/tests/Feature/Mcp/ShowUserToolTest.php index 54d148598100..6467dcb5d6d6 100644 --- a/tests/Feature/Mcp/ShowUserToolTest.php +++ b/tests/Feature/Mcp/ShowUserToolTest.php @@ -13,7 +13,7 @@ class ShowUserToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->viewUsers()->create()); } private function handle(array $args): ResponseFactory @@ -102,4 +102,12 @@ public function test_response_includes_company_and_department() $this->assertArrayHasKey('department', $content); $this->assertArrayHasKey('location', $content); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/UpdateAccessoryToolTest.php b/tests/Feature/Mcp/UpdateAccessoryToolTest.php index 11636e1a2219..44e9307c8e63 100644 --- a/tests/Feature/Mcp/UpdateAccessoryToolTest.php +++ b/tests/Feature/Mcp/UpdateAccessoryToolTest.php @@ -16,7 +16,7 @@ class UpdateAccessoryToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->editAccessories()->create()); } private function handle(array $args): ResponseFactory @@ -115,4 +115,15 @@ public function test_response_includes_id_and_name() $this->assertEquals($accessory->id, $content['id']); $this->assertEquals('Response Accessory', $content['name']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(['name' => 'Locked Accessory']); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/UpdateAssetToolTest.php b/tests/Feature/Mcp/UpdateAssetToolTest.php index 6c51541bb5d3..22fa47f5f580 100644 --- a/tests/Feature/Mcp/UpdateAssetToolTest.php +++ b/tests/Feature/Mcp/UpdateAssetToolTest.php @@ -16,7 +16,7 @@ class UpdateAssetToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->editAssets()->create()); } private function handle(array $args): ResponseFactory @@ -130,4 +130,15 @@ public function test_response_includes_asset_tag_and_id() $this->assertEquals($asset->asset_tag, $content['asset_tag']); $this->assertEquals($asset->id, $content['id']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'Should Not Update', + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/UpdateComponentToolTest.php b/tests/Feature/Mcp/UpdateComponentToolTest.php index 160629a3a4d4..82b7d6b64157 100644 --- a/tests/Feature/Mcp/UpdateComponentToolTest.php +++ b/tests/Feature/Mcp/UpdateComponentToolTest.php @@ -16,7 +16,7 @@ class UpdateComponentToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->editComponents()->create()); } private function handle(array $args): ResponseFactory @@ -115,4 +115,15 @@ public function test_response_includes_id_and_name() $this->assertEquals($component->id, $content['id']); $this->assertEquals('Response Component', $content['name']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/UpdateUserToolTest.php b/tests/Feature/Mcp/UpdateUserToolTest.php index a58862d6c142..e5e12b86ccb5 100644 --- a/tests/Feature/Mcp/UpdateUserToolTest.php +++ b/tests/Feature/Mcp/UpdateUserToolTest.php @@ -16,7 +16,7 @@ class UpdateUserToolTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->actingAs(User::factory()->create()); + $this->actingAs(User::factory()->editUsers()->create()); } private function handle(array $args): ResponseFactory @@ -222,4 +222,15 @@ public function test_non_auth_fields_are_still_updated_even_without_can_edit_aut $this->assertTrue($content['success']); $this->assertDatabaseHas('users', ['id' => $admin->id, 'jobtitle' => 'New Title']); } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $user->id, + 'first_name' => 'Should Not Update', + ])->responses()->first()->isError()); + } } From 08b2d0c85d1fa284188a8f2906ddf48cd4c1c223 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 10:37:25 +0100 Subject: [PATCH 21/45] Licenses MCP stuff --- app/Mcp/Servers/SnipeMCPServer.php | 7 + app/Mcp/Tools/CheckinLicenseTool.php | 105 +++++++++++ app/Mcp/Tools/CheckoutLicenseTool.php | 149 ++++++++++++++++ app/Mcp/Tools/CreateLicenseTool.php | 119 +++++++++++++ app/Mcp/Tools/DeleteLicenseTool.php | 89 ++++++++++ app/Mcp/Tools/ListLicensesTool.php | 113 ++++++++++++ app/Mcp/Tools/ShowLicenseTool.php | 102 +++++++++++ app/Mcp/Tools/UpdateLicenseTool.php | 143 +++++++++++++++ tests/Feature/Mcp/CheckinLicenseToolTest.php | 139 +++++++++++++++ tests/Feature/Mcp/CheckoutLicenseToolTest.php | 164 ++++++++++++++++++ tests/Feature/Mcp/CreateLicenseToolTest.php | 148 ++++++++++++++++ tests/Feature/Mcp/DeleteLicenseToolTest.php | 82 +++++++++ tests/Feature/Mcp/ListLicensesToolTest.php | 120 +++++++++++++ tests/Feature/Mcp/ShowLicenseToolTest.php | 102 +++++++++++ tests/Feature/Mcp/UpdateLicenseToolTest.php | 106 +++++++++++ 15 files changed, 1688 insertions(+) create mode 100644 app/Mcp/Tools/CheckinLicenseTool.php create mode 100644 app/Mcp/Tools/CheckoutLicenseTool.php create mode 100644 app/Mcp/Tools/CreateLicenseTool.php create mode 100644 app/Mcp/Tools/DeleteLicenseTool.php create mode 100644 app/Mcp/Tools/ListLicensesTool.php create mode 100644 app/Mcp/Tools/ShowLicenseTool.php create mode 100644 app/Mcp/Tools/UpdateLicenseTool.php create mode 100644 tests/Feature/Mcp/CheckinLicenseToolTest.php create mode 100644 tests/Feature/Mcp/CheckoutLicenseToolTest.php create mode 100644 tests/Feature/Mcp/CreateLicenseToolTest.php create mode 100644 tests/Feature/Mcp/DeleteLicenseToolTest.php create mode 100644 tests/Feature/Mcp/ListLicensesToolTest.php create mode 100644 tests/Feature/Mcp/ShowLicenseToolTest.php create mode 100644 tests/Feature/Mcp/UpdateLicenseToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index f8a29d1e3fe7..3cf74a48f0e8 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -6,23 +6,30 @@ use App\Mcp\Tools\CheckinAccessoryTool; use App\Mcp\Tools\CheckinAssetTool; use App\Mcp\Tools\CheckinComponentTool; +use App\Mcp\Tools\CheckinLicenseTool; use App\Mcp\Tools\CheckoutAccessoryTool; use App\Mcp\Tools\CheckoutAssetTool; use App\Mcp\Tools\CheckoutComponentTool; +use App\Mcp\Tools\CheckoutLicenseTool; use App\Mcp\Tools\CreateAccessoryTool; use App\Mcp\Tools\CreateComponentTool; +use App\Mcp\Tools\CreateLicenseTool; use App\Mcp\Tools\CreateUserTool; use App\Mcp\Tools\DeleteAccessoryTool; use App\Mcp\Tools\DeleteAssetTool; use App\Mcp\Tools\DeleteComponentTool; +use App\Mcp\Tools\DeleteLicenseTool; use App\Mcp\Tools\DeleteUserTool; use App\Mcp\Tools\ListAssetsTool; +use App\Mcp\Tools\ListLicensesTool; use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\ShowAssetTool; +use App\Mcp\Tools\ShowLicenseTool; use App\Mcp\Tools\ShowUserTool; use App\Mcp\Tools\UpdateAccessoryTool; use App\Mcp\Tools\UpdateAssetTool; use App\Mcp\Tools\UpdateComponentTool; +use App\Mcp\Tools\UpdateLicenseTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; diff --git a/app/Mcp/Tools/CheckinLicenseTool.php b/app/Mcp/Tools/CheckinLicenseTool.php new file mode 100644 index 000000000000..2019e4d61ac5 --- /dev/null +++ b/app/Mcp/Tools/CheckinLicenseTool.php @@ -0,0 +1,105 @@ +validate([ + 'seat_id' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $seat = LicenseSeat::with('license')->find($request->get('seat_id')); + + if (! $seat) { + return Response::make(Response::error('License seat not found')); + } + + if (is_null($seat->assigned_to) && is_null($seat->asset_id)) { + return Response::make(Response::error('This seat is not currently checked out')); + } + + $license = $seat->license; + + if (! $license) { + return Response::make(Response::error('License not found')); + } + + // License checkin uses the checkout gate (matching application behavior) + if (! Gate::allows('checkout', $license)) { + return Response::make(Response::error('Unauthorized')); + } + + $returnTo = null; + if ($seat->assigned_to) { + $returnTo = User::withTrashed()->find($seat->assigned_to); + } elseif ($seat->asset_id) { + $returnTo = Asset::find($seat->asset_id); + } + + $note = $request->get('note'); + + $seat->assigned_to = null; + $seat->asset_id = null; + $seat->notes = $note; + + if (! $license->reassignable) { + $seat->unreassignable_seat = true; + } + + if ($seat->save()) { + event(new CheckoutableCheckedIn($seat, $returnTo, auth()->user(), $note)); + + return Response::make( + Response::text('License seat '.$seat->id.' checked in successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'License seat checked in successfully', + 'seat_id' => $seat->id, + 'license_id' => $license->id, + 'license_name' => $license->name, + ]); + } + + return Response::make(Response::error('Checkin failed')); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'seat_id' => $schema->number()->description('ID of the license seat to check in (returned by checkout_license)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'seat_id' => $schema->number()->description('ID of the seat that was checked in'), + 'license_id' => $schema->number()->description('Numeric ID of the license'), + 'license_name' => $schema->string()->description('Name of the license'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutLicenseTool.php b/app/Mcp/Tools/CheckoutLicenseTool.php new file mode 100644 index 000000000000..a71be3c94090 --- /dev/null +++ b/app/Mcp/Tools/CheckoutLicenseTool.php @@ -0,0 +1,149 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'assigned_to' => 'nullable|integer', + 'asset_id' => 'nullable|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error('License not found')); + } + + if (! Gate::allows('checkout', $license)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($license->numRemaining() < 1) { + return Response::make(Response::error('No available seats for this license')); + } + + if (! $request->filled('assigned_to') && ! $request->filled('asset_id')) { + return Response::make(Response::error('Please provide either assigned_to (user ID) or asset_id')); + } + + $seat = $license->freeSeat(); + + if (! $seat) { + return Response::make(Response::error('No free seat found for this license')); + } + + $note = $request->get('note'); + + if ($request->filled('assigned_to')) { + $target = User::find($request->get('assigned_to')); + if (! $target) { + return Response::make(Response::error('User not found')); + } + $seat->assigned_to = $target->id; + $seat->notes = $note; + + if ($seat->save()) { + event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); + + return Response::make( + Response::text('License seat checked out to user '.$target->username) + )->withStructuredContent([ + 'success' => true, + 'message' => 'License seat checked out successfully', + 'license_id' => $license->id, + 'license_name' => $license->name, + 'seat_id' => $seat->id, + 'assigned_to_type' => 'user', + 'assigned_to_id' => $target->id, + ]); + } + } elseif ($request->filled('asset_id')) { + $target = Asset::find($request->get('asset_id')); + if (! $target) { + return Response::make(Response::error('Asset not found')); + } + $seat->asset_id = $target->id; + if ($target->checkedOutToUser()) { + $seat->assigned_to = $target->assigned_to; + } + $seat->notes = $note; + + if ($seat->save()) { + event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); + + return Response::make( + Response::text('License seat checked out to asset '.$target->asset_tag) + )->withStructuredContent([ + 'success' => true, + 'message' => 'License seat checked out successfully', + 'license_id' => $license->id, + 'license_name' => $license->name, + 'seat_id' => $seat->id, + 'assigned_to_type' => 'asset', + 'assigned_to_id' => $target->id, + ]); + } + } + + return Response::make(Response::error('Checkout failed')); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the license to check out'), + 'name' => $schema->string()->description('Name of the license to check out'), + 'assigned_to' => $schema->number()->description('User ID to assign the seat to'), + 'asset_id' => $schema->number()->description('Asset ID to assign the seat to'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'license_id' => $schema->number()->description('Numeric ID of the license'), + 'license_name' => $schema->string()->description('Name of the license'), + 'seat_id' => $schema->number()->description('ID of the seat record (use this for checkin)'), + 'assigned_to_type' => $schema->string()->description('Type of entity checked out to: user or asset'), + 'assigned_to_id' => $schema->number()->description('ID of the entity checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateLicenseTool.php b/app/Mcp/Tools/CreateLicenseTool.php new file mode 100644 index 000000000000..d58e3cce1d0d --- /dev/null +++ b/app/Mcp/Tools/CreateLicenseTool.php @@ -0,0 +1,119 @@ +validate([ + 'name' => 'required|string|max:255', + 'seats' => 'required|integer|min:1', + 'category_id' => 'required|integer|exists:categories,id', + 'serial' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_order' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'expiration_date' => 'nullable|date_format:Y-m-d', + 'termination_date' => 'nullable|date_format:Y-m-d', + 'license_name' => 'nullable|string|max:255', + 'license_email' => 'nullable|email|max:255', + 'maintained' => 'nullable|boolean', + 'reassignable' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'min_amt' => 'nullable|integer|min:0', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $license = new License; + $license->fill($request->only([ + 'name', 'seats', 'category_id', 'serial', 'manufacturer_id', + 'supplier_id', 'purchase_date', 'purchase_cost', 'purchase_order', + 'order_number', 'expiration_date', 'termination_date', + 'license_name', 'license_email', 'maintained', 'reassignable', + 'notes', 'min_amt', + ])); + + $license->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $license->created_by = auth()->id(); + + if ($license->save()) { + return Response::make( + Response::text('License '.$license->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'License created successfully', + 'id' => $license->id, + 'name' => $license->name, + 'seats' => $license->seats, + 'category_id' => $license->category_id, + ]); + } + + return Response::make(Response::error('Create failed: '.$license->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('License name (required)'), + 'seats' => $schema->number()->description('Number of seats (required, min 1)'), + 'category_id' => $schema->number()->description('Category ID — must be a license category (required)'), + 'serial' => $schema->string()->description('Product key / serial number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'purchase_order' => $schema->string()->description('Purchase order number'), + 'order_number' => $schema->string()->description('Order number'), + 'expiration_date' => $schema->string()->description('License expiration date (YYYY-MM-DD)'), + 'termination_date' => $schema->string()->description('License termination date (YYYY-MM-DD)'), + 'license_name' => $schema->string()->description('Name of the licensed user/organization'), + 'license_email' => $schema->string()->description('Email of the licensed user/organization'), + 'maintained' => $schema->boolean()->description('Whether the license is under maintenance'), + 'reassignable' => $schema->boolean()->description('Whether seats can be reassigned after checkin'), + 'notes' => $schema->string()->description('Notes'), + 'min_amt' => $schema->number()->description('Minimum seat threshold for alerts'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the license was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new license'), + 'name' => $schema->string()->description('Name of the new license'), + 'seats' => $schema->number()->description('Total seat count'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteLicenseTool.php b/app/Mcp/Tools/DeleteLicenseTool.php new file mode 100644 index 000000000000..4c27d6b20fc6 --- /dev/null +++ b/app/Mcp/Tools/DeleteLicenseTool.php @@ -0,0 +1,89 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error('License not found')); + } + + if (! Gate::allows('delete', $license)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($license->assignedCount()->count() > 0) { + return Response::make(Response::error('License has seats currently assigned and cannot be deleted. Check in all seats first.')); + } + + $name = $license->name; + + DB::table('license_seats') + ->where('license_id', $license->id) + ->update(['assigned_to' => null, 'asset_id' => null]); + + $license->licenseseats()->delete(); + $license->delete(); + + return Response::make( + Response::text('License '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'License deleted successfully', + 'name' => $name, + ]); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the license to delete'), + 'name' => $schema->string()->description('Name of the license to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted license'), + ]; + } +} diff --git a/app/Mcp/Tools/ListLicensesTool.php b/app/Mcp/Tools/ListLicensesTool.php new file mode 100644 index 000000000000..4f458f080ab5 --- /dev/null +++ b/app/Mcp/Tools/ListLicensesTool.php @@ -0,0 +1,113 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'supplier_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $licenses = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count'); + + if ($request->filled('search')) { + $licenses->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $licenses->where('licenses.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('category_id')) { + $licenses->where('category_id', '=', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $licenses->where('manufacturer_id', '=', $request->get('manufacturer_id')); + } + + if ($request->filled('supplier_id')) { + $licenses->where('supplier_id', '=', $request->get('supplier_id')); + } + + $licenses->orderBy('licenses.created_at', 'desc'); + + $total = $licenses->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $licenses->skip($offset)->take($limit)->get(); + + $licensesData = $results->map(fn (License $license) => [ + 'id' => $license->id, + 'name' => $license->name, + 'serial' => $license->serial, + 'seats' => $license->seats, + 'free_seats' => $license->free_seats_count, + 'category' => $license->category?->name, + 'manufacturer' => $license->manufacturer?->name, + 'company' => $license->company?->name, + 'supplier' => $license->supplier?->name, + 'expiration_date' => $license->expiration_date?->format('Y-m-d'), + 'purchase_date' => $license->purchase_date?->format('Y-m-d'), + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} licenses, returning ".count($licensesData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'licenses' => $licensesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across name, serial, notes, and order number'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'supplier_id' => $schema->number()->description('Filter by supplier ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching licenses')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ShowLicenseTool.php b/app/Mcp/Tools/ShowLicenseTool.php new file mode 100644 index 000000000000..cbe529f37a6e --- /dev/null +++ b/app/Mcp/Tools/ShowLicenseTool.php @@ -0,0 +1,102 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $license = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count') + ->find($request->get('id')); + } elseif ($request->filled('name')) { + $license = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count') + ->where('name', $request->get('name')) + ->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $license) { + return Response::make(Response::error('License not found')); + } + + if (! Gate::allows('view', $license)) { + return Response::make(Response::error('Unauthorized')); + } + + $assignedCount = $license->assignedCount()->count(); + + return Response::make( + Response::text('License '.$license->name.' found') + )->withStructuredContent([ + 'id' => $license->id, + 'name' => $license->name, + 'serial' => $license->serial, + 'seats' => $license->seats, + 'free_seats' => $license->free_seats_count, + 'assigned_seats' => $assignedCount, + 'category' => $license->category?->name, + 'category_id' => $license->category_id, + 'manufacturer' => $license->manufacturer?->name, + 'manufacturer_id' => $license->manufacturer_id, + 'company' => $license->company?->name, + 'company_id' => $license->company_id, + 'supplier' => $license->supplier?->name, + 'supplier_id' => $license->supplier_id, + 'license_name' => $license->license_name, + 'license_email' => $license->license_email, + 'maintained' => (bool) $license->maintained, + 'reassignable' => (bool) $license->reassignable, + 'purchase_date' => $license->purchase_date?->format('Y-m-d'), + 'purchase_cost' => $license->purchase_cost, + 'purchase_order' => $license->purchase_order, + 'order_number' => $license->order_number, + 'expiration_date' => $license->expiration_date?->format('Y-m-d'), + 'termination_date' => $license->termination_date?->format('Y-m-d'), + 'notes' => $license->notes, + 'created_at' => $license->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $license->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric license ID'), + 'name' => $schema->string()->description('License name to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric license ID')->required(), + 'name' => $schema->string()->description('License name')->required(), + 'seats' => $schema->number()->description('Total seat count'), + 'free_seats' => $schema->number()->description('Number of available (unassigned) seats'), + 'assigned_seats' => $schema->number()->description('Number of currently assigned seats'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateLicenseTool.php b/app/Mcp/Tools/UpdateLicenseTool.php new file mode 100644 index 000000000000..9efb477a951a --- /dev/null +++ b/app/Mcp/Tools/UpdateLicenseTool.php @@ -0,0 +1,143 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'seats' => 'nullable|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_order' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'expiration_date' => 'nullable|date_format:Y-m-d', + 'termination_date' => 'nullable|date_format:Y-m-d', + 'license_name' => 'nullable|string|max:255', + 'license_email' => 'nullable|email|max:255', + 'maintained' => 'nullable|boolean', + 'reassignable' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'min_amt' => 'nullable|integer|min:0', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error('License not found')); + } + + if (! Gate::allows('update', $license)) { + return Response::make(Response::error('Unauthorized')); + } + + $updatable = [ + 'seats', 'serial', 'category_id', 'manufacturer_id', 'supplier_id', + 'purchase_date', 'purchase_cost', 'purchase_order', 'order_number', + 'expiration_date', 'termination_date', 'license_name', 'license_email', + 'maintained', 'reassignable', 'notes', 'min_amt', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $license->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $license->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $license->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($license->save()) { + return Response::make( + Response::text('License '.$license->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'License updated successfully', + 'id' => $license->id, + 'name' => $license->name, + 'seats' => $license->seats, + ]); + } + + return Response::make(Response::error('Update failed: '.$license->getErrors()->first())); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the license'), + 'name' => $schema->string()->description('Name to identify the license'), + 'new_name' => $schema->string()->description('New name (renames the license)'), + 'seats' => $schema->number()->description('Total seat count'), + 'serial' => $schema->string()->description('Product key / serial number'), + 'category_id' => $schema->number()->description('Category ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'purchase_order' => $schema->string()->description('Purchase order number'), + 'order_number' => $schema->string()->description('Order number'), + 'expiration_date' => $schema->string()->description('Expiration date (YYYY-MM-DD)'), + 'termination_date' => $schema->string()->description('Termination date (YYYY-MM-DD)'), + 'license_name' => $schema->string()->description('Name of the licensed user/organization'), + 'license_email' => $schema->string()->description('Email of the licensed user/organization'), + 'maintained' => $schema->boolean()->description('Whether the license is under maintenance'), + 'reassignable' => $schema->boolean()->description('Whether seats can be reassigned after checkin'), + 'notes' => $schema->string()->description('Notes'), + 'min_amt' => $schema->number()->description('Minimum seat threshold for alerts'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the license'), + 'name' => $schema->string()->description('Name of the license'), + 'seats' => $schema->number()->description('Total seat count'), + ]; + } +} diff --git a/tests/Feature/Mcp/CheckinLicenseToolTest.php b/tests/Feature/Mcp/CheckinLicenseToolTest.php new file mode 100644 index 000000000000..54afe683e109 --- /dev/null +++ b/tests/Feature/Mcp/CheckinLicenseToolTest.php @@ -0,0 +1,139 @@ +actingAs(User::factory()->checkoutLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinLicenseTool)->handle(new Request($args)); + } + + private function checkoutToUser(License $license, User $user): LicenseSeat + { + $response = (new CheckoutLicenseTool)->handle(new Request([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])); + + $seatId = $response->getStructuredContent()['seat_id']; + + return LicenseSeat::find($seatId); + } + + public function test_checks_in_seat_by_seat_id() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $content = $this->handle(['seat_id' => $seat->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'assigned_to' => null, + 'asset_id' => null, + ]); + } + + public function test_response_includes_license_info() + { + $license = License::factory()->create(['name' => 'Checkin License', 'seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $content = $this->handle(['seat_id' => $seat->id])->getStructuredContent(); + + $this->assertEquals($seat->id, $content['seat_id']); + $this->assertEquals($license->id, $content['license_id']); + $this->assertEquals('Checkin License', $content['license_name']); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->handle(['seat_id' => $seat->id]); + + Event::assertDispatched(CheckoutableCheckedIn::class); + } + + public function test_returns_error_when_seat_not_found() + { + $this->assertTrue($this->handle(['seat_id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_seat_is_not_checked_out() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $seat = $license->freeSeat(); + + $this->assertTrue($this->handle(['seat_id' => $seat->id])->responses()->first()->isError()); + } + + public function test_sets_unreassignable_flag_when_license_not_reassignable() + { + $license = License::factory()->create(['seats' => 1, 'reassignable' => false]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $this->handle(['seat_id' => $seat->id]); + + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'unreassignable_seat' => true, + ]); + } + + public function test_does_not_set_unreassignable_flag_when_license_is_reassignable() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->handle(['seat_id' => $seat->id]); + + $refreshed = LicenseSeat::find($seat->id); + $this->assertFalse((bool) $refreshed->unreassignable_seat); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['seat_id' => $seat->id])->responses()->first()->isError()); + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'assigned_to' => $user->id, + ]); + } +} diff --git a/tests/Feature/Mcp/CheckoutLicenseToolTest.php b/tests/Feature/Mcp/CheckoutLicenseToolTest.php new file mode 100644 index 000000000000..f53642835052 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutLicenseToolTest.php @@ -0,0 +1,164 @@ +actingAs(User::factory()->checkoutLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutLicenseTool)->handle(new Request($args)); + } + + public function test_checks_out_license_to_user_by_id() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('user', $content['assigned_to_type']); + $this->assertEquals($user->id, $content['assigned_to_id']); + $this->assertDatabaseHas('license_seats', [ + 'license_id' => $license->id, + 'assigned_to' => $user->id, + ]); + } + + public function test_checks_out_license_to_user_by_name() + { + $license = License::factory()->create(['name' => 'Named Checkout License', 'seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout License', + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_license_to_asset() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('asset', $content['assigned_to_type']); + $this->assertEquals($asset->id, $content['assigned_to_id']); + $this->assertDatabaseHas('license_seats', [ + 'license_id' => $license->id, + 'asset_id' => $asset->id, + ]); + } + + public function test_response_includes_seat_id_for_checkin() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('seat_id', $content); + $this->assertIsInt($content['seat_id']); + $this->assertNotNull(LicenseSeat::find($content['seat_id'])); + } + + public function test_fires_checkout_event() + { + Event::fake([CheckoutableCheckedOut::class]); + + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ]); + + Event::assertDispatched(CheckoutableCheckedOut::class); + } + + public function test_returns_error_when_no_seats_remaining() + { + $license = License::factory()->create(['seats' => 1, 'reassignable' => true]); + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user1->id; + $seat->save(); + + $response = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user2->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_neither_assignee_provided() + { + $license = License::factory()->create(['seats' => 3]); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_license_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'assigned_to' => User::factory()->create()->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found() + { + $license = License::factory()->create(['seats' => 3]); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'assigned_to' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateLicenseToolTest.php b/tests/Feature/Mcp/CreateLicenseToolTest.php new file mode 100644 index 000000000000..0a2f3266cd0a --- /dev/null +++ b/tests/Feature/Mcp/CreateLicenseToolTest.php @@ -0,0 +1,148 @@ +actingAs(User::factory()->createLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateLicenseTool)->handle(new Request($args)); + } + + public function test_creates_license_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Test License', + 'seats' => 5, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['name' => 'Test License', 'seats' => 5]); + } + + public function test_response_includes_id_name_seats_and_category() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Response License', + 'seats' => 10, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response License', $content['name']); + $this->assertEquals(10, $content['seats']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_creates_license_seats_automatically() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Seat Auto License', + 'seats' => 3, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $licenseId = $content['id']; + $this->assertEquals(3, LicenseSeat::where('license_id', $licenseId)->count()); + } + + public function test_creates_license_with_optional_fields() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->handle([ + 'name' => 'Full License', + 'seats' => 2, + 'category_id' => $category->id, + 'serial' => 'SN-FULL-001', + 'license_name' => 'Acme Corp', + 'license_email' => 'admin@acme.com', + 'purchase_date' => '2024-01-15', + 'purchase_cost' => 299.99, + 'expiration_date' => '2025-01-15', + 'maintained' => true, + 'reassignable' => false, + 'notes' => 'Important license', + ]); + + $this->assertDatabaseHas('licenses', [ + 'name' => 'Full License', + 'serial' => 'SN-FULL-001', + 'license_name' => 'Acme Corp', + 'license_email' => 'admin@acme.com', + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'seats' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_seats_missing() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'name' => 'No Seats', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + 'seats' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'seats' => 5, + 'category_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked License', + 'seats' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('licenses', ['name' => 'Blocked License']); + } +} diff --git a/tests/Feature/Mcp/DeleteLicenseToolTest.php b/tests/Feature/Mcp/DeleteLicenseToolTest.php new file mode 100644 index 000000000000..d844de967db1 --- /dev/null +++ b/tests/Feature/Mcp/DeleteLicenseToolTest.php @@ -0,0 +1,82 @@ +actingAs(User::factory()->deleteLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteLicenseTool)->handle(new Request($args)); + } + + public function test_deletes_license_by_id() + { + $license = License::factory()->create(); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_deletes_license_by_name() + { + $license = License::factory()->create(['name' => 'Delete By Name License']); + + $content = $this->handle(['name' => 'Delete By Name License'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_response_includes_name() + { + $license = License::factory()->create(['name' => 'Named License']); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals('Named License', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_seats_are_assigned() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $response = $this->handle(['id' => $license->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('licenses', ['id' => $license->id]); + } +} diff --git a/tests/Feature/Mcp/ListLicensesToolTest.php b/tests/Feature/Mcp/ListLicensesToolTest.php new file mode 100644 index 000000000000..fcd6dc67a4d2 --- /dev/null +++ b/tests/Feature/Mcp/ListLicensesToolTest.php @@ -0,0 +1,120 @@ +actingAs(User::factory()->viewLicenses()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListLicensesTool)->handle(new Request($args)); + } + + public function test_returns_total_licenses_and_pagination_metadata() + { + License::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('licenses', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_license_includes_key_fields() + { + License::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $license = $content['licenses'][0]; + $this->assertArrayHasKey('id', $license); + $this->assertArrayHasKey('name', $license); + $this->assertArrayHasKey('seats', $license); + $this->assertArrayHasKey('free_seats', $license); + $this->assertArrayHasKey('category', $license); + } + + public function test_limit_controls_number_of_results_returned() + { + License::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['licenses']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_offset_skips_results_for_pagination() + { + License::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 4, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 4, 'offset' => 4])->getStructuredContent(); + + $ids1 = array_column($page1['licenses'], 'id'); + $ids2 = array_column($page2['licenses'], 'id'); + + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_search_matches_on_name() + { + $target = License::factory()->create(['name' => 'UniqueLicenseXYZ']); + License::factory()->create(['name' => 'Something Else']); + + $content = $this->handle(['search' => 'UniqueLicenseXYZ'])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($target->id, $ids); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'license']); + $matching = License::factory()->create(['category_id' => $category->id]); + $other = License::factory()->create(); + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($matching->id, $ids); + $this->assertNotContains($other->id, $ids); + } + + public function test_filters_by_manufacturer_id() + { + $manufacturer = Manufacturer::factory()->create(); + $matching = License::factory()->create(['manufacturer_id' => $manufacturer->id]); + $other = License::factory()->create(['manufacturer_id' => null]); + + $content = $this->handle(['manufacturer_id' => $manufacturer->id])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($matching->id, $ids); + $this->assertNotContains($other->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowLicenseToolTest.php b/tests/Feature/Mcp/ShowLicenseToolTest.php new file mode 100644 index 000000000000..b668ec24e047 --- /dev/null +++ b/tests/Feature/Mcp/ShowLicenseToolTest.php @@ -0,0 +1,102 @@ +actingAs(User::factory()->viewLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ShowLicenseTool)->handle(new Request($args)); + } + + public function test_returns_license_by_id() + { + $license = License::factory()->create(['name' => 'Test License', 'seats' => 5]); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals($license->id, $content['id']); + $this->assertEquals('Test License', $content['name']); + $this->assertEquals(5, $content['seats']); + } + + public function test_returns_license_by_name() + { + $license = License::factory()->create(['name' => 'FindByName License']); + + $content = $this->handle(['name' => 'FindByName License'])->getStructuredContent(); + + $this->assertEquals($license->id, $content['id']); + $this->assertEquals('FindByName License', $content['name']); + } + + public function test_response_includes_seat_counts() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals(3, $content['seats']); + $this->assertEquals(1, $content['assigned_seats']); + $this->assertEquals(2, $content['free_seats']); + } + + public function test_response_includes_key_fields() + { + $license = License::factory()->create([ + 'name' => 'Full Details License', + 'serial' => 'SN-12345', + 'license_name' => 'Acme Corp', + 'license_email' => 'legal@acme.com', + ]); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertArrayHasKey('serial', $content); + $this->assertArrayHasKey('license_name', $content); + $this->assertArrayHasKey('license_email', $content); + $this->assertArrayHasKey('maintained', $content); + $this->assertArrayHasKey('reassignable', $content); + $this->assertArrayHasKey('category', $content); + $this->assertArrayHasKey('category_id', $content); + $this->assertEquals('SN-12345', $content['serial']); + $this->assertEquals('Acme Corp', $content['license_name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateLicenseToolTest.php b/tests/Feature/Mcp/UpdateLicenseToolTest.php new file mode 100644 index 000000000000..1152d402d61e --- /dev/null +++ b/tests/Feature/Mcp/UpdateLicenseToolTest.php @@ -0,0 +1,106 @@ +actingAs(User::factory()->editLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateLicenseTool)->handle(new Request($args)); + } + + public function test_updates_license_by_id() + { + $license = License::factory()->create(['name' => 'Original Name']); + + $content = $this->handle([ + 'id' => $license->id, + 'new_name' => 'Updated Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Updated Name']); + } + + public function test_updates_license_by_name() + { + $license = License::factory()->create(['name' => 'Find By Name License']); + + $content = $this->handle([ + 'name' => 'Find By Name License', + 'new_name' => 'Renamed License', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Renamed License']); + } + + public function test_response_includes_id_name_and_seats() + { + $license = License::factory()->create(['seats' => 5]); + + $content = $this->handle([ + 'id' => $license->id, + 'new_name' => 'Response Check License', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check License', $content['name']); + $this->assertEquals(5, $content['seats']); + } + + public function test_updates_multiple_fields() + { + $license = License::factory()->create(); + $category = Category::factory()->create(['category_type' => 'license']); + + $this->handle([ + 'id' => $license->id, + 'category_id' => $category->id, + 'serial' => 'SN-NEW-001', + 'purchase_cost' => 199.99, + 'expiration_date' => '2026-12-31', + 'maintained' => true, + 'reassignable' => true, + 'notes' => 'Updated notes', + ]); + + $this->assertDatabaseHas('licenses', [ + 'id' => $license->id, + 'category_id' => $category->id, + 'serial' => 'SN-NEW-001', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(['name' => 'Unchanged License']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Unchanged License']); + } +} From 664a1906c16623b29b07fd6dfa91e04457fd7d29 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 10:59:06 +0100 Subject: [PATCH 22/45] Dept tooling --- app/Mcp/Servers/SnipeMCPServer.php | 13 ++ app/Mcp/Tools/CreateDepartmentTool.php | 87 ++++++++++++++ app/Mcp/Tools/DeleteDepartmentTool.php | 83 +++++++++++++ app/Mcp/Tools/UpdateDepartmentTool.php | 112 ++++++++++++++++++ database/factories/UserFactory.php | 10 ++ .../Feature/Mcp/CreateDepartmentToolTest.php | 84 +++++++++++++ tests/Feature/Mcp/CreateLicenseToolTest.php | 1 - .../Feature/Mcp/DeleteDepartmentToolTest.php | 78 ++++++++++++ tests/Feature/Mcp/ShowLicenseToolTest.php | 1 - .../Feature/Mcp/UpdateDepartmentToolTest.php | 110 +++++++++++++++++ 10 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 app/Mcp/Tools/CreateDepartmentTool.php create mode 100644 app/Mcp/Tools/DeleteDepartmentTool.php create mode 100644 app/Mcp/Tools/UpdateDepartmentTool.php create mode 100644 tests/Feature/Mcp/CreateDepartmentToolTest.php create mode 100644 tests/Feature/Mcp/DeleteDepartmentToolTest.php create mode 100644 tests/Feature/Mcp/UpdateDepartmentToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 3cf74a48f0e8..c9345a208200 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -13,11 +13,13 @@ use App\Mcp\Tools\CheckoutLicenseTool; use App\Mcp\Tools\CreateAccessoryTool; use App\Mcp\Tools\CreateComponentTool; +use App\Mcp\Tools\CreateDepartmentTool; use App\Mcp\Tools\CreateLicenseTool; use App\Mcp\Tools\CreateUserTool; use App\Mcp\Tools\DeleteAccessoryTool; use App\Mcp\Tools\DeleteAssetTool; use App\Mcp\Tools\DeleteComponentTool; +use App\Mcp\Tools\DeleteDepartmentTool; use App\Mcp\Tools\DeleteLicenseTool; use App\Mcp\Tools\DeleteUserTool; use App\Mcp\Tools\ListAssetsTool; @@ -29,6 +31,7 @@ use App\Mcp\Tools\UpdateAccessoryTool; use App\Mcp\Tools\UpdateAssetTool; use App\Mcp\Tools\UpdateComponentTool; +use App\Mcp\Tools\UpdateDepartmentTool; use App\Mcp\Tools\UpdateLicenseTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; @@ -64,6 +67,16 @@ class SnipeMCPServer extends Server DeleteComponentTool::class, CheckoutComponentTool::class, CheckinComponentTool::class, + ListLicensesTool::class, + ShowLicenseTool::class, + CreateLicenseTool::class, + UpdateLicenseTool::class, + DeleteLicenseTool::class, + CheckoutLicenseTool::class, + CheckinLicenseTool::class, + CreateDepartmentTool::class, + UpdateDepartmentTool::class, + DeleteDepartmentTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/CreateDepartmentTool.php b/app/Mcp/Tools/CreateDepartmentTool.php new file mode 100644 index 000000000000..0cba3af4cf00 --- /dev/null +++ b/app/Mcp/Tools/CreateDepartmentTool.php @@ -0,0 +1,87 @@ +validate([ + 'name' => 'required|string|max:255', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:255', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $department = new Department; + $department->fill($request->only([ + 'name', 'location_id', 'manager_id', 'phone', 'fax', 'notes', + ])); + + $department->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $department->created_by = auth()->id(); + + if ($department->save()) { + return Response::make( + Response::text('Department '.$department->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Department created successfully', + 'id' => $department->id, + 'name' => $department->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$department->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Department name (required)'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'manager_id' => $schema->number()->description('User ID of the department manager'), + 'phone' => $schema->string()->description('Department phone number'), + 'fax' => $schema->string()->description('Department fax number'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the department was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new department'), + 'name' => $schema->string()->description('Name of the new department'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteDepartmentTool.php b/app/Mcp/Tools/DeleteDepartmentTool.php new file mode 100644 index 000000000000..163ca6150a5f --- /dev/null +++ b/app/Mcp/Tools/DeleteDepartmentTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $department = $this->resolveDepartment($request); + + if (! $department) { + return Response::make(Response::error('Department not found')); + } + + if (! Gate::allows('delete', $department)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($department->users->count() > 0) { + return Response::make(Response::error('Department has users assigned and cannot be deleted. Reassign all users first.')); + } + + $name = $department->name; + + $department->delete(); + + return Response::make( + Response::text('Department '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Department deleted successfully', + 'name' => $name, + ]); + } + + private function resolveDepartment(Request $request): ?Department + { + if ($request->filled('id')) { + return Department::find($request->get('id')); + } + if ($request->filled('name')) { + return Department::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the department to delete'), + 'name' => $schema->string()->description('Name of the department to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted department'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateDepartmentTool.php b/app/Mcp/Tools/UpdateDepartmentTool.php new file mode 100644 index 000000000000..af996003f59c --- /dev/null +++ b/app/Mcp/Tools/UpdateDepartmentTool.php @@ -0,0 +1,112 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:255', + ]); + + $department = $this->resolveDepartment($request); + + if (! $department) { + return Response::make(Response::error('Department not found')); + } + + if (! Gate::allows('update', $department)) { + return Response::make(Response::error('Unauthorized')); + } + + $updatable = ['location_id', 'manager_id', 'phone', 'fax', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $department->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $department->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $department->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($department->save()) { + return Response::make( + Response::text('Department '.$department->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Department updated successfully', + 'id' => $department->id, + 'name' => $department->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$department->getErrors()->first())); + } + + private function resolveDepartment(Request $request): ?Department + { + if ($request->filled('id')) { + return Department::find($request->get('id')); + } + if ($request->filled('name')) { + return Department::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the department'), + 'name' => $schema->string()->description('Name to identify the department'), + 'new_name' => $schema->string()->description('New name (renames the department)'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'manager_id' => $schema->number()->description('User ID of the department manager'), + 'phone' => $schema->string()->description('Department phone number'), + 'fax' => $schema->string()->description('Department fax number'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the department'), + 'name' => $schema->string()->description('Name of the department'), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 5e9dd8cf9cb2..c7630f633804 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -250,6 +250,16 @@ public function checkoutConsumables() return $this->appendPermission(['consumables.checkout' => '1']); } + public function createDepartments() + { + return $this->appendPermission(['departments.create' => '1']); + } + + public function editDepartments() + { + return $this->appendPermission(['departments.edit' => '1']); + } + public function deleteDepartments() { return $this->appendPermission(['departments.delete' => '1']); diff --git a/tests/Feature/Mcp/CreateDepartmentToolTest.php b/tests/Feature/Mcp/CreateDepartmentToolTest.php new file mode 100644 index 000000000000..dd32646a4348 --- /dev/null +++ b/tests/Feature/Mcp/CreateDepartmentToolTest.php @@ -0,0 +1,84 @@ +actingAs(User::factory()->createDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateDepartmentTool)->handle(new Request($args)); + } + + public function test_creates_department_with_required_fields() + { + $content = $this->handle(['name' => 'Test Department'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['name' => 'Test Department']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Department'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Department', $content['name']); + } + + public function test_creates_department_with_optional_fields() + { + $location = Location::factory()->create(); + $manager = User::factory()->create(); + + $this->handle([ + 'name' => 'Full Department', + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-1234', + 'fax' => '555-5678', + 'notes' => 'Department notes', + ]); + + $this->assertDatabaseHas('departments', [ + 'name' => 'Full Department', + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-1234', + 'fax' => '555-5678', + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_location_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Location Dept', + 'location_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Department'])->responses()->first()->isError()); + $this->assertDatabaseMissing('departments', ['name' => 'Blocked Department']); + } +} diff --git a/tests/Feature/Mcp/CreateLicenseToolTest.php b/tests/Feature/Mcp/CreateLicenseToolTest.php index 0a2f3266cd0a..2db0e9478239 100644 --- a/tests/Feature/Mcp/CreateLicenseToolTest.php +++ b/tests/Feature/Mcp/CreateLicenseToolTest.php @@ -4,7 +4,6 @@ use App\Mcp\Tools\CreateLicenseTool; use App\Models\Category; -use App\Models\License; use App\Models\LicenseSeat; use App\Models\User; use Laravel\Mcp\Request; diff --git a/tests/Feature/Mcp/DeleteDepartmentToolTest.php b/tests/Feature/Mcp/DeleteDepartmentToolTest.php new file mode 100644 index 000000000000..a192541b5fb0 --- /dev/null +++ b/tests/Feature/Mcp/DeleteDepartmentToolTest.php @@ -0,0 +1,78 @@ +actingAs(User::factory()->deleteDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteDepartmentTool)->handle(new Request($args)); + } + + public function test_deletes_department_by_id() + { + $department = Department::factory()->create(); + + $content = $this->handle(['id' => $department->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_deletes_department_by_name() + { + $department = Department::factory()->create(['name' => 'Delete By Name Dept']); + + $content = $this->handle(['name' => 'Delete By Name Dept'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_response_includes_name() + { + $department = Department::factory()->create(['name' => 'Named Department']); + + $content = $this->handle(['id' => $department->id])->getStructuredContent(); + + $this->assertEquals('Named Department', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_department_has_users() + { + $department = Department::factory()->create(); + User::factory()->create(['department_id' => $department->id]); + + $response = $this->handle(['id' => $department->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $department = Department::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $department->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('departments', ['id' => $department->id]); + } +} diff --git a/tests/Feature/Mcp/ShowLicenseToolTest.php b/tests/Feature/Mcp/ShowLicenseToolTest.php index b668ec24e047..8f2e5ff6a965 100644 --- a/tests/Feature/Mcp/ShowLicenseToolTest.php +++ b/tests/Feature/Mcp/ShowLicenseToolTest.php @@ -4,7 +4,6 @@ use App\Mcp\Tools\ShowLicenseTool; use App\Models\License; -use App\Models\LicenseSeat; use App\Models\User; use Laravel\Mcp\Request; use Laravel\Mcp\ResponseFactory; diff --git a/tests/Feature/Mcp/UpdateDepartmentToolTest.php b/tests/Feature/Mcp/UpdateDepartmentToolTest.php new file mode 100644 index 000000000000..c9dfca0b3234 --- /dev/null +++ b/tests/Feature/Mcp/UpdateDepartmentToolTest.php @@ -0,0 +1,110 @@ +actingAs(User::factory()->editDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateDepartmentTool)->handle(new Request($args)); + } + + public function test_updates_department_by_id() + { + $department = Department::factory()->create(['name' => 'Original Name']); + + $content = $this->handle([ + 'id' => $department->id, + 'new_name' => 'Updated Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Updated Name']); + } + + public function test_updates_department_by_name() + { + $department = Department::factory()->create(['name' => 'Find By Name Dept']); + + $content = $this->handle([ + 'name' => 'Find By Name Dept', + 'new_name' => 'Renamed Dept', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Renamed Dept']); + } + + public function test_response_includes_id_and_name() + { + $department = Department::factory()->create(); + + $content = $this->handle([ + 'id' => $department->id, + 'new_name' => 'Response Check Dept', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Dept', $content['name']); + } + + public function test_updates_multiple_fields() + { + $department = Department::factory()->create(); + $location = Location::factory()->create(); + $manager = User::factory()->create(); + + $this->handle([ + 'id' => $department->id, + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-9999', + 'fax' => '555-8888', + 'notes' => 'Updated notes', + ]); + + $this->assertDatabaseHas('departments', [ + 'id' => $department->id, + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-9999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle(['new_name' => 'No ID'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $department = Department::factory()->create(['name' => 'Unchanged Dept']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $department->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Unchanged Dept']); + } +} From 2542221fc998f3121c9d8c6dfb4ed03a31cfeb7c Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 11:45:16 +0100 Subject: [PATCH 23/45] Added tests --- .../Mcp/CheckoutConsumableToolTest.php | 86 ++++++++++++ .../Feature/Mcp/CreateAssetModelToolTest.php | 80 +++++++++++ tests/Feature/Mcp/CreateAssetToolTest.php | 107 +++++++++++++++ tests/Feature/Mcp/CreateCategoryToolTest.php | 87 ++++++++++++ tests/Feature/Mcp/CreateCompanyToolTest.php | 69 ++++++++++ .../Feature/Mcp/CreateConsumableToolTest.php | 126 ++++++++++++++++++ .../Mcp/CreateDepreciationToolTest.php | 62 +++++++++ tests/Feature/Mcp/CreateGroupToolTest.php | 60 +++++++++ tests/Feature/Mcp/CreateLocationToolTest.php | 69 ++++++++++ .../Feature/Mcp/CreateMaintenanceToolTest.php | 88 ++++++++++++ .../Mcp/CreateManufacturerToolTest.php | 52 ++++++++ .../Feature/Mcp/CreateStatusLabelToolTest.php | 76 +++++++++++ tests/Feature/Mcp/CreateSupplierToolTest.php | 52 ++++++++ .../Feature/Mcp/DeleteAssetModelToolTest.php | 70 ++++++++++ tests/Feature/Mcp/DeleteCategoryToolTest.php | 58 ++++++++ tests/Feature/Mcp/DeleteCompanyToolTest.php | 67 ++++++++++ .../Feature/Mcp/DeleteConsumableToolTest.php | 87 ++++++++++++ .../Mcp/DeleteDepreciationToolTest.php | 58 ++++++++ tests/Feature/Mcp/DeleteGroupToolTest.php | 55 ++++++++ tests/Feature/Mcp/DeleteLocationToolTest.php | 69 ++++++++++ .../Mcp/DeleteManufacturerToolTest.php | 58 ++++++++ .../Feature/Mcp/DeleteStatusLabelToolTest.php | 70 ++++++++++ tests/Feature/Mcp/DeleteSupplierToolTest.php | 58 ++++++++ tests/Feature/Mcp/GetActivityLogToolTest.php | 88 ++++++++++++ tests/Feature/Mcp/GetCurrentUserToolTest.php | 42 ++++++ tests/Feature/Mcp/GetUserAssetsToolTest.php | 60 +++++++++ tests/Feature/Mcp/ListAssetModelsToolTest.php | 71 ++++++++++ tests/Feature/Mcp/ListCategoriesToolTest.php | 69 ++++++++++ tests/Feature/Mcp/ListCompaniesToolTest.php | 65 +++++++++ tests/Feature/Mcp/ListConsumablesToolTest.php | 82 ++++++++++++ .../Feature/Mcp/ListDepreciationsToolTest.php | 55 ++++++++ tests/Feature/Mcp/ListGroupsToolTest.php | 54 ++++++++ tests/Feature/Mcp/ListLocationsToolTest.php | 67 ++++++++++ .../Feature/Mcp/ListMaintenancesToolTest.php | 60 +++++++++ .../Feature/Mcp/ListManufacturersToolTest.php | 63 +++++++++ .../Feature/Mcp/ListStatusLabelsToolTest.php | 54 ++++++++ tests/Feature/Mcp/ListSuppliersToolTest.php | 53 ++++++++ tests/Feature/Mcp/Reset2FAToolTest.php | 58 ++++++++ tests/Feature/Mcp/RestoreAssetToolTest.php | 61 +++++++++ tests/Feature/Mcp/RestoreUserToolTest.php | 60 +++++++++ tests/Feature/Mcp/ShowAssetModelToolTest.php | 62 +++++++++ tests/Feature/Mcp/ShowCategoryToolTest.php | 62 +++++++++ tests/Feature/Mcp/ShowCompanyToolTest.php | 62 +++++++++ tests/Feature/Mcp/ShowConsumableToolTest.php | 72 ++++++++++ .../Feature/Mcp/ShowDepreciationToolTest.php | 62 +++++++++ tests/Feature/Mcp/ShowGroupToolTest.php | 62 +++++++++ tests/Feature/Mcp/ShowLocationToolTest.php | 62 +++++++++ .../Feature/Mcp/ShowManufacturerToolTest.php | 61 +++++++++ tests/Feature/Mcp/ShowStatusLabelToolTest.php | 61 +++++++++ tests/Feature/Mcp/ShowSupplierToolTest.php | 61 +++++++++ .../Feature/Mcp/UpdateAssetModelToolTest.php | 70 ++++++++++ tests/Feature/Mcp/UpdateCategoryToolTest.php | 68 ++++++++++ tests/Feature/Mcp/UpdateCompanyToolTest.php | 86 ++++++++++++ .../Feature/Mcp/UpdateConsumableToolTest.php | 81 +++++++++++ .../Mcp/UpdateDepreciationToolTest.php | 83 ++++++++++++ tests/Feature/Mcp/UpdateGroupToolTest.php | 57 ++++++++ tests/Feature/Mcp/UpdateLocationToolTest.php | 70 ++++++++++ .../Mcp/UpdateManufacturerToolTest.php | 68 ++++++++++ .../Feature/Mcp/UpdateStatusLabelToolTest.php | 87 ++++++++++++ tests/Feature/Mcp/UpdateSupplierToolTest.php | 68 ++++++++++ 60 files changed, 4091 insertions(+) create mode 100644 tests/Feature/Mcp/CheckoutConsumableToolTest.php create mode 100644 tests/Feature/Mcp/CreateAssetModelToolTest.php create mode 100644 tests/Feature/Mcp/CreateAssetToolTest.php create mode 100644 tests/Feature/Mcp/CreateCategoryToolTest.php create mode 100644 tests/Feature/Mcp/CreateCompanyToolTest.php create mode 100644 tests/Feature/Mcp/CreateConsumableToolTest.php create mode 100644 tests/Feature/Mcp/CreateDepreciationToolTest.php create mode 100644 tests/Feature/Mcp/CreateGroupToolTest.php create mode 100644 tests/Feature/Mcp/CreateLocationToolTest.php create mode 100644 tests/Feature/Mcp/CreateMaintenanceToolTest.php create mode 100644 tests/Feature/Mcp/CreateManufacturerToolTest.php create mode 100644 tests/Feature/Mcp/CreateStatusLabelToolTest.php create mode 100644 tests/Feature/Mcp/CreateSupplierToolTest.php create mode 100644 tests/Feature/Mcp/DeleteAssetModelToolTest.php create mode 100644 tests/Feature/Mcp/DeleteCategoryToolTest.php create mode 100644 tests/Feature/Mcp/DeleteCompanyToolTest.php create mode 100644 tests/Feature/Mcp/DeleteConsumableToolTest.php create mode 100644 tests/Feature/Mcp/DeleteDepreciationToolTest.php create mode 100644 tests/Feature/Mcp/DeleteGroupToolTest.php create mode 100644 tests/Feature/Mcp/DeleteLocationToolTest.php create mode 100644 tests/Feature/Mcp/DeleteManufacturerToolTest.php create mode 100644 tests/Feature/Mcp/DeleteStatusLabelToolTest.php create mode 100644 tests/Feature/Mcp/DeleteSupplierToolTest.php create mode 100644 tests/Feature/Mcp/GetActivityLogToolTest.php create mode 100644 tests/Feature/Mcp/GetCurrentUserToolTest.php create mode 100644 tests/Feature/Mcp/GetUserAssetsToolTest.php create mode 100644 tests/Feature/Mcp/ListAssetModelsToolTest.php create mode 100644 tests/Feature/Mcp/ListCategoriesToolTest.php create mode 100644 tests/Feature/Mcp/ListCompaniesToolTest.php create mode 100644 tests/Feature/Mcp/ListConsumablesToolTest.php create mode 100644 tests/Feature/Mcp/ListDepreciationsToolTest.php create mode 100644 tests/Feature/Mcp/ListGroupsToolTest.php create mode 100644 tests/Feature/Mcp/ListLocationsToolTest.php create mode 100644 tests/Feature/Mcp/ListMaintenancesToolTest.php create mode 100644 tests/Feature/Mcp/ListManufacturersToolTest.php create mode 100644 tests/Feature/Mcp/ListStatusLabelsToolTest.php create mode 100644 tests/Feature/Mcp/ListSuppliersToolTest.php create mode 100644 tests/Feature/Mcp/Reset2FAToolTest.php create mode 100644 tests/Feature/Mcp/RestoreAssetToolTest.php create mode 100644 tests/Feature/Mcp/RestoreUserToolTest.php create mode 100644 tests/Feature/Mcp/ShowAssetModelToolTest.php create mode 100644 tests/Feature/Mcp/ShowCategoryToolTest.php create mode 100644 tests/Feature/Mcp/ShowCompanyToolTest.php create mode 100644 tests/Feature/Mcp/ShowConsumableToolTest.php create mode 100644 tests/Feature/Mcp/ShowDepreciationToolTest.php create mode 100644 tests/Feature/Mcp/ShowGroupToolTest.php create mode 100644 tests/Feature/Mcp/ShowLocationToolTest.php create mode 100644 tests/Feature/Mcp/ShowManufacturerToolTest.php create mode 100644 tests/Feature/Mcp/ShowStatusLabelToolTest.php create mode 100644 tests/Feature/Mcp/ShowSupplierToolTest.php create mode 100644 tests/Feature/Mcp/UpdateAssetModelToolTest.php create mode 100644 tests/Feature/Mcp/UpdateCategoryToolTest.php create mode 100644 tests/Feature/Mcp/UpdateCompanyToolTest.php create mode 100644 tests/Feature/Mcp/UpdateConsumableToolTest.php create mode 100644 tests/Feature/Mcp/UpdateDepreciationToolTest.php create mode 100644 tests/Feature/Mcp/UpdateGroupToolTest.php create mode 100644 tests/Feature/Mcp/UpdateLocationToolTest.php create mode 100644 tests/Feature/Mcp/UpdateManufacturerToolTest.php create mode 100644 tests/Feature/Mcp/UpdateStatusLabelToolTest.php create mode 100644 tests/Feature/Mcp/UpdateSupplierToolTest.php diff --git a/tests/Feature/Mcp/CheckoutConsumableToolTest.php b/tests/Feature/Mcp/CheckoutConsumableToolTest.php new file mode 100644 index 000000000000..f1c9213ef804 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutConsumableToolTest.php @@ -0,0 +1,86 @@ +actingAs(User::factory()->checkoutConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CheckoutConsumableTool)->handle(new Request($args)); + } + + public function test_checks_out_consumable_to_user() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables_users', [ + 'consumable_id' => $consumable->id, + 'assigned_to' => $user->id, + ]); + } + + public function test_returns_error_when_no_units_remaining() + { + $consumable = Consumable::factory()->withoutItemsRemaining()->create(); + $user = User::factory()->create(); + + $response = $this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_consumable_not_found() + { + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => 999999, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'assigned_to' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateAssetModelToolTest.php b/tests/Feature/Mcp/CreateAssetModelToolTest.php new file mode 100644 index 000000000000..ae5ee162b004 --- /dev/null +++ b/tests/Feature/Mcp/CreateAssetModelToolTest.php @@ -0,0 +1,80 @@ +actingAs(User::factory()->createAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateAssetModelTool)->handle(new Request($args)); + } + + public function test_creates_model_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $content = $this->handle([ + 'name' => 'Test Model', + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('models', [ + 'name' => 'Test Model', + 'category_id' => $category->id, + ]); + } + + public function test_response_includes_id_name_and_category() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $content = $this->handle([ + 'name' => 'Response Model', + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Model', $content['name']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $this->assertTrue($this->handle(['category_id' => $category->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle(['name' => 'No Category Model'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'asset']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Model', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('models', ['name' => 'Blocked Model']); + } +} diff --git a/tests/Feature/Mcp/CreateAssetToolTest.php b/tests/Feature/Mcp/CreateAssetToolTest.php new file mode 100644 index 000000000000..9890282c2a05 --- /dev/null +++ b/tests/Feature/Mcp/CreateAssetToolTest.php @@ -0,0 +1,107 @@ +actingAs(User::factory()->createAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateAssetTool)->handle(new Request($args)); + } + + public function test_creates_asset_with_required_fields() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $content = $this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TEST-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TEST-001', + ]); + } + + public function test_response_includes_id_and_asset_tag() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $content = $this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TAG-RESP-001', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('TAG-RESP-001', $content['asset_tag']); + } + + public function test_returns_error_when_model_id_missing() + { + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'status_id' => $status->id, + 'asset_tag' => 'TAG-NO-MODEL', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_status_id_missing() + { + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'asset_tag' => 'TAG-NO-STATUS', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_tag_missing() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TAG-BLOCKED', + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('assets', ['asset_tag' => 'TAG-BLOCKED']); + } +} diff --git a/tests/Feature/Mcp/CreateCategoryToolTest.php b/tests/Feature/Mcp/CreateCategoryToolTest.php new file mode 100644 index 000000000000..783a5f384ff2 --- /dev/null +++ b/tests/Feature/Mcp/CreateCategoryToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->createCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateCategoryTool)->handle(new Request($args)); + } + + public function test_creates_asset_category() + { + $content = $this->handle([ + 'name' => 'Test Asset Category', + 'category_type' => 'asset', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['name' => 'Test Asset Category', 'category_type' => 'asset']); + } + + public function test_creates_license_category() + { + $content = $this->handle([ + 'name' => 'Test License Category', + 'category_type' => 'license', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['name' => 'Test License Category', 'category_type' => 'license']); + } + + public function test_response_includes_id_name_and_type() + { + $content = $this->handle([ + 'name' => 'Response Category', + 'category_type' => 'accessory', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Category', $content['name']); + $this->assertEquals('accessory', $content['category_type']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['category_type' => 'asset'])->responses()->first()->isError()); + } + + public function test_returns_error_when_type_missing() + { + $this->assertTrue($this->handle(['name' => 'No Type Category'])->responses()->first()->isError()); + } + + public function test_returns_error_when_invalid_type() + { + $this->assertTrue($this->handle([ + 'name' => 'Invalid Type Category', + 'category_type' => 'invalid', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Category', + 'category_type' => 'asset', + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('categories', ['name' => 'Blocked Category']); + } +} diff --git a/tests/Feature/Mcp/CreateCompanyToolTest.php b/tests/Feature/Mcp/CreateCompanyToolTest.php new file mode 100644 index 000000000000..c448df246715 --- /dev/null +++ b/tests/Feature/Mcp/CreateCompanyToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->createCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateCompanyTool)->handle(new Request($args)); + } + + public function test_creates_company_with_required_fields() + { + $content = $this->handle(['name' => 'Test Company LLC'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['name' => 'Test Company LLC']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Company Inc'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Company Inc', $content['name']); + } + + public function test_creates_with_optional_fields() + { + $this->handle([ + 'name' => 'Full Company Corp', + 'phone' => '555-1234567', + 'fax' => '555-7654321', + 'email' => 'company@example.com', + ]); + + $this->assertDatabaseHas('companies', [ + 'name' => 'Full Company Corp', + 'phone' => '555-1234567', + 'fax' => '555-7654321', + 'email' => 'company@example.com', + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Company'])->responses()->first()->isError()); + $this->assertDatabaseMissing('companies', ['name' => 'Blocked Company']); + } +} diff --git a/tests/Feature/Mcp/CreateConsumableToolTest.php b/tests/Feature/Mcp/CreateConsumableToolTest.php new file mode 100644 index 000000000000..48ae352048cd --- /dev/null +++ b/tests/Feature/Mcp/CreateConsumableToolTest.php @@ -0,0 +1,126 @@ +actingAs(User::factory()->createConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateConsumableTool)->handle(new Request($args)); + } + + public function test_creates_consumable_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $content = $this->handle([ + 'name' => 'Test Toner Cartridge', + 'qty' => 10, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables', [ + 'name' => 'Test Toner Cartridge', + 'qty' => 10, + 'category_id' => $category->id, + ]); + } + + public function test_response_includes_id_name_and_qty() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $content = $this->handle([ + 'name' => 'Response Check Consumable', + 'qty' => 5, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Consumable', $content['name']); + $this->assertEquals(5, $content['qty']); + } + + public function test_creates_with_optional_fields() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Consumable', + 'qty' => 20, + 'category_id' => $category->id, + 'location_id' => $location->id, + 'order_number' => 'ORD-CONS-001', + 'purchase_cost' => 9.99, + 'purchase_date' => '2024-06-01', + 'min_amt' => 5, + 'notes' => 'Test consumable notes', + ]); + + $this->assertDatabaseHas('consumables', [ + 'name' => 'Full Consumable', + 'qty' => 20, + 'location_id' => $location->id, + 'order_number' => 'ORD-CONS-001', + 'min_amt' => 5, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'qty' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_qty_missing() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'name' => 'No Qty Consumable', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category Consumable', + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Consumable', + 'qty' => 1, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('consumables', ['name' => 'Blocked Consumable']); + } +} diff --git a/tests/Feature/Mcp/CreateDepreciationToolTest.php b/tests/Feature/Mcp/CreateDepreciationToolTest.php new file mode 100644 index 000000000000..456b15948a40 --- /dev/null +++ b/tests/Feature/Mcp/CreateDepreciationToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->createDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateDepreciationTool)->handle(new Request($args)); + } + + public function test_creates_depreciation() + { + $content = $this->handle(['name' => 'Test Depreciation', 'months' => 36])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('depreciations', [ + 'name' => 'Test Depreciation', + 'months' => 36, + ]); + } + + public function test_response_includes_id_name_and_months() + { + $content = $this->handle(['name' => 'Response Depreciation', 'months' => 24])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Depreciation', $content['name']); + $this->assertEquals(24, $content['months']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['months' => 36])->responses()->first()->isError()); + } + + public function test_returns_error_when_months_missing() + { + $this->assertTrue($this->handle(['name' => 'No Months'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Depreciation', 'months' => 12])->responses()->first()->isError()); + $this->assertDatabaseMissing('depreciations', ['name' => 'Blocked Depreciation']); + } +} diff --git a/tests/Feature/Mcp/CreateGroupToolTest.php b/tests/Feature/Mcp/CreateGroupToolTest.php new file mode 100644 index 000000000000..e5b8258f9676 --- /dev/null +++ b/tests/Feature/Mcp/CreateGroupToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateGroupTool)->handle(new Request($args)); + } + + public function test_creates_group() + { + $name = 'Test MCP Group ' . uniqid(); + + $this->handle(['name' => $name]); + + $this->assertDatabaseHas('permission_groups', ['name' => $name]); + } + + public function test_response_includes_id_and_name() + { + $name = 'Test MCP Group ' . uniqid(); + + $content = $this->handle(['name' => $name])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('name', $content); + $this->assertEquals($name, $content['name']); + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $name = 'Unauthorized Group ' . uniqid(); + + $this->handle(['name' => $name]); + + $this->assertDatabaseMissing('permission_groups', ['name' => $name]); + } +} diff --git a/tests/Feature/Mcp/CreateLocationToolTest.php b/tests/Feature/Mcp/CreateLocationToolTest.php new file mode 100644 index 000000000000..56fb9991b298 --- /dev/null +++ b/tests/Feature/Mcp/CreateLocationToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->createLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateLocationTool)->handle(new Request($args)); + } + + public function test_creates_location_with_required_fields() + { + $content = $this->handle(['name' => 'Test Location'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', ['name' => 'Test Location']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Location With ID'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Location With ID', $content['name']); + } + + public function test_creates_child_location() + { + $parent = Location::factory()->create(); + + $content = $this->handle([ + 'name' => 'Child Location', + 'parent_id' => $parent->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', [ + 'name' => 'Child Location', + 'parent_id' => $parent->id, + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Location'])->responses()->first()->isError()); + $this->assertDatabaseMissing('locations', ['name' => 'Blocked Location']); + } +} diff --git a/tests/Feature/Mcp/CreateMaintenanceToolTest.php b/tests/Feature/Mcp/CreateMaintenanceToolTest.php new file mode 100644 index 000000000000..06a4e0b25832 --- /dev/null +++ b/tests/Feature/Mcp/CreateMaintenanceToolTest.php @@ -0,0 +1,88 @@ +actingAs(User::factory()->editAssets()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateMaintenanceTool)->handle(new Request($args)); + } + + public function test_creates_maintenance_for_asset() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Test Maintenance', + 'start_date' => '2024-01-01', + ]); + + $this->assertDatabaseHas('maintenances', [ + 'asset_id' => $asset->id, + 'name' => 'Test Maintenance', + ]); + } + + public function test_response_includes_id_title_and_asset_id() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $content = $this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Test Maintenance Response', + 'start_date' => '2024-01-01', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('title', $content); + $this->assertArrayHasKey('asset_id', $content); + $this->assertEquals($asset->id, $content['asset_id']); + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_id_missing() + { + $this->assertTrue($this->handle(['title' => 'Test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_title_missing() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->assertTrue($this->handle(['asset_id' => $asset->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle([ + 'asset_id' => 999999, + 'title' => 'Test', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->assertTrue($this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Unauthorized Maintenance', + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateManufacturerToolTest.php b/tests/Feature/Mcp/CreateManufacturerToolTest.php new file mode 100644 index 000000000000..606b77082994 --- /dev/null +++ b/tests/Feature/Mcp/CreateManufacturerToolTest.php @@ -0,0 +1,52 @@ +actingAs(User::factory()->createManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateManufacturerTool)->handle(new Request($args)); + } + + public function test_creates_manufacturer_with_required_fields() + { + $content = $this->handle(['name' => 'Test Manufacturer'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('manufacturers', ['name' => 'Test Manufacturer']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Manufacturer'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Manufacturer', $content['name']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Manufacturer'])->responses()->first()->isError()); + $this->assertDatabaseMissing('manufacturers', ['name' => 'Blocked Manufacturer']); + } +} diff --git a/tests/Feature/Mcp/CreateStatusLabelToolTest.php b/tests/Feature/Mcp/CreateStatusLabelToolTest.php new file mode 100644 index 000000000000..259a1fd2c1a8 --- /dev/null +++ b/tests/Feature/Mcp/CreateStatusLabelToolTest.php @@ -0,0 +1,76 @@ +actingAs(User::factory()->createStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateStatusLabelTool)->handle(new Request($args)); + } + + public function test_creates_deployable_status_label() + { + $content = $this->handle(['name' => 'Test Deployable', 'type' => 'deployable'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', [ + 'name' => 'Test Deployable', + 'deployable' => 1, + 'pending' => 0, + 'archived' => 0, + ]); + } + + public function test_creates_pending_status_label() + { + $content = $this->handle(['name' => 'Test Pending', 'type' => 'pending'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', [ + 'name' => 'Test Pending', + 'deployable' => 0, + 'pending' => 1, + 'archived' => 0, + ]); + } + + public function test_response_includes_id_name_and_type() + { + $content = $this->handle(['name' => 'Response Status Label', 'type' => 'deployable'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Status Label', $content['name']); + $this->assertEquals('deployable', $content['type']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['type' => 'deployable'])->responses()->first()->isError()); + } + + public function test_returns_error_when_type_missing() + { + $this->assertTrue($this->handle(['name' => 'No Type Label'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Label', 'type' => 'deployable'])->responses()->first()->isError()); + $this->assertDatabaseMissing('status_labels', ['name' => 'Blocked Label']); + } +} diff --git a/tests/Feature/Mcp/CreateSupplierToolTest.php b/tests/Feature/Mcp/CreateSupplierToolTest.php new file mode 100644 index 000000000000..7f0626cfc437 --- /dev/null +++ b/tests/Feature/Mcp/CreateSupplierToolTest.php @@ -0,0 +1,52 @@ +actingAs(User::factory()->createSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateSupplierTool)->handle(new Request($args)); + } + + public function test_creates_supplier_with_required_fields() + { + $content = $this->handle(['name' => 'Test Supplier'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('suppliers', ['name' => 'Test Supplier']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Supplier'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Supplier', $content['name']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Supplier'])->responses()->first()->isError()); + $this->assertDatabaseMissing('suppliers', ['name' => 'Blocked Supplier']); + } +} diff --git a/tests/Feature/Mcp/DeleteAssetModelToolTest.php b/tests/Feature/Mcp/DeleteAssetModelToolTest.php new file mode 100644 index 000000000000..0cfe151497a6 --- /dev/null +++ b/tests/Feature/Mcp/DeleteAssetModelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->deleteAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteAssetModelTool)->handle(new Request($args)); + } + + public function test_deletes_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['id' => $model->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('models', ['id' => $model->id]); + } + + public function test_deletes_model_by_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['name' => $model->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('models', ['id' => $model->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_model_has_assets() + { + $model = AssetModel::factory()->create(); + Asset::factory()->create(['model_id' => $model->id]); + + $response = $this->handle(['id' => $model->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('models', ['id' => $model->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('models', ['id' => $model->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteCategoryToolTest.php b/tests/Feature/Mcp/DeleteCategoryToolTest.php new file mode 100644 index 000000000000..886fbef2f03d --- /dev/null +++ b/tests/Feature/Mcp/DeleteCategoryToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteCategoryTool)->handle(new Request($args)); + } + + public function test_deletes_category_by_id() + { + $category = Category::factory()->create(); + + $content = $this->handle(['id' => $category->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('categories', ['id' => $category->id]); + } + + public function test_deletes_category_by_name() + { + $category = Category::factory()->create(['name' => 'Delete By Name Cat']); + + $content = $this->handle(['name' => 'Delete By Name Cat'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('categories', ['id' => $category->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $category->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('categories', ['id' => $category->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteCompanyToolTest.php b/tests/Feature/Mcp/DeleteCompanyToolTest.php new file mode 100644 index 000000000000..4e06f393416f --- /dev/null +++ b/tests/Feature/Mcp/DeleteCompanyToolTest.php @@ -0,0 +1,67 @@ +actingAs(User::factory()->deleteCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteCompanyTool)->handle(new Request($args)); + } + + public function test_deletes_company_by_id() + { + $company = Company::factory()->create(); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('companies', ['id' => $company->id]); + } + + public function test_deletes_company_by_name() + { + $company = Company::factory()->create(['name' => 'Delete By Name Corp']); + + $content = $this->handle(['name' => 'Delete By Name Corp'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('companies', ['id' => $company->id]); + } + + public function test_response_includes_name() + { + $company = Company::factory()->create(['name' => 'Named Company']); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertEquals('Named Company', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $company->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('companies', ['id' => $company->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteConsumableToolTest.php b/tests/Feature/Mcp/DeleteConsumableToolTest.php new file mode 100644 index 000000000000..387782aa4774 --- /dev/null +++ b/tests/Feature/Mcp/DeleteConsumableToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->deleteConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteConsumableTool)->handle(new Request($args)); + } + + public function test_deletes_consumable_by_id() + { + $consumable = Consumable::factory()->create(); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_deletes_consumable_by_name() + { + $consumable = Consumable::factory()->create(['name' => 'Delete By Name Consumable']); + + $content = $this->handle(['name' => 'Delete By Name Consumable'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_response_includes_name() + { + $consumable = Consumable::factory()->create(['name' => 'Named Delete Consumable']); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals('Named Delete Consumable', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_consumable_has_checkouts() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + DB::table('consumables_users')->insert([ + 'consumable_id' => $consumable->id, + 'assigned_to' => User::factory()->create()->id, + 'created_at' => now(), + 'updated_at' => now(), + 'note' => null, + 'created_by' => auth()->id(), + ]); + + $response = $this->handle(['id' => $consumable->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(); + + $this->assertTrue($this->handle(['id' => $consumable->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('consumables', ['id' => $consumable->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteDepreciationToolTest.php b/tests/Feature/Mcp/DeleteDepreciationToolTest.php new file mode 100644 index 000000000000..f0033dfbaaf0 --- /dev/null +++ b/tests/Feature/Mcp/DeleteDepreciationToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteDepreciationTool)->handle(new Request($args)); + } + + public function test_deletes_depreciation_by_id() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['id' => $dep->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('depreciations', ['id' => $dep->id]); + } + + public function test_deletes_depreciation_by_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['name' => $dep->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('depreciations', ['id' => $dep->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id])->responses()->first()->isError()); + $this->assertDatabaseHas('depreciations', ['id' => $dep->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteGroupToolTest.php b/tests/Feature/Mcp/DeleteGroupToolTest.php new file mode 100644 index 000000000000..d7c1c8e82d0a --- /dev/null +++ b/tests/Feature/Mcp/DeleteGroupToolTest.php @@ -0,0 +1,55 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteGroupTool)->handle(new Request($args)); + } + + public function test_deletes_group_by_id() + { + $group = Group::factory()->create(); + + $this->handle(['id' => $group->id]); + + $this->assertDatabaseMissing('permission_groups', ['id' => $group->id]); + } + + public function test_deletes_group_by_name() + { + $group = Group::factory()->create(); + + $this->handle(['name' => $group->name]); + + $this->assertDatabaseMissing('permission_groups', ['id' => $group->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/DeleteLocationToolTest.php b/tests/Feature/Mcp/DeleteLocationToolTest.php new file mode 100644 index 000000000000..1722a6cf47f5 --- /dev/null +++ b/tests/Feature/Mcp/DeleteLocationToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->deleteLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteLocationTool)->handle(new Request($args)); + } + + public function test_deletes_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle(['id' => $location->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_deletes_location_by_name() + { + $location = Location::factory()->create(); + + $content = $this->handle(['name' => $location->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_location_has_users() + { + $location = Location::factory()->create(); + User::factory()->create(['location_id' => $location->id]); + + $response = $this->handle(['id' => $location->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('locations', ['id' => $location->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteManufacturerToolTest.php b/tests/Feature/Mcp/DeleteManufacturerToolTest.php new file mode 100644 index 000000000000..593d5d7c5f6e --- /dev/null +++ b/tests/Feature/Mcp/DeleteManufacturerToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteManufacturerTool)->handle(new Request($args)); + } + + public function test_deletes_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(); + + $content = $this->handle(['id' => $manufacturer->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } + + public function test_deletes_manufacturer_by_name() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Delete By Name Manufacturer']); + + $content = $this->handle(['name' => 'Delete By Name Manufacturer'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $manufacturer->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteStatusLabelToolTest.php b/tests/Feature/Mcp/DeleteStatusLabelToolTest.php new file mode 100644 index 000000000000..64ed0891cd2f --- /dev/null +++ b/tests/Feature/Mcp/DeleteStatusLabelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->deleteStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteStatusLabelTool)->handle(new Request($args)); + } + + public function test_deletes_status_label_by_id() + { + $label = Statuslabel::factory()->create(); + + $content = $this->handle(['id' => $label->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_deletes_status_label_by_name() + { + $label = Statuslabel::factory()->create(['name' => 'Delete By Name Status Label']); + + $content = $this->handle(['name' => 'Delete By Name Status Label'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_status_label_has_assets() + { + $label = Statuslabel::factory()->rtd()->create(); + Asset::factory()->create(['status_id' => $label->id]); + + $response = $this->handle(['id' => $label->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $label->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('status_labels', ['id' => $label->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteSupplierToolTest.php b/tests/Feature/Mcp/DeleteSupplierToolTest.php new file mode 100644 index 000000000000..aca7b5d4c46d --- /dev/null +++ b/tests/Feature/Mcp/DeleteSupplierToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteSupplierTool)->handle(new Request($args)); + } + + public function test_deletes_supplier_by_id() + { + $supplier = Supplier::factory()->create(); + + $content = $this->handle(['id' => $supplier->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('suppliers', ['id' => $supplier->id]); + } + + public function test_deletes_supplier_by_name() + { + $supplier = Supplier::factory()->create(['name' => 'Delete By Name Supplier']); + + $content = $this->handle(['name' => 'Delete By Name Supplier'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('suppliers', ['id' => $supplier->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $supplier->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('suppliers', ['id' => $supplier->id]); + } +} diff --git a/tests/Feature/Mcp/GetActivityLogToolTest.php b/tests/Feature/Mcp/GetActivityLogToolTest.php new file mode 100644 index 000000000000..536e2544ce62 --- /dev/null +++ b/tests/Feature/Mcp/GetActivityLogToolTest.php @@ -0,0 +1,88 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new GetActivityLogTool)->handle(new Request($args)); + } + + public function test_returns_activity_log() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'update'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('activity', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(1, $content['total']); + } + + public function test_filters_by_action_type() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'checkout'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle(['action_type' => 'checkout'])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['activity'] as $entry) { + $this->assertEquals('checkout', $entry['action_type']); + } + } + + public function test_filters_by_item_id() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'update'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle([ + 'item_id' => $asset->id, + 'item_type' => Asset::class, + ])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['activity'] as $entry) { + $this->assertEquals($asset->id, $entry['item_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/GetCurrentUserToolTest.php b/tests/Feature/Mcp/GetCurrentUserToolTest.php new file mode 100644 index 000000000000..7120e4c8ad34 --- /dev/null +++ b/tests/Feature/Mcp/GetCurrentUserToolTest.php @@ -0,0 +1,42 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new GetCurrentUserTool)->handle(new Request($args)); + } + + public function test_returns_current_user_info() + { + $user = auth()->user(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + public function test_response_includes_expected_fields() + { + $content = $this->handle()->getStructuredContent(); + + foreach (['id', 'username', 'first_name', 'last_name', 'email', 'company', 'department', 'location', 'employee_num', 'title', 'phone', 'activated'] as $field) { + $this->assertArrayHasKey($field, $content); + } + } +} diff --git a/tests/Feature/Mcp/GetUserAssetsToolTest.php b/tests/Feature/Mcp/GetUserAssetsToolTest.php new file mode 100644 index 000000000000..900cb94437be --- /dev/null +++ b/tests/Feature/Mcp/GetUserAssetsToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->viewUsers()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new GetUserAssetsTool)->handle(new Request($args)); + } + + public function test_returns_assets_for_user() + { + $user = User::factory()->create(); + Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals($user->id, $content['user_id']); + $this->assertEquals(1, $content['total']); + $this->assertCount(1, $content['assets']); + } + + public function test_returns_empty_array_when_user_has_no_assets() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertCount(0, $content['assets']); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListAssetModelsToolTest.php b/tests/Feature/Mcp/ListAssetModelsToolTest.php new file mode 100644 index 000000000000..7ce33fe0e0ed --- /dev/null +++ b/tests/Feature/Mcp/ListAssetModelsToolTest.php @@ -0,0 +1,71 @@ +actingAs(User::factory()->viewAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListAssetModelsTool)->handle(new Request($args)); + } + + public function test_returns_models_with_pagination() + { + AssetModel::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('models', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_model_has_key_fields() + { + AssetModel::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['models']); + $first = $content['models'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + $this->assertArrayHasKey('category', $first); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'asset']); + $model = AssetModel::factory()->create(['category_id' => $category->id]); + AssetModel::factory()->create(); // another model with different category + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + + $ids = array_column($content['models'], 'id'); + $this->assertContains($model->id, $ids); + foreach ($ids as $id) { + $this->assertEquals($category->id, collect($content['models'])->firstWhere('id', $id)['category_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListCategoriesToolTest.php b/tests/Feature/Mcp/ListCategoriesToolTest.php new file mode 100644 index 000000000000..2e6b89a1fc64 --- /dev/null +++ b/tests/Feature/Mcp/ListCategoriesToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->viewCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListCategoriesTool)->handle(new Request($args)); + } + + public function test_returns_categories_with_pagination_metadata() + { + Category::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('categories', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_category_includes_key_fields() + { + Category::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['categories']); + $category = $content['categories'][0]; + $this->assertArrayHasKey('id', $category); + $this->assertArrayHasKey('name', $category); + $this->assertArrayHasKey('category_type', $category); + } + + public function test_filters_by_category_type() + { + $assetCategory = Category::factory()->create(['category_type' => 'asset', 'name' => 'Asset Type Cat '.uniqid()]); + $licenseCategory = Category::factory()->create(['category_type' => 'license', 'name' => 'License Type Cat '.uniqid()]); + + $content = $this->handle(['category_type' => 'asset'])->getStructuredContent(); + + $ids = array_column($content['categories'], 'id'); + $this->assertContains($assetCategory->id, $ids); + $this->assertNotContains($licenseCategory->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListCompaniesToolTest.php b/tests/Feature/Mcp/ListCompaniesToolTest.php new file mode 100644 index 000000000000..6bccce1719e0 --- /dev/null +++ b/tests/Feature/Mcp/ListCompaniesToolTest.php @@ -0,0 +1,65 @@ +actingAs(User::factory()->viewCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListCompaniesTool)->handle(new Request($args)); + } + + public function test_returns_total_and_pagination_metadata() + { + Company::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertGreaterThanOrEqual(3, $content['total']); + $this->assertArrayHasKey('companies', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + } + + public function test_each_company_includes_key_fields() + { + Company::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['companies']); + $company = $content['companies'][0]; + $this->assertArrayHasKey('id', $company); + $this->assertArrayHasKey('name', $company); + } + + public function test_limit_controls_results() + { + Company::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['companies']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListConsumablesToolTest.php b/tests/Feature/Mcp/ListConsumablesToolTest.php new file mode 100644 index 000000000000..87085ffb526b --- /dev/null +++ b/tests/Feature/Mcp/ListConsumablesToolTest.php @@ -0,0 +1,82 @@ +actingAs(User::factory()->viewConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListConsumablesTool)->handle(new Request($args)); + } + + public function test_returns_total_and_pagination_metadata() + { + Consumable::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('consumables', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_consumable_includes_key_fields() + { + Consumable::factory()->create(['name' => 'Test Ink Cartridge']); + + $content = $this->handle(['limit' => 500])->getStructuredContent(); + + $consumable = collect($content['consumables'])->firstWhere('name', 'Test Ink Cartridge'); + + $this->assertNotNull($consumable); + $this->assertArrayHasKey('id', $consumable); + $this->assertArrayHasKey('name', $consumable); + $this->assertArrayHasKey('qty', $consumable); + $this->assertArrayHasKey('category', $consumable); + } + + public function test_limit_controls_results() + { + Consumable::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['consumables']); + $this->assertEquals(3, $content['limit']); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + Consumable::factory()->create(['name' => 'Category Match', 'category_id' => $category->id]); + Consumable::factory()->create(['name' => 'Other Consumable']); + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('Category Match', $content['consumables'][0]['name']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListDepreciationsToolTest.php b/tests/Feature/Mcp/ListDepreciationsToolTest.php new file mode 100644 index 000000000000..683c0f7b74bb --- /dev/null +++ b/tests/Feature/Mcp/ListDepreciationsToolTest.php @@ -0,0 +1,55 @@ +actingAs(User::factory()->viewDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListDepreciationsTool)->handle(new Request($args)); + } + + public function test_returns_depreciations_with_pagination() + { + Depreciation::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('depreciations', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_depreciation_has_key_fields() + { + Depreciation::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['depreciations']); + $first = $content['depreciations'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + $this->assertArrayHasKey('months', $first); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListGroupsToolTest.php b/tests/Feature/Mcp/ListGroupsToolTest.php new file mode 100644 index 000000000000..9fcfbd2b6b4d --- /dev/null +++ b/tests/Feature/Mcp/ListGroupsToolTest.php @@ -0,0 +1,54 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListGroupsTool)->handle(new Request($args)); + } + + public function test_returns_groups_with_pagination() + { + Group::factory()->count(2)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('groups', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(2, $content['total']); + } + + public function test_each_group_has_key_fields() + { + Group::factory()->create(); + + $content = $this->handle(['limit' => 1])->getStructuredContent(); + + $this->assertNotEmpty($content['groups']); + $group = $content['groups'][0]; + $this->assertArrayHasKey('id', $group); + $this->assertArrayHasKey('name', $group); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListLocationsToolTest.php b/tests/Feature/Mcp/ListLocationsToolTest.php new file mode 100644 index 000000000000..d7ef0d9af19c --- /dev/null +++ b/tests/Feature/Mcp/ListLocationsToolTest.php @@ -0,0 +1,67 @@ +actingAs(User::factory()->viewLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListLocationsTool)->handle(new Request($args)); + } + + public function test_returns_locations_with_pagination() + { + Location::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('locations', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_location_has_key_fields() + { + Location::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['locations']); + $first = $content['locations'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + } + + public function test_filters_by_parent_id() + { + $parent = Location::factory()->create(); + $child = Location::factory()->create(['parent_id' => $parent->id]); + Location::factory()->create(); // another location without parent + + $content = $this->handle(['parent_id' => $parent->id])->getStructuredContent(); + + $ids = array_column($content['locations'], 'id'); + $this->assertContains($child->id, $ids); + $this->assertNotContains($parent->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListMaintenancesToolTest.php b/tests/Feature/Mcp/ListMaintenancesToolTest.php new file mode 100644 index 000000000000..93608135dc7c --- /dev/null +++ b/tests/Feature/Mcp/ListMaintenancesToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListMaintenancesTool)->handle(new Request($args)); + } + + public function test_returns_maintenances_with_pagination() + { + $asset = Asset::factory()->laptopZenbook()->create(); + Maintenance::factory()->create(['asset_id' => $asset->id]); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('maintenances', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(1, $content['total']); + } + + public function test_filters_by_asset_id() + { + $asset1 = Asset::factory()->laptopZenbook()->create(); + $asset2 = Asset::factory()->laptopZenbook()->create(); + + $maintenance1 = Maintenance::factory()->create(['asset_id' => $asset1->id]); + Maintenance::factory()->create(['asset_id' => $asset2->id]); + + $content = $this->handle(['asset_id' => $asset1->id])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['maintenances'] as $m) { + $this->assertEquals($asset1->id, $m['asset_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListManufacturersToolTest.php b/tests/Feature/Mcp/ListManufacturersToolTest.php new file mode 100644 index 000000000000..2ae42cff304f --- /dev/null +++ b/tests/Feature/Mcp/ListManufacturersToolTest.php @@ -0,0 +1,63 @@ +actingAs(User::factory()->viewManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListManufacturersTool)->handle(new Request($args)); + } + + public function test_returns_manufacturers_with_pagination() + { + Manufacturer::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('manufacturers', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_manufacturer_has_key_fields() + { + Manufacturer::factory()->create(['name' => 'Key Fields Manufacturer']); + + $content = $this->handle(['search' => 'Key Fields Manufacturer'])->getStructuredContent(); + + $this->assertNotEmpty($content['manufacturers']); + $manufacturer = $content['manufacturers'][0]; + $this->assertArrayHasKey('id', $manufacturer); + $this->assertArrayHasKey('name', $manufacturer); + } + + public function test_limit_controls_results() + { + Manufacturer::factory()->count(5)->create(); + + $content = $this->handle(['limit' => 2])->getStructuredContent(); + + $this->assertCount(2, $content['manufacturers']); + $this->assertEquals(2, $content['limit']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListStatusLabelsToolTest.php b/tests/Feature/Mcp/ListStatusLabelsToolTest.php new file mode 100644 index 000000000000..3c6d3a83b528 --- /dev/null +++ b/tests/Feature/Mcp/ListStatusLabelsToolTest.php @@ -0,0 +1,54 @@ +actingAs(User::factory()->viewStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListStatusLabelsTool)->handle(new Request($args)); + } + + public function test_returns_status_labels_with_pagination() + { + Statuslabel::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('status_labels', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_status_label_has_key_fields() + { + Statuslabel::factory()->create(['name' => 'Key Fields Status Label']); + + $content = $this->handle(['search' => 'Key Fields Status Label'])->getStructuredContent(); + + $this->assertNotEmpty($content['status_labels']); + $label = $content['status_labels'][0]; + $this->assertArrayHasKey('id', $label); + $this->assertArrayHasKey('name', $label); + $this->assertArrayHasKey('type', $label); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListSuppliersToolTest.php b/tests/Feature/Mcp/ListSuppliersToolTest.php new file mode 100644 index 000000000000..9c4373e4e361 --- /dev/null +++ b/tests/Feature/Mcp/ListSuppliersToolTest.php @@ -0,0 +1,53 @@ +actingAs(User::factory()->viewSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListSuppliersTool)->handle(new Request($args)); + } + + public function test_returns_suppliers_with_pagination() + { + Supplier::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('suppliers', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_supplier_has_key_fields() + { + Supplier::factory()->create(['name' => 'Key Fields Supplier']); + + $content = $this->handle(['search' => 'Key Fields Supplier'])->getStructuredContent(); + + $this->assertNotEmpty($content['suppliers']); + $supplier = $content['suppliers'][0]; + $this->assertArrayHasKey('id', $supplier); + $this->assertArrayHasKey('name', $supplier); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/Reset2FAToolTest.php b/tests/Feature/Mcp/Reset2FAToolTest.php new file mode 100644 index 000000000000..e7cb67ead2a9 --- /dev/null +++ b/tests/Feature/Mcp/Reset2FAToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->editUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new Reset2FATool)->handle(new Request($args)); + } + + public function test_resets_two_factor_for_user() + { + $user = User::factory()->create([ + 'two_factor_enrolled' => 1, + 'two_factor_optin' => 1, + 'two_factor_secret' => 'somesecret', + ]); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + + $user->refresh(); + $this->assertEquals(0, $user->two_factor_enrolled); + $this->assertEquals(0, $user->two_factor_optin); + $this->assertNull($user->two_factor_secret); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(['two_factor_enrolled' => 1]); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + + $user->refresh(); + $this->assertEquals(1, $user->two_factor_enrolled); + } +} diff --git a/tests/Feature/Mcp/RestoreAssetToolTest.php b/tests/Feature/Mcp/RestoreAssetToolTest.php new file mode 100644 index 000000000000..650d1f4ba4f0 --- /dev/null +++ b/tests/Feature/Mcp/RestoreAssetToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new RestoreAssetTool)->handle(new Request($args)); + } + + public function test_restores_soft_deleted_asset() + { + $asset = Asset::factory()->create(); + $asset->delete(); + + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_deleted() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'deleted_at' => null]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $asset = Asset::factory()->create(); + $asset->delete(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } +} diff --git a/tests/Feature/Mcp/RestoreUserToolTest.php b/tests/Feature/Mcp/RestoreUserToolTest.php new file mode 100644 index 000000000000..6b7c4ea24ced --- /dev/null +++ b/tests/Feature/Mcp/RestoreUserToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new RestoreUserTool)->handle(new Request($args)); + } + + public function test_restores_soft_deleted_user() + { + $user = User::factory()->create(); + $user->delete(); + + $this->assertSoftDeleted('users', ['id' => $user->id]); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_deleted() + { + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(); + $user->delete(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } +} diff --git a/tests/Feature/Mcp/ShowAssetModelToolTest.php b/tests/Feature/Mcp/ShowAssetModelToolTest.php new file mode 100644 index 000000000000..5a0f87431712 --- /dev/null +++ b/tests/Feature/Mcp/ShowAssetModelToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowAssetModelTool)->handle(new Request($args)); + } + + public function test_returns_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['id' => $model->id])->getStructuredContent(); + + $this->assertEquals($model->id, $content['id']); + $this->assertEquals($model->name, $content['name']); + } + + public function test_returns_model_by_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['name' => $model->name])->getStructuredContent(); + + $this->assertEquals($model->id, $content['id']); + $this->assertEquals($model->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowCategoryToolTest.php b/tests/Feature/Mcp/ShowCategoryToolTest.php new file mode 100644 index 000000000000..ad9a59f03096 --- /dev/null +++ b/tests/Feature/Mcp/ShowCategoryToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowCategoryTool)->handle(new Request($args)); + } + + public function test_returns_category_by_id() + { + $category = Category::factory()->create(['name' => 'Lookup By ID Cat']); + + $content = $this->handle(['id' => $category->id])->getStructuredContent(); + + $this->assertEquals($category->id, $content['id']); + $this->assertEquals('Lookup By ID Cat', $content['name']); + } + + public function test_returns_category_by_name() + { + $category = Category::factory()->create(['name' => 'Lookup By Name Cat']); + + $content = $this->handle(['name' => 'Lookup By Name Cat'])->getStructuredContent(); + + $this->assertEquals($category->id, $content['id']); + $this->assertEquals('Lookup By Name Cat', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $category->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowCompanyToolTest.php b/tests/Feature/Mcp/ShowCompanyToolTest.php new file mode 100644 index 000000000000..de76c2c11ecc --- /dev/null +++ b/tests/Feature/Mcp/ShowCompanyToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowCompanyTool)->handle(new Request($args)); + } + + public function test_returns_company_by_id() + { + $company = Company::factory()->create(['name' => 'Lookup By ID Corp']); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertEquals($company->id, $content['id']); + $this->assertEquals('Lookup By ID Corp', $content['name']); + } + + public function test_returns_company_by_name() + { + $company = Company::factory()->create(['name' => 'Lookup By Name Corp']); + + $content = $this->handle(['name' => 'Lookup By Name Corp'])->getStructuredContent(); + + $this->assertEquals($company->id, $content['id']); + $this->assertEquals('Lookup By Name Corp', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $company->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowConsumableToolTest.php b/tests/Feature/Mcp/ShowConsumableToolTest.php new file mode 100644 index 000000000000..00c5b8868a17 --- /dev/null +++ b/tests/Feature/Mcp/ShowConsumableToolTest.php @@ -0,0 +1,72 @@ +actingAs(User::factory()->viewConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowConsumableTool)->handle(new Request($args)); + } + + public function test_returns_consumable_by_id() + { + $consumable = Consumable::factory()->create(['name' => 'Find By ID Consumable']); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals($consumable->id, $content['id']); + $this->assertEquals('Find By ID Consumable', $content['name']); + } + + public function test_returns_consumable_by_name() + { + Consumable::factory()->create(['name' => 'Find By Name Consumable']); + + $content = $this->handle(['name' => 'Find By Name Consumable'])->getStructuredContent(); + + $this->assertEquals('Find By Name Consumable', $content['name']); + } + + public function test_response_includes_qty_and_users_count() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals(5, $content['qty']); + $this->assertArrayHasKey('users_count', $content); + $this->assertEquals(0, $content['users_count']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(); + + $this->assertTrue($this->handle(['id' => $consumable->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowDepreciationToolTest.php b/tests/Feature/Mcp/ShowDepreciationToolTest.php new file mode 100644 index 000000000000..7aa4ac2a99f7 --- /dev/null +++ b/tests/Feature/Mcp/ShowDepreciationToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowDepreciationTool)->handle(new Request($args)); + } + + public function test_returns_depreciation_by_id() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['id' => $dep->id])->getStructuredContent(); + + $this->assertEquals($dep->id, $content['id']); + $this->assertEquals($dep->name, $content['name']); + } + + public function test_returns_depreciation_by_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['name' => $dep->name])->getStructuredContent(); + + $this->assertEquals($dep->id, $content['id']); + $this->assertEquals($dep->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowGroupToolTest.php b/tests/Feature/Mcp/ShowGroupToolTest.php new file mode 100644 index 000000000000..8c521abe47d9 --- /dev/null +++ b/tests/Feature/Mcp/ShowGroupToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowGroupTool)->handle(new Request($args)); + } + + public function test_returns_group_by_id() + { + $group = Group::factory()->create(); + + $content = $this->handle(['id' => $group->id])->getStructuredContent(); + + $this->assertEquals($group->id, $content['id']); + $this->assertEquals($group->name, $content['name']); + } + + public function test_returns_group_by_name() + { + $group = Group::factory()->create(); + + $content = $this->handle(['name' => $group->name])->getStructuredContent(); + + $this->assertEquals($group->id, $content['id']); + $this->assertEquals($group->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowLocationToolTest.php b/tests/Feature/Mcp/ShowLocationToolTest.php new file mode 100644 index 000000000000..01e9bde67994 --- /dev/null +++ b/tests/Feature/Mcp/ShowLocationToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowLocationTool)->handle(new Request($args)); + } + + public function test_returns_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle(['id' => $location->id])->getStructuredContent(); + + $this->assertEquals($location->id, $content['id']); + $this->assertEquals($location->name, $content['name']); + } + + public function test_returns_location_by_name() + { + $location = Location::factory()->create(); + + $content = $this->handle(['name' => $location->name])->getStructuredContent(); + + $this->assertEquals($location->id, $content['id']); + $this->assertEquals($location->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowManufacturerToolTest.php b/tests/Feature/Mcp/ShowManufacturerToolTest.php new file mode 100644 index 000000000000..f4d85ec32d6d --- /dev/null +++ b/tests/Feature/Mcp/ShowManufacturerToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowManufacturerTool)->handle(new Request($args)); + } + + public function test_returns_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Show By ID Manufacturer']); + + $content = $this->handle(['id' => $manufacturer->id])->getStructuredContent(); + + $this->assertEquals($manufacturer->id, $content['id']); + $this->assertEquals('Show By ID Manufacturer', $content['name']); + } + + public function test_returns_manufacturer_by_name() + { + Manufacturer::factory()->create(['name' => 'Show By Name Manufacturer']); + + $content = $this->handle(['name' => 'Show By Name Manufacturer'])->getStructuredContent(); + + $this->assertEquals('Show By Name Manufacturer', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $manufacturer->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowStatusLabelToolTest.php b/tests/Feature/Mcp/ShowStatusLabelToolTest.php new file mode 100644 index 000000000000..dc0cf6195809 --- /dev/null +++ b/tests/Feature/Mcp/ShowStatusLabelToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowStatusLabelTool)->handle(new Request($args)); + } + + public function test_returns_status_label_by_id() + { + $label = Statuslabel::factory()->create(['name' => 'Show By ID Status Label']); + + $content = $this->handle(['id' => $label->id])->getStructuredContent(); + + $this->assertEquals($label->id, $content['id']); + $this->assertEquals('Show By ID Status Label', $content['name']); + } + + public function test_returns_status_label_by_name() + { + Statuslabel::factory()->create(['name' => 'Show By Name Status Label']); + + $content = $this->handle(['name' => 'Show By Name Status Label'])->getStructuredContent(); + + $this->assertEquals('Show By Name Status Label', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $label->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowSupplierToolTest.php b/tests/Feature/Mcp/ShowSupplierToolTest.php new file mode 100644 index 000000000000..4ed49a700ef5 --- /dev/null +++ b/tests/Feature/Mcp/ShowSupplierToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowSupplierTool)->handle(new Request($args)); + } + + public function test_returns_supplier_by_id() + { + $supplier = Supplier::factory()->create(['name' => 'Show By ID Supplier']); + + $content = $this->handle(['id' => $supplier->id])->getStructuredContent(); + + $this->assertEquals($supplier->id, $content['id']); + $this->assertEquals('Show By ID Supplier', $content['name']); + } + + public function test_returns_supplier_by_name() + { + Supplier::factory()->create(['name' => 'Show By Name Supplier']); + + $content = $this->handle(['name' => 'Show By Name Supplier'])->getStructuredContent(); + + $this->assertEquals('Show By Name Supplier', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $supplier->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateAssetModelToolTest.php b/tests/Feature/Mcp/UpdateAssetModelToolTest.php new file mode 100644 index 000000000000..fdb57832a1d0 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAssetModelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->editAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateAssetModelTool)->handle(new Request($args)); + } + + public function test_updates_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle([ + 'id' => $model->id, + 'model_number' => 'MN-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('models', [ + 'id' => $model->id, + 'model_number' => 'MN-001', + ]); + } + + public function test_renames_via_new_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle([ + 'id' => $model->id, + 'new_name' => 'Renamed Model', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Model', $content['name']); + $this->assertDatabaseHas('models', [ + 'id' => $model->id, + 'name' => 'Renamed Model', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id, 'model_number' => 'HACKED'])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateCategoryToolTest.php b/tests/Feature/Mcp/UpdateCategoryToolTest.php new file mode 100644 index 000000000000..e779d554aaaf --- /dev/null +++ b/tests/Feature/Mcp/UpdateCategoryToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateCategoryTool)->handle(new Request($args)); + } + + public function test_updates_category_by_id() + { + $category = Category::factory()->create(['name' => 'Original Cat Name']); + + $content = $this->handle([ + 'id' => $category->id, + 'new_name' => 'Updated Cat Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'Updated Cat Name']); + } + + public function test_renames_via_new_name() + { + $category = Category::factory()->create(['name' => 'Before Cat Rename']); + + $content = $this->handle([ + 'id' => $category->id, + 'new_name' => 'After Cat Rename', + ])->getStructuredContent(); + + $this->assertEquals('After Cat Rename', $content['name']); + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'After Cat Rename']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost Cat'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(['name' => 'Unchanged Cat']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $category->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'Unchanged Cat']); + } +} diff --git a/tests/Feature/Mcp/UpdateCompanyToolTest.php b/tests/Feature/Mcp/UpdateCompanyToolTest.php new file mode 100644 index 000000000000..c8c2483743e7 --- /dev/null +++ b/tests/Feature/Mcp/UpdateCompanyToolTest.php @@ -0,0 +1,86 @@ +actingAs(User::factory()->editCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateCompanyTool)->handle(new Request($args)); + } + + public function test_updates_company_by_id() + { + $company = Company::factory()->create(['name' => 'Original Corp']); + + $content = $this->handle([ + 'id' => $company->id, + 'new_name' => 'Updated Corp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Updated Corp']); + } + + public function test_updates_company_by_name() + { + $company = Company::factory()->create(['name' => 'Find By Name Corp']); + + $content = $this->handle([ + 'name' => 'Find By Name Corp', + 'new_name' => 'Renamed Corp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Renamed Corp']); + } + + public function test_renames_via_new_name() + { + $company = Company::factory()->create(['name' => 'Before Rename']); + + $content = $this->handle([ + 'id' => $company->id, + 'new_name' => 'After Rename', + ])->getStructuredContent(); + + $this->assertEquals('After Rename', $content['name']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'After Rename']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost Corp'])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle(['new_name' => 'X'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(['name' => 'Unchanged Corp']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $company->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Unchanged Corp']); + } +} diff --git a/tests/Feature/Mcp/UpdateConsumableToolTest.php b/tests/Feature/Mcp/UpdateConsumableToolTest.php new file mode 100644 index 000000000000..f59f811893e2 --- /dev/null +++ b/tests/Feature/Mcp/UpdateConsumableToolTest.php @@ -0,0 +1,81 @@ +actingAs(User::factory()->editConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateConsumableTool)->handle(new Request($args)); + } + + public function test_updates_consumable_by_id() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $consumable->id, + 'qty' => 20, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(20, $content['qty']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'qty' => 20]); + } + + public function test_updates_consumable_by_name() + { + $consumable = Consumable::factory()->create(['name' => 'Update By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Update By Name', + 'qty' => 15, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'qty' => 15]); + } + + public function test_renames_via_new_name() + { + $consumable = Consumable::factory()->create(['name' => 'Old Consumable Name']); + + $content = $this->handle([ + 'id' => $consumable->id, + 'new_name' => 'New Consumable Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Consumable Name', $content['name']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'name' => 'New Consumable Name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'qty' => 5])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'qty' => 99, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateDepreciationToolTest.php b/tests/Feature/Mcp/UpdateDepreciationToolTest.php new file mode 100644 index 000000000000..7b7c2e023db1 --- /dev/null +++ b/tests/Feature/Mcp/UpdateDepreciationToolTest.php @@ -0,0 +1,83 @@ +actingAs(User::factory()->editDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateDepreciationTool)->handle(new Request($args)); + } + + public function test_updates_depreciation_by_id() + { + $dep = Depreciation::factory()->create(['months' => 12]); + + $content = $this->handle([ + 'id' => $dep->id, + 'months' => 24, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('depreciations', [ + 'id' => $dep->id, + 'months' => 24, + ]); + } + + public function test_updates_months() + { + $dep = Depreciation::factory()->create(['months' => 36]); + + $content = $this->handle([ + 'id' => $dep->id, + 'months' => 48, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(48, $content['months']); + } + + public function test_renames_via_new_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle([ + 'id' => $dep->id, + 'new_name' => 'Renamed Depreciation', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Depreciation', $content['name']); + $this->assertDatabaseHas('depreciations', [ + 'id' => $dep->id, + 'name' => 'Renamed Depreciation', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id, 'months' => 99])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateGroupToolTest.php b/tests/Feature/Mcp/UpdateGroupToolTest.php new file mode 100644 index 000000000000..cce215a058dd --- /dev/null +++ b/tests/Feature/Mcp/UpdateGroupToolTest.php @@ -0,0 +1,57 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateGroupTool)->handle(new Request($args)); + } + + public function test_updates_group_by_id() + { + $group = Group::factory()->create(); + $newNotes = 'Updated notes ' . uniqid(); + + $this->handle(['id' => $group->id, 'notes' => $newNotes]); + + $this->assertDatabaseHas('permission_groups', ['id' => $group->id, 'notes' => $newNotes]); + } + + public function test_renames_via_new_name() + { + $group = Group::factory()->create(); + $newName = 'Renamed Group ' . uniqid(); + + $this->handle(['id' => $group->id, 'new_name' => $newName]); + + $this->assertDatabaseHas('permission_groups', ['id' => $group->id, 'name' => $newName]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'notes' => 'test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id, 'notes' => 'test'])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateLocationToolTest.php b/tests/Feature/Mcp/UpdateLocationToolTest.php new file mode 100644 index 000000000000..c94dcfd30260 --- /dev/null +++ b/tests/Feature/Mcp/UpdateLocationToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->editLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateLocationTool)->handle(new Request($args)); + } + + public function test_updates_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $location->id, + 'city' => 'New City', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', [ + 'id' => $location->id, + 'city' => 'New City', + ]); + } + + public function test_renames_via_new_name() + { + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $location->id, + 'new_name' => 'Renamed Location', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Location', $content['name']); + $this->assertDatabaseHas('locations', [ + 'id' => $location->id, + 'name' => 'Renamed Location', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id, 'city' => 'Hacked'])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateManufacturerToolTest.php b/tests/Feature/Mcp/UpdateManufacturerToolTest.php new file mode 100644 index 000000000000..21c390d27940 --- /dev/null +++ b/tests/Feature/Mcp/UpdateManufacturerToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateManufacturerTool)->handle(new Request($args)); + } + + public function test_updates_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Original Manufacturer']); + + $content = $this->handle([ + 'id' => $manufacturer->id, + 'new_name' => 'Updated Manufacturer', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('manufacturers', ['id' => $manufacturer->id, 'name' => 'Updated Manufacturer']); + } + + public function test_renames_via_new_name() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Old Manufacturer Name']); + + $content = $this->handle([ + 'name' => 'Old Manufacturer Name', + 'new_name' => 'New Manufacturer Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Manufacturer Name', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Unchanged Manufacturer']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $manufacturer->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('manufacturers', ['id' => $manufacturer->id, 'name' => 'Unchanged Manufacturer']); + } +} diff --git a/tests/Feature/Mcp/UpdateStatusLabelToolTest.php b/tests/Feature/Mcp/UpdateStatusLabelToolTest.php new file mode 100644 index 000000000000..1aaffa19d13c --- /dev/null +++ b/tests/Feature/Mcp/UpdateStatusLabelToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->editStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateStatusLabelTool)->handle(new Request($args)); + } + + public function test_updates_status_label_by_id() + { + $label = Statuslabel::factory()->create(['name' => 'Original Status Label']); + + $content = $this->handle([ + 'id' => $label->id, + 'new_name' => 'Updated Status Label', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', ['id' => $label->id, 'name' => 'Updated Status Label']); + } + + public function test_renames_via_new_name() + { + $label = Statuslabel::factory()->create(['name' => 'Old Status Label Name']); + + $content = $this->handle([ + 'name' => 'Old Status Label Name', + 'new_name' => 'New Status Label Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Status Label Name', $content['name']); + } + + public function test_updates_type() + { + $label = Statuslabel::factory()->rtd()->create(['name' => 'Type Change Label']); + + $content = $this->handle([ + 'id' => $label->id, + 'type' => 'pending', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('pending', $content['type']); + $this->assertDatabaseHas('status_labels', [ + 'id' => $label->id, + 'pending' => 1, + 'deployable' => 0, + 'archived' => 0, + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(['name' => 'Unchanged Status Label']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $label->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('status_labels', ['id' => $label->id, 'name' => 'Unchanged Status Label']); + } +} diff --git a/tests/Feature/Mcp/UpdateSupplierToolTest.php b/tests/Feature/Mcp/UpdateSupplierToolTest.php new file mode 100644 index 000000000000..a06b1dea050e --- /dev/null +++ b/tests/Feature/Mcp/UpdateSupplierToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateSupplierTool)->handle(new Request($args)); + } + + public function test_updates_supplier_by_id() + { + $supplier = Supplier::factory()->create(['name' => 'Original Supplier']); + + $content = $this->handle([ + 'id' => $supplier->id, + 'new_name' => 'Updated Supplier', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('suppliers', ['id' => $supplier->id, 'name' => 'Updated Supplier']); + } + + public function test_renames_via_new_name() + { + $supplier = Supplier::factory()->create(['name' => 'Old Supplier Name']); + + $content = $this->handle([ + 'name' => 'Old Supplier Name', + 'new_name' => 'New Supplier Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Supplier Name', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(['name' => 'Unchanged Supplier']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $supplier->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('suppliers', ['id' => $supplier->id, 'name' => 'Unchanged Supplier']); + } +} From 9c97a06c7e15af0fa6b17f5db71a7e07de31026b Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 11:45:30 +0100 Subject: [PATCH 24/45] Additional tools --- app/Mcp/Servers/SnipeMCPServer.php | 159 +++++++++++++++++- app/Mcp/Tools/CheckoutConsumableTool.php | 113 +++++++++++++ app/Mcp/Tools/CreateAssetModelTool.php | 97 +++++++++++ app/Mcp/Tools/CreateAssetTool.php | 108 ++++++++++++ app/Mcp/Tools/CreateCategoryTool.php | 89 ++++++++++ app/Mcp/Tools/CreateCompanyTool.php | 90 ++++++++++ app/Mcp/Tools/CreateConsumableTool.php | 106 ++++++++++++ app/Mcp/Tools/CreateDepreciationTool.php | 74 ++++++++ app/Mcp/Tools/CreateGroupTool.php | 75 +++++++++ app/Mcp/Tools/CreateLocationTool.php | 97 +++++++++++ app/Mcp/Tools/CreateMaintenanceTool.php | 105 ++++++++++++ app/Mcp/Tools/CreateManufacturerTool.php | 83 +++++++++ app/Mcp/Tools/CreateStatusLabelTool.php | 95 +++++++++++ app/Mcp/Tools/CreateSupplierTool.php | 96 +++++++++++ app/Mcp/Tools/DeleteAssetModelTool.php | 83 +++++++++ app/Mcp/Tools/DeleteCategoryTool.php | 83 +++++++++ app/Mcp/Tools/DeleteCompanyTool.php | 79 +++++++++ app/Mcp/Tools/DeleteConsumableTool.php | 83 +++++++++ app/Mcp/Tools/DeleteDepreciationTool.php | 79 +++++++++ app/Mcp/Tools/DeleteGroupTool.php | 75 +++++++++ app/Mcp/Tools/DeleteLocationTool.php | 87 ++++++++++ app/Mcp/Tools/DeleteManufacturerTool.php | 79 +++++++++ app/Mcp/Tools/DeleteStatusLabelTool.php | 83 +++++++++ app/Mcp/Tools/DeleteSupplierTool.php | 79 +++++++++ app/Mcp/Tools/GetActivityLogTool.php | 102 +++++++++++ app/Mcp/Tools/GetCurrentUserTool.php | 72 ++++++++ app/Mcp/Tools/GetUserAssetsTool.php | 82 +++++++++ app/Mcp/Tools/ListAssetModelsTool.php | 101 +++++++++++ app/Mcp/Tools/ListCategoriesTool.php | 98 +++++++++++ app/Mcp/Tools/ListCompaniesTool.php | 88 ++++++++++ app/Mcp/Tools/ListConsumablesTool.php | 110 ++++++++++++ app/Mcp/Tools/ListDepreciationsTool.php | 82 +++++++++ app/Mcp/Tools/ListGroupsTool.php | 81 +++++++++ app/Mcp/Tools/ListLocationsTool.php | 101 +++++++++++ app/Mcp/Tools/ListMaintenancesTool.php | 89 ++++++++++ app/Mcp/Tools/ListManufacturersTool.php | 93 ++++++++++ app/Mcp/Tools/ListStatusLabelsTool.php | 99 +++++++++++ app/Mcp/Tools/ListSuppliersTool.php | 92 ++++++++++ app/Mcp/Tools/Reset2FATool.php | 68 ++++++++ app/Mcp/Tools/RestoreAssetTool.php | 69 ++++++++ app/Mcp/Tools/RestoreUserTool.php | 73 ++++++++ app/Mcp/Tools/ShowAssetModelTool.php | 105 ++++++++++++ app/Mcp/Tools/ShowCategoryTool.php | 95 +++++++++++ app/Mcp/Tools/ShowCompanyTool.php | 89 ++++++++++ app/Mcp/Tools/ShowConsumableTool.php | 107 ++++++++++++ app/Mcp/Tools/ShowDepreciationTool.php | 87 ++++++++++ app/Mcp/Tools/ShowGroupTool.php | 75 +++++++++ app/Mcp/Tools/ShowLocationTool.php | 109 ++++++++++++ app/Mcp/Tools/ShowManufacturerTool.php | 97 +++++++++++ app/Mcp/Tools/ShowStatusLabelTool.php | 101 +++++++++++ app/Mcp/Tools/ShowSupplierTool.php | 111 ++++++++++++ app/Mcp/Tools/UpdateAssetModelTool.php | 111 ++++++++++++ app/Mcp/Tools/UpdateCategoryTool.php | 105 ++++++++++++ app/Mcp/Tools/UpdateCompanyTool.php | 101 +++++++++++ app/Mcp/Tools/UpdateConsumableTool.php | 120 +++++++++++++ app/Mcp/Tools/UpdateDepreciationTool.php | 95 +++++++++++ app/Mcp/Tools/UpdateGroupTool.php | 92 ++++++++++ app/Mcp/Tools/UpdateLocationTool.php | 113 +++++++++++++ app/Mcp/Tools/UpdateManufacturerTool.php | 107 ++++++++++++ app/Mcp/Tools/UpdateStatusLabelTool.php | 122 ++++++++++++++ app/Mcp/Tools/UpdateSupplierTool.php | 119 +++++++++++++ database/factories/UserFactory.php | 108 +++++++++++- .../Feature/Mcp/CreateAssetModelToolTest.php | 1 - tests/Feature/Mcp/CreateAssetToolTest.php | 1 - .../Mcp/CreateDepreciationToolTest.php | 1 - tests/Feature/Mcp/CreateGroupToolTest.php | 6 +- tests/Feature/Mcp/UpdateGroupToolTest.php | 4 +- 67 files changed, 5865 insertions(+), 14 deletions(-) create mode 100644 app/Mcp/Tools/CheckoutConsumableTool.php create mode 100644 app/Mcp/Tools/CreateAssetModelTool.php create mode 100644 app/Mcp/Tools/CreateAssetTool.php create mode 100644 app/Mcp/Tools/CreateCategoryTool.php create mode 100644 app/Mcp/Tools/CreateCompanyTool.php create mode 100644 app/Mcp/Tools/CreateConsumableTool.php create mode 100644 app/Mcp/Tools/CreateDepreciationTool.php create mode 100644 app/Mcp/Tools/CreateGroupTool.php create mode 100644 app/Mcp/Tools/CreateLocationTool.php create mode 100644 app/Mcp/Tools/CreateMaintenanceTool.php create mode 100644 app/Mcp/Tools/CreateManufacturerTool.php create mode 100644 app/Mcp/Tools/CreateStatusLabelTool.php create mode 100644 app/Mcp/Tools/CreateSupplierTool.php create mode 100644 app/Mcp/Tools/DeleteAssetModelTool.php create mode 100644 app/Mcp/Tools/DeleteCategoryTool.php create mode 100644 app/Mcp/Tools/DeleteCompanyTool.php create mode 100644 app/Mcp/Tools/DeleteConsumableTool.php create mode 100644 app/Mcp/Tools/DeleteDepreciationTool.php create mode 100644 app/Mcp/Tools/DeleteGroupTool.php create mode 100644 app/Mcp/Tools/DeleteLocationTool.php create mode 100644 app/Mcp/Tools/DeleteManufacturerTool.php create mode 100644 app/Mcp/Tools/DeleteStatusLabelTool.php create mode 100644 app/Mcp/Tools/DeleteSupplierTool.php create mode 100644 app/Mcp/Tools/GetActivityLogTool.php create mode 100644 app/Mcp/Tools/GetCurrentUserTool.php create mode 100644 app/Mcp/Tools/GetUserAssetsTool.php create mode 100644 app/Mcp/Tools/ListAssetModelsTool.php create mode 100644 app/Mcp/Tools/ListCategoriesTool.php create mode 100644 app/Mcp/Tools/ListCompaniesTool.php create mode 100644 app/Mcp/Tools/ListConsumablesTool.php create mode 100644 app/Mcp/Tools/ListDepreciationsTool.php create mode 100644 app/Mcp/Tools/ListGroupsTool.php create mode 100644 app/Mcp/Tools/ListLocationsTool.php create mode 100644 app/Mcp/Tools/ListMaintenancesTool.php create mode 100644 app/Mcp/Tools/ListManufacturersTool.php create mode 100644 app/Mcp/Tools/ListStatusLabelsTool.php create mode 100644 app/Mcp/Tools/ListSuppliersTool.php create mode 100644 app/Mcp/Tools/Reset2FATool.php create mode 100644 app/Mcp/Tools/RestoreAssetTool.php create mode 100644 app/Mcp/Tools/RestoreUserTool.php create mode 100644 app/Mcp/Tools/ShowAssetModelTool.php create mode 100644 app/Mcp/Tools/ShowCategoryTool.php create mode 100644 app/Mcp/Tools/ShowCompanyTool.php create mode 100644 app/Mcp/Tools/ShowConsumableTool.php create mode 100644 app/Mcp/Tools/ShowDepreciationTool.php create mode 100644 app/Mcp/Tools/ShowGroupTool.php create mode 100644 app/Mcp/Tools/ShowLocationTool.php create mode 100644 app/Mcp/Tools/ShowManufacturerTool.php create mode 100644 app/Mcp/Tools/ShowStatusLabelTool.php create mode 100644 app/Mcp/Tools/ShowSupplierTool.php create mode 100644 app/Mcp/Tools/UpdateAssetModelTool.php create mode 100644 app/Mcp/Tools/UpdateCategoryTool.php create mode 100644 app/Mcp/Tools/UpdateCompanyTool.php create mode 100644 app/Mcp/Tools/UpdateConsumableTool.php create mode 100644 app/Mcp/Tools/UpdateDepreciationTool.php create mode 100644 app/Mcp/Tools/UpdateGroupTool.php create mode 100644 app/Mcp/Tools/UpdateLocationTool.php create mode 100644 app/Mcp/Tools/UpdateManufacturerTool.php create mode 100644 app/Mcp/Tools/UpdateStatusLabelTool.php create mode 100644 app/Mcp/Tools/UpdateSupplierTool.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index c9345a208200..9716baef30ab 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -10,29 +10,89 @@ use App\Mcp\Tools\CheckoutAccessoryTool; use App\Mcp\Tools\CheckoutAssetTool; use App\Mcp\Tools\CheckoutComponentTool; +use App\Mcp\Tools\CheckoutConsumableTool; use App\Mcp\Tools\CheckoutLicenseTool; use App\Mcp\Tools\CreateAccessoryTool; +use App\Mcp\Tools\CreateAssetModelTool; +use App\Mcp\Tools\CreateAssetTool; +use App\Mcp\Tools\CreateCategoryTool; +use App\Mcp\Tools\CreateCompanyTool; use App\Mcp\Tools\CreateComponentTool; +use App\Mcp\Tools\CreateConsumableTool; use App\Mcp\Tools\CreateDepartmentTool; +use App\Mcp\Tools\CreateDepreciationTool; +use App\Mcp\Tools\CreateGroupTool; use App\Mcp\Tools\CreateLicenseTool; +use App\Mcp\Tools\CreateLocationTool; +use App\Mcp\Tools\CreateMaintenanceTool; +use App\Mcp\Tools\CreateManufacturerTool; +use App\Mcp\Tools\CreateStatusLabelTool; +use App\Mcp\Tools\CreateSupplierTool; use App\Mcp\Tools\CreateUserTool; use App\Mcp\Tools\DeleteAccessoryTool; +use App\Mcp\Tools\DeleteAssetModelTool; use App\Mcp\Tools\DeleteAssetTool; +use App\Mcp\Tools\DeleteCategoryTool; +use App\Mcp\Tools\DeleteCompanyTool; use App\Mcp\Tools\DeleteComponentTool; +use App\Mcp\Tools\DeleteConsumableTool; use App\Mcp\Tools\DeleteDepartmentTool; +use App\Mcp\Tools\DeleteDepreciationTool; +use App\Mcp\Tools\DeleteGroupTool; use App\Mcp\Tools\DeleteLicenseTool; +use App\Mcp\Tools\DeleteLocationTool; +use App\Mcp\Tools\DeleteManufacturerTool; +use App\Mcp\Tools\DeleteStatusLabelTool; +use App\Mcp\Tools\DeleteSupplierTool; use App\Mcp\Tools\DeleteUserTool; +use App\Mcp\Tools\GetActivityLogTool; +use App\Mcp\Tools\GetCurrentUserTool; +use App\Mcp\Tools\GetUserAssetsTool; +use App\Mcp\Tools\ListAssetModelsTool; use App\Mcp\Tools\ListAssetsTool; +use App\Mcp\Tools\ListCategoriesTool; +use App\Mcp\Tools\ListCompaniesTool; +use App\Mcp\Tools\ListConsumablesTool; +use App\Mcp\Tools\ListDepreciationsTool; +use App\Mcp\Tools\ListGroupsTool; use App\Mcp\Tools\ListLicensesTool; +use App\Mcp\Tools\ListLocationsTool; +use App\Mcp\Tools\ListMaintenancesTool; +use App\Mcp\Tools\ListManufacturersTool; +use App\Mcp\Tools\ListStatusLabelsTool; +use App\Mcp\Tools\ListSuppliersTool; use App\Mcp\Tools\ListUsersTool; +use App\Mcp\Tools\Reset2FATool; +use App\Mcp\Tools\RestoreAssetTool; +use App\Mcp\Tools\RestoreUserTool; +use App\Mcp\Tools\ShowAssetModelTool; use App\Mcp\Tools\ShowAssetTool; +use App\Mcp\Tools\ShowCategoryTool; +use App\Mcp\Tools\ShowCompanyTool; +use App\Mcp\Tools\ShowConsumableTool; +use App\Mcp\Tools\ShowDepreciationTool; +use App\Mcp\Tools\ShowGroupTool; use App\Mcp\Tools\ShowLicenseTool; +use App\Mcp\Tools\ShowLocationTool; +use App\Mcp\Tools\ShowManufacturerTool; +use App\Mcp\Tools\ShowStatusLabelTool; +use App\Mcp\Tools\ShowSupplierTool; use App\Mcp\Tools\ShowUserTool; use App\Mcp\Tools\UpdateAccessoryTool; +use App\Mcp\Tools\UpdateAssetModelTool; use App\Mcp\Tools\UpdateAssetTool; +use App\Mcp\Tools\UpdateCategoryTool; +use App\Mcp\Tools\UpdateCompanyTool; use App\Mcp\Tools\UpdateComponentTool; +use App\Mcp\Tools\UpdateConsumableTool; use App\Mcp\Tools\UpdateDepartmentTool; +use App\Mcp\Tools\UpdateDepreciationTool; +use App\Mcp\Tools\UpdateGroupTool; use App\Mcp\Tools\UpdateLicenseTool; +use App\Mcp\Tools\UpdateLocationTool; +use App\Mcp\Tools\UpdateManufacturerTool; +use App\Mcp\Tools\UpdateStatusLabelTool; +use App\Mcp\Tools\UpdateSupplierTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; @@ -45,28 +105,51 @@ class SnipeMCPServer extends Server { protected array $tools = [ + // Assets ShowAssetTool::class, ListAssetsTool::class, - CheckoutAssetTool::class, - CheckinAssetTool::class, + CreateAssetTool::class, UpdateAssetTool::class, DeleteAssetTool::class, + RestoreAssetTool::class, + CheckoutAssetTool::class, + CheckinAssetTool::class, AuditAssetTool::class, + + // Users ListUsersTool::class, ShowUserTool::class, CreateUserTool::class, UpdateUserTool::class, DeleteUserTool::class, + RestoreUserTool::class, + GetCurrentUserTool::class, + GetUserAssetsTool::class, + Reset2FATool::class, + + // Accessories CreateAccessoryTool::class, UpdateAccessoryTool::class, DeleteAccessoryTool::class, CheckoutAccessoryTool::class, CheckinAccessoryTool::class, + + // Components CreateComponentTool::class, UpdateComponentTool::class, DeleteComponentTool::class, CheckoutComponentTool::class, CheckinComponentTool::class, + + // Consumables + ListConsumablesTool::class, + ShowConsumableTool::class, + CreateConsumableTool::class, + UpdateConsumableTool::class, + DeleteConsumableTool::class, + CheckoutConsumableTool::class, + + // Licenses ListLicensesTool::class, ShowLicenseTool::class, CreateLicenseTool::class, @@ -74,9 +157,81 @@ class SnipeMCPServer extends Server DeleteLicenseTool::class, CheckoutLicenseTool::class, CheckinLicenseTool::class, + + // Departments CreateDepartmentTool::class, UpdateDepartmentTool::class, DeleteDepartmentTool::class, + + // Companies + ListCompaniesTool::class, + ShowCompanyTool::class, + CreateCompanyTool::class, + UpdateCompanyTool::class, + DeleteCompanyTool::class, + + // Categories + ListCategoriesTool::class, + ShowCategoryTool::class, + CreateCategoryTool::class, + UpdateCategoryTool::class, + DeleteCategoryTool::class, + + // Manufacturers + ListManufacturersTool::class, + ShowManufacturerTool::class, + CreateManufacturerTool::class, + UpdateManufacturerTool::class, + DeleteManufacturerTool::class, + + // Suppliers + ListSuppliersTool::class, + ShowSupplierTool::class, + CreateSupplierTool::class, + UpdateSupplierTool::class, + DeleteSupplierTool::class, + + // Status Labels + ListStatusLabelsTool::class, + ShowStatusLabelTool::class, + CreateStatusLabelTool::class, + UpdateStatusLabelTool::class, + DeleteStatusLabelTool::class, + + // Locations + ListLocationsTool::class, + ShowLocationTool::class, + CreateLocationTool::class, + UpdateLocationTool::class, + DeleteLocationTool::class, + + // Asset Models + ListAssetModelsTool::class, + ShowAssetModelTool::class, + CreateAssetModelTool::class, + UpdateAssetModelTool::class, + DeleteAssetModelTool::class, + + // Depreciations + ListDepreciationsTool::class, + ShowDepreciationTool::class, + CreateDepreciationTool::class, + UpdateDepreciationTool::class, + DeleteDepreciationTool::class, + + // Groups + ListGroupsTool::class, + ShowGroupTool::class, + CreateGroupTool::class, + UpdateGroupTool::class, + DeleteGroupTool::class, + + // Maintenance + ListMaintenancesTool::class, + CreateMaintenanceTool::class, + + // Activity Log + GetActivityLogTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/CheckoutConsumableTool.php b/app/Mcp/Tools/CheckoutConsumableTool.php new file mode 100644 index 000000000000..6ac8dfdb5671 --- /dev/null +++ b/app/Mcp/Tools/CheckoutConsumableTool.php @@ -0,0 +1,113 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'assigned_to' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error('Consumable not found')); + } + + if (! Gate::allows('checkout', $consumable)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($consumable->numRemaining() <= 0) { + return Response::make(Response::error('No units remaining')); + } + + $user = User::find($request->get('assigned_to')); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + $consumable->users()->attach($consumable->id, [ + 'consumable_id' => $consumable->id, + 'created_by' => auth()->id(), + 'assigned_to' => $user->id, + 'note' => $request->get('note'), + ]); + + event(new CheckoutableCheckedOut( + $consumable, + $user, + auth()->user(), + $request->get('note'), + [], + 1, + )); + + return Response::make( + Response::text('Consumable '.$consumable->name.' checked out to '.$user->username) + )->withStructuredContent([ + 'success' => true, + 'message' => 'Consumable checked out successfully', + 'consumable_id' => $consumable->id, + 'consumable_name' => $consumable->name, + 'assigned_to_id' => $user->id, + 'assigned_to_username' => $user->username, + ]); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to check out'), + 'name' => $schema->string()->description('Name of the consumable to check out'), + 'assigned_to' => $schema->number()->description('User ID to check out to (required)'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'consumable_id' => $schema->number()->description('Numeric ID of the consumable'), + 'consumable_name' => $schema->string()->description('Name of the consumable'), + 'assigned_to_id' => $schema->number()->description('ID of the user the consumable was checked out to'), + 'assigned_to_username' => $schema->string()->description('Username of the user the consumable was checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAssetModelTool.php b/app/Mcp/Tools/CreateAssetModelTool.php new file mode 100644 index 000000000000..f9352195e2bf --- /dev/null +++ b/app/Mcp/Tools/CreateAssetModelTool.php @@ -0,0 +1,97 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_id' => 'required|integer|exists:categories,id', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'depreciation_id' => 'nullable|integer|exists:depreciations,id', + 'eol' => 'nullable|integer|min:0|max:240', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + 'requestable' => 'nullable|boolean', + 'require_serial' => 'nullable|boolean', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $assetModel = new AssetModel; + $assetModel->name = $request->get('name'); + $assetModel->category_id = $request->get('category_id'); + $assetModel->created_by = auth()->id(); + + foreach (['model_number', 'manufacturer_id', 'depreciation_id', 'eol', 'min_amt', 'notes', 'requestable', 'require_serial'] as $f) { + if ($request->filled($f)) { + $assetModel->{$f} = $request->get($f); + } + } + + if ($assetModel->save()) { + return Response::make( + Response::text('Asset model '.$assetModel->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset model created successfully', + 'id' => $assetModel->id, + 'name' => $assetModel->name, + 'category_id' => $assetModel->category_id, + ]); + } + + return Response::make(Response::error('Create failed: '.$assetModel->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Asset model name (required)'), + 'category_id' => $schema->number()->description('Category ID (required)'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'eol' => $schema->number()->description('End of life in months (0-240)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'requestable' => $schema->boolean()->description('Whether the model can be requested'), + 'require_serial' => $schema->boolean()->description('Whether serial numbers are required'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the asset model was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new asset model'), + 'name' => $schema->string()->description('Name of the new asset model'), + 'category_id' => $schema->number()->description('Category ID of the new asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAssetTool.php b/app/Mcp/Tools/CreateAssetTool.php new file mode 100644 index 000000000000..b837dee5170a --- /dev/null +++ b/app/Mcp/Tools/CreateAssetTool.php @@ -0,0 +1,108 @@ +validate([ + 'model_id' => 'required|integer|exists:models,id', + 'status_id' => 'required|integer|exists:status_labels,id', + 'asset_tag' => 'required|string|max:255', + 'name' => 'nullable|string|max:255', + 'serial' => 'nullable|string', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'rtd_location_id' => 'nullable|integer|exists:locations,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric', + 'order_number' => 'nullable|string|max:191', + 'warranty_months' => 'nullable|integer|min:0|max:240', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string|max:65535', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $asset = new Asset; + $asset->model_id = $request->get('model_id'); + $asset->status_id = $request->get('status_id'); + $asset->asset_tag = $request->get('asset_tag'); + $asset->created_by = auth()->id(); + + foreach (['name', 'serial', 'company_id', 'location_id', 'rtd_location_id', 'supplier_id', 'purchase_date', 'purchase_cost', 'order_number', 'warranty_months', 'requestable', 'notes'] as $field) { + if ($request->filled($field)) { + $asset->{$field} = $request->get($field); + } + } + + if ($asset->save()) { + return Response::make( + Response::text('Asset '.$asset->asset_tag.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset created successfully', + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$asset->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'model_id' => $schema->number()->description('Asset model ID (required)'), + 'status_id' => $schema->number()->description('Status label ID (required)'), + 'asset_tag' => $schema->string()->description('Asset tag (required)'), + 'name' => $schema->string()->description('Display name for the asset'), + 'serial' => $schema->string()->description('Serial number'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Current location ID'), + 'rtd_location_id' => $schema->number()->description('Default RTD location ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'order_number' => $schema->string()->description('Order number'), + 'warranty_months' => $schema->number()->description('Warranty length in months (0-240)'), + 'requestable' => $schema->boolean()->description('Whether the asset is user-requestable'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the asset was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new asset'), + 'asset_tag' => $schema->string()->description('Asset tag of the new asset'), + 'name' => $schema->string()->description('Display name of the new asset'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateCategoryTool.php b/app/Mcp/Tools/CreateCategoryTool.php new file mode 100644 index 000000000000..c50bb14ed383 --- /dev/null +++ b/app/Mcp/Tools/CreateCategoryTool.php @@ -0,0 +1,89 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_type' => 'required|string|in:asset,accessory,consumable,component,license', + 'checkin_email' => 'nullable|boolean', + 'require_acceptance' => 'nullable|boolean', + 'use_default_eula' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $category = new Category; + $category->name = $request->get('name'); + $category->category_type = $request->get('category_type'); + $category->created_by = auth()->id(); + + foreach (['checkin_email', 'require_acceptance', 'use_default_eula', 'notes'] as $field) { + if ($request->filled($field)) { + $category->{$field} = $request->get($field); + } + } + + if ($category->save()) { + return Response::make( + Response::text('Category '.$category->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Category created successfully', + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + ]); + } + + return Response::make(Response::error('Create failed: '.$category->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Category name (required)'), + 'category_type' => $schema->string()->description('Category type (required): asset, accessory, consumable, component, or license'), + 'checkin_email' => $schema->boolean()->description('Send checkin email when items are checked in'), + 'require_acceptance' => $schema->boolean()->description('Require user acceptance when checking out'), + 'use_default_eula' => $schema->boolean()->description('Use the default EULA'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the category was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new category'), + 'name' => $schema->string()->description('Name of the new category'), + 'category_type' => $schema->string()->description('Type of the new category'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateCompanyTool.php b/app/Mcp/Tools/CreateCompanyTool.php new file mode 100644 index 000000000000..b9ab3fd777e3 --- /dev/null +++ b/app/Mcp/Tools/CreateCompanyTool.php @@ -0,0 +1,90 @@ +validate([ + 'name' => 'required|string|max:255', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $company = new Company; + $company->name = $request->get('name'); + if ($request->filled('phone')) { + $company->phone = $request->get('phone'); + } + if ($request->filled('fax')) { + $company->fax = $request->get('fax'); + } + if ($request->filled('email')) { + $company->email = $request->get('email'); + } + if ($request->filled('notes')) { + $company->notes = $request->get('notes'); + } + $company->created_by = auth()->id(); + + if ($company->save()) { + return Response::make( + Response::text('Company '.$company->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Company created successfully', + 'id' => $company->id, + 'name' => $company->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$company->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Company name (required)'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the company was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new company'), + 'name' => $schema->string()->description('Name of the new company'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateConsumableTool.php b/app/Mcp/Tools/CreateConsumableTool.php new file mode 100644 index 000000000000..48cc2a51ae09 --- /dev/null +++ b/app/Mcp/Tools/CreateConsumableTool.php @@ -0,0 +1,106 @@ +validate([ + 'name' => 'required|string|max:255', + 'qty' => 'required|integer|min:0', + 'category_id' => 'required|integer|exists:categories,id', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'item_no' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $consumable = new Consumable; + $consumable->fill($request->only([ + 'name', 'qty', 'category_id', 'company_id', 'location_id', 'manufacturer_id', + 'supplier_id', 'item_no', 'order_number', 'model_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ])); + $consumable->created_by = auth()->id(); + + if ($consumable->save()) { + return Response::make( + Response::text('Consumable '.$consumable->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Consumable created successfully', + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'category_id' => $consumable->category_id, + ]); + } + + return Response::make(Response::error('Create failed: '.$consumable->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Consumable name (required)'), + 'qty' => $schema->number()->description('Total quantity in stock (required)'), + 'category_id' => $schema->number()->description('Category ID — must be a consumable category (required)'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'item_no' => $schema->string()->description('Item number'), + 'order_number' => $schema->string()->description('Order number'), + 'model_number' => $schema->string()->description('Model number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'requestable' => $schema->boolean()->description('Whether users can request this consumable'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the consumable was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new consumable'), + 'name' => $schema->string()->description('Name of the new consumable'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateDepreciationTool.php b/app/Mcp/Tools/CreateDepreciationTool.php new file mode 100644 index 000000000000..9894f59dc8f1 --- /dev/null +++ b/app/Mcp/Tools/CreateDepreciationTool.php @@ -0,0 +1,74 @@ +validate([ + 'name' => 'required|string|max:255', + 'months' => 'required|integer|min:1|max:3600', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $depreciation = new Depreciation; + $depreciation->name = $request->get('name'); + $depreciation->months = $request->get('months'); + + if ($depreciation->save()) { + return Response::make( + Response::text('Depreciation '.$depreciation->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Depreciation created successfully', + 'id' => $depreciation->id, + 'name' => $depreciation->name, + 'months' => $depreciation->months, + ]); + } + + return Response::make(Response::error('Create failed: '.$depreciation->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Depreciation name (required)'), + 'months' => $schema->number()->description('Depreciation period in months (required, 1-3600)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the depreciation was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new depreciation'), + 'name' => $schema->string()->description('Name of the new depreciation'), + 'months' => $schema->number()->description('Depreciation period in months'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateGroupTool.php b/app/Mcp/Tools/CreateGroupTool.php new file mode 100644 index 000000000000..28191639070c --- /dev/null +++ b/app/Mcp/Tools/CreateGroupTool.php @@ -0,0 +1,75 @@ +validate([ + 'name' => 'required|string|max:255', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $group = new Group; + $group->name = $request->get('name'); + if ($request->filled('notes')) { + $group->notes = $request->get('notes'); + } + $group->created_by = auth()->id(); + + if ($group->save()) { + return Response::make( + Response::text('Group '.$group->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Group created successfully', + 'id' => $group->id, + 'name' => $group->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$group->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Group name (required, must be unique)'), + 'notes' => $schema->string()->description('Notes about the group'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the group was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new group'), + 'name' => $schema->string()->description('Name of the new group'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateLocationTool.php b/app/Mcp/Tools/CreateLocationTool.php new file mode 100644 index 000000000000..fabc7fbf62ee --- /dev/null +++ b/app/Mcp/Tools/CreateLocationTool.php @@ -0,0 +1,97 @@ +validate([ + 'name' => 'required|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'currency' => 'nullable|string', + 'parent_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $location = new Location; + $location->name = $request->get('name'); + + foreach (['address', 'address2', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'currency', 'parent_id', 'manager_id'] as $field) { + if ($request->filled($field)) { + $location->{$field} = $request->get($field); + } + } + + if ($location->save()) { + return Response::make( + Response::text('Location '.$location->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Location created successfully', + 'id' => $location->id, + 'name' => $location->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$location->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Location name (required)'), + 'address' => $schema->string()->description('Street address'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'currency' => $schema->string()->description('Currency code'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the location was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new location'), + 'name' => $schema->string()->description('Name of the new location'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateMaintenanceTool.php b/app/Mcp/Tools/CreateMaintenanceTool.php new file mode 100644 index 000000000000..cf89e3f8cfed --- /dev/null +++ b/app/Mcp/Tools/CreateMaintenanceTool.php @@ -0,0 +1,105 @@ +validate([ + 'asset_id' => 'required|integer|exists:assets,id', + 'title' => 'required|string|max:255', + 'asset_maintenance_type' => 'nullable|string|max:255', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'is_warranty' => 'nullable|boolean', + 'cost' => 'nullable|numeric|min:0', + 'start_date' => 'nullable|date_format:Y-m-d', + 'completion_date' => 'nullable|date_format:Y-m-d', + 'notes' => 'nullable|string', + 'user_id' => 'nullable|integer|exists:users,id', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $maintenance = new Maintenance; + $maintenance->asset_id = $request->get('asset_id'); + $maintenance->name = $request->get('title'); + $maintenance->asset_maintenance_type = $request->get('asset_maintenance_type', 'Maintenance'); + $maintenance->start_date = $request->filled('start_date') ? $request->get('start_date') : now()->format('Y-m-d'); + $maintenance->created_by = auth()->id(); + $maintenance->is_warranty = 0; + + foreach (['supplier_id', 'is_warranty', 'cost', 'completion_date', 'notes', 'user_id'] as $field) { + if ($request->filled($field)) { + $maintenance->{$field} = $request->get($field); + } + } + + if ($maintenance->save()) { + $maintenance->load('asset'); + + return Response::make( + Response::text('Maintenance '.$maintenance->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Maintenance created successfully', + 'id' => $maintenance->id, + 'title' => $maintenance->name, + 'asset_id' => $maintenance->asset_id, + 'asset_tag' => $maintenance->asset?->asset_tag, + ]); + } + + return Response::make(Response::error('Create failed: '.$maintenance->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Asset ID the maintenance is for (required)'), + 'title' => $schema->string()->description('Maintenance title/name (required)'), + 'asset_maintenance_type' => $schema->string()->description('Type of maintenance (e.g. maintenance, repair, upgrade)'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'is_warranty' => $schema->boolean()->description('Whether this is a warranty maintenance'), + 'cost' => $schema->number()->description('Cost of the maintenance'), + 'start_date' => $schema->string()->description('Start date (YYYY-MM-DD, defaults to today)'), + 'completion_date' => $schema->string()->description('Completion date (YYYY-MM-DD)'), + 'notes' => $schema->string()->description('Notes about the maintenance'), + 'user_id' => $schema->number()->description('Technician user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the maintenance was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new maintenance record'), + 'title' => $schema->string()->description('Title of the maintenance'), + 'asset_id' => $schema->number()->description('Asset ID'), + 'asset_tag' => $schema->string()->description('Asset tag'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateManufacturerTool.php b/app/Mcp/Tools/CreateManufacturerTool.php new file mode 100644 index 000000000000..77ce84297901 --- /dev/null +++ b/app/Mcp/Tools/CreateManufacturerTool.php @@ -0,0 +1,83 @@ +validate([ + 'name' => 'required|string|max:255', + 'url' => 'nullable|string|max:255', + 'support_url' => 'nullable|string|max:255', + 'support_email' => 'nullable|email|max:191', + 'support_phone' => 'nullable|string|max:191', + 'warranty_lookup_url' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $manufacturer = new Manufacturer; + $manufacturer->fill($request->only([ + 'name', 'url', 'support_url', 'support_email', 'support_phone', 'warranty_lookup_url', 'notes', + ])); + + if ($manufacturer->save()) { + return Response::make( + Response::text('Manufacturer '.$manufacturer->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Manufacturer created successfully', + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$manufacturer->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Manufacturer name (required)'), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the manufacturer was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new manufacturer'), + 'name' => $schema->string()->description('Name of the new manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateStatusLabelTool.php b/app/Mcp/Tools/CreateStatusLabelTool.php new file mode 100644 index 000000000000..2d50597cf304 --- /dev/null +++ b/app/Mcp/Tools/CreateStatusLabelTool.php @@ -0,0 +1,95 @@ +validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:deployable,pending,archived,undeployable', + 'color' => 'nullable|string', + 'notes' => 'nullable|string', + 'default_label' => 'nullable|boolean', + 'show_in_nav' => 'nullable|boolean', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $statuslabel = new Statuslabel; + $statuslabel->name = $request->get('name'); + + $statusType = Statuslabel::getStatuslabelTypesForDB($request->get('type')); + $statuslabel->deployable = $statusType['deployable']; + $statuslabel->pending = $statusType['pending']; + $statuslabel->archived = $statusType['archived']; + + if ($request->filled('color')) { + $statuslabel->color = $request->get('color'); + } + if ($request->filled('notes')) { + $statuslabel->notes = $request->get('notes'); + } + $statuslabel->default_label = $request->get('default_label', 0); + $statuslabel->show_in_nav = $request->get('show_in_nav', 0); + + if ($statuslabel->save()) { + return Response::make( + Response::text('Status label '.$statuslabel->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Status label created successfully', + 'id' => $statuslabel->id, + 'name' => $statuslabel->name, + 'type' => $statuslabel->getStatuslabelType(), + ]); + } + + return Response::make(Response::error('Create failed: '.$statuslabel->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Status label name (required)'), + 'type' => $schema->string()->description('Status label type: deployable, pending, archived, or undeployable (required)'), + 'color' => $schema->string()->description('Display color in #RRGGBB format'), + 'notes' => $schema->string()->description('Notes'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the status label was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new status label'), + 'name' => $schema->string()->description('Name of the new status label'), + 'type' => $schema->string()->description('Type of the new status label'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateSupplierTool.php b/app/Mcp/Tools/CreateSupplierTool.php new file mode 100644 index 000000000000..ef4d849b048a --- /dev/null +++ b/app/Mcp/Tools/CreateSupplierTool.php @@ -0,0 +1,96 @@ +validate([ + 'name' => 'required|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|email', + 'url' => 'nullable|string', + 'contact' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $supplier = new Supplier; + $supplier->fill($request->only([ + 'name', 'address', 'address2', 'city', 'state', 'country', 'zip', + 'phone', 'fax', 'email', 'url', 'contact', 'notes', + ])); + + if ($supplier->save()) { + return Response::make( + Response::text('Supplier '.$supplier->name.' created successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Supplier created successfully', + 'id' => $supplier->id, + 'name' => $supplier->name, + ]); + } + + return Response::make(Response::error('Create failed: '.$supplier->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Supplier name (required)'), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the supplier was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new supplier'), + 'name' => $schema->string()->description('Name of the new supplier'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteAssetModelTool.php b/app/Mcp/Tools/DeleteAssetModelTool.php new file mode 100644 index 000000000000..f2a5d0d6f6c3 --- /dev/null +++ b/app/Mcp/Tools/DeleteAssetModelTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $model = $this->resolveModel($request); + + if (! $model) { + return Response::make(Response::error('Asset model not found')); + } + + if (! Gate::allows('delete', $model)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($model->assets()->count() > 0) { + return Response::make(Response::error('Model has assets and cannot be deleted')); + } + + $name = $model->name; + + $model->delete(); + + return Response::make( + Response::text('Asset model '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset model deleted successfully', + 'name' => $name, + ]); + } + + private function resolveModel(Request $request): ?AssetModel + { + if ($request->filled('id')) { + return AssetModel::find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset model to delete'), + 'name' => $schema->string()->description('Name of the asset model to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteCategoryTool.php b/app/Mcp/Tools/DeleteCategoryTool.php new file mode 100644 index 000000000000..a930366f5f9b --- /dev/null +++ b/app/Mcp/Tools/DeleteCategoryTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $category = $this->resolveCategory($request); + + if (! $category) { + return Response::make(Response::error('Category not found')); + } + + if (! Gate::allows('delete', $category)) { + return Response::make(Response::error('Unauthorized')); + } + + $name = $category->name; + + try { + $category->delete(); + } catch (\Exception $e) { + return Response::make(Response::error('Category cannot be deleted: '.$e->getMessage())); + } + + return Response::make( + Response::text('Category '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Category deleted successfully', + 'name' => $name, + ]); + } + + private function resolveCategory(Request $request): ?Category + { + if ($request->filled('id')) { + return Category::find($request->get('id')); + } + if ($request->filled('name')) { + return Category::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the category to delete'), + 'name' => $schema->string()->description('Name of the category to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted category'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteCompanyTool.php b/app/Mcp/Tools/DeleteCompanyTool.php new file mode 100644 index 000000000000..eb7fe3ff41a4 --- /dev/null +++ b/app/Mcp/Tools/DeleteCompanyTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $company = $this->resolveCompany($request); + + if (! $company) { + return Response::make(Response::error('Company not found')); + } + + if (! Gate::allows('delete', $company)) { + return Response::make(Response::error('Unauthorized')); + } + + $name = $company->name; + + $company->delete(); + + return Response::make( + Response::text('Company '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Company deleted successfully', + 'name' => $name, + ]); + } + + private function resolveCompany(Request $request): ?Company + { + if ($request->filled('id')) { + return Company::find($request->get('id')); + } + if ($request->filled('name')) { + return Company::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the company to delete'), + 'name' => $schema->string()->description('Name of the company to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted company'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteConsumableTool.php b/app/Mcp/Tools/DeleteConsumableTool.php new file mode 100644 index 000000000000..027f9d7958f5 --- /dev/null +++ b/app/Mcp/Tools/DeleteConsumableTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error('Consumable not found')); + } + + if (! Gate::allows('delete', $consumable)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($consumable->users()->count() > 0) { + return Response::make(Response::error('Consumable has items checked out and cannot be deleted')); + } + + $name = $consumable->name; + + $consumable->delete(); + + return Response::make( + Response::text('Consumable '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Consumable deleted successfully', + 'name' => $name, + ]); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to delete'), + 'name' => $schema->string()->description('Name of the consumable to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted consumable'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteDepreciationTool.php b/app/Mcp/Tools/DeleteDepreciationTool.php new file mode 100644 index 000000000000..2573294f78b8 --- /dev/null +++ b/app/Mcp/Tools/DeleteDepreciationTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $dep = $this->resolveDepreciation($request); + + if (! $dep) { + return Response::make(Response::error('Depreciation not found')); + } + + if (! Gate::allows('delete', $dep)) { + return Response::make(Response::error('Unauthorized')); + } + + $name = $dep->name; + + $dep->delete(); + + return Response::make( + Response::text('Depreciation '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Depreciation deleted successfully', + 'name' => $name, + ]); + } + + private function resolveDepreciation(Request $request): ?Depreciation + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the depreciation to delete'), + 'name' => $schema->string()->description('Name of the depreciation to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted depreciation'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteGroupTool.php b/app/Mcp/Tools/DeleteGroupTool.php new file mode 100644 index 000000000000..9674f58ae219 --- /dev/null +++ b/app/Mcp/Tools/DeleteGroupTool.php @@ -0,0 +1,75 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $group = Group::find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $group) { + return Response::make(Response::error('Group not found')); + } + + $groupName = $group->name; + + if ($group->delete()) { + return Response::make( + Response::text('Group '.$groupName.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Group deleted successfully', + 'name' => $groupName, + ]); + } + + return Response::make(Response::error('Delete failed')); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID to delete'), + 'name' => $schema->string()->description('Group name to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted group'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteLocationTool.php b/app/Mcp/Tools/DeleteLocationTool.php new file mode 100644 index 000000000000..1fb09f3a9bce --- /dev/null +++ b/app/Mcp/Tools/DeleteLocationTool.php @@ -0,0 +1,87 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $location = $this->resolveLocation($request); + + if (! $location) { + return Response::make(Response::error('Location not found')); + } + + if (! Gate::allows('delete', $location)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($location->users()->count() > 0) { + return Response::make(Response::error('Location has users assigned and cannot be deleted')); + } + + if ($location->children()->count() > 0) { + return Response::make(Response::error('Location has child locations and cannot be deleted')); + } + + $name = $location->name; + + $location->delete(); + + return Response::make( + Response::text('Location '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Location deleted successfully', + 'name' => $name, + ]); + } + + private function resolveLocation(Request $request): ?Location + { + if ($request->filled('id')) { + return Location::find($request->get('id')); + } + if ($request->filled('name')) { + return Location::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the location to delete'), + 'name' => $schema->string()->description('Name of the location to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted location'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteManufacturerTool.php b/app/Mcp/Tools/DeleteManufacturerTool.php new file mode 100644 index 000000000000..9466107b8e28 --- /dev/null +++ b/app/Mcp/Tools/DeleteManufacturerTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + return Response::make(Response::error('Manufacturer not found')); + } + + if (! Gate::allows('delete', $manufacturer)) { + return Response::make(Response::error('Unauthorized')); + } + + $name = $manufacturer->name; + + $manufacturer->delete(); + + return Response::make( + Response::text('Manufacturer '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Manufacturer deleted successfully', + 'name' => $name, + ]); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer to delete'), + 'name' => $schema->string()->description('Name of the manufacturer to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteStatusLabelTool.php b/app/Mcp/Tools/DeleteStatusLabelTool.php new file mode 100644 index 000000000000..19f41daff58b --- /dev/null +++ b/app/Mcp/Tools/DeleteStatusLabelTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + return Response::make(Response::error('Status label not found')); + } + + if (! Gate::allows('delete', $label)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($label->assets()->count() > 0) { + return Response::make(Response::error('Status label has assets assigned and cannot be deleted')); + } + + $name = $label->name; + + $label->delete(); + + return Response::make( + Response::text('Status label '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Status label deleted successfully', + 'name' => $name, + ]); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label to delete'), + 'name' => $schema->string()->description('Name of the status label to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted status label'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteSupplierTool.php b/app/Mcp/Tools/DeleteSupplierTool.php new file mode 100644 index 000000000000..42cf3bfd19ab --- /dev/null +++ b/app/Mcp/Tools/DeleteSupplierTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + return Response::make(Response::error('Supplier not found')); + } + + if (! Gate::allows('delete', $supplier)) { + return Response::make(Response::error('Unauthorized')); + } + + $name = $supplier->name; + + $supplier->delete(); + + return Response::make( + Response::text('Supplier '.$name.' deleted successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Supplier deleted successfully', + 'name' => $name, + ]); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier to delete'), + 'name' => $schema->string()->description('Name of the supplier to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted supplier'), + ]; + } +} diff --git a/app/Mcp/Tools/GetActivityLogTool.php b/app/Mcp/Tools/GetActivityLogTool.php new file mode 100644 index 000000000000..d15baeb858b3 --- /dev/null +++ b/app/Mcp/Tools/GetActivityLogTool.php @@ -0,0 +1,102 @@ +validate([ + 'item_type' => 'nullable|string|max:255', + 'item_id' => 'nullable|integer', + 'user_id' => 'nullable|integer', + 'action_type' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $logs = Actionlog::with('user', 'item')->orderBy('created_at', 'desc'); + + if ($request->filled('item_type')) { + $logs->where('item_type', $request->get('item_type')); + } + + if ($request->filled('item_id')) { + $logs->where('item_id', $request->get('item_id')); + } + + if ($request->filled('user_id')) { + $logs->where('user_id', $request->get('user_id')); + } + + if ($request->filled('action_type')) { + $logs->where('action_type', $request->get('action_type')); + } + + $total = $logs->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $logs->skip($offset)->take($limit)->get(); + + $activityData = $results->map(fn (Actionlog $log) => [ + 'id' => $log->id, + 'action_type' => $log->action_type, + 'item_type' => $log->item_type, + 'item_id' => $log->item_id, + 'user_id' => $log->user_id, + 'user' => $log->user?->username, + 'note' => $log->note, + 'created_at' => $log->created_at?->toDateTimeString(), + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} activity log entries, returning ".count($activityData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'activity' => $activityData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'item_type' => $schema->string()->description('Filter by item type (e.g. App\\Models\\Asset)'), + 'item_id' => $schema->number()->description('Filter by item ID'), + 'user_id' => $schema->number()->description('Filter by user ID'), + 'action_type' => $schema->string()->description('Filter by action type (e.g. checkout, checkin, update)'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching log entries')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'activity' => $schema->array()->description('List of activity log entries')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetCurrentUserTool.php b/app/Mcp/Tools/GetCurrentUserTool.php new file mode 100644 index 000000000000..dac5fecd757b --- /dev/null +++ b/app/Mcp/Tools/GetCurrentUserTool.php @@ -0,0 +1,72 @@ +check()) { + return Response::make(Response::error('Not authenticated')); + } + + $user = User::with('company', 'department', 'userloc')->find(auth()->id()); + + if (! $user) { + return Response::make(Response::error('Not authenticated')); + } + + return Response::make( + Response::text('Current user: '.$user->username) + )->withStructuredContent([ + 'id' => $user->id, + 'username' => $user->username, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'company' => $user->company?->name, + 'department' => $user->department?->name, + 'location' => $user->userloc?->name, + 'employee_num' => $user->employee_num, + 'title' => $user->jobtitle, + 'phone' => $user->phone, + 'activated' => (bool) $user->activated, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID')->required(), + 'username' => $schema->string()->description('Username')->required(), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'email' => $schema->string()->description('Email address'), + 'company' => $schema->string()->description('Company name'), + 'department' => $schema->string()->description('Department name'), + 'location' => $schema->string()->description('Default location name'), + 'employee_num' => $schema->string()->description('Employee number'), + 'title' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'activated' => $schema->boolean()->description('Whether the account is activated'), + ]; + } +} diff --git a/app/Mcp/Tools/GetUserAssetsTool.php b/app/Mcp/Tools/GetUserAssetsTool.php new file mode 100644 index 000000000000..3e5eea9cf790 --- /dev/null +++ b/app/Mcp/Tools/GetUserAssetsTool.php @@ -0,0 +1,82 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::find($request->get('id')); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + $assets = Asset::where('assigned_to', $user->id) + ->where('assigned_type', User::class) + ->with('model', 'status', 'location') + ->get(); + + $data = $assets->map(fn ($asset) => [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'model' => $asset->model?->name, + 'status' => $asset->status?->name, + ])->values()->all(); + + return Response::make( + Response::text('Found '.count($data).' assets for user '.$user->username) + )->withStructuredContent([ + 'user_id' => $user->id, + 'username' => $user->username, + 'total' => count($data), + 'assets' => $data, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user whose assets should be listed (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'user_id' => $schema->number()->description('Numeric ID of the user')->required(), + 'username' => $schema->string()->description('Username of the user')->required(), + 'total' => $schema->number()->description('Total number of assets checked out to the user')->required(), + 'assets' => $schema->array()->description('List of checked-out assets'), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetModelsTool.php b/app/Mcp/Tools/ListAssetModelsTool.php new file mode 100644 index 000000000000..289e54820815 --- /dev/null +++ b/app/Mcp/Tools/ListAssetModelsTool.php @@ -0,0 +1,101 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $models = AssetModel::with('category', 'manufacturer', 'depreciation') + ->withCount('assets as assets_count'); + + if ($request->filled('search')) { + $models->TextSearch($request->get('search')); + } + + if ($request->filled('category_id')) { + $models->where('category_id', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $models->where('manufacturer_id', $request->get('manufacturer_id')); + } + + $models->orderBy('models.created_at', 'desc'); + + $total = $models->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $models->skip($offset)->take($limit)->get(); + + $modelsData = $results->map(fn (AssetModel $model) => [ + 'id' => $model->id, + 'name' => $model->name, + 'model_number' => $model->model_number, + 'category_id' => $model->category_id, + 'category' => $model->category?->name, + 'manufacturer_id' => $model->manufacturer_id, + 'manufacturer' => $model->manufacturer?->name, + 'assets_count' => $model->assets_count, + 'eol' => $model->eol, + 'min_amt' => $model->min_amt, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} asset models, returning ".count($modelsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'models' => $modelsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across model name, model number'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching asset models')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'models' => $schema->array()->description('List of asset models')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListCategoriesTool.php b/app/Mcp/Tools/ListCategoriesTool.php new file mode 100644 index 000000000000..8638c700ad44 --- /dev/null +++ b/app/Mcp/Tools/ListCategoriesTool.php @@ -0,0 +1,98 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'category_type' => 'nullable|string|in:asset,accessory,consumable,component,license', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $categories = Category::withCount( + 'showableAssets as assets_count', + 'accessories as accessories_count', + 'consumables as consumables_count', + 'components as components_count', + 'licenses as licenses_count' + ); + + if ($request->filled('search')) { + $categories->TextSearch($request->get('search')); + } + + if ($request->filled('category_type')) { + $categories->where('category_type', $request->get('category_type')); + } + + $categories->orderBy('created_at', 'desc'); + + $total = $categories->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $categories->skip($offset)->take($limit)->get(); + + $categoriesData = $results->map(fn (Category $category) => [ + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + 'assets_count' => $category->assets_count, + 'accessories_count' => $category->accessories_count, + 'consumables_count' => $category->consumables_count, + 'components_count' => $category->components_count, + 'licenses_count' => $category->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} categories, returning ".count($categoriesData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'categories' => $categoriesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across category name, type, notes'), + 'category_type' => $schema->string()->description('Filter by type: asset, accessory, consumable, component, or license'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching categories')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'categories' => $schema->array()->description('List of categories')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListCompaniesTool.php b/app/Mcp/Tools/ListCompaniesTool.php new file mode 100644 index 000000000000..27acb91d5374 --- /dev/null +++ b/app/Mcp/Tools/ListCompaniesTool.php @@ -0,0 +1,88 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $companies = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('licenses as licenses_count', 'users as users_count'); + + if ($request->filled('search')) { + $companies->TextSearch($request->get('search')); + } + + $companies->orderBy('created_at', 'desc'); + + $total = $companies->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $companies->skip($offset)->take($limit)->get(); + + $companiesData = $results->map(fn (Company $company) => [ + 'id' => $company->id, + 'name' => $company->name, + 'phone' => $company->phone, + 'fax' => $company->fax, + 'email' => $company->email, + 'assets_count' => $company->assets_count, + 'licenses_count' => $company->licenses_count, + 'users_count' => $company->users_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} companies, returning ".count($companiesData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'companies' => $companiesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across company name, phone, fax, email'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching companies')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'companies' => $schema->array()->description('List of companies')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListConsumablesTool.php b/app/Mcp/Tools/ListConsumablesTool.php new file mode 100644 index 000000000000..192a37e3d954 --- /dev/null +++ b/app/Mcp/Tools/ListConsumablesTool.php @@ -0,0 +1,110 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $consumables = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location') + ->withCount('users as users_count'); + + if ($request->filled('search')) { + $consumables->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $consumables->where('consumables.company_id', $request->get('company_id')); + } + + if ($request->filled('category_id')) { + $consumables->where('category_id', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $consumables->where('manufacturer_id', $request->get('manufacturer_id')); + } + + if ($request->filled('location_id')) { + $consumables->where('location_id', $request->get('location_id')); + } + + $total = $consumables->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $consumables->orderBy('consumables.created_at', 'desc')->skip($offset)->take($limit)->get(); + + $consumablesData = $results->map(fn (Consumable $consumable) => [ + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'users_count' => $consumable->users_count, + 'category' => $consumable->category?->name, + 'manufacturer' => $consumable->manufacturer?->name, + 'company' => $consumable->company?->name, + 'location' => $consumable->location?->name, + 'purchase_cost' => $consumable->purchase_cost, + 'purchase_date' => $consumable->purchase_date?->format('Y-m-d'), + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} consumables, returning ".count($consumablesData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'consumables' => $consumablesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across consumable name and other fields'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'location_id' => $schema->number()->description('Filter by location ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching consumables')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListDepreciationsTool.php b/app/Mcp/Tools/ListDepreciationsTool.php new file mode 100644 index 000000000000..d784b256277a --- /dev/null +++ b/app/Mcp/Tools/ListDepreciationsTool.php @@ -0,0 +1,82 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $depreciations = Depreciation::withCount('models as models_count'); + + if ($request->filled('search')) { + $depreciations->TextSearch($request->get('search')); + } + + $depreciations->orderBy('created_at', 'desc'); + + $total = $depreciations->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $depreciations->skip($offset)->take($limit)->get(); + + $depreciationsData = $results->map(fn (Depreciation $dep) => [ + 'id' => $dep->id, + 'name' => $dep->name, + 'months' => $dep->months, + 'models_count' => $dep->models_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} depreciations, returning ".count($depreciationsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'depreciations' => $depreciationsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across depreciation name'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching depreciations')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'depreciations' => $schema->array()->description('List of depreciation schedules')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListGroupsTool.php b/app/Mcp/Tools/ListGroupsTool.php new file mode 100644 index 000000000000..80a478332764 --- /dev/null +++ b/app/Mcp/Tools/ListGroupsTool.php @@ -0,0 +1,81 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $groups = Group::withCount('users as users_count') + ->orderBy('created_at', 'desc'); + + if ($request->filled('search')) { + $groups->TextSearch($request->get('search')); + } + + $total = $groups->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $groups->skip($offset)->take($limit)->get(); + + $groupsData = $results->map(fn (Group $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'notes' => $group->notes, + 'users_count' => $group->users_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} groups, returning ".count($groupsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'groups' => $groupsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search groups by name or notes'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching groups')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'groups' => $schema->array()->description('List of groups')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListLocationsTool.php b/app/Mcp/Tools/ListLocationsTool.php new file mode 100644 index 000000000000..d5d452e80dfc --- /dev/null +++ b/app/Mcp/Tools/ListLocationsTool.php @@ -0,0 +1,101 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'parent_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $locations = Location::with('parent')->withCount( + 'assets as assets_count', + 'users as users_count', + 'children as children_count' + ); + + if ($request->filled('search')) { + $locations->TextSearch($request->get('search')); + } + + if ($request->filled('parent_id')) { + $locations->where('parent_id', $request->get('parent_id')); + } + + $locations->orderBy('created_at', 'desc'); + + $total = $locations->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $locations->skip($offset)->take($limit)->get(); + + $locationsData = $results->map(fn (Location $location) => [ + 'id' => $location->id, + 'name' => $location->name, + 'address' => $location->address, + 'city' => $location->city, + 'state' => $location->state, + 'country' => $location->country, + 'zip' => $location->zip, + 'phone' => $location->phone, + 'parent_id' => $location->parent_id, + 'parent' => $location->parent?->name, + 'assets_count' => $location->assets_count, + 'users_count' => $location->users_count, + 'children_count' => $location->children_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} locations, returning ".count($locationsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'locations' => $locationsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across location name, city, state, country'), + 'parent_id' => $schema->number()->description('Filter by parent location ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching locations')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'locations' => $schema->array()->description('List of locations')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListMaintenancesTool.php b/app/Mcp/Tools/ListMaintenancesTool.php new file mode 100644 index 000000000000..ac1243a145aa --- /dev/null +++ b/app/Mcp/Tools/ListMaintenancesTool.php @@ -0,0 +1,89 @@ +validate([ + 'asset_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $maintenances = Maintenance::with('asset', 'supplier'); + + if ($request->filled('asset_id')) { + $maintenances->where('asset_id', $request->get('asset_id')); + } + + $maintenances->orderBy('created_at', 'desc'); + + $total = $maintenances->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $maintenances->skip($offset)->take($limit)->get(); + + $maintenancesData = $results->map(fn (Maintenance $maintenance) => [ + 'id' => $maintenance->id, + 'title' => $maintenance->name, + 'asset_id' => $maintenance->asset_id, + 'asset_tag' => $maintenance->asset?->asset_tag, + 'is_warranty' => (bool) $maintenance->is_warranty, + 'cost' => $maintenance->cost, + 'start_date' => $maintenance->start_date, + 'completion_date' => $maintenance->completion_date, + 'supplier' => $maintenance->supplier?->name, + 'notes' => $maintenance->notes, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} maintenances, returning ".count($maintenancesData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'maintenances' => $maintenancesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Filter by asset ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching maintenances')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'maintenances' => $schema->array()->description('List of maintenances')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListManufacturersTool.php b/app/Mcp/Tools/ListManufacturersTool.php new file mode 100644 index 000000000000..139781e3c167 --- /dev/null +++ b/app/Mcp/Tools/ListManufacturersTool.php @@ -0,0 +1,93 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $manufacturers = Manufacturer::withCount( + 'assets as assets_count', + 'licenses as licenses_count', + 'accessories as accessories_count', + 'components as components_count' + ); + + if ($request->filled('search')) { + $manufacturers->TextSearch($request->get('search')); + } + + $manufacturers->orderBy('created_at', 'desc'); + + $total = $manufacturers->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $manufacturers->skip($offset)->take($limit)->get(); + + $manufacturersData = $results->map(fn (Manufacturer $manufacturer) => [ + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + 'url' => $manufacturer->url, + 'support_url' => $manufacturer->support_url, + 'support_email' => $manufacturer->support_email, + 'support_phone' => $manufacturer->support_phone, + 'assets_count' => $manufacturer->assets_count, + 'licenses_count' => $manufacturer->licenses_count, + 'accessories_count' => $manufacturer->accessories_count, + 'components_count' => $manufacturer->components_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} manufacturers, returning ".count($manufacturersData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'manufacturers' => $manufacturersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across manufacturer name and notes'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching manufacturers')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'manufacturers' => $schema->array()->description('List of manufacturers')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListStatusLabelsTool.php b/app/Mcp/Tools/ListStatusLabelsTool.php new file mode 100644 index 000000000000..4fe247f727b2 --- /dev/null +++ b/app/Mcp/Tools/ListStatusLabelsTool.php @@ -0,0 +1,99 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'status_type' => 'nullable|string|in:deployable,pending,archived,undeployable', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $labels = Statuslabel::withCount('assets as assets_count'); + + if ($request->filled('search')) { + $labels->TextSearch($request->get('search')); + } + + if ($request->filled('status_type')) { + $type = $request->get('status_type'); + if ($type === 'deployable') { + $labels->Deployable(); + } elseif ($type === 'pending') { + $labels->Pending(); + } elseif ($type === 'archived') { + $labels->Archived(); + } elseif ($type === 'undeployable') { + $labels->Undeployable(); + } + } + + $total = $labels->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $labels->skip($offset)->take($limit)->get(); + + $labelsData = $results->map(fn (Statuslabel $label) => [ + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + 'color' => $label->color, + 'assets_count' => $label->assets_count, + 'deployable' => $label->deployable, + 'pending' => $label->pending, + 'archived' => $label->archived, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} status labels, returning ".count($labelsData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'status_labels' => $labelsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across status label name and notes'), + 'status_type' => $schema->string()->description('Filter by type: deployable, pending, archived, undeployable'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching status labels')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'status_labels' => $schema->array()->description('List of status labels')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListSuppliersTool.php b/app/Mcp/Tools/ListSuppliersTool.php new file mode 100644 index 000000000000..d2f75b1c0dfe --- /dev/null +++ b/app/Mcp/Tools/ListSuppliersTool.php @@ -0,0 +1,92 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $suppliers = Supplier::withCount( + 'assets as assets_count', + 'licenses as licenses_count' + ); + + if ($request->filled('search')) { + $suppliers->TextSearch($request->get('search')); + } + + $suppliers->orderBy('created_at', 'desc'); + + $total = $suppliers->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $suppliers->skip($offset)->take($limit)->get(); + + $suppliersData = $results->map(fn (Supplier $supplier) => [ + 'id' => $supplier->id, + 'name' => $supplier->name, + 'address' => $supplier->address, + 'city' => $supplier->city, + 'state' => $supplier->state, + 'country' => $supplier->country, + 'phone' => $supplier->phone, + 'email' => $supplier->email, + 'url' => $supplier->url, + 'assets_count' => $supplier->assets_count, + 'licenses_count' => $supplier->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text("Found {$total} suppliers, returning ".count($suppliersData)) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'suppliers' => $suppliersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across supplier fields'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching suppliers')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'suppliers' => $schema->array()->description('List of suppliers')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Reset2FATool.php b/app/Mcp/Tools/Reset2FATool.php new file mode 100644 index 000000000000..6cecd54ba5e7 --- /dev/null +++ b/app/Mcp/Tools/Reset2FATool.php @@ -0,0 +1,68 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::find($request->get('id')); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + $user->two_factor_secret = null; + $user->two_factor_enrolled = 0; + $user->two_factor_optin = 0; + $user->save(); + + return Response::make( + Response::text('Two-factor authentication reset for '.$user->username) + )->withStructuredContent([ + 'success' => true, + 'message' => 'Two-factor authentication reset successfully', + 'id' => $user->id, + 'username' => $user->username, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user whose 2FA should be reset (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the reset succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the user'), + 'username' => $schema->string()->description('Username of the user'), + ]; + } +} diff --git a/app/Mcp/Tools/RestoreAssetTool.php b/app/Mcp/Tools/RestoreAssetTool.php new file mode 100644 index 000000000000..8a3a5c780d09 --- /dev/null +++ b/app/Mcp/Tools/RestoreAssetTool.php @@ -0,0 +1,69 @@ +validate([ + 'id' => 'required|integer', + ]); + + $asset = Asset::withTrashed()->find($request->get('id')); + + if (! $asset) { + return Response::make(Response::error('Asset not found')); + } + + if (! $asset->deleted_at) { + return Response::make(Response::error('Asset is not deleted')); + } + + if (! Gate::allows('delete', Asset::class)) { + return Response::make(Response::error('Unauthorized')); + } + + $asset->restore(); + + return Response::make( + Response::text('Asset '.$asset->asset_tag.' restored successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset restored successfully', + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset to restore (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the restore succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the restored asset'), + 'asset_tag' => $schema->string()->description('Asset tag of the restored asset'), + ]; + } +} diff --git a/app/Mcp/Tools/RestoreUserTool.php b/app/Mcp/Tools/RestoreUserTool.php new file mode 100644 index 000000000000..4bf2a2e11960 --- /dev/null +++ b/app/Mcp/Tools/RestoreUserTool.php @@ -0,0 +1,73 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::withTrashed()->find($request->get('id')); + + if (! $user) { + return Response::make(Response::error('User not found')); + } + + if (! $user->deleted_at) { + return Response::make(Response::error('User is not deleted')); + } + + if (! Gate::allows('delete', User::class)) { + return Response::make(Response::error('Unauthorized')); + } + + $user->restore(); + + return Response::make( + Response::text('User '.$user->username.' restored successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'User restored successfully', + 'id' => $user->id, + 'username' => $user->username, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user to restore (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the restore succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the restored user'), + 'username' => $schema->string()->description('Username of the restored user'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowAssetModelTool.php b/app/Mcp/Tools/ShowAssetModelTool.php new file mode 100644 index 000000000000..2e3b9c24f31d --- /dev/null +++ b/app/Mcp/Tools/ShowAssetModelTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $model = $this->resolveModel($request); + + if ($model === false) { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $model) { + return Response::make(Response::error('Asset model not found')); + } + + if (! Gate::allows('view', $model)) { + return Response::make(Response::error('Unauthorized')); + } + + $model->loadCount('assets as assets_count'); + + return Response::make( + Response::text('Asset model '.$model->name.' found') + )->withStructuredContent([ + 'id' => $model->id, + 'name' => $model->name, + 'model_number' => $model->model_number, + 'category_id' => $model->category_id, + 'category' => $model->category?->name, + 'manufacturer_id' => $model->manufacturer_id, + 'manufacturer' => $model->manufacturer?->name, + 'depreciation_id' => $model->depreciation_id, + 'depreciation' => $model->depreciation?->name, + 'assets_count' => $model->assets_count, + 'eol' => $model->eol, + 'min_amt' => $model->min_amt, + 'notes' => $model->notes, + 'created_at' => $model->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $model->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveModel(Request $request): AssetModel|false|null + { + if ($request->filled('id')) { + return AssetModel::with('category', 'manufacturer', 'depreciation')->find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::with('category', 'manufacturer', 'depreciation')->where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset model to look up'), + 'name' => $schema->string()->description('Name of the asset model to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric asset model ID'), + 'name' => $schema->string()->description('Asset model name'), + 'model_number' => $schema->string()->description('Model number'), + 'category_id' => $schema->number()->description('Category ID'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'depreciation' => $schema->string()->description('Depreciation schedule name'), + 'assets_count' => $schema->number()->description('Number of assets using this model'), + 'eol' => $schema->number()->description('End of life in months'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowCategoryTool.php b/app/Mcp/Tools/ShowCategoryTool.php new file mode 100644 index 000000000000..596c28c75443 --- /dev/null +++ b/app/Mcp/Tools/ShowCategoryTool.php @@ -0,0 +1,95 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $withCounts = [ + 'showableAssets as assets_count', + 'accessories as accessories_count', + 'consumables as consumables_count', + 'components as components_count', + 'licenses as licenses_count', + ]; + + $category = null; + + if ($request->filled('id')) { + $category = Category::withCount($withCounts)->find($request->get('id')); + } elseif ($request->filled('name')) { + $category = Category::withCount($withCounts)->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $category) { + return Response::make(Response::error('Category not found')); + } + + if (! Gate::allows('view', $category)) { + return Response::make(Response::error('Unauthorized')); + } + + return Response::make( + Response::text('Category '.$category->name.' found') + )->withStructuredContent([ + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + 'assets_count' => $category->assets_count, + 'accessories_count' => $category->accessories_count, + 'consumables_count' => $category->consumables_count, + 'components_count' => $category->components_count, + 'licenses_count' => $category->licenses_count, + 'notes' => $category->notes, + 'created_at' => $category->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $category->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the category to look up'), + 'name' => $schema->string()->description('Name of the category to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric category ID'), + 'name' => $schema->string()->description('Category name'), + 'category_type' => $schema->string()->description('Category type: asset, accessory, consumable, component, or license'), + 'assets_count' => $schema->number()->description('Number of assets in this category'), + 'accessories_count' => $schema->number()->description('Number of accessories in this category'), + 'consumables_count' => $schema->number()->description('Number of consumables in this category'), + 'components_count' => $schema->number()->description('Number of components in this category'), + 'licenses_count' => $schema->number()->description('Number of licenses in this category'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowCompanyTool.php b/app/Mcp/Tools/ShowCompanyTool.php new file mode 100644 index 000000000000..9003ebf3186e --- /dev/null +++ b/app/Mcp/Tools/ShowCompanyTool.php @@ -0,0 +1,89 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $company = null; + + if ($request->filled('id')) { + $company = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('users as users_count')->find($request->get('id')); + } elseif ($request->filled('name')) { + $company = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('users as users_count')->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $company) { + return Response::make(Response::error('Company not found')); + } + + if (! Gate::allows('view', $company)) { + return Response::make(Response::error('Unauthorized')); + } + + return Response::make( + Response::text('Company '.$company->name.' found') + )->withStructuredContent([ + 'id' => $company->id, + 'name' => $company->name, + 'phone' => $company->phone, + 'fax' => $company->fax, + 'email' => $company->email, + 'assets_count' => $company->assets_count, + 'users_count' => $company->users_count, + 'notes' => $company->notes, + 'created_at' => $company->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $company->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the company to look up'), + 'name' => $schema->string()->description('Name of the company to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric company ID'), + 'name' => $schema->string()->description('Company name'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'assets_count' => $schema->number()->description('Number of assets belonging to this company'), + 'users_count' => $schema->number()->description('Number of users belonging to this company'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowConsumableTool.php b/app/Mcp/Tools/ShowConsumableTool.php new file mode 100644 index 000000000000..7436d1251634 --- /dev/null +++ b/app/Mcp/Tools/ShowConsumableTool.php @@ -0,0 +1,107 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $consumable = null; + + if ($request->filled('id')) { + $consumable = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location')->find($request->get('id')); + } elseif ($request->filled('name')) { + $consumable = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location')->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error('Either id or name is required')); + } + + if (! $consumable) { + return Response::make(Response::error('Consumable not found')); + } + + if (! Gate::allows('view', $consumable)) { + return Response::make(Response::error('Unauthorized')); + } + + $usersCount = $consumable->users()->count(); + + return Response::make( + Response::text('Consumable '.$consumable->name.' found') + )->withStructuredContent([ + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'users_count' => $usersCount, + 'min_amt' => $consumable->min_amt, + 'category_id' => $consumable->category_id, + 'category' => $consumable->category?->name, + 'manufacturer_id' => $consumable->manufacturer_id, + 'manufacturer' => $consumable->manufacturer?->name, + 'company_id' => $consumable->company_id, + 'company' => $consumable->company?->name, + 'location_id' => $consumable->location_id, + 'location' => $consumable->location?->name, + 'purchase_cost' => $consumable->purchase_cost, + 'purchase_date' => $consumable->purchase_date?->format('Y-m-d'), + 'order_number' => $consumable->order_number, + 'model_number' => $consumable->model_number, + 'notes' => $consumable->notes, + 'created_at' => $consumable->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $consumable->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to look up'), + 'name' => $schema->string()->description('Name of the consumable to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric consumable ID'), + 'name' => $schema->string()->description('Consumable name'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'users_count' => $schema->number()->description('Number of units checked out'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'category_id' => $schema->number()->description('Category ID'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'company_id' => $schema->number()->description('Company ID'), + 'company' => $schema->string()->description('Company name'), + 'location_id' => $schema->number()->description('Location ID'), + 'location' => $schema->string()->description('Location name'), + 'purchase_cost' => $schema->string()->description('Purchase cost'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'order_number' => $schema->string()->description('Order number'), + 'model_number' => $schema->string()->description('Model number'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last updated timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowDepreciationTool.php b/app/Mcp/Tools/ShowDepreciationTool.php new file mode 100644 index 000000000000..4af218731eed --- /dev/null +++ b/app/Mcp/Tools/ShowDepreciationTool.php @@ -0,0 +1,87 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $depreciation = $this->resolveDepreciation($request); + + if ($depreciation === false) { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $depreciation) { + return Response::make(Response::error('Depreciation not found')); + } + + if (! Gate::allows('view', $depreciation)) { + return Response::make(Response::error('Unauthorized')); + } + + $depreciation->loadCount('models as models_count'); + + return Response::make( + Response::text('Depreciation '.$depreciation->name.' found') + )->withStructuredContent([ + 'id' => $depreciation->id, + 'name' => $depreciation->name, + 'months' => $depreciation->months, + 'models_count' => $depreciation->models_count, + 'created_at' => $depreciation->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $depreciation->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveDepreciation(Request $request): Depreciation|false|null + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the depreciation to look up'), + 'name' => $schema->string()->description('Name of the depreciation to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric depreciation ID'), + 'name' => $schema->string()->description('Depreciation name'), + 'months' => $schema->number()->description('Depreciation period in months'), + 'models_count' => $schema->number()->description('Number of asset models using this depreciation'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowGroupTool.php b/app/Mcp/Tools/ShowGroupTool.php new file mode 100644 index 000000000000..9cbe386579d2 --- /dev/null +++ b/app/Mcp/Tools/ShowGroupTool.php @@ -0,0 +1,75 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $group = Group::withCount('users as users_count')->find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::withCount('users as users_count') + ->where('name', $request->get('name')) + ->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $group) { + return Response::make(Response::error('Group not found')); + } + + return Response::make( + Response::text('Group '.$group->name.' found') + )->withStructuredContent([ + 'id' => $group->id, + 'name' => $group->name, + 'notes' => $group->notes, + 'permissions' => $group->decodePermissions(), + 'users_count' => $group->users_count, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID'), + 'name' => $schema->string()->description('Group name to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID')->required(), + 'name' => $schema->string()->description('Group name')->required(), + 'notes' => $schema->string()->description('Notes about the group'), + 'permissions' => $schema->object()->description('Decoded permissions array'), + 'users_count' => $schema->number()->description('Number of users in this group'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowLocationTool.php b/app/Mcp/Tools/ShowLocationTool.php new file mode 100644 index 000000000000..b3cf1ce7465d --- /dev/null +++ b/app/Mcp/Tools/ShowLocationTool.php @@ -0,0 +1,109 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $location = $this->resolveLocation($request); + + if ($location === false) { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $location) { + return Response::make(Response::error('Location not found')); + } + + if (! Gate::allows('view', $location)) { + return Response::make(Response::error('Unauthorized')); + } + + $location->loadCount('assets as assets_count', 'users as users_count', 'children as children_count'); + + return Response::make( + Response::text('Location '.$location->name.' found') + )->withStructuredContent([ + 'id' => $location->id, + 'name' => $location->name, + 'address' => $location->address, + 'address2' => $location->address2, + 'city' => $location->city, + 'state' => $location->state, + 'country' => $location->country, + 'zip' => $location->zip, + 'phone' => $location->phone, + 'fax' => $location->fax, + 'parent_id' => $location->parent_id, + 'parent' => $location->parent?->name, + 'assets_count' => $location->assets_count, + 'users_count' => $location->users_count, + 'children_count' => $location->children_count, + 'created_at' => $location->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $location->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveLocation(Request $request): Location|false|null + { + if ($request->filled('id')) { + return Location::with('parent')->find($request->get('id')); + } + if ($request->filled('name')) { + return Location::with('parent')->where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the location to look up'), + 'name' => $schema->string()->description('Name of the location to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric location ID'), + 'name' => $schema->string()->description('Location name'), + 'address' => $schema->string()->description('Street address'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'parent' => $schema->string()->description('Parent location name'), + 'assets_count' => $schema->number()->description('Number of assets at this location'), + 'users_count' => $schema->number()->description('Number of users at this location'), + 'children_count' => $schema->number()->description('Number of child locations'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowManufacturerTool.php b/app/Mcp/Tools/ShowManufacturerTool.php new file mode 100644 index 000000000000..671d06b63023 --- /dev/null +++ b/app/Mcp/Tools/ShowManufacturerTool.php @@ -0,0 +1,97 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error('Please provide an id or name')); + } + + return Response::make(Response::error('Manufacturer not found')); + } + + if (! Gate::allows('view', $manufacturer)) { + return Response::make(Response::error('Unauthorized')); + } + + $manufacturer->loadCount('assets as assets_count'); + + return Response::make( + Response::text('Manufacturer: '.$manufacturer->name) + )->withStructuredContent([ + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + 'url' => $manufacturer->url, + 'support_url' => $manufacturer->support_url, + 'support_email' => $manufacturer->support_email, + 'support_phone' => $manufacturer->support_phone, + 'warranty_lookup_url' => $manufacturer->warranty_lookup_url, + 'assets_count' => $manufacturer->assets_count, + 'notes' => $manufacturer->notes, + 'created_at' => $manufacturer->created_at?->toISOString(), + 'updated_at' => $manufacturer->updated_at?->toISOString(), + ]); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer to show'), + 'name' => $schema->string()->description('Name of the manufacturer to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer'), + 'name' => $schema->string()->description('Manufacturer name')->required(), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'assets_count' => $schema->number()->description('Number of assets from this manufacturer'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowStatusLabelTool.php b/app/Mcp/Tools/ShowStatusLabelTool.php new file mode 100644 index 000000000000..08199ce7b35e --- /dev/null +++ b/app/Mcp/Tools/ShowStatusLabelTool.php @@ -0,0 +1,101 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error('Please provide an id or name')); + } + + return Response::make(Response::error('Status label not found')); + } + + if (! Gate::allows('view', $label)) { + return Response::make(Response::error('Unauthorized')); + } + + $label->loadCount('assets as assets_count'); + + return Response::make( + Response::text('Status label: '.$label->name) + )->withStructuredContent([ + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + 'color' => $label->color, + 'deployable' => $label->deployable, + 'pending' => $label->pending, + 'archived' => $label->archived, + 'assets_count' => $label->assets_count, + 'default_label' => $label->default_label, + 'show_in_nav' => $label->show_in_nav, + 'notes' => $label->notes, + 'created_at' => $label->created_at?->toISOString(), + 'updated_at' => $label->updated_at?->toISOString(), + ]); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label to show'), + 'name' => $schema->string()->description('Name of the status label to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label'), + 'name' => $schema->string()->description('Status label name')->required(), + 'type' => $schema->string()->description('Status label type (deployable, pending, archived, undeployable)'), + 'color' => $schema->string()->description('Display color'), + 'deployable' => $schema->boolean()->description('Whether status is deployable'), + 'pending' => $schema->boolean()->description('Whether status is pending'), + 'archived' => $schema->boolean()->description('Whether status is archived'), + 'assets_count' => $schema->number()->description('Number of assets with this status'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowSupplierTool.php b/app/Mcp/Tools/ShowSupplierTool.php new file mode 100644 index 000000000000..3ae06dedd90a --- /dev/null +++ b/app/Mcp/Tools/ShowSupplierTool.php @@ -0,0 +1,111 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error('Please provide an id or name')); + } + + return Response::make(Response::error('Supplier not found')); + } + + if (! Gate::allows('view', $supplier)) { + return Response::make(Response::error('Unauthorized')); + } + + $supplier->loadCount('assets as assets_count', 'licenses as licenses_count'); + + return Response::make( + Response::text('Supplier: '.$supplier->name) + )->withStructuredContent([ + 'id' => $supplier->id, + 'name' => $supplier->name, + 'address' => $supplier->address, + 'address2' => $supplier->address2, + 'city' => $supplier->city, + 'state' => $supplier->state, + 'country' => $supplier->country, + 'zip' => $supplier->zip, + 'phone' => $supplier->phone, + 'fax' => $supplier->fax, + 'email' => $supplier->email, + 'url' => $supplier->url, + 'contact' => $supplier->contact, + 'notes' => $supplier->notes, + 'assets_count' => $supplier->assets_count, + 'licenses_count' => $supplier->licenses_count, + 'created_at' => $supplier->created_at?->toISOString(), + 'updated_at' => $supplier->updated_at?->toISOString(), + ]); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier to show'), + 'name' => $schema->string()->description('Name of the supplier to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier'), + 'name' => $schema->string()->description('Supplier name')->required(), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + 'assets_count' => $schema->number()->description('Number of assets from this supplier'), + 'licenses_count' => $schema->number()->description('Number of licenses from this supplier'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAssetModelTool.php b/app/Mcp/Tools/UpdateAssetModelTool.php new file mode 100644 index 000000000000..f40701def23e --- /dev/null +++ b/app/Mcp/Tools/UpdateAssetModelTool.php @@ -0,0 +1,111 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'depreciation_id' => 'nullable|integer|exists:depreciations,id', + 'model_number' => 'nullable|string|max:255', + 'eol' => 'nullable|integer|min:0|max:240', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + 'requestable' => 'nullable|boolean', + 'require_serial' => 'nullable|boolean', + ]); + + $model = $this->resolveModel($request); + + if (! $model) { + return Response::make(Response::error('Asset model not found')); + } + + if (! Gate::allows('update', $model)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $model->name = $request->get('new_name'); + } + + foreach (['category_id', 'manufacturer_id', 'depreciation_id', 'model_number', 'eol', 'min_amt', 'notes', 'requestable', 'require_serial'] as $field) { + if ($request->filled($field)) { + $model->{$field} = $request->get($field); + } + } + + if ($model->save()) { + return Response::make( + Response::text('Asset model '.$model->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Asset model updated successfully', + 'id' => $model->id, + 'name' => $model->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$model->getErrors()->first())); + } + + private function resolveModel(Request $request): ?AssetModel + { + if ($request->filled('id')) { + return AssetModel::find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the asset model'), + 'name' => $schema->string()->description('Name to identify the asset model'), + 'new_name' => $schema->string()->description('New name (renames the asset model)'), + 'category_id' => $schema->number()->description('Category ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'model_number' => $schema->string()->description('Model number'), + 'eol' => $schema->number()->description('End of life in months (0-240)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'requestable' => $schema->boolean()->description('Whether the model can be requested'), + 'require_serial' => $schema->boolean()->description('Whether serial numbers are required'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the asset model'), + 'name' => $schema->string()->description('Name of the asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateCategoryTool.php b/app/Mcp/Tools/UpdateCategoryTool.php new file mode 100644 index 000000000000..048576393e56 --- /dev/null +++ b/app/Mcp/Tools/UpdateCategoryTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_type' => 'nullable|string|in:asset,accessory,consumable,component,license', + 'notes' => 'nullable|string', + 'checkin_email' => 'nullable|boolean', + 'require_acceptance' => 'nullable|boolean', + 'use_default_eula' => 'nullable|boolean', + ]); + + $category = $this->resolveCategory($request); + + if (! $category) { + return Response::make(Response::error('Category not found')); + } + + if (! Gate::allows('update', $category)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $category->name = $request->get('new_name'); + } + + foreach (['category_type', 'notes', 'checkin_email', 'require_acceptance', 'use_default_eula'] as $field) { + if ($request->filled($field)) { + $category->{$field} = $request->get($field); + } + } + + if ($category->save()) { + return Response::make( + Response::text('Category '.$category->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Category updated successfully', + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + ]); + } + + return Response::make(Response::error('Update failed: '.$category->getErrors()->first())); + } + + private function resolveCategory(Request $request): ?Category + { + if ($request->filled('id')) { + return Category::find($request->get('id')); + } + if ($request->filled('name')) { + return Category::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the category'), + 'name' => $schema->string()->description('Name to identify the category'), + 'new_name' => $schema->string()->description('New name (renames the category)'), + 'category_type' => $schema->string()->description('Category type: asset, accessory, consumable, component, or license'), + 'checkin_email' => $schema->boolean()->description('Send checkin email when items are checked in'), + 'require_acceptance' => $schema->boolean()->description('Require user acceptance when checking out'), + 'use_default_eula' => $schema->boolean()->description('Use the default EULA'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the category'), + 'name' => $schema->string()->description('Name of the category'), + 'category_type' => $schema->string()->description('Type of the category'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateCompanyTool.php b/app/Mcp/Tools/UpdateCompanyTool.php new file mode 100644 index 000000000000..0c7edec457a4 --- /dev/null +++ b/app/Mcp/Tools/UpdateCompanyTool.php @@ -0,0 +1,101 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $company = $this->resolveCompany($request); + + if (! $company) { + return Response::make(Response::error('Company not found')); + } + + if (! Gate::allows('update', $company)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $company->name = $request->get('new_name'); + } + + foreach (['phone', 'fax', 'email', 'notes'] as $field) { + if ($request->filled($field)) { + $company->{$field} = $request->get($field); + } + } + + if ($company->save()) { + return Response::make( + Response::text('Company '.$company->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Company updated successfully', + 'id' => $company->id, + 'name' => $company->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$company->getErrors()->first())); + } + + private function resolveCompany(Request $request): ?Company + { + if ($request->filled('id')) { + return Company::find($request->get('id')); + } + if ($request->filled('name')) { + return Company::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the company'), + 'name' => $schema->string()->description('Name to identify the company'), + 'new_name' => $schema->string()->description('New name (renames the company)'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the company'), + 'name' => $schema->string()->description('Name of the company'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateConsumableTool.php b/app/Mcp/Tools/UpdateConsumableTool.php new file mode 100644 index 000000000000..62f70beb2f43 --- /dev/null +++ b/app/Mcp/Tools/UpdateConsumableTool.php @@ -0,0 +1,120 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'qty' => 'nullable|integer|min:0', + 'category_id' => 'nullable|integer|exists:categories,id', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error('Consumable not found')); + } + + if (! Gate::allows('update', $consumable)) { + return Response::make(Response::error('Unauthorized')); + } + + $updatable = [ + 'qty', 'category_id', 'company_id', 'location_id', 'manufacturer_id', + 'supplier_id', 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $consumable->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $consumable->name = $request->get('new_name'); + } + + if ($consumable->save()) { + return Response::make( + Response::text('Consumable '.$consumable->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Consumable updated successfully', + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + ]); + } + + return Response::make(Response::error('Update failed: '.$consumable->getErrors()->first())); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the consumable'), + 'name' => $schema->string()->description('Name to identify the consumable'), + 'new_name' => $schema->string()->description('New name (renames the consumable)'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'category_id' => $schema->number()->description('Category ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the consumable'), + 'name' => $schema->string()->description('Name of the consumable'), + 'qty' => $schema->number()->description('Total quantity'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateDepreciationTool.php b/app/Mcp/Tools/UpdateDepreciationTool.php new file mode 100644 index 000000000000..1fb2ba22230d --- /dev/null +++ b/app/Mcp/Tools/UpdateDepreciationTool.php @@ -0,0 +1,95 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'months' => 'nullable|integer|min:1|max:3600', + ]); + + $dep = $this->resolveDepreciation($request); + + if (! $dep) { + return Response::make(Response::error('Depreciation not found')); + } + + if (! Gate::allows('update', $dep)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $dep->name = $request->get('new_name'); + } + + if ($request->filled('months')) { + $dep->months = $request->get('months'); + } + + if ($dep->save()) { + return Response::make( + Response::text('Depreciation '.$dep->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Depreciation updated successfully', + 'id' => $dep->id, + 'name' => $dep->name, + 'months' => $dep->months, + ]); + } + + return Response::make(Response::error('Update failed: '.$dep->getErrors()->first())); + } + + private function resolveDepreciation(Request $request): ?Depreciation + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the depreciation'), + 'name' => $schema->string()->description('Name to identify the depreciation'), + 'new_name' => $schema->string()->description('New name (renames the depreciation)'), + 'months' => $schema->number()->description('Depreciation period in months (1-3600)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the depreciation'), + 'name' => $schema->string()->description('Name of the depreciation'), + 'months' => $schema->number()->description('Depreciation period in months'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateGroupTool.php b/app/Mcp/Tools/UpdateGroupTool.php new file mode 100644 index 000000000000..286c367af26d --- /dev/null +++ b/app/Mcp/Tools/UpdateGroupTool.php @@ -0,0 +1,92 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + if ($request->filled('id')) { + $group = Group::find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error('Please provide an id or name')); + } + + if (! $group) { + return Response::make(Response::error('Group not found')); + } + + if ($request->filled('new_name')) { + $group->name = $request->get('new_name'); + } + + if ($request->filled('notes')) { + $group->notes = $request->get('notes'); + } + + if ($group->save()) { + return Response::make( + Response::text('Group '.$group->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Group updated successfully', + 'id' => $group->id, + 'name' => $group->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$group->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID to update'), + 'name' => $schema->string()->description('Group name to look up for updating'), + 'new_name' => $schema->string()->description('New name to rename the group to'), + 'notes' => $schema->string()->description('Updated notes for the group'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated group'), + 'name' => $schema->string()->description('Name of the updated group'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateLocationTool.php b/app/Mcp/Tools/UpdateLocationTool.php new file mode 100644 index 000000000000..27af3a14684e --- /dev/null +++ b/app/Mcp/Tools/UpdateLocationTool.php @@ -0,0 +1,113 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'address' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'currency' => 'nullable|string', + 'parent_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + ]); + + $location = $this->resolveLocation($request); + + if (! $location) { + return Response::make(Response::error('Location not found')); + } + + if (! Gate::allows('update', $location)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $location->name = $request->get('new_name'); + } + + foreach (['address', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'currency', 'parent_id', 'manager_id'] as $field) { + if ($request->filled($field)) { + $location->{$field} = $request->get($field); + } + } + + if ($location->save()) { + return Response::make( + Response::text('Location '.$location->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Location updated successfully', + 'id' => $location->id, + 'name' => $location->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$location->getErrors()->first())); + } + + private function resolveLocation(Request $request): ?Location + { + if ($request->filled('id')) { + return Location::find($request->get('id')); + } + if ($request->filled('name')) { + return Location::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the location'), + 'name' => $schema->string()->description('Name to identify the location'), + 'new_name' => $schema->string()->description('New name (renames the location)'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'currency' => $schema->string()->description('Currency code'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the location'), + 'name' => $schema->string()->description('Name of the location'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateManufacturerTool.php b/app/Mcp/Tools/UpdateManufacturerTool.php new file mode 100644 index 000000000000..11a79272bb59 --- /dev/null +++ b/app/Mcp/Tools/UpdateManufacturerTool.php @@ -0,0 +1,107 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'url' => 'nullable|string|max:255', + 'support_url' => 'nullable|string|max:255', + 'support_email' => 'nullable|email|max:191', + 'support_phone' => 'nullable|string|max:191', + 'warranty_lookup_url' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + return Response::make(Response::error('Manufacturer not found')); + } + + if (! Gate::allows('update', $manufacturer)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $manufacturer->name = $request->get('new_name'); + } + + $updatable = ['url', 'support_url', 'support_email', 'support_phone', 'warranty_lookup_url', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $manufacturer->{$field} = $request->get($field); + } + } + + if ($manufacturer->save()) { + return Response::make( + Response::text('Manufacturer '.$manufacturer->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Manufacturer updated successfully', + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$manufacturer->getErrors()->first())); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the manufacturer'), + 'name' => $schema->string()->description('Name to identify the manufacturer'), + 'new_name' => $schema->string()->description('New name (renames the manufacturer)'), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the manufacturer'), + 'name' => $schema->string()->description('Name of the manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateStatusLabelTool.php b/app/Mcp/Tools/UpdateStatusLabelTool.php new file mode 100644 index 000000000000..66676a48610a --- /dev/null +++ b/app/Mcp/Tools/UpdateStatusLabelTool.php @@ -0,0 +1,122 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'type' => 'nullable|string|in:deployable,pending,archived,undeployable', + 'color' => 'nullable|string', + 'notes' => 'nullable|string', + 'default_label' => 'nullable|boolean', + 'show_in_nav' => 'nullable|boolean', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + return Response::make(Response::error('Status label not found')); + } + + if (! Gate::allows('update', $label)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $label->name = $request->get('new_name'); + } + + if ($request->filled('type')) { + $statusType = Statuslabel::getStatuslabelTypesForDB($request->get('type')); + $label->deployable = $statusType['deployable']; + $label->pending = $statusType['pending']; + $label->archived = $statusType['archived']; + } + + if ($request->filled('color')) { + $label->color = $request->get('color'); + } + + if ($request->filled('notes')) { + $label->notes = $request->get('notes'); + } + + if ($request->has('default_label')) { + $label->default_label = $request->get('default_label'); + } + + if ($request->has('show_in_nav')) { + $label->show_in_nav = $request->get('show_in_nav'); + } + + if ($label->save()) { + return Response::make( + Response::text('Status label '.$label->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Status label updated successfully', + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + ]); + } + + return Response::make(Response::error('Update failed: '.$label->getErrors()->first())); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the status label'), + 'name' => $schema->string()->description('Name to identify the status label'), + 'new_name' => $schema->string()->description('New name (renames the status label)'), + 'type' => $schema->string()->description('New type: deployable, pending, archived, or undeployable'), + 'color' => $schema->string()->description('Display color in #RRGGBB format'), + 'notes' => $schema->string()->description('Notes'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the status label'), + 'name' => $schema->string()->description('Name of the status label'), + 'type' => $schema->string()->description('Type of the status label'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateSupplierTool.php b/app/Mcp/Tools/UpdateSupplierTool.php new file mode 100644 index 000000000000..ccfe22b214bb --- /dev/null +++ b/app/Mcp/Tools/UpdateSupplierTool.php @@ -0,0 +1,119 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|email', + 'url' => 'nullable|string', + 'contact' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + return Response::make(Response::error('Supplier not found')); + } + + if (! Gate::allows('update', $supplier)) { + return Response::make(Response::error('Unauthorized')); + } + + if ($request->filled('new_name')) { + $supplier->name = $request->get('new_name'); + } + + $updatable = ['address', 'address2', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'email', 'url', 'contact', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $supplier->{$field} = $request->get($field); + } + } + + if ($supplier->save()) { + return Response::make( + Response::text('Supplier '.$supplier->name.' updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Supplier updated successfully', + 'id' => $supplier->id, + 'name' => $supplier->name, + ]); + } + + return Response::make(Response::error('Update failed: '.$supplier->getErrors()->first())); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the supplier'), + 'name' => $schema->string()->description('Name to identify the supplier'), + 'new_name' => $schema->string()->description('New name (renames the supplier)'), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the supplier'), + 'name' => $schema->string()->description('Name of the supplier'), + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c7630f633804..a6ba7e40b032 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -180,14 +180,24 @@ public function viewRequestableAssets() return $this->appendPermission(['assets.view.requestable' => '1']); } - public function deleteAssetModels() + public function viewAssetModels() { - return $this->appendPermission(['models.delete' => '1']); + return $this->appendPermission(['models.view' => '1']); } - public function viewAssetModels() + public function createAssetModels() { - return $this->appendPermission(['models.view' => '1']); + return $this->appendPermission(['models.create' => '1']); + } + + public function editAssetModels() + { + return $this->appendPermission(['models.edit' => '1']); + } + + public function deleteAssetModels() + { + return $this->appendPermission(['models.delete' => '1']); } public function viewAccessories() @@ -370,11 +380,41 @@ public function deleteUsers() return $this->appendPermission(['users.delete' => '1']); } + public function viewCategories() + { + return $this->appendPermission(['categories.view' => '1']); + } + + public function createCategories() + { + return $this->appendPermission(['categories.create' => '1']); + } + + public function editCategories() + { + return $this->appendPermission(['categories.edit' => '1']); + } + public function deleteCategories() { return $this->appendPermission(['categories.delete' => '1']); } + public function viewLocations() + { + return $this->appendPermission(['locations.view' => '1']); + } + + public function createLocations() + { + return $this->appendPermission(['locations.create' => '1']); + } + + public function editLocations() + { + return $this->appendPermission(['locations.edit' => '1']); + } + public function deleteLocations() { return $this->appendPermission(['locations.delete' => '1']); @@ -415,11 +455,41 @@ public function deleteCustomFieldsets() return $this->appendPermission(['customfields.delete' => '1']); } + public function viewDepreciations() + { + return $this->appendPermission(['depreciations.view' => '1']); + } + + public function createDepreciations() + { + return $this->appendPermission(['depreciations.create' => '1']); + } + + public function editDepreciations() + { + return $this->appendPermission(['depreciations.edit' => '1']); + } + public function deleteDepreciations() { return $this->appendPermission(['depreciations.delete' => '1']); } + public function viewManufacturers() + { + return $this->appendPermission(['manufacturers.view' => '1']); + } + + public function createManufacturers() + { + return $this->appendPermission(['manufacturers.create' => '1']); + } + + public function editManufacturers() + { + return $this->appendPermission(['manufacturers.edit' => '1']); + } + public function deleteManufacturers() { return $this->appendPermission(['manufacturers.delete' => '1']); @@ -435,11 +505,41 @@ public function viewPredefinedKits() return $this->appendPermission(['kits.view' => '1']); } + public function viewStatusLabels() + { + return $this->appendPermission(['statuslabels.view' => '1']); + } + + public function createStatusLabels() + { + return $this->appendPermission(['statuslabels.create' => '1']); + } + + public function editStatusLabels() + { + return $this->appendPermission(['statuslabels.edit' => '1']); + } + public function deleteStatusLabels() { return $this->appendPermission(['statuslabels.delete' => '1']); } + public function viewSuppliers() + { + return $this->appendPermission(['suppliers.view' => '1']); + } + + public function createSuppliers() + { + return $this->appendPermission(['suppliers.create' => '1']); + } + + public function editSuppliers() + { + return $this->appendPermission(['suppliers.edit' => '1']); + } + public function deleteSuppliers() { return $this->appendPermission(['suppliers.delete' => '1']); diff --git a/tests/Feature/Mcp/CreateAssetModelToolTest.php b/tests/Feature/Mcp/CreateAssetModelToolTest.php index ae5ee162b004..327dc7b988e2 100644 --- a/tests/Feature/Mcp/CreateAssetModelToolTest.php +++ b/tests/Feature/Mcp/CreateAssetModelToolTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Mcp; use App\Mcp\Tools\CreateAssetModelTool; -use App\Models\AssetModel; use App\Models\Category; use App\Models\User; use Laravel\Mcp\Request; diff --git a/tests/Feature/Mcp/CreateAssetToolTest.php b/tests/Feature/Mcp/CreateAssetToolTest.php index 9890282c2a05..bd59641c7933 100644 --- a/tests/Feature/Mcp/CreateAssetToolTest.php +++ b/tests/Feature/Mcp/CreateAssetToolTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Mcp; use App\Mcp\Tools\CreateAssetTool; -use App\Models\Asset; use App\Models\AssetModel; use App\Models\Statuslabel; use App\Models\User; diff --git a/tests/Feature/Mcp/CreateDepreciationToolTest.php b/tests/Feature/Mcp/CreateDepreciationToolTest.php index 456b15948a40..34a0f9049b59 100644 --- a/tests/Feature/Mcp/CreateDepreciationToolTest.php +++ b/tests/Feature/Mcp/CreateDepreciationToolTest.php @@ -3,7 +3,6 @@ namespace Tests\Feature\Mcp; use App\Mcp\Tools\CreateDepreciationTool; -use App\Models\Depreciation; use App\Models\User; use Laravel\Mcp\Request; use Laravel\Mcp\ResponseFactory; diff --git a/tests/Feature/Mcp/CreateGroupToolTest.php b/tests/Feature/Mcp/CreateGroupToolTest.php index e5b8258f9676..67fcd2b7ca88 100644 --- a/tests/Feature/Mcp/CreateGroupToolTest.php +++ b/tests/Feature/Mcp/CreateGroupToolTest.php @@ -23,7 +23,7 @@ private function handle(array $args = []): ResponseFactory public function test_creates_group() { - $name = 'Test MCP Group ' . uniqid(); + $name = 'Test MCP Group '.uniqid(); $this->handle(['name' => $name]); @@ -32,7 +32,7 @@ public function test_creates_group() public function test_response_includes_id_and_name() { - $name = 'Test MCP Group ' . uniqid(); + $name = 'Test MCP Group '.uniqid(); $content = $this->handle(['name' => $name])->getStructuredContent(); @@ -51,7 +51,7 @@ public function test_returns_error_when_user_lacks_permission() { $this->actingAs(User::factory()->create()); - $name = 'Unauthorized Group ' . uniqid(); + $name = 'Unauthorized Group '.uniqid(); $this->handle(['name' => $name]); diff --git a/tests/Feature/Mcp/UpdateGroupToolTest.php b/tests/Feature/Mcp/UpdateGroupToolTest.php index cce215a058dd..78d6950e2164 100644 --- a/tests/Feature/Mcp/UpdateGroupToolTest.php +++ b/tests/Feature/Mcp/UpdateGroupToolTest.php @@ -25,7 +25,7 @@ private function handle(array $args = []): ResponseFactory public function test_updates_group_by_id() { $group = Group::factory()->create(); - $newNotes = 'Updated notes ' . uniqid(); + $newNotes = 'Updated notes '.uniqid(); $this->handle(['id' => $group->id, 'notes' => $newNotes]); @@ -35,7 +35,7 @@ public function test_updates_group_by_id() public function test_renames_via_new_name() { $group = Group::factory()->create(); - $newName = 'Renamed Group ' . uniqid(); + $newName = 'Renamed Group '.uniqid(); $this->handle(['id' => $group->id, 'new_name' => $newName]); From 96a3a11f00a1cee306af0e905e689216be199a68 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 13:09:54 +0100 Subject: [PATCH 25/45] =?UTF-8?q?This=20doesn=E2=80=99t=20actually=20work?= =?UTF-8?q?=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Models/User.php | 2 + app/Providers/AppServiceProvider.php | 2 + resources/views/mcp/authorize.blade.php | 308 +++++++++++------------- routes/ai.php | 2 +- 4 files changed, 142 insertions(+), 172 deletions(-) diff --git a/app/Models/User.php b/app/Models/User.php index b4fff56ae854..d5d43e4d60d8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,6 +30,8 @@ use Laravel\Passport\HasApiTokens; use Watson\Validating\ValidatingTrait; +//use Laravel\Passport\Contracts\OAuthenticatable; + class User extends SnipeModel implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, HasLocalePreference { use CompanyableTrait; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 602f21cf061a..9dd0d665c77c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -64,6 +64,8 @@ public function boot(UrlGenerator $url) $url->forceScheme('https'); } + Passport::enablePasswordGrant(); + Passport::authorizationView(function ($parameters) { return view('mcp.authorize', $parameters); }); diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php index 7d14787490d4..9f8a7ab4f1f5 100644 --- a/resources/views/mcp/authorize.blade.php +++ b/resources/views/mcp/authorize.blade.php @@ -1,180 +1,146 @@ - - ($appearance ?? 'system') == 'dark'])> - - - +@extends('layouts/basic') - {{-- Inline script to detect system dark mode preference and apply it immediately --}} - + + + + + +
+
+ +
+ +
+
+

Authorize {{ $client->name }}

+
+ +
+
+ + + @include('notifications') + +

+ This application will be able to use available MCP functionality. +

+ + +

Logged in as: {{ $user->username }}

+ + + + @if(count($scopes) > 0) + +

Permissions:

+ +
    + @foreach($scopes as $scope) +
  • + {{ $scope->description }} +
  • + @endforeach +
+ + @endif + + +
- - - Authorize Application - {{ config('app.name', 'MCP Server') }} - - - - - - - - - - - - @vite(['resources/css/app.css']) - - -
-
- -
- -
-
- - - - -
- -

- Authorize {{ $client->name }} -

- -

- This application will be able to:
Use available MCP functionality. -

-
- - -
- -
-

Logged in as:

-

{{ $user->email }}

-
- - - @if(count($scopes) > 0) -
-

Permissions:

- -
    - @foreach($scopes as $scope) -
  • -
    -
    -
    - - {{ $scope->description }} - -
  • - @endforeach -
- @endif -
- - -
- -
- @csrf - @method('DELETE') - - - - -
- - -
- @csrf - - - - -
-
-
-
-
- - - - + + // Handle cancel button... + const cancelForm = document.querySelector('form[method="POST"]:has(input[name="_method"][value="DELETE"])'); + if (cancelForm) { + cancelForm.addEventListener('submit', function (e) { + setTimeout(function () { + window.close(); + }, 200); + }); + } + }); + + +@stop + + diff --git a/routes/ai.php b/routes/ai.php index e242b2197757..87b621766859 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use Laravel\Mcp\Facades\Mcp; Mcp::oauthRoutes(); -Mcp::web('/mcp/snipe-it', SnipeMCPServer::class); +Mcp::web('/mcp/snipe-it', SnipeMCPServer::class)->middleware('auth:api'); From c75d0effe25f4a3ff77e0111c6837925b84931e0 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 13:10:06 +0100 Subject: [PATCH 26/45] Pint :( --- app/Models/User.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/User.php b/app/Models/User.php index d5d43e4d60d8..53cf430199e8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,7 +30,7 @@ use Laravel\Passport\HasApiTokens; use Watson\Validating\ValidatingTrait; -//use Laravel\Passport\Contracts\OAuthenticatable; +// use Laravel\Passport\Contracts\OAuthenticatable; class User extends SnipeModel implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, HasLocalePreference { From 8ccc7054730d540664f0fb6e9379d598bf233771 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 14:58:08 +0100 Subject: [PATCH 27/45] Add a tool to update your own profile --- app/Mcp/Servers/SnipeMCPServer.php | 2 + app/Mcp/Tools/UpdateProfileTool.php | 106 ++++++++++++++++++++ config/cors.php | 4 +- tests/Feature/Mcp/UpdateProfileToolTest.php | 87 ++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 app/Mcp/Tools/UpdateProfileTool.php create mode 100644 tests/Feature/Mcp/UpdateProfileToolTest.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 9716baef30ab..b086e7cf9c25 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -93,6 +93,7 @@ use App\Mcp\Tools\UpdateManufacturerTool; use App\Mcp\Tools\UpdateStatusLabelTool; use App\Mcp\Tools\UpdateSupplierTool; +use App\Mcp\Tools\UpdateProfileTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; @@ -124,6 +125,7 @@ class SnipeMCPServer extends Server DeleteUserTool::class, RestoreUserTool::class, GetCurrentUserTool::class, + UpdateProfileTool::class, GetUserAssetsTool::class, Reset2FATool::class, diff --git a/app/Mcp/Tools/UpdateProfileTool.php b/app/Mcp/Tools/UpdateProfileTool.php new file mode 100644 index 000000000000..8d2297a7c32d --- /dev/null +++ b/app/Mcp/Tools/UpdateProfileTool.php @@ -0,0 +1,106 @@ +validate([ + 'first_name' => 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string|max:35', + 'website' => 'nullable|url|max:255', + 'gravatar' => 'nullable|string|max:255', + 'locale' => 'nullable|string|max:10', + 'two_factor_optin'=> 'nullable|boolean', + 'location_id' => 'nullable|integer|exists:locations,id', + ]); + + $user = auth()->user(); + + if (Gate::allows('self.profile') && ! config('app.lock_passwords')) { + foreach (['first_name', 'last_name', 'phone', 'website', 'gravatar'] as $field) { + if ($request->filled($field)) { + $user->{$field} = $request->get($field); + } + } + } + + if ($request->filled('locale')) { + $user->locale = $request->get('locale'); + } + + if ( + $request->filled('two_factor_optin') && + Gate::allows('self.two_factor') && + Setting::getSettings()->two_factor_enabled == '1' && + ! config('app.lock_passwords') + ) { + $user->two_factor_optin = $request->get('two_factor_optin'); + } + + if ($request->filled('location_id') && Gate::allows('self.edit_location') && ! config('app.lock_passwords')) { + $user->location_id = $request->get('location_id'); + } + + if ($user->save()) { + return Response::make( + Response::text('Profile updated successfully') + )->withStructuredContent([ + 'success' => true, + 'message' => 'Profile updated successfully', + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'website' => $user->website, + 'locale' => $user->locale, + 'location_id'=> $user->location_id, + ]); + } + + return Response::make(Response::error('Update failed: '.$user->getErrors()->first())); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'phone' => $schema->string()->description('Phone number'), + 'website' => $schema->string()->description('Personal website URL'), + 'gravatar' => $schema->string()->description('Gravatar email or hash'), + 'locale' => $schema->string()->description('Locale/language code (e.g. en-US)'), + 'two_factor_optin' => $schema->boolean()->description('Opt in to two-factor authentication (requires self.two_factor permission and 2FA enabled in settings)'), + 'location_id' => $schema->number()->description('Default location ID (requires self.edit_location permission)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'first_name' => $schema->string()->description('Updated first name'), + 'last_name' => $schema->string()->description('Updated last name'), + 'phone' => $schema->string()->description('Updated phone number'), + 'website' => $schema->string()->description('Updated website URL'), + 'locale' => $schema->string()->description('Updated locale'), + 'location_id' => $schema->number()->description('Updated location ID'), + ]; + } +} diff --git a/config/cors.php b/config/cors.php index d84570195621..a3fb72eee87d 100644 --- a/config/cors.php +++ b/config/cors.php @@ -16,7 +16,7 @@ * list of urls, explode that out into an array to whitelist just those urls. */ $allowed_origins = env('CORS_ALLOWED_ORIGINS') !== null ? - explode(',', env('CORS_ALLOWED_ORIGINS')) : []; + explode(',', env('CORS_ALLOWED_ORIGINS')) : ['*']; /** * Original Laravel CORS package config file modifications end here @@ -41,6 +41,6 @@ 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'exposed_headers' => [], 'max_age' => 0, - 'paths' => ['api/*', 'sanctum/csrf-cookie'], + 'paths' => ['api/*', 'sanctum/csrf-cookie', '.well-known/*', 'oauth/*', 'mcp/*'], ]; diff --git a/tests/Feature/Mcp/UpdateProfileToolTest.php b/tests/Feature/Mcp/UpdateProfileToolTest.php new file mode 100644 index 000000000000..2c67b355e59c --- /dev/null +++ b/tests/Feature/Mcp/UpdateProfileToolTest.php @@ -0,0 +1,87 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateProfileTool)->handle(new Request($args)); + } + + public function test_updates_profile_fields() + { + $content = $this->handle([ + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'phone' => '555-1234', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'phone' => '555-1234', + ]); + } + + public function test_updates_locale() + { + $content = $this->handle(['locale' => 'fr'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'locale' => 'fr']); + } + + public function test_updates_location_with_permission() + { + $this->actingAs(User::factory()->canEditOwnLocation()->create()); + $location = Location::factory()->create(); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals($location->id, $content['location_id']); + } + + public function test_does_not_update_location_without_permission() + { + $location = Location::factory()->create(); + + $originalLocation = $this->user->location_id; + + $this->handle(['location_id' => $location->id]); + + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'location_id' => $originalLocation]); + } + + public function test_only_updates_provided_fields() + { + $originalLastName = $this->user->last_name; + + $this->handle(['first_name' => 'Changed']); + + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'first_name' => 'Changed', + 'last_name' => $originalLastName, + ]); + } +} From 926f7dd5f76e8b2005015e8312e997ca7e46233b Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:03:49 +0100 Subject: [PATCH 28/45] Added profile update tool --- app/Mcp/Servers/SnipeMCPServer.php | 2 +- app/Mcp/Tools/UpdateProfileTool.php | 58 ++++++++++----------- tests/Feature/Mcp/UpdateProfileToolTest.php | 14 ++--- 3 files changed, 37 insertions(+), 37 deletions(-) diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index b086e7cf9c25..f5b66ed2b3c8 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -91,9 +91,9 @@ use App\Mcp\Tools\UpdateLicenseTool; use App\Mcp\Tools\UpdateLocationTool; use App\Mcp\Tools\UpdateManufacturerTool; +use App\Mcp\Tools\UpdateProfileTool; use App\Mcp\Tools\UpdateStatusLabelTool; use App\Mcp\Tools\UpdateSupplierTool; -use App\Mcp\Tools\UpdateProfileTool; use App\Mcp\Tools\UpdateUserTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Instructions; diff --git a/app/Mcp/Tools/UpdateProfileTool.php b/app/Mcp/Tools/UpdateProfileTool.php index 8d2297a7c32d..fe91d48afada 100644 --- a/app/Mcp/Tools/UpdateProfileTool.php +++ b/app/Mcp/Tools/UpdateProfileTool.php @@ -21,14 +21,14 @@ class UpdateProfileTool extends Tool public function handle(Request $request): ResponseFactory { $request->validate([ - 'first_name' => 'nullable|string|max:255', - 'last_name' => 'nullable|string|max:255', - 'phone' => 'nullable|string|max:35', - 'website' => 'nullable|url|max:255', - 'gravatar' => 'nullable|string|max:255', - 'locale' => 'nullable|string|max:10', - 'two_factor_optin'=> 'nullable|boolean', - 'location_id' => 'nullable|integer|exists:locations,id', + 'first_name' => 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string|max:35', + 'website' => 'nullable|url|max:255', + 'gravatar' => 'nullable|string|max:255', + 'locale' => 'nullable|string|max:10', + 'two_factor_optin' => 'nullable|boolean', + 'location_id' => 'nullable|integer|exists:locations,id', ]); $user = auth()->user(); @@ -62,14 +62,14 @@ public function handle(Request $request): ResponseFactory return Response::make( Response::text('Profile updated successfully') )->withStructuredContent([ - 'success' => true, - 'message' => 'Profile updated successfully', + 'success' => true, + 'message' => 'Profile updated successfully', 'first_name' => $user->first_name, - 'last_name' => $user->last_name, - 'phone' => $user->phone, - 'website' => $user->website, - 'locale' => $user->locale, - 'location_id'=> $user->location_id, + 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'website' => $user->website, + 'locale' => $user->locale, + 'location_id' => $user->location_id, ]); } @@ -79,27 +79,27 @@ public function handle(Request $request): ResponseFactory public function schema(JsonSchema $schema): array { return [ - 'first_name' => $schema->string()->description('First name'), - 'last_name' => $schema->string()->description('Last name'), - 'phone' => $schema->string()->description('Phone number'), - 'website' => $schema->string()->description('Personal website URL'), - 'gravatar' => $schema->string()->description('Gravatar email or hash'), - 'locale' => $schema->string()->description('Locale/language code (e.g. en-US)'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'phone' => $schema->string()->description('Phone number'), + 'website' => $schema->string()->description('Personal website URL'), + 'gravatar' => $schema->string()->description('Gravatar email or hash'), + 'locale' => $schema->string()->description('Locale/language code (e.g. en-US)'), 'two_factor_optin' => $schema->boolean()->description('Opt in to two-factor authentication (requires self.two_factor permission and 2FA enabled in settings)'), - 'location_id' => $schema->number()->description('Default location ID (requires self.edit_location permission)'), + 'location_id' => $schema->number()->description('Default location ID (requires self.edit_location permission)'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the update succeeded'), - 'message' => $schema->string()->description('Human-readable result message')->required(), - 'first_name' => $schema->string()->description('Updated first name'), - 'last_name' => $schema->string()->description('Updated last name'), - 'phone' => $schema->string()->description('Updated phone number'), - 'website' => $schema->string()->description('Updated website URL'), - 'locale' => $schema->string()->description('Updated locale'), + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'first_name' => $schema->string()->description('Updated first name'), + 'last_name' => $schema->string()->description('Updated last name'), + 'phone' => $schema->string()->description('Updated phone number'), + 'website' => $schema->string()->description('Updated website URL'), + 'locale' => $schema->string()->description('Updated locale'), 'location_id' => $schema->number()->description('Updated location ID'), ]; } diff --git a/tests/Feature/Mcp/UpdateProfileToolTest.php b/tests/Feature/Mcp/UpdateProfileToolTest.php index 2c67b355e59c..6ee01be36a38 100644 --- a/tests/Feature/Mcp/UpdateProfileToolTest.php +++ b/tests/Feature/Mcp/UpdateProfileToolTest.php @@ -29,16 +29,16 @@ public function test_updates_profile_fields() { $content = $this->handle([ 'first_name' => 'Updated', - 'last_name' => 'Name', - 'phone' => '555-1234', + 'last_name' => 'Name', + 'phone' => '555-1234', ])->getStructuredContent(); $this->assertTrue($content['success']); $this->assertDatabaseHas('users', [ - 'id' => $this->user->id, + 'id' => $this->user->id, 'first_name' => 'Updated', - 'last_name' => 'Name', - 'phone' => '555-1234', + 'last_name' => 'Name', + 'phone' => '555-1234', ]); } @@ -79,9 +79,9 @@ public function test_only_updates_provided_fields() $this->handle(['first_name' => 'Changed']); $this->assertDatabaseHas('users', [ - 'id' => $this->user->id, + 'id' => $this->user->id, 'first_name' => 'Changed', - 'last_name' => $originalLastName, + 'last_name' => $originalLastName, ]); } } From ef4b2349eb74a542f7bd38181b0416885740e6ee Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:14:27 +0100 Subject: [PATCH 29/45] Added common prompts --- app/Mcp/Prompts/AuditLocationPrompt.php | 46 ++++++++++++++++ app/Mcp/Prompts/EndOfLifeReviewPrompt.php | 55 +++++++++++++++++++ app/Mcp/Prompts/ExpiringLicensesPrompt.php | 44 +++++++++++++++ app/Mcp/Prompts/FindAvailableAssetPrompt.php | 57 +++++++++++++++++++ app/Mcp/Prompts/InventorySummaryPrompt.php | 55 +++++++++++++++++++ app/Mcp/Prompts/OffboardEmployeePrompt.php | 46 ++++++++++++++++ app/Mcp/Prompts/OnboardEmployeePrompt.php | 58 ++++++++++++++++++++ app/Mcp/Prompts/UserInventoryPrompt.php | 45 +++++++++++++++ app/Mcp/Prompts/WarrantyExpiringPrompt.php | 43 +++++++++++++++ 9 files changed, 449 insertions(+) create mode 100644 app/Mcp/Prompts/AuditLocationPrompt.php create mode 100644 app/Mcp/Prompts/EndOfLifeReviewPrompt.php create mode 100644 app/Mcp/Prompts/ExpiringLicensesPrompt.php create mode 100644 app/Mcp/Prompts/FindAvailableAssetPrompt.php create mode 100644 app/Mcp/Prompts/InventorySummaryPrompt.php create mode 100644 app/Mcp/Prompts/OffboardEmployeePrompt.php create mode 100644 app/Mcp/Prompts/OnboardEmployeePrompt.php create mode 100644 app/Mcp/Prompts/UserInventoryPrompt.php create mode 100644 app/Mcp/Prompts/WarrantyExpiringPrompt.php diff --git a/app/Mcp/Prompts/AuditLocationPrompt.php b/app/Mcp/Prompts/AuditLocationPrompt.php new file mode 100644 index 000000000000..ed93ae0245fe --- /dev/null +++ b/app/Mcp/Prompts/AuditLocationPrompt.php @@ -0,0 +1,46 @@ +get('location'); + + $prompt = <<get('department'); + $category = $request->get('category'); + + $scope = collect([ + $department ? "department: {$department}" : null, + $category ? "category: {$category}" : null, + ])->filter()->implode(' and '); + + $scopeLine = $scope + ? "Limit the review to assets in {$scope}." + : 'Review assets across the entire organisation.'; + + $prompt = <<get('days', 30)); + + $prompt = <<get('category'); + $model = $request->get('model'); + $assignTo = $request->get('assign_to'); + + $assetDescription = collect([ + $category ? "category: {$category}" : null, + $model ? "model: {$model}" : null, + ])->filter()->implode(' / '); + + $assignLine = $assignTo + ? "If a suitable asset is found, check it out to the user: {$assignTo}." + : 'Ask whether the found asset should be checked out to a specific user before proceeding.'; + + $prompt = <<get('location'); + $department = $request->get('department'); + + $scope = collect([ + $location ? "location: {$location}" : null, + $department ? "department: {$department}" : null, + ])->filter()->implode(' and '); + + $scopeLine = $scope + ? "Scope the report to {$scope}." + : 'Report across the entire organisation.'; + + $prompt = <<get('username'); + + $prompt = <<get('name'); + $department = $request->get('department'); + $location = $request->get('location'); + $title = $request->get('title'); + + $context = collect([ + $department ? "Department: {$department}" : null, + $location ? "Location: {$location}" : null, + $title ? "Job title: {$title}" : null, + ])->filter()->implode("\n"); + + $prompt = <<get('username'); + + $prompt = <<get('days', 90)); + + $prompt = << Date: Fri, 8 May 2026 15:17:55 +0100 Subject: [PATCH 30/45] Split name into two pieces --- app/Mcp/Prompts/EndOfLifeReviewPrompt.php | 4 +- app/Mcp/Prompts/FindAvailableAssetPrompt.php | 4 +- app/Mcp/Prompts/InventorySummaryPrompt.php | 4 +- app/Mcp/Prompts/OnboardEmployeePrompt.php | 22 +++-- app/Mcp/Servers/SnipeMCPServer.php | 21 ++++- app/Mcp/Tools/AddAssetNoteTool.php | 97 ++++++++++++++++++++ tests/Feature/Mcp/AddAssetNoteToolTest.php | 89 ++++++++++++++++++ 7 files changed, 226 insertions(+), 15 deletions(-) create mode 100644 app/Mcp/Tools/AddAssetNoteTool.php create mode 100644 tests/Feature/Mcp/AddAssetNoteToolTest.php diff --git a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php index a0c93f019b25..3299221e9e8f 100644 --- a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php +++ b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php @@ -18,11 +18,11 @@ class EndOfLifeReviewPrompt extends Prompt public function handle(Request $request): Response { $department = $request->get('department'); - $category = $request->get('category'); + $category = $request->get('category'); $scope = collect([ $department ? "department: {$department}" : null, - $category ? "category: {$category}" : null, + $category ? "category: {$category}" : null, ])->filter()->implode(' and '); $scopeLine = $scope diff --git a/app/Mcp/Prompts/FindAvailableAssetPrompt.php b/app/Mcp/Prompts/FindAvailableAssetPrompt.php index 96e8c759ef47..c1d373565e2b 100644 --- a/app/Mcp/Prompts/FindAvailableAssetPrompt.php +++ b/app/Mcp/Prompts/FindAvailableAssetPrompt.php @@ -18,12 +18,12 @@ class FindAvailableAssetPrompt extends Prompt public function handle(Request $request): Response { $category = $request->get('category'); - $model = $request->get('model'); + $model = $request->get('model'); $assignTo = $request->get('assign_to'); $assetDescription = collect([ $category ? "category: {$category}" : null, - $model ? "model: {$model}" : null, + $model ? "model: {$model}" : null, ])->filter()->implode(' / '); $assignLine = $assignTo diff --git a/app/Mcp/Prompts/InventorySummaryPrompt.php b/app/Mcp/Prompts/InventorySummaryPrompt.php index d14dac02220e..2dd1d03c67ab 100644 --- a/app/Mcp/Prompts/InventorySummaryPrompt.php +++ b/app/Mcp/Prompts/InventorySummaryPrompt.php @@ -17,11 +17,11 @@ class InventorySummaryPrompt extends Prompt { public function handle(Request $request): Response { - $location = $request->get('location'); + $location = $request->get('location'); $department = $request->get('department'); $scope = collect([ - $location ? "location: {$location}" : null, + $location ? "location: {$location}" : null, $department ? "department: {$department}" : null, ])->filter()->implode(' and '); diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php index c11ad5db8186..fae690c59986 100644 --- a/app/Mcp/Prompts/OnboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -17,10 +17,13 @@ class OnboardEmployeePrompt extends Prompt { public function handle(Request $request): Response { - $name = $request->get('name'); + $firstName = $request->get('first_name'); + $lastName = $request->get('last_name'); $department = $request->get('department'); - $location = $request->get('location'); - $title = $request->get('title'); + $location = $request->get('location'); + $title = $request->get('title'); + + $fullName = trim("{$firstName} {$lastName}"); $context = collect([ $department ? "Department: {$department}" : null, @@ -29,18 +32,20 @@ public function handle(Request $request): Response ])->filter()->implode("\n"); $prompt = <<validate([ + 'asset_tag' => 'nullable|string|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'note' => 'required|string|max:50000', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error('Asset not found')); + } + + if (! Gate::allows('update', $asset)) { + return Response::make(Response::error('Unauthorized')); + } + + $logaction = new Actionlog; + $logaction->item_type = Asset::class; + $logaction->item_id = $asset->id; + $logaction->note = $request->get('note'); + $logaction->created_by = auth()->id(); + + if ($logaction->logaction('note added')) { + return Response::make( + Response::text('Note added to asset '.$asset->asset_tag) + )->withStructuredContent([ + 'success' => true, + 'message' => 'Note added successfully', + 'asset_tag' => $asset->asset_tag, + 'asset_id' => $asset->id, + 'note' => $logaction->note, + ]); + } + + return Response::make(Response::error('Failed to save note')); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'serial' => $schema->string()->description('Serial number of the asset'), + 'id' => $schema->number()->description('Numeric ID of the asset'), + 'note' => $schema->string()->description('Note text to add to the asset'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the note was saved'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'asset_id' => $schema->number()->description('Numeric ID of the asset'), + 'note' => $schema->string()->description('The note that was saved'), + ]; + } +} diff --git a/tests/Feature/Mcp/AddAssetNoteToolTest.php b/tests/Feature/Mcp/AddAssetNoteToolTest.php new file mode 100644 index 000000000000..e7208f0b0841 --- /dev/null +++ b/tests/Feature/Mcp/AddAssetNoteToolTest.php @@ -0,0 +1,89 @@ +actingAs(User::factory()->editAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new AddAssetNoteTool)->handle(new Request($args)); + } + + public function test_adds_note_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => (string) $asset->asset_tag, + 'note' => 'This is a test note.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('This is a test note.', $content['note']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'This is a test note.', + ]); + } + + public function test_adds_note_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $asset->id, + 'note' => 'Note via ID.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'Note via ID.', + ]); + } + + public function test_adds_note_by_serial() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'serial' => $asset->serial, + 'note' => 'Note via serial.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + ]); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'note' => 'test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id, 'note' => 'test'])->responses()->first()->isError()); + $this->assertDatabaseMissing('action_logs', ['item_id' => $asset->id, 'action_type' => 'note added']); + } +} From 4090e05536c73db443f0d1d5ec68e9e83e7bdb37 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:18:46 +0100 Subject: [PATCH 31/45] Pint --- app/Mcp/Prompts/OnboardEmployeePrompt.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php index fae690c59986..e3133eaa5e77 100644 --- a/app/Mcp/Prompts/OnboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -17,18 +17,18 @@ class OnboardEmployeePrompt extends Prompt { public function handle(Request $request): Response { - $firstName = $request->get('first_name'); - $lastName = $request->get('last_name'); + $firstName = $request->get('first_name'); + $lastName = $request->get('last_name'); $department = $request->get('department'); - $location = $request->get('location'); - $title = $request->get('title'); + $location = $request->get('location'); + $title = $request->get('title'); $fullName = trim("{$firstName} {$lastName}"); $context = collect([ $department ? "Department: {$department}" : null, - $location ? "Location: {$location}" : null, - $title ? "Job title: {$title}" : null, + $location ? "Location: {$location}" : null, + $title ? "Job title: {$title}" : null, ])->filter()->implode("\n"); $prompt = << Date: Fri, 8 May 2026 15:18:54 +0100 Subject: [PATCH 32/45] Added readme --- app/Mcp/README.md | 1068 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1068 insertions(+) create mode 100644 app/Mcp/README.md diff --git a/app/Mcp/README.md b/app/Mcp/README.md new file mode 100644 index 000000000000..c10732cee506 --- /dev/null +++ b/app/Mcp/README.md @@ -0,0 +1,1068 @@ +# Snipe-IT MCP Server + +This directory contains the Model Context Protocol (MCP) server implementation for Snipe-IT. It exposes the Snipe-IT asset management database to AI assistants through a standard interface, allowing them to look up assets, manage users, process checkouts and check-ins, and run common IT workflows — all in plain language. + +## Table of Contents + +- [Connecting to an AI Service](#connecting-to-an-ai-service) +- [Authentication](#authentication) +- [Prompts](#prompts) +- [Tools Reference](#tools-reference) + - [Assets](#assets) + - [Users](#users) + - [Accessories](#accessories) + - [Components](#components) + - [Consumables](#consumables) + - [Licenses](#licenses) + - [Departments](#departments) + - [Companies](#companies) + - [Categories](#categories) + - [Manufacturers](#manufacturers) + - [Suppliers](#suppliers) + - [Status Labels](#status-labels) + - [Locations](#locations) + - [Asset Models](#asset-models) + - [Depreciations](#depreciations) + - [Groups](#groups) + - [Maintenance](#maintenance) + - [Activity Log](#activity-log) + +--- + +## Connecting to an AI Service + +The MCP server is available at: + +``` +https://your-snipeit-domain.com/mcp/snipe-it +``` + +It uses **OAuth 2.0** for authentication (see [Authentication](#authentication) below). Any MCP-compatible client that supports OAuth and the Streamable HTTP transport can connect to it. + +### Claude Desktop + +Add the server to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "snipe-it": { + "url": "https://your-snipeit-domain.com/mcp/snipe-it" + } + } +} +``` + +Claude Desktop will initiate the OAuth flow on first connection. Once authorised, you can use tools and prompts directly in conversation: + +> "Check out asset LAPTOP-0042 to jsmith." + +> "What's assigned to sarah.chen right now?" + +### MCP Inspector + +The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is useful for exploring and testing tools before integrating with a client: + +1. Run `npx @modelcontextprotocol/inspector` +2. Set the transport to **Streamable HTTP** +3. Enter the server URL: `https://your-snipeit-domain.com/mcp/snipe-it` +4. Click **Connect** and complete the OAuth flow +5. Browse tools and prompts from the sidebar + +### Cursor / VS Code / Other MCP Clients + +Any client that supports the MCP Streamable HTTP transport and OAuth can connect using the same URL. Refer to your client's documentation for how to add a remote MCP server. + +--- + +## Authentication + +The server uses **OAuth 2.0 with dynamic client registration**. Clients that support OAuth will handle this automatically. On first connection: + +1. The client discovers the OAuth server via `/.well-known/oauth-authorization-server` +2. It registers itself dynamically via `/oauth/register` +3. The user is redirected to Snipe-IT's login page to authorise access +4. The client receives a bearer token and uses it for all subsequent requests + +The authenticated user's Snipe-IT permissions apply — a user without `delete assets` permission cannot call `delete_asset`, for example. + +--- + +## Prompts + +Prompts are pre-built conversation starters for common multi-step workflows. In clients that support them (such as Claude Desktop), prompts appear in a slash-command menu or prompt picker. Select a prompt, fill in any arguments, and the AI will walk through the workflow using the available tools automatically. + +--- + +### `onboard_employee` + +**Guide through creating a new employee account and assigning appropriate equipment and licenses.** + +Creates the user account, finds available assets suitable for their role, checks equipment out to them, and assigns any relevant license seats. + +| Argument | Required | Description | +|----------|----------|-------------| +| `first_name` | Yes | First name of the new employee | +| `last_name` | No | Last name of the new employee | +| `department` | No | Department the employee will join | +| `location` | No | Primary office location | +| `title` | No | Job title | + +**Example usage in Claude:** +> Use the `onboard_employee` prompt → first_name: "Marcus", last_name: "Webb", department: "Engineering", location: "Austin HQ" + +--- + +### `offboard_employee` + +**Check in all equipment and licenses from a departing employee and deactivate their account.** + +Looks up everything assigned to the user, checks in all assets and accessories, revokes license seats, and deactivates the account. + +| Argument | Required | Description | +|----------|----------|-------------| +| `username` | Yes | Username of the departing employee | + +**Example usage in Claude:** +> Use the `offboard_employee` prompt → username: "marcus.webb" + +--- + +### `audit_location` + +**Review all assets at a location, flag overdue audits and status anomalies.** + +Lists all assets at the location, identifies overdue audit dates, flags unexpected statuses, and produces a summary with recommended actions. + +| Argument | Required | Description | +|----------|----------|-------------| +| `location` | Yes | Name or ID of the location to audit | + +**Example usage in Claude:** +> Use the `audit_location` prompt → location: "Austin HQ" + +--- + +### `find_available_asset` + +**Find an undeployed asset by category or model and optionally check it out to a user.** + +Searches for Ready-to-Deploy assets matching the criteria, lists options if multiple are available, and can check out the selected asset immediately. + +| Argument | Required | Description | +|----------|----------|-------------| +| `category` | No | Asset category to search (e.g. Laptop, Monitor) | +| `model` | No | Specific model name to search for | +| `assign_to` | No | Username to check the asset out to once found | + +**Example usage in Claude:** +> Use the `find_available_asset` prompt → category: "Laptop", assign_to: "marcus.webb" + +--- + +### `expiring_licenses` + +**Review license seat usage and flag licenses expiring within a given number of days.** + +Lists all licenses, identifies those expiring soon, flags over-deployed and under-used licenses, and produces a prioritised action list. + +| Argument | Required | Description | +|----------|----------|-------------| +| `days` | No | Days ahead to check for expiry (default: 30) | + +**Example usage in Claude:** +> Use the `expiring_licenses` prompt → days: 60 + +--- + +### `end_of_life_review` + +**Identify assets that have passed their EOL date or are fully depreciated, and recommend disposition actions.** + +Can be scoped to a department or category. Groups findings and recommends retirement, redeployment, repair, or archival. + +| Argument | Required | Description | +|----------|----------|-------------| +| `department` | No | Limit review to a specific department | +| `category` | No | Limit review to a specific asset category | + +**Example usage in Claude:** +> Use the `end_of_life_review` prompt → category: "Laptop" + +--- + +### `warranty_expiring` + +**List assets whose warranty expires within a given number of days.** + +Groups findings by urgency (within 30 days, 31–60, 61–N), flags assets in critical roles, and recommends extensions or replacements. + +| Argument | Required | Description | +|----------|----------|-------------| +| `days` | No | Days ahead to check for warranty expiry (default: 90) | + +**Example usage in Claude:** +> Use the `warranty_expiring` prompt → days: 30 + +--- + +### `inventory_summary` + +**Produce a high-level inventory count by category, broken down by deployment status.** + +Can be scoped to a location or department. Shows deployed vs. available counts, top models, total value, and stock-out risks. + +| Argument | Required | Description | +|----------|----------|-------------| +| `location` | No | Limit report to a specific location | +| `department` | No | Limit report to a specific department | + +**Example usage in Claude:** +> Use the `inventory_summary` prompt → location: "Austin HQ" + +--- + +### `user_inventory` + +**List everything currently assigned to a specific user across all asset types.** + +Shows assets, accessories, license seats, and consumables assigned to the user, plus total assigned value if cost data is available. + +| Argument | Required | Description | +|----------|----------|-------------| +| `username` | Yes | Username of the user to review | + +**Example usage in Claude:** +> Use the `user_inventory` prompt → username: "sarah.chen" + +--- + +## Tools Reference + +Tools are individual actions the AI can call directly. They can also be combined freely in conversation without using a prompt — just describe what you want in plain language. + +> "Find the MacBook Pro with serial C02XL0AAJGH5 and check it in." + +> "Create a new location called 'Denver Office' in Colorado." + +> "List all licenses expiring before the end of the year." + +--- + +### Assets + +#### `show_asset` +Look up a single asset by asset tag, serial number, or numeric ID. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Asset tag | +| `serial` | string | No | Serial number | +| `id` | number | No | Numeric ID | + +#### `list_assets` +Search and list assets with optional filters. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across tag, serial, name, model | +| `status_type` | string | No | `RTD`, `Deployed`, `Archived`, `Pending`, or `Undeployable` | +| `company_id` | number | No | Filter by company | +| `location_id` | number | No | Filter by location | +| `category_id` | number | No | Filter by category | +| `model_id` | number | No | Filter by model | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip for pagination | + +#### `create_asset` +Create a new asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `model_id` | number | **Yes** | Asset model ID | +| `status_id` | number | **Yes** | Status label ID | +| `asset_tag` | string | **Yes** | Asset tag | +| `name` | string | No | Display name | +| `serial` | string | No | Serial number | +| `company_id` | number | No | Company ID | +| `location_id` | number | No | Current location ID | +| `rtd_location_id` | number | No | Default RTD location ID | +| `supplier_id` | number | No | Supplier ID | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `order_number` | string | No | Order number | +| `warranty_months` | number | No | Warranty length in months (0–240) | +| `requestable` | boolean | No | Whether users can request this asset | +| `notes` | string | No | Notes | + +#### `update_asset` +Update fields on an asset. Identify it by `asset_tag`, `serial`, or `id`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | New display name | +| `new_asset_tag` | string | No | Rename the asset tag | +| `new_serial` | string | No | New serial number | +| `status_id` | number | No | Status label ID | +| `model_id` | number | No | Model ID | +| `notes` | string | No | Notes | +| `order_number` | string | No | Order number | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `warranty_months` | number | No | Warranty length in months | +| `location_id` | number | No | Current location ID | +| `rtd_location_id` | number | No | Default RTD location ID | +| `supplier_id` | number | No | Supplier ID | +| `requestable` | boolean | No | User-requestable flag | +| `byod` | boolean | No | Bring-your-own-device flag | +| `asset_eol_date` | string | No | End-of-life date (YYYY-MM-DD) | +| `expected_checkin` | string | No | Expected check-in date (YYYY-MM-DD) | +| `next_audit_date` | string | No | Next audit date (YYYY-MM-DD) | + +#### `delete_asset` +Soft-delete an asset. If checked out, it will be checked in first. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | + +#### `restore_asset` +Restore a soft-deleted asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric ID of the asset to restore | + +#### `checkout_asset` +Check out an asset to a user, location, or another asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify asset by tag | +| `id` | number | No | Identify asset by numeric ID | +| `checkout_to_type` | string | **Yes** | `user`, `location`, or `asset` | +| `assigned_user` | number | No | User ID (when checking out to a user) | +| `assigned_location` | number | No | Location ID (when checking out to a location) | +| `assigned_asset` | number | No | Asset ID (when checking out to an asset) | +| `note` | string | No | Checkout note | +| `checkout_at` | string | No | Checkout date (YYYY-MM-DD, defaults to now) | +| `expected_checkin` | string | No | Expected check-in date (YYYY-MM-DD) | + +#### `checkin_asset` +Check a currently checked-out asset back in. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `id` | number | No | Identify by numeric ID | +| `note` | string | No | Check-in note | + +#### `audit_asset` +Record an audit for an asset, updating the last audit date and optionally the location. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `note` | string | No | Audit note | +| `location_id` | number | No | Location where the asset was found | +| `next_audit_date` | string | No | Override the next audit date (YYYY-MM-DD) | + +#### `add_asset_note` +Add a manual note to an asset. The note is recorded in the asset's activity log. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `note` | string | **Yes** | Note text to add | + +--- + +### Users + +#### `list_users` +Search and list users. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, username, email, employee number | +| `company_id` | number | No | Filter by company | +| `department_id` | number | No | Filter by department | +| `location_id` | number | No | Filter by location | +| `activated` | boolean | No | Filter by account activated status | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip for pagination | + +#### `show_user` +Look up a single user by numeric ID, username, or email address. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Numeric user ID | +| `username` | string | No | Username | +| `email` | string | No | Email address | + +#### `create_user` +Create a new user account. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `first_name` | string | **Yes** | First name | +| `username` | string | **Yes** | Username (must be unique) | +| `last_name` | string | No | Last name | +| `email` | string | No | Email address | +| `password` | string | No | Password (min 8 characters) | +| `employee_num` | string | No | Employee number | +| `jobtitle` | string | No | Job title | +| `phone` | string | No | Phone number | +| `company_id` | number | No | Company ID | +| `department_id` | number | No | Department ID | +| `location_id` | number | No | Location ID | +| `manager_id` | number | No | Manager user ID | +| `activated` | boolean | No | Whether the account is active (default: true) | +| `notes` | string | No | Notes | +| `start_date` | string | No | Employment start date (YYYY-MM-DD) | +| `end_date` | string | No | Employment end date (YYYY-MM-DD) | +| `vip` | boolean | No | Mark as VIP | +| `remote` | boolean | No | Mark as remote worker | +| `address` | string | No | Street address | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal/ZIP code | + +#### `update_user` +Update fields on a user. Identify by `id`, `username`, or `email`. Use `new_username` or `new_email` to change those values. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `username` | string | No | Identify by username | +| `email` | string | No | Identify by email | +| `new_username` | string | No | Rename the username | +| `new_email` | string | No | New email address | +| `first_name` | string | No | First name | +| `last_name` | string | No | Last name | +| `password` | string | No | New password (min 8 characters) | +| `employee_num` | string | No | Employee number | +| `jobtitle` | string | No | Job title | +| `phone` | string | No | Phone number | +| `company_id` | number | No | Company ID | +| `department_id` | number | No | Department ID | +| `location_id` | number | No | Location ID | +| `manager_id` | number | No | Manager user ID | +| `activated` | boolean | No | Account active status | +| `notes` | string | No | Notes | +| `start_date` | string | No | Employment start date (YYYY-MM-DD) | +| `end_date` | string | No | Employment end date (YYYY-MM-DD) | +| `vip` | boolean | No | VIP flag | +| `remote` | boolean | No | Remote worker flag | + +#### `delete_user` +Soft-delete a user. The user must have no assets, licenses, accessories, or consumables assigned. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Numeric user ID | +| `username` | string | No | Username | +| `email` | string | No | Email address | + +#### `restore_user` +Restore a soft-deleted user. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric ID of the user to restore | + +#### `get_current_user` +Return information about the currently authenticated user. No parameters. + +#### `update_profile` +Update the authenticated user's own profile. Fields protected by the `self.profile` gate (`first_name`, `last_name`, `phone`, `website`, `gravatar`) require profile editing to be enabled in Snipe-IT settings. `location_id` requires the `self.edit_location` permission. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `first_name` | string | No | First name | +| `last_name` | string | No | Last name | +| `phone` | string | No | Phone number | +| `website` | string | No | Personal website URL | +| `gravatar` | string | No | Gravatar email or hash | +| `locale` | string | No | Locale/language code (e.g. `en-US`) | +| `two_factor_optin` | boolean | No | Opt in to 2FA (requires `self.two_factor` permission and 2FA enabled in settings) | +| `location_id` | number | No | Default location ID (requires `self.edit_location` permission) | + +#### `get_user_assets` +Return all assets currently checked out to a user. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric user ID | + +#### `reset_2fa` +Reset two-factor authentication for a user (requires admin permission). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric user ID | + +--- + +### Accessories + +#### `create_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Accessory name | +| `category_id` | number | **Yes** | Category ID (must be an accessory category) | +| `qty` | number | No | Total quantity in stock | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `order_number` | string | No | Order number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `requestable` | boolean | No | User-requestable flag | +| `notes` | string | No | Notes | + +#### `update_accessory` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_accessory` +The accessory must have no units currently checked out. Identify by `id` or `name`. + +#### `checkout_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `checkout_to_type` | string | **Yes** | `user`, `location`, or `asset` | +| `assigned_user` | number | No | User ID to check out to | +| `assigned_location` | number | No | Location ID to check out to | +| `assigned_asset` | number | No | Asset ID to check out to | +| `note` | string | No | Checkout note | + +#### `checkin_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `checkout_id` | number | **Yes** | ID of the checkout record to check in | +| `note` | string | No | Check-in note | + +--- + +### Components + +#### `create_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Component name | +| `category_id` | number | **Yes** | Category ID (must be a component category) | +| `qty` | number | **Yes** | Total quantity in stock (min 1) | +| `serial` | string | No | Serial number | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `order_number` | string | No | Order number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `notes` | string | No | Notes | + +#### `update_component` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_component` +The component must have no units checked out to assets. Identify by `id` or `name`. + +#### `checkout_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `asset_id` | number | **Yes** | Asset ID to check the component out to | +| `assigned_qty` | number | No | Number of units to check out (default: 1) | +| `note` | string | No | Checkout note | + +#### `checkin_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `component_asset_id` | number | **Yes** | ID of the checkout record to check in | +| `checkin_qty` | number | No | Units to check in (default: all) | +| `note` | string | No | Check-in note | + +--- + +### Consumables + +#### `list_consumables` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `company_id` | number | No | Filter by company | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `location_id` | number | No | Filter by location | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_consumable` +Look up by `id` or `name`. + +#### `create_consumable` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Consumable name | +| `qty` | number | **Yes** | Quantity in stock | +| `category_id` | number | **Yes** | Category ID (must be a consumable category) | +| `company_id` | number | No | Company ID | +| `location_id` | number | No | Location ID | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `item_no` | string | No | Item number | +| `order_number` | string | No | Order number | +| `model_number` | string | No | Model number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `requestable` | boolean | No | User-requestable flag | +| `notes` | string | No | Notes | + +#### `update_consumable` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_consumable` +The consumable must have no units currently checked out. Identify by `id` or `name`. + +#### `checkout_consumable` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `assigned_to` | number | **Yes** | User ID to check out to | +| `note` | string | No | Checkout note | + +--- + +### Licenses + +#### `list_licenses` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, serial, notes, order number | +| `company_id` | number | No | Filter by company | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `supplier_id` | number | No | Filter by supplier | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_license` +Look up by `id` or `name`. Returns seat counts. + +#### `create_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | License name | +| `seats` | number | **Yes** | Number of seats (min 1) | +| `category_id` | number | **Yes** | Category ID (must be a license category) | +| `serial` | string | No | Product key / serial number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `company_id` | number | No | Company ID | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `expiration_date` | string | No | Expiration date (YYYY-MM-DD) | +| `license_name` | string | No | Name of the licensed user/organisation | +| `license_email` | string | No | Email of the licensed user/organisation | +| `maintained` | boolean | No | Whether the license is under maintenance | +| `reassignable` | boolean | No | Whether seats can be reassigned after check-in | +| `notes` | string | No | Notes | +| `min_amt` | number | No | Minimum seat threshold for alerts | + +#### `update_license` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_license` +The license must have no seats currently assigned. Identify by `id` or `name`. + +#### `checkout_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `assigned_to` | number | No | User ID to assign the seat to | +| `asset_id` | number | No | Asset ID to assign the seat to | +| `note` | string | No | Checkout note | + +#### `checkin_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `seat_id` | number | **Yes** | ID of the license seat to check in | +| `note` | string | No | Check-in note | + +--- + +### Departments + +#### `create_department` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Department name | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `manager_id` | number | No | Manager user ID | +| `phone` | string | No | Department phone number | +| `fax` | string | No | Department fax number | +| `notes` | string | No | Notes | + +#### `update_department` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_department` +The department must have no users assigned. Identify by `id` or `name`. + +--- + +### Companies + +#### `list_companies` +Search with optional `search`, `limit`, `offset`. + +#### `show_company` +Look up by `id` or `name`. + +#### `create_company` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Company name | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `email` | string | No | Email address | +| `notes` | string | No | Notes | + +#### `update_company` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_company` +Identify by `id` or `name`. + +--- + +### Categories + +#### `list_categories` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `category_type` | string | No | `asset`, `accessory`, `consumable`, `component`, or `license` | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_category` +Look up by `id` or `name`. + +#### `create_category` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Category name | +| `category_type` | string | **Yes** | `asset`, `accessory`, `consumable`, `component`, or `license` | +| `checkin_email` | boolean | No | Send check-in email | +| `require_acceptance` | boolean | No | Require user acceptance on checkout | +| `use_default_eula` | boolean | No | Use the default EULA | +| `notes` | string | No | Notes | + +#### `update_category` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_category` +The category must have no items assigned. Identify by `id` or `name`. + +--- + +### Manufacturers + +#### `list_manufacturers` +Search with optional `search`, `limit`, `offset`. + +#### `show_manufacturer` +Look up by `id` or `name`. + +#### `create_manufacturer` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Manufacturer name | +| `url` | string | No | Website URL | +| `support_url` | string | No | Support website URL | +| `support_email` | string | No | Support email | +| `support_phone` | string | No | Support phone | +| `warranty_lookup_url` | string | No | Warranty lookup URL | +| `notes` | string | No | Notes | + +#### `update_manufacturer` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_manufacturer` +Identify by `id` or `name`. + +--- + +### Suppliers + +#### `list_suppliers` +Search with optional `search`, `limit`, `offset`. + +#### `show_supplier` +Look up by `id` or `name`. + +#### `create_supplier` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Supplier name | +| `address` | string | No | Address line 1 | +| `address2` | string | No | Address line 2 | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal code | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `email` | string | No | Email address | +| `url` | string | No | Website URL | +| `contact` | string | No | Contact name | +| `notes` | string | No | Notes | + +#### `update_supplier` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_supplier` +Identify by `id` or `name`. + +--- + +### Status Labels + +#### `list_status_labels` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `status_type` | string | No | `deployable`, `pending`, `archived`, or `undeployable` | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_status_label` +Look up by `id` or `name`. + +#### `create_status_label` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Status label name | +| `type` | string | **Yes** | `deployable`, `pending`, `archived`, or `undeployable` | +| `color` | string | No | Display colour in `#RRGGBB` format | +| `notes` | string | No | Notes | +| `default_label` | boolean | No | Set as default label | +| `show_in_nav` | boolean | No | Show in navigation | + +#### `update_status_label` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_status_label` +Identify by `id` or `name`. + +--- + +### Locations + +#### `list_locations` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `parent_id` | number | No | Filter by parent location ID | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_location` +Look up by `id` or `name`. + +#### `create_location` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Location name | +| `address` | string | No | Street address | +| `address2` | string | No | Address line 2 | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal code | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `currency` | string | No | Currency code | +| `parent_id` | number | No | Parent location ID | +| `manager_id` | number | No | Manager user ID | + +#### `update_location` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_location` +Identify by `id` or `name`. + +--- + +### Asset Models + +#### `list_asset_models` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, model number | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_asset_model` +Look up by `id` or `name`. + +#### `create_asset_model` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Model name | +| `category_id` | number | **Yes** | Category ID | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `depreciation_id` | number | No | Depreciation schedule ID | +| `eol` | number | No | End of life in months (0–240) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `notes` | string | No | Notes | +| `requestable` | boolean | No | Whether the model can be requested | +| `require_serial` | boolean | No | Whether serial numbers are required | + +#### `update_asset_model` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_asset_model` +Identify by `id` or `name`. + +--- + +### Depreciations + +#### `list_depreciations` +Search with optional `search`, `limit`, `offset`. + +#### `show_depreciation` +Look up by `id` or `name`. + +#### `create_depreciation` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Depreciation schedule name | +| `months` | number | **Yes** | Depreciation period in months (1–3600) | + +#### `update_depreciation` +Identify by `id` or `name`. Use `new_name` to rename, `months` to change the period. + +#### `delete_depreciation` +Identify by `id` or `name`. + +--- + +### Groups + +#### `list_groups` +Search with optional `search`, `limit`, `offset`. + +#### `show_group` +Look up by `id` or `name`. + +#### `create_group` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Group name (must be unique) | +| `notes` | string | No | Notes | + +#### `update_group` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_group` +The group must have no users assigned. Identify by `id` or `name`. + +--- + +### Maintenance + +#### `list_maintenances` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_id` | number | No | Filter by asset ID | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `create_maintenance` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_id` | number | **Yes** | Asset ID the maintenance is for | +| `title` | string | **Yes** | Maintenance title | +| `asset_maintenance_type` | string | No | Type (e.g. `maintenance`, `repair`, `upgrade`) | +| `supplier_id` | number | No | Supplier ID | +| `is_warranty` | boolean | No | Whether this is a warranty repair | +| `cost` | number | No | Cost of the maintenance | +| `start_date` | string | No | Start date (YYYY-MM-DD, defaults to today) | +| `completion_date` | string | No | Completion date (YYYY-MM-DD) | +| `notes` | string | No | Notes | +| `user_id` | number | No | Technician user ID | + +--- + +### Activity Log + +#### `get_activity_log` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `item_type` | string | No | Filter by item type (e.g. `App\Models\Asset`) | +| `item_id` | number | No | Filter by item ID | +| `user_id` | number | No | Filter by user ID | +| `action_type` | string | No | Filter by action (e.g. `checkout`, `checkin`, `update`) | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | From 082ebeb27f6fd88115070bb8ae4264e87f323778 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:39:09 +0100 Subject: [PATCH 33/45] Localize prompts and tools --- app/Mcp/Prompts/AuditLocationPrompt.php | 6 +- app/Mcp/Prompts/EndOfLifeReviewPrompt.php | 6 +- app/Mcp/Prompts/ExpiringLicensesPrompt.php | 6 +- app/Mcp/Prompts/FindAvailableAssetPrompt.php | 6 +- app/Mcp/Prompts/InventorySummaryPrompt.php | 6 +- app/Mcp/Prompts/OffboardEmployeePrompt.php | 6 +- app/Mcp/Prompts/OnboardEmployeePrompt.php | 6 +- app/Mcp/Prompts/SnipePrompt.php | 24 ++ app/Mcp/Prompts/UserInventoryPrompt.php | 6 +- app/Mcp/Prompts/WarrantyExpiringPrompt.php | 6 +- app/Mcp/Tools/AddAssetNoteTool.php | 10 +- app/Mcp/Tools/AuditAssetTool.php | 10 +- app/Mcp/Tools/CheckinAccessoryTool.php | 12 +- app/Mcp/Tools/CheckinAssetTool.php | 12 +- app/Mcp/Tools/CheckinComponentTool.php | 10 +- app/Mcp/Tools/CheckinLicenseTool.php | 14 +- app/Mcp/Tools/CheckoutAccessoryTool.php | 12 +- app/Mcp/Tools/CheckoutAssetTool.php | 14 +- app/Mcp/Tools/CheckoutComponentTool.php | 8 +- app/Mcp/Tools/CheckoutConsumableTool.php | 12 +- app/Mcp/Tools/CheckoutLicenseTool.php | 24 +- app/Mcp/Tools/CreateAccessoryTool.php | 8 +- app/Mcp/Tools/CreateAssetModelTool.php | 8 +- app/Mcp/Tools/CreateAssetTool.php | 8 +- app/Mcp/Tools/CreateCategoryTool.php | 8 +- app/Mcp/Tools/CreateCompanyTool.php | 8 +- app/Mcp/Tools/CreateComponentTool.php | 8 +- app/Mcp/Tools/CreateConsumableTool.php | 8 +- app/Mcp/Tools/CreateDepartmentTool.php | 8 +- app/Mcp/Tools/CreateDepreciationTool.php | 8 +- app/Mcp/Tools/CreateGroupTool.php | 8 +- app/Mcp/Tools/CreateLicenseTool.php | 8 +- app/Mcp/Tools/CreateLocationTool.php | 8 +- app/Mcp/Tools/CreateMaintenanceTool.php | 8 +- app/Mcp/Tools/CreateManufacturerTool.php | 8 +- app/Mcp/Tools/CreateStatusLabelTool.php | 8 +- app/Mcp/Tools/CreateSupplierTool.php | 6 +- app/Mcp/Tools/CreateUserTool.php | 6 +- resources/lang/en-US/mcp.php | 245 +++++++++++++++++++ 39 files changed, 431 insertions(+), 162 deletions(-) create mode 100644 app/Mcp/Prompts/SnipePrompt.php create mode 100644 resources/lang/en-US/mcp.php diff --git a/app/Mcp/Prompts/AuditLocationPrompt.php b/app/Mcp/Prompts/AuditLocationPrompt.php index ed93ae0245fe..cf8ea4d46930 100644 --- a/app/Mcp/Prompts/AuditLocationPrompt.php +++ b/app/Mcp/Prompts/AuditLocationPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('audit_location')] #[Title('Audit Location')] #[Description('Review all assets at a location, flag overdue audits and status anomalies')] -class AuditLocationPrompt extends Prompt +class AuditLocationPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -34,7 +34,7 @@ public function handle(Request $request): Response Present the findings clearly so they can be acted on or exported. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php index 3299221e9e8f..9e7d7fa93ccf 100644 --- a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php +++ b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('end_of_life_review')] #[Title('End of Life Review')] #[Description('Identify assets that have passed their EOL date or are fully depreciated, and recommend disposition actions')] -class EndOfLifeReviewPrompt extends Prompt +class EndOfLifeReviewPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -42,7 +42,7 @@ public function handle(Request $request): Response 6. Provide a cost summary if purchase cost data is available — total value of end-of-life assets. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/ExpiringLicensesPrompt.php b/app/Mcp/Prompts/ExpiringLicensesPrompt.php index 0d4cc0830689..8267e5f02f58 100644 --- a/app/Mcp/Prompts/ExpiringLicensesPrompt.php +++ b/app/Mcp/Prompts/ExpiringLicensesPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('expiring_licenses')] #[Title('Expiring Licenses')] #[Description('Review license seat usage and flag licenses expiring within a given number of days')] -class ExpiringLicensesPrompt extends Prompt +class ExpiringLicensesPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -32,7 +32,7 @@ public function handle(Request $request): Response 6. Produce a prioritised action list: renewals needed urgently, over-deployments to resolve, and possible cancellations. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/FindAvailableAssetPrompt.php b/app/Mcp/Prompts/FindAvailableAssetPrompt.php index c1d373565e2b..118a4de62da8 100644 --- a/app/Mcp/Prompts/FindAvailableAssetPrompt.php +++ b/app/Mcp/Prompts/FindAvailableAssetPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('find_available_asset')] #[Title('Find Available Asset')] #[Description('Find an undeployed asset by category or model and optionally check it out to a user')] -class FindAvailableAssetPrompt extends Prompt +class FindAvailableAssetPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -43,7 +43,7 @@ public function handle(Request $request): Response If no available assets match, report what was found and suggest alternatives (different models in the same category, or assets currently out for repair that may return soon). TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/InventorySummaryPrompt.php b/app/Mcp/Prompts/InventorySummaryPrompt.php index 2dd1d03c67ab..b97592ecfd1b 100644 --- a/app/Mcp/Prompts/InventorySummaryPrompt.php +++ b/app/Mcp/Prompts/InventorySummaryPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('inventory_summary')] #[Title('Inventory Summary')] #[Description('Produce a high-level inventory count by category, broken down by deployment status')] -class InventorySummaryPrompt extends Prompt +class InventorySummaryPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -42,7 +42,7 @@ public function handle(Request $request): Response 6. Present the results as a concise executive summary with a supporting breakdown table. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/OffboardEmployeePrompt.php b/app/Mcp/Prompts/OffboardEmployeePrompt.php index 5300b2e1d8bb..49182a78540d 100644 --- a/app/Mcp/Prompts/OffboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OffboardEmployeePrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('offboard_employee')] #[Title('Offboard Employee')] #[Description('Guide through checking in all equipment and licenses from a departing employee and deactivating their account')] -class OffboardEmployeePrompt extends Prompt +class OffboardEmployeePrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -34,7 +34,7 @@ public function handle(Request $request): Response If any items cannot be checked in automatically, flag them for manual follow-up. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php index e3133eaa5e77..06aaaf94730d 100644 --- a/app/Mcp/Prompts/OnboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('onboard_employee')] #[Title('Onboard Employee')] #[Description('Guide through creating a new employee account and assigning appropriate equipment and licenses')] -class OnboardEmployeePrompt extends Prompt +class OnboardEmployeePrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -48,7 +48,7 @@ public function handle(Request $request): Response 5. Summarise what was set up: the user account created, assets checked out, and licenses assigned. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/SnipePrompt.php b/app/Mcp/Prompts/SnipePrompt.php new file mode 100644 index 000000000000..36976f07c323 --- /dev/null +++ b/app/Mcp/Prompts/SnipePrompt.php @@ -0,0 +1,24 @@ +user()?->locale ?? app()->getLocale(); + + if (str_starts_with($locale, 'en')) { + return ''; + } + + return "\n\nPlease respond in the language that corresponds to locale: {$locale}."; + } +} diff --git a/app/Mcp/Prompts/UserInventoryPrompt.php b/app/Mcp/Prompts/UserInventoryPrompt.php index 7a8756bf1959..10ef66c3c234 100644 --- a/app/Mcp/Prompts/UserInventoryPrompt.php +++ b/app/Mcp/Prompts/UserInventoryPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('user_inventory')] #[Title('User Inventory')] #[Description('List everything currently assigned to a specific user across all asset types')] -class UserInventoryPrompt extends Prompt +class UserInventoryPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -33,7 +33,7 @@ public function handle(Request $request): Response 7. Present a clean summary grouped by item type, suitable for sharing with a manager or for an audit. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Prompts/WarrantyExpiringPrompt.php b/app/Mcp/Prompts/WarrantyExpiringPrompt.php index 0e0f2f2aba30..3927a4ea6f92 100644 --- a/app/Mcp/Prompts/WarrantyExpiringPrompt.php +++ b/app/Mcp/Prompts/WarrantyExpiringPrompt.php @@ -7,13 +7,13 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use Laravel\Mcp\Server\Prompt; +use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('warranty_expiring')] #[Title('Warranty Expiring')] #[Description('List assets whose warranty expires within a given number of days')] -class WarrantyExpiringPrompt extends Prompt +class WarrantyExpiringPrompt extends SnipePrompt { public function handle(Request $request): Response { @@ -31,7 +31,7 @@ public function handle(Request $request): Response 5. Recommend actions: extend warranty, schedule replacement, or note as acceptable risk. TEXT; - return Response::text(trim($prompt)); + return Response::text(trim($prompt).$this->localeInstruction()); } public function arguments(): array diff --git a/app/Mcp/Tools/AddAssetNoteTool.php b/app/Mcp/Tools/AddAssetNoteTool.php index 42d8dc99b1af..ccc889fc67cc 100644 --- a/app/Mcp/Tools/AddAssetNoteTool.php +++ b/app/Mcp/Tools/AddAssetNoteTool.php @@ -31,11 +31,11 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('update', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $logaction = new Actionlog; @@ -46,17 +46,17 @@ public function handle(Request $request): ResponseFactory if ($logaction->logaction('note added')) { return Response::make( - Response::text('Note added to asset '.$asset->asset_tag) + Response::text(trans('mcp.note_added_to_asset', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Note added successfully', + 'message' => trans('mcp.note_added_successfully'), 'asset_tag' => $asset->asset_tag, 'asset_id' => $asset->id, 'note' => $logaction->note, ]); } - return Response::make(Response::error('Failed to save note')); + return Response::make(Response::error(trans('mcp.note_save_failed'))); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/AuditAssetTool.php b/app/Mcp/Tools/AuditAssetTool.php index c0004179cf2a..59489d4d43ba 100644 --- a/app/Mcp/Tools/AuditAssetTool.php +++ b/app/Mcp/Tools/AuditAssetTool.php @@ -34,11 +34,11 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('audit', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $originalValues = $asset->getRawOriginal(); @@ -64,10 +64,10 @@ public function handle(Request $request): ResponseFactory $asset->logAudit($request->get('note'), $request->get('location_id'), null, $originalValues); return Response::make( - Response::text('Audit recorded for asset '.$asset->asset_tag) + Response::text(trans('mcp.asset_audited', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Audit recorded successfully', + 'message' => trans('mcp.asset_audited', ['asset_tag' => $asset->asset_tag]), 'asset_tag' => $asset->asset_tag, 'last_audit_date' => $asset->last_audit_date, 'next_audit_date' => $asset->next_audit_date, @@ -75,7 +75,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Audit failed: '.$asset->getErrors()->first())); + return Response::make(Response::error(trans('mcp.audit_failed', ['error' => $asset->getErrors()->first()]))); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/CheckinAccessoryTool.php b/app/Mcp/Tools/CheckinAccessoryTool.php index 32aa0189e1ab..00dab7c603e6 100644 --- a/app/Mcp/Tools/CheckinAccessoryTool.php +++ b/app/Mcp/Tools/CheckinAccessoryTool.php @@ -29,17 +29,17 @@ public function handle(Request $request): ResponseFactory $checkout = AccessoryCheckout::find($request->get('checkout_id')); if (! $checkout) { - return Response::make(Response::error('Accessory checkout record not found')); + return Response::make(Response::error(trans('mcp.accessory_checkout_not_found'))); } $accessory = Accessory::find($checkout->accessory_id); if (! $accessory) { - return Response::make(Response::error('Accessory not found')); + return Response::make(Response::error(trans('mcp.accessory_not_found'))); } if (! Gate::allows('checkin', $accessory)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $target = $checkout->assigned_type && $checkout->assigned_to @@ -50,16 +50,16 @@ public function handle(Request $request): ResponseFactory if ($checkout->delete()) { return Response::make( - Response::text('Accessory '.$accessory->name.' checked in successfully') + Response::text(trans('mcp.accessory_checked_in', ['name' => $accessory->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Accessory checked in successfully', + 'message' => trans('mcp.accessory_checked_in', ['name' => $accessory->name]), 'accessory_id' => $accessory->id, 'accessory_name' => $accessory->name, ]); } - return Response::make(Response::error('Checkin failed')); + return Response::make(Response::error(trans('mcp.checkin_failed'))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CheckinAssetTool.php b/app/Mcp/Tools/CheckinAssetTool.php index bc94c9960cb1..c6ca06ac53b4 100644 --- a/app/Mcp/Tools/CheckinAssetTool.php +++ b/app/Mcp/Tools/CheckinAssetTool.php @@ -30,17 +30,17 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('checkin', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $target = $asset->assignedTo; if (is_null($target)) { - return Response::make(Response::error('Asset '.$asset->asset_tag.' is not currently checked out')); + return Response::make(Response::error(trans('mcp.asset_not_checked_out', ['asset_tag' => $asset->asset_tag]))); } $originalValues = $asset->getRawOriginal(); @@ -56,17 +56,17 @@ public function handle(Request $request): ResponseFactory event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->get('note'), $checkinAt, $originalValues)); return Response::make( - Response::text('Asset '.$asset->asset_tag.' checked in successfully') + Response::text(trans('mcp.asset_checked_in', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset checked in successfully', + 'message' => trans('mcp.asset_checked_in', ['asset_tag' => $asset->asset_tag]), 'asset_tag' => $asset->asset_tag, 'model' => $asset->model?->name, 'location' => $asset->location?->name, ]); } - return Response::make(Response::error('Checkin failed: '.$asset->getErrors()->first())); + return Response::make(Response::error(trans('mcp.checkin_failed_error', ['error' => $asset->getErrors()->first()]))); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/CheckinComponentTool.php b/app/Mcp/Tools/CheckinComponentTool.php index afb040c17266..b76f29de33f3 100644 --- a/app/Mcp/Tools/CheckinComponentTool.php +++ b/app/Mcp/Tools/CheckinComponentTool.php @@ -33,17 +33,17 @@ public function handle(Request $request): ResponseFactory $componentAsset = DB::table('components_assets')->find($request->get('component_asset_id')); if (! $componentAsset) { - return Response::make(Response::error('Component checkout record not found')); + return Response::make(Response::error(trans('mcp.component_checkout_not_found'))); } $component = Component::find($componentAsset->component_id); if (! $component) { - return Response::make(Response::error('Component not found')); + return Response::make(Response::error(trans('mcp.component_not_found'))); } if (! Gate::allows('checkin', $component)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $maxCheckin = $componentAsset->assigned_qty ?? 1; @@ -68,10 +68,10 @@ public function handle(Request $request): ResponseFactory event(new CheckoutableCheckedIn($component, $asset, auth()->user(), $request->get('note'), Carbon::now())); return Response::make( - Response::text('Component '.$component->name.' checked in successfully') + Response::text(trans('mcp.component_checked_in', ['name' => $component->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Component checked in successfully', + 'message' => trans('mcp.component_checked_in', ['name' => $component->name]), 'component_id' => $component->id, 'component_name' => $component->name, 'checkin_qty' => $checkinQty, diff --git a/app/Mcp/Tools/CheckinLicenseTool.php b/app/Mcp/Tools/CheckinLicenseTool.php index 2019e4d61ac5..4ec5ba5c220a 100644 --- a/app/Mcp/Tools/CheckinLicenseTool.php +++ b/app/Mcp/Tools/CheckinLicenseTool.php @@ -32,22 +32,22 @@ public function handle(Request $request): ResponseFactory $seat = LicenseSeat::with('license')->find($request->get('seat_id')); if (! $seat) { - return Response::make(Response::error('License seat not found')); + return Response::make(Response::error(trans('mcp.license_seat_not_found'))); } if (is_null($seat->assigned_to) && is_null($seat->asset_id)) { - return Response::make(Response::error('This seat is not currently checked out')); + return Response::make(Response::error(trans('mcp.seat_not_checked_out'))); } $license = $seat->license; if (! $license) { - return Response::make(Response::error('License not found')); + return Response::make(Response::error(trans('mcp.license_not_found'))); } // License checkin uses the checkout gate (matching application behavior) if (! Gate::allows('checkout', $license)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $returnTo = null; @@ -71,17 +71,17 @@ public function handle(Request $request): ResponseFactory event(new CheckoutableCheckedIn($seat, $returnTo, auth()->user(), $note)); return Response::make( - Response::text('License seat '.$seat->id.' checked in successfully') + Response::text(trans('mcp.license_seat_checked_in', ['id' => $seat->id])) )->withStructuredContent([ 'success' => true, - 'message' => 'License seat checked in successfully', + 'message' => trans('mcp.license_seat_checked_in', ['id' => $seat->id]), 'seat_id' => $seat->id, 'license_id' => $license->id, 'license_name' => $license->name, ]); } - return Response::make(Response::error('Checkin failed')); + return Response::make(Response::error(trans('mcp.checkin_failed'))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CheckoutAccessoryTool.php b/app/Mcp/Tools/CheckoutAccessoryTool.php index 0fc9078abeb8..996802831af4 100644 --- a/app/Mcp/Tools/CheckoutAccessoryTool.php +++ b/app/Mcp/Tools/CheckoutAccessoryTool.php @@ -39,15 +39,15 @@ public function handle(Request $request): ResponseFactory $accessory = $this->resolveAccessory($request); if (! $accessory) { - return Response::make(Response::error('Accessory not found')); + return Response::make(Response::error(trans('mcp.accessory_not_found'))); } if (! Gate::allows('checkout', $accessory)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($accessory->numRemaining() < 1) { - return Response::make(Response::error('No units of this accessory are available for checkout')); + return Response::make(Response::error(trans('mcp.no_units_available'))); } $checkoutType = $request->get('checkout_to_type'); @@ -59,7 +59,7 @@ public function handle(Request $request): ResponseFactory }; if (! $target) { - return Response::make(Response::error('The specified '.$checkoutType.' was not found')); + return Response::make(Response::error(trans('mcp.checkout_target_not_found', ['type' => $checkoutType]))); } $checkout = new AccessoryCheckout([ @@ -82,10 +82,10 @@ public function handle(Request $request): ResponseFactory )); return Response::make( - Response::text('Accessory '.$accessory->name.' checked out successfully') + Response::text(trans('mcp.accessory_checked_out', ['name' => $accessory->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Accessory checked out successfully', + 'message' => trans('mcp.accessory_checked_out', ['name' => $accessory->name]), 'accessory_id' => $accessory->id, 'accessory_name' => $accessory->name, 'checkout_id' => $checkout->id, diff --git a/app/Mcp/Tools/CheckoutAssetTool.php b/app/Mcp/Tools/CheckoutAssetTool.php index 8b33da0968b7..4bd31441b509 100644 --- a/app/Mcp/Tools/CheckoutAssetTool.php +++ b/app/Mcp/Tools/CheckoutAssetTool.php @@ -37,15 +37,15 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('checkout', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if (! $asset->availableForCheckout()) { - return Response::make(Response::error('Asset '.$asset->asset_tag.' is not available for checkout')); + return Response::make(Response::error(trans('mcp.asset_not_available', ['asset_tag' => $asset->asset_tag]))); } $checkoutType = $request->get('checkout_to_type'); @@ -69,7 +69,7 @@ public function handle(Request $request): ResponseFactory } if (! $target) { - return Response::make(Response::error('The specified '.$checkoutType.' was not found')); + return Response::make(Response::error(trans('mcp.checkout_target_not_found', ['type' => $checkoutType]))); } $checkoutAt = $request->filled('checkout_at') ? $request->get('checkout_at') : date('Y-m-d H:i:s'); @@ -78,17 +78,17 @@ public function handle(Request $request): ResponseFactory if ($asset->checkOut($target, auth()->user(), $checkoutAt, $expectedCheckin, $note, $asset->name, $asset->location_id)) { return Response::make( - Response::text('Asset '.$asset->asset_tag.' checked out successfully') + Response::text(trans('mcp.asset_checked_out', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset checked out successfully', + 'message' => trans('mcp.asset_checked_out', ['asset_tag' => $asset->asset_tag]), 'asset_tag' => $asset->asset_tag, 'checked_out_to_type' => $checkoutType, 'checked_out_to_id' => $target->id, ]); } - return Response::make(Response::error('Checkout failed')); + return Response::make(Response::error(trans('mcp.checkout_failed'))); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/CheckoutComponentTool.php b/app/Mcp/Tools/CheckoutComponentTool.php index 404865e12c3a..56d27caac200 100644 --- a/app/Mcp/Tools/CheckoutComponentTool.php +++ b/app/Mcp/Tools/CheckoutComponentTool.php @@ -38,11 +38,11 @@ public function handle(Request $request): ResponseFactory $component = $this->resolveComponent($request); if (! $component) { - return Response::make(Response::error('Component not found')); + return Response::make(Response::error(trans('mcp.component_not_found'))); } if (! Gate::allows('checkout', $component)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $qty = (int) $request->get('assigned_qty', 1); @@ -69,10 +69,10 @@ public function handle(Request $request): ResponseFactory $component->logCheckout($request->get('note'), $asset, null, [], $qty); return Response::make( - Response::text('Component '.$component->name.' checked out to asset '.$asset->asset_tag) + Response::text(trans('mcp.component_checked_out', ['name' => $component->name, 'asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Component checked out successfully', + 'message' => trans('mcp.component_checked_out', ['name' => $component->name, 'asset_tag' => $asset->asset_tag]), 'component_id' => $component->id, 'component_name' => $component->name, 'asset_id' => $asset->id, diff --git a/app/Mcp/Tools/CheckoutConsumableTool.php b/app/Mcp/Tools/CheckoutConsumableTool.php index 6ac8dfdb5671..5bd834b5751f 100644 --- a/app/Mcp/Tools/CheckoutConsumableTool.php +++ b/app/Mcp/Tools/CheckoutConsumableTool.php @@ -32,21 +32,21 @@ public function handle(Request $request): ResponseFactory $consumable = $this->resolveConsumable($request); if (! $consumable) { - return Response::make(Response::error('Consumable not found')); + return Response::make(Response::error(trans('mcp.consumable_not_found'))); } if (! Gate::allows('checkout', $consumable)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($consumable->numRemaining() <= 0) { - return Response::make(Response::error('No units remaining')); + return Response::make(Response::error(trans('mcp.no_units_remaining'))); } $user = User::find($request->get('assigned_to')); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } $consumable->users()->attach($consumable->id, [ @@ -66,10 +66,10 @@ public function handle(Request $request): ResponseFactory )); return Response::make( - Response::text('Consumable '.$consumable->name.' checked out to '.$user->username) + Response::text(trans('mcp.consumable_checked_out', ['name' => $consumable->name, 'username' => $user->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'Consumable checked out successfully', + 'message' => trans('mcp.consumable_checked_out', ['name' => $consumable->name, 'username' => $user->username]), 'consumable_id' => $consumable->id, 'consumable_name' => $consumable->name, 'assigned_to_id' => $user->id, diff --git a/app/Mcp/Tools/CheckoutLicenseTool.php b/app/Mcp/Tools/CheckoutLicenseTool.php index a71be3c94090..256b3ec5018a 100644 --- a/app/Mcp/Tools/CheckoutLicenseTool.php +++ b/app/Mcp/Tools/CheckoutLicenseTool.php @@ -34,25 +34,25 @@ public function handle(Request $request): ResponseFactory $license = $this->resolveLicense($request); if (! $license) { - return Response::make(Response::error('License not found')); + return Response::make(Response::error(trans('mcp.license_not_found'))); } if (! Gate::allows('checkout', $license)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($license->numRemaining() < 1) { - return Response::make(Response::error('No available seats for this license')); + return Response::make(Response::error(trans('mcp.no_available_seats'))); } if (! $request->filled('assigned_to') && ! $request->filled('asset_id')) { - return Response::make(Response::error('Please provide either assigned_to (user ID) or asset_id')); + return Response::make(Response::error(trans('mcp.provide_user_or_asset'))); } $seat = $license->freeSeat(); if (! $seat) { - return Response::make(Response::error('No free seat found for this license')); + return Response::make(Response::error(trans('mcp.no_free_seat'))); } $note = $request->get('note'); @@ -60,7 +60,7 @@ public function handle(Request $request): ResponseFactory if ($request->filled('assigned_to')) { $target = User::find($request->get('assigned_to')); if (! $target) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } $seat->assigned_to = $target->id; $seat->notes = $note; @@ -69,10 +69,10 @@ public function handle(Request $request): ResponseFactory event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); return Response::make( - Response::text('License seat checked out to user '.$target->username) + Response::text(trans('mcp.license_seat_checked_out_user', ['username' => $target->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'License seat checked out successfully', + 'message' => trans('mcp.license_seat_checked_out_user', ['username' => $target->username]), 'license_id' => $license->id, 'license_name' => $license->name, 'seat_id' => $seat->id, @@ -83,7 +83,7 @@ public function handle(Request $request): ResponseFactory } elseif ($request->filled('asset_id')) { $target = Asset::find($request->get('asset_id')); if (! $target) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } $seat->asset_id = $target->id; if ($target->checkedOutToUser()) { @@ -95,10 +95,10 @@ public function handle(Request $request): ResponseFactory event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); return Response::make( - Response::text('License seat checked out to asset '.$target->asset_tag) + Response::text(trans('mcp.license_seat_checked_out_asset', ['asset_tag' => $target->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'License seat checked out successfully', + 'message' => trans('mcp.license_seat_checked_out_asset', ['asset_tag' => $target->asset_tag]), 'license_id' => $license->id, 'license_name' => $license->name, 'seat_id' => $seat->id, @@ -108,7 +108,7 @@ public function handle(Request $request): ResponseFactory } } - return Response::make(Response::error('Checkout failed')); + return Response::make(Response::error(trans('mcp.checkout_failed'))); } private function resolveLicense(Request $request): ?License diff --git a/app/Mcp/Tools/CreateAccessoryTool.php b/app/Mcp/Tools/CreateAccessoryTool.php index 5e4ae6025fe7..e93b94f7121a 100644 --- a/app/Mcp/Tools/CreateAccessoryTool.php +++ b/app/Mcp/Tools/CreateAccessoryTool.php @@ -23,7 +23,7 @@ class CreateAccessoryTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Accessory::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -59,10 +59,10 @@ public function handle(Request $request): ResponseFactory if ($accessory->save()) { return Response::make( - Response::text('Accessory '.$accessory->name.' created successfully') + Response::text(trans('mcp.accessory_created', ['name' => $accessory->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Accessory created successfully', + 'message' => trans('mcp.accessory_created', ['name' => $accessory->name]), 'id' => $accessory->id, 'name' => $accessory->name, 'qty' => $accessory->qty, @@ -70,7 +70,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$accessory->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $accessory->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateAssetModelTool.php b/app/Mcp/Tools/CreateAssetModelTool.php index f9352195e2bf..10372f815572 100644 --- a/app/Mcp/Tools/CreateAssetModelTool.php +++ b/app/Mcp/Tools/CreateAssetModelTool.php @@ -22,7 +22,7 @@ class CreateAssetModelTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', AssetModel::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -55,17 +55,17 @@ public function handle(Request $request): ResponseFactory if ($assetModel->save()) { return Response::make( - Response::text('Asset model '.$assetModel->name.' created successfully') + Response::text(trans('mcp.asset_model_created', ['name' => $assetModel->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset model created successfully', + 'message' => trans('mcp.asset_model_created', ['name' => $assetModel->name]), 'id' => $assetModel->id, 'name' => $assetModel->name, 'category_id' => $assetModel->category_id, ]); } - return Response::make(Response::error('Create failed: '.$assetModel->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $assetModel->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateAssetTool.php b/app/Mcp/Tools/CreateAssetTool.php index b837dee5170a..3f0101739212 100644 --- a/app/Mcp/Tools/CreateAssetTool.php +++ b/app/Mcp/Tools/CreateAssetTool.php @@ -22,7 +22,7 @@ class CreateAssetTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -61,17 +61,17 @@ public function handle(Request $request): ResponseFactory if ($asset->save()) { return Response::make( - Response::text('Asset '.$asset->asset_tag.' created successfully') + Response::text(trans('mcp.asset_created', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset created successfully', + 'message' => trans('mcp.asset_created', ['asset_tag' => $asset->asset_tag]), 'id' => $asset->id, 'asset_tag' => $asset->asset_tag, 'name' => $asset->name, ]); } - return Response::make(Response::error('Create failed: '.$asset->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $asset->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateCategoryTool.php b/app/Mcp/Tools/CreateCategoryTool.php index c50bb14ed383..51d516192bcc 100644 --- a/app/Mcp/Tools/CreateCategoryTool.php +++ b/app/Mcp/Tools/CreateCategoryTool.php @@ -22,7 +22,7 @@ class CreateCategoryTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Category::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -51,17 +51,17 @@ public function handle(Request $request): ResponseFactory if ($category->save()) { return Response::make( - Response::text('Category '.$category->name.' created successfully') + Response::text(trans('mcp.category_created', ['name' => $category->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Category created successfully', + 'message' => trans('mcp.category_created', ['name' => $category->name]), 'id' => $category->id, 'name' => $category->name, 'category_type' => $category->category_type, ]); } - return Response::make(Response::error('Create failed: '.$category->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $category->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateCompanyTool.php b/app/Mcp/Tools/CreateCompanyTool.php index b9ab3fd777e3..c4737cc2ed1a 100644 --- a/app/Mcp/Tools/CreateCompanyTool.php +++ b/app/Mcp/Tools/CreateCompanyTool.php @@ -22,7 +22,7 @@ class CreateCompanyTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Company::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -55,16 +55,16 @@ public function handle(Request $request): ResponseFactory if ($company->save()) { return Response::make( - Response::text('Company '.$company->name.' created successfully') + Response::text(trans('mcp.company_created', ['name' => $company->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Company created successfully', + 'message' => trans('mcp.company_created', ['name' => $company->name]), 'id' => $company->id, 'name' => $company->name, ]); } - return Response::make(Response::error('Create failed: '.$company->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $company->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateComponentTool.php b/app/Mcp/Tools/CreateComponentTool.php index 23db2ba970b5..b0d5d3bb5810 100644 --- a/app/Mcp/Tools/CreateComponentTool.php +++ b/app/Mcp/Tools/CreateComponentTool.php @@ -23,7 +23,7 @@ class CreateComponentTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Component::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -59,10 +59,10 @@ public function handle(Request $request): ResponseFactory if ($component->save()) { return Response::make( - Response::text('Component '.$component->name.' created successfully') + Response::text(trans('mcp.component_created', ['name' => $component->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Component created successfully', + 'message' => trans('mcp.component_created', ['name' => $component->name]), 'id' => $component->id, 'name' => $component->name, 'qty' => $component->qty, @@ -70,7 +70,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$component->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $component->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateConsumableTool.php b/app/Mcp/Tools/CreateConsumableTool.php index 48cc2a51ae09..402825505b4c 100644 --- a/app/Mcp/Tools/CreateConsumableTool.php +++ b/app/Mcp/Tools/CreateConsumableTool.php @@ -22,7 +22,7 @@ class CreateConsumableTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Consumable::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -57,10 +57,10 @@ public function handle(Request $request): ResponseFactory if ($consumable->save()) { return Response::make( - Response::text('Consumable '.$consumable->name.' created successfully') + Response::text(trans('mcp.consumable_created', ['name' => $consumable->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Consumable created successfully', + 'message' => trans('mcp.consumable_created', ['name' => $consumable->name]), 'id' => $consumable->id, 'name' => $consumable->name, 'qty' => $consumable->qty, @@ -68,7 +68,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$consumable->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $consumable->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateDepartmentTool.php b/app/Mcp/Tools/CreateDepartmentTool.php index 0cba3af4cf00..ec4c439bf158 100644 --- a/app/Mcp/Tools/CreateDepartmentTool.php +++ b/app/Mcp/Tools/CreateDepartmentTool.php @@ -23,7 +23,7 @@ class CreateDepartmentTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Department::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -50,16 +50,16 @@ public function handle(Request $request): ResponseFactory if ($department->save()) { return Response::make( - Response::text('Department '.$department->name.' created successfully') + Response::text(trans('mcp.department_created', ['name' => $department->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Department created successfully', + 'message' => trans('mcp.department_created', ['name' => $department->name]), 'id' => $department->id, 'name' => $department->name, ]); } - return Response::make(Response::error('Create failed: '.$department->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $department->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateDepreciationTool.php b/app/Mcp/Tools/CreateDepreciationTool.php index 9894f59dc8f1..c7f75a115093 100644 --- a/app/Mcp/Tools/CreateDepreciationTool.php +++ b/app/Mcp/Tools/CreateDepreciationTool.php @@ -22,7 +22,7 @@ class CreateDepreciationTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Depreciation::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -40,17 +40,17 @@ public function handle(Request $request): ResponseFactory if ($depreciation->save()) { return Response::make( - Response::text('Depreciation '.$depreciation->name.' created successfully') + Response::text(trans('mcp.depreciation_created', ['name' => $depreciation->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Depreciation created successfully', + 'message' => trans('mcp.depreciation_created', ['name' => $depreciation->name]), 'id' => $depreciation->id, 'name' => $depreciation->name, 'months' => $depreciation->months, ]); } - return Response::make(Response::error('Create failed: '.$depreciation->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $depreciation->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateGroupTool.php b/app/Mcp/Tools/CreateGroupTool.php index 28191639070c..b847a91c36b7 100644 --- a/app/Mcp/Tools/CreateGroupTool.php +++ b/app/Mcp/Tools/CreateGroupTool.php @@ -22,7 +22,7 @@ class CreateGroupTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('superadmin')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -43,16 +43,16 @@ public function handle(Request $request): ResponseFactory if ($group->save()) { return Response::make( - Response::text('Group '.$group->name.' created successfully') + Response::text(trans('mcp.group_created', ['name' => $group->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Group created successfully', + 'message' => trans('mcp.group_created', ['name' => $group->name]), 'id' => $group->id, 'name' => $group->name, ]); } - return Response::make(Response::error('Create failed: '.$group->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $group->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateLicenseTool.php b/app/Mcp/Tools/CreateLicenseTool.php index d58e3cce1d0d..14f0ad0e3fef 100644 --- a/app/Mcp/Tools/CreateLicenseTool.php +++ b/app/Mcp/Tools/CreateLicenseTool.php @@ -23,7 +23,7 @@ class CreateLicenseTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', License::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -66,10 +66,10 @@ public function handle(Request $request): ResponseFactory if ($license->save()) { return Response::make( - Response::text('License '.$license->name.' created successfully') + Response::text(trans('mcp.license_created', ['name' => $license->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'License created successfully', + 'message' => trans('mcp.license_created', ['name' => $license->name]), 'id' => $license->id, 'name' => $license->name, 'seats' => $license->seats, @@ -77,7 +77,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$license->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $license->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateLocationTool.php b/app/Mcp/Tools/CreateLocationTool.php index fabc7fbf62ee..81f43893dfb6 100644 --- a/app/Mcp/Tools/CreateLocationTool.php +++ b/app/Mcp/Tools/CreateLocationTool.php @@ -22,7 +22,7 @@ class CreateLocationTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Location::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -55,16 +55,16 @@ public function handle(Request $request): ResponseFactory if ($location->save()) { return Response::make( - Response::text('Location '.$location->name.' created successfully') + Response::text(trans('mcp.location_created', ['name' => $location->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Location created successfully', + 'message' => trans('mcp.location_created', ['name' => $location->name]), 'id' => $location->id, 'name' => $location->name, ]); } - return Response::make(Response::error('Create failed: '.$location->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $location->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateMaintenanceTool.php b/app/Mcp/Tools/CreateMaintenanceTool.php index cf89e3f8cfed..4d2157fab515 100644 --- a/app/Mcp/Tools/CreateMaintenanceTool.php +++ b/app/Mcp/Tools/CreateMaintenanceTool.php @@ -23,7 +23,7 @@ class CreateMaintenanceTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('update', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -61,10 +61,10 @@ public function handle(Request $request): ResponseFactory $maintenance->load('asset'); return Response::make( - Response::text('Maintenance '.$maintenance->name.' created successfully') + Response::text(trans('mcp.maintenance_created', ['name' => $maintenance->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Maintenance created successfully', + 'message' => trans('mcp.maintenance_created', ['name' => $maintenance->name]), 'id' => $maintenance->id, 'title' => $maintenance->name, 'asset_id' => $maintenance->asset_id, @@ -72,7 +72,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$maintenance->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $maintenance->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateManufacturerTool.php b/app/Mcp/Tools/CreateManufacturerTool.php index 77ce84297901..bcbd2cf3541c 100644 --- a/app/Mcp/Tools/CreateManufacturerTool.php +++ b/app/Mcp/Tools/CreateManufacturerTool.php @@ -22,7 +22,7 @@ class CreateManufacturerTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Manufacturer::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -46,16 +46,16 @@ public function handle(Request $request): ResponseFactory if ($manufacturer->save()) { return Response::make( - Response::text('Manufacturer '.$manufacturer->name.' created successfully') + Response::text(trans('mcp.manufacturer_created', ['name' => $manufacturer->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Manufacturer created successfully', + 'message' => trans('mcp.manufacturer_created', ['name' => $manufacturer->name]), 'id' => $manufacturer->id, 'name' => $manufacturer->name, ]); } - return Response::make(Response::error('Create failed: '.$manufacturer->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $manufacturer->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateStatusLabelTool.php b/app/Mcp/Tools/CreateStatusLabelTool.php index 2d50597cf304..81b2bea47f5e 100644 --- a/app/Mcp/Tools/CreateStatusLabelTool.php +++ b/app/Mcp/Tools/CreateStatusLabelTool.php @@ -22,7 +22,7 @@ class CreateStatusLabelTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Statuslabel::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -57,17 +57,17 @@ public function handle(Request $request): ResponseFactory if ($statuslabel->save()) { return Response::make( - Response::text('Status label '.$statuslabel->name.' created successfully') + Response::text(trans('mcp.status_label_created', ['name' => $statuslabel->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Status label created successfully', + 'message' => trans('mcp.status_label_created', ['name' => $statuslabel->name]), 'id' => $statuslabel->id, 'name' => $statuslabel->name, 'type' => $statuslabel->getStatuslabelType(), ]); } - return Response::make(Response::error('Create failed: '.$statuslabel->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $statuslabel->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateSupplierTool.php b/app/Mcp/Tools/CreateSupplierTool.php index ef4d849b048a..35ae3af056a3 100644 --- a/app/Mcp/Tools/CreateSupplierTool.php +++ b/app/Mcp/Tools/CreateSupplierTool.php @@ -22,7 +22,7 @@ class CreateSupplierTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', Supplier::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -53,10 +53,10 @@ public function handle(Request $request): ResponseFactory if ($supplier->save()) { return Response::make( - Response::text('Supplier '.$supplier->name.' created successfully') + Response::text(trans('mcp.supplier_created', ['name' => $supplier->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Supplier created successfully', + 'message' => trans('mcp.supplier_created', ['name' => $supplier->name]), 'id' => $supplier->id, 'name' => $supplier->name, ]); diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php index 8a06d2a45a3f..226e836996a8 100644 --- a/app/Mcp/Tools/CreateUserTool.php +++ b/app/Mcp/Tools/CreateUserTool.php @@ -23,7 +23,7 @@ class CreateUserTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('create', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -78,10 +78,10 @@ public function handle(Request $request): ResponseFactory if ($user->save()) { return Response::make( - Response::text('User '.$user->username.' created successfully') + Response::text(trans('mcp.user_created', ['username' => $user->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'User created successfully', + 'message' => trans('mcp.user_created', ['username' => $user->username]), 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, diff --git a/resources/lang/en-US/mcp.php b/resources/lang/en-US/mcp.php new file mode 100644 index 000000000000..0748e0e17b3f --- /dev/null +++ b/resources/lang/en-US/mcp.php @@ -0,0 +1,245 @@ + 'Not authenticated', + 'unauthorized' => 'Unauthorized', + 'id_or_name_required' => 'Either id or name is required', + 'id_username_or_email_required' => 'Please provide an id, username, or email', + 'provide_user_or_asset' => 'Please provide either assigned_to (user ID) or asset_id', + + // ----------------------------------------------------------------- + // "Not found" errors + // ----------------------------------------------------------------- + 'asset_not_found' => 'Asset not found', + 'user_not_found' => 'User not found', + 'accessory_not_found' => 'Accessory not found', + 'accessory_checkout_not_found' => 'Accessory checkout record not found', + 'component_not_found' => 'Component not found', + 'component_checkout_not_found' => 'Component checkout record not found', + 'consumable_not_found' => 'Consumable not found', + 'license_not_found' => 'License not found', + 'license_seat_not_found' => 'License seat not found', + 'department_not_found' => 'Department not found', + 'company_not_found' => 'Company not found', + 'category_not_found' => 'Category not found', + 'manufacturer_not_found' => 'Manufacturer not found', + 'supplier_not_found' => 'Supplier not found', + 'status_label_not_found' => 'Status label not found', + 'location_not_found' => 'Location not found', + 'asset_model_not_found' => 'Asset model not found', + 'depreciation_not_found' => 'Depreciation not found', + 'group_not_found' => 'Group not found', + 'checkout_target_not_found' => 'The specified :type was not found', + + // ----------------------------------------------------------------- + // Business rule errors + // ----------------------------------------------------------------- + 'asset_not_available' => 'Asset :asset_tag is not available for checkout', + 'asset_not_checked_out' => 'Asset :asset_tag is not currently checked out', + 'asset_not_deleted' => 'Asset is not deleted', + 'user_not_deleted' => 'User is not deleted', + 'user_cannot_delete_self' => 'You cannot delete your own account', + 'user_has_items' => 'User has assigned items and cannot be deleted. Check in all items first.', + 'cannot_edit_auth_fields' => 'You do not have permission to edit auth fields (username, email, password, activated) for this user', + 'no_available_seats' => 'No available seats for this license', + 'no_free_seat' => 'No free seat found for this license', + 'seat_not_checked_out' => 'This seat is not currently checked out', + 'no_units_available' => 'No units of this accessory are available for checkout', + 'no_units_remaining' => 'No units remaining', + 'accessory_has_checkouts' => 'Accessory has units checked out and cannot be deleted. Check them in first.', + 'component_has_checkouts' => 'Component has units checked out and cannot be deleted. Check them in first.', + 'consumable_has_checkouts' => 'Consumable has items checked out and cannot be deleted', + 'license_has_seats_assigned' => 'License has seats currently assigned and cannot be deleted. Check in all seats first.', + 'department_has_users' => 'Department has users assigned and cannot be deleted. Reassign all users first.', + 'location_has_child_locations' => 'Location has child locations and cannot be deleted', + 'location_has_users' => 'Location has users assigned and cannot be deleted', + 'status_label_has_assets' => 'Status label has assets assigned and cannot be deleted', + 'model_has_assets' => 'Model has assets and cannot be deleted', + + // ----------------------------------------------------------------- + // Operation failures + // ----------------------------------------------------------------- + 'create_failed' => 'Create failed: :error', + 'update_failed' => 'Update failed: :error', + 'delete_failed' => 'Delete failed', + 'delete_failed_error' => 'Delete failed: :error', + 'checkin_failed' => 'Checkin failed', + 'checkin_failed_error' => 'Checkin failed: :error', + 'checkout_failed' => 'Checkout failed', + 'audit_failed' => 'Audit failed: :error', + 'note_save_failed' => 'Failed to save note', + + // ----------------------------------------------------------------- + // Asset messages + // ----------------------------------------------------------------- + 'asset_found' => 'Asset :asset_tag found', + 'asset_created' => 'Asset :asset_tag created successfully', + 'asset_updated' => 'Asset :asset_tag updated successfully', + 'asset_deleted' => 'Asset :asset_tag deleted successfully', + 'asset_restored' => 'Asset :asset_tag restored successfully', + 'asset_checked_out' => 'Asset :asset_tag checked out successfully', + 'asset_checked_in' => 'Asset :asset_tag checked in successfully', + 'asset_audited' => 'Audit recorded for asset :asset_tag', + 'note_added_to_asset' => 'Note added to asset :asset_tag', + 'note_added_successfully' => 'Note added successfully', + + // ----------------------------------------------------------------- + // User messages + // ----------------------------------------------------------------- + 'current_user' => 'Current user: :username', + 'user_found' => 'User :username found', + 'user_created' => 'User :username created successfully', + 'user_updated' => 'User :username updated successfully', + 'user_deleted' => 'User :username deleted successfully', + 'user_restored' => 'User :username restored successfully', + 'two_factor_reset' => 'Two-factor authentication reset for :username', + 'user_assets_found' => 'Found :count assets for user :username', + 'profile_updated' => 'Profile updated successfully', + + // ----------------------------------------------------------------- + // Accessory messages + // ----------------------------------------------------------------- + 'accessory_created' => 'Accessory :name created successfully', + 'accessory_updated' => 'Accessory :name updated successfully', + 'accessory_deleted' => 'Accessory :name deleted successfully', + 'accessory_checked_out' => 'Accessory :name checked out successfully', + 'accessory_checked_in' => 'Accessory :name checked in successfully', + + // ----------------------------------------------------------------- + // Component messages + // ----------------------------------------------------------------- + 'component_created' => 'Component :name created successfully', + 'component_updated' => 'Component :name updated successfully', + 'component_deleted' => 'Component :name deleted successfully', + 'component_checked_out' => 'Component :name checked out to asset :asset_tag', + 'component_checked_in' => 'Component :name checked in successfully', + + // ----------------------------------------------------------------- + // Consumable messages + // ----------------------------------------------------------------- + 'consumable_found' => 'Consumable :name found', + 'consumable_created' => 'Consumable :name created successfully', + 'consumable_updated' => 'Consumable :name updated successfully', + 'consumable_deleted' => 'Consumable :name deleted successfully', + 'consumable_checked_out' => 'Consumable :name checked out to :username', + + // ----------------------------------------------------------------- + // License messages + // ----------------------------------------------------------------- + 'license_found' => 'License :name found', + 'license_created' => 'License :name created successfully', + 'license_updated' => 'License :name updated successfully', + 'license_deleted' => 'License :name deleted successfully', + 'license_seat_checked_out_user' => 'License seat checked out to user :username', + 'license_seat_checked_out_asset' => 'License seat checked out to asset :asset_tag', + 'license_seat_checked_in' => 'License seat :id checked in successfully', + + // ----------------------------------------------------------------- + // Department messages + // ----------------------------------------------------------------- + 'department_created' => 'Department :name created successfully', + 'department_updated' => 'Department :name updated successfully', + 'department_deleted' => 'Department :name deleted successfully', + + // ----------------------------------------------------------------- + // Company messages + // ----------------------------------------------------------------- + 'company_found' => 'Company :name found', + 'company_created' => 'Company :name created successfully', + 'company_updated' => 'Company :name updated successfully', + 'company_deleted' => 'Company :name deleted successfully', + + // ----------------------------------------------------------------- + // Category messages + // ----------------------------------------------------------------- + 'category_found' => 'Category :name found', + 'category_created' => 'Category :name created successfully', + 'category_updated' => 'Category :name updated successfully', + 'category_deleted' => 'Category :name deleted successfully', + 'category_delete_failed' => 'Category cannot be deleted: :error', + + // ----------------------------------------------------------------- + // Manufacturer messages + // ----------------------------------------------------------------- + 'manufacturer_found' => 'Manufacturer :name found', + 'manufacturer_created' => 'Manufacturer :name created successfully', + 'manufacturer_updated' => 'Manufacturer :name updated successfully', + 'manufacturer_deleted' => 'Manufacturer :name deleted successfully', + + // ----------------------------------------------------------------- + // Supplier messages + // ----------------------------------------------------------------- + 'supplier_found' => 'Supplier :name found', + 'supplier_created' => 'Supplier :name created successfully', + 'supplier_updated' => 'Supplier :name updated successfully', + 'supplier_deleted' => 'Supplier :name deleted successfully', + + // ----------------------------------------------------------------- + // Status label messages + // ----------------------------------------------------------------- + 'status_label_found' => 'Status label :name found', + 'status_label_created' => 'Status label :name created successfully', + 'status_label_updated' => 'Status label :name updated successfully', + 'status_label_deleted' => 'Status label :name deleted successfully', + + // ----------------------------------------------------------------- + // Location messages + // ----------------------------------------------------------------- + 'location_found' => 'Location :name found', + 'location_created' => 'Location :name created successfully', + 'location_updated' => 'Location :name updated successfully', + 'location_deleted' => 'Location :name deleted successfully', + + // ----------------------------------------------------------------- + // Asset model messages + // ----------------------------------------------------------------- + 'asset_model_found' => 'Asset model :name found', + 'asset_model_created' => 'Asset model :name created successfully', + 'asset_model_updated' => 'Asset model :name updated successfully', + 'asset_model_deleted' => 'Asset model :name deleted successfully', + + // ----------------------------------------------------------------- + // Depreciation messages + // ----------------------------------------------------------------- + 'depreciation_found' => 'Depreciation :name found', + 'depreciation_created' => 'Depreciation :name created successfully', + 'depreciation_updated' => 'Depreciation :name updated successfully', + 'depreciation_deleted' => 'Depreciation :name deleted successfully', + + // ----------------------------------------------------------------- + // Group messages + // ----------------------------------------------------------------- + 'group_found' => 'Group :name found', + 'group_created' => 'Group :name created successfully', + 'group_updated' => 'Group :name updated successfully', + 'group_deleted' => 'Group :name deleted successfully', + + // ----------------------------------------------------------------- + // Maintenance messages + // ----------------------------------------------------------------- + 'maintenance_created' => 'Maintenance :name created successfully', + + // ----------------------------------------------------------------- + // List messages + // ----------------------------------------------------------------- + 'list_assets' => 'Found :total assets, returning :count', + 'list_users' => 'Found :total users, returning :count', + 'list_consumables' => 'Found :total consumables, returning :count', + 'list_licenses' => 'Found :total licenses, returning :count', + 'list_companies' => 'Found :total companies, returning :count', + 'list_categories' => 'Found :total categories, returning :count', + 'list_manufacturers' => 'Found :total manufacturers, returning :count', + 'list_suppliers' => 'Found :total suppliers, returning :count', + 'list_status_labels' => 'Found :total status labels, returning :count', + 'list_locations' => 'Found :total locations, returning :count', + 'list_asset_models' => 'Found :total asset models, returning :count', + 'list_depreciations' => 'Found :total depreciations, returning :count', + 'list_groups' => 'Found :total groups, returning :count', + 'list_maintenances' => 'Found :total maintenances, returning :count', + 'list_activity' => 'Found :total activity log entries, returning :count', + +]; From e3a042f334d03b54c955afae1031797f3d3310a6 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:39:23 +0100 Subject: [PATCH 34/45] More translations --- app/Mcp/Prompts/AuditLocationPrompt.php | 1 - app/Mcp/Prompts/EndOfLifeReviewPrompt.php | 1 - app/Mcp/Prompts/ExpiringLicensesPrompt.php | 1 - app/Mcp/Prompts/FindAvailableAssetPrompt.php | 1 - app/Mcp/Prompts/InventorySummaryPrompt.php | 1 - app/Mcp/Prompts/OffboardEmployeePrompt.php | 1 - app/Mcp/Prompts/OnboardEmployeePrompt.php | 1 - app/Mcp/Prompts/UserInventoryPrompt.php | 1 - app/Mcp/Prompts/WarrantyExpiringPrompt.php | 1 - app/Mcp/Tools/CreateSupplierTool.php | 2 +- app/Mcp/Tools/CreateUserTool.php | 2 +- resources/lang/en-US/mcp.php | 294 +++++++++---------- 12 files changed, 149 insertions(+), 158 deletions(-) diff --git a/app/Mcp/Prompts/AuditLocationPrompt.php b/app/Mcp/Prompts/AuditLocationPrompt.php index cf8ea4d46930..276f92bee954 100644 --- a/app/Mcp/Prompts/AuditLocationPrompt.php +++ b/app/Mcp/Prompts/AuditLocationPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('audit_location')] diff --git a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php index 9e7d7fa93ccf..cd756494228b 100644 --- a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php +++ b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('end_of_life_review')] diff --git a/app/Mcp/Prompts/ExpiringLicensesPrompt.php b/app/Mcp/Prompts/ExpiringLicensesPrompt.php index 8267e5f02f58..ac556e4286d4 100644 --- a/app/Mcp/Prompts/ExpiringLicensesPrompt.php +++ b/app/Mcp/Prompts/ExpiringLicensesPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('expiring_licenses')] diff --git a/app/Mcp/Prompts/FindAvailableAssetPrompt.php b/app/Mcp/Prompts/FindAvailableAssetPrompt.php index 118a4de62da8..1c81cb0fdee9 100644 --- a/app/Mcp/Prompts/FindAvailableAssetPrompt.php +++ b/app/Mcp/Prompts/FindAvailableAssetPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('find_available_asset')] diff --git a/app/Mcp/Prompts/InventorySummaryPrompt.php b/app/Mcp/Prompts/InventorySummaryPrompt.php index b97592ecfd1b..3904b472d751 100644 --- a/app/Mcp/Prompts/InventorySummaryPrompt.php +++ b/app/Mcp/Prompts/InventorySummaryPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('inventory_summary')] diff --git a/app/Mcp/Prompts/OffboardEmployeePrompt.php b/app/Mcp/Prompts/OffboardEmployeePrompt.php index 49182a78540d..8201172d041c 100644 --- a/app/Mcp/Prompts/OffboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OffboardEmployeePrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('offboard_employee')] diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php index 06aaaf94730d..bb574e04ae81 100644 --- a/app/Mcp/Prompts/OnboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('onboard_employee')] diff --git a/app/Mcp/Prompts/UserInventoryPrompt.php b/app/Mcp/Prompts/UserInventoryPrompt.php index 10ef66c3c234..633d513266fe 100644 --- a/app/Mcp/Prompts/UserInventoryPrompt.php +++ b/app/Mcp/Prompts/UserInventoryPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('user_inventory')] diff --git a/app/Mcp/Prompts/WarrantyExpiringPrompt.php b/app/Mcp/Prompts/WarrantyExpiringPrompt.php index 3927a4ea6f92..b18fdc6529dc 100644 --- a/app/Mcp/Prompts/WarrantyExpiringPrompt.php +++ b/app/Mcp/Prompts/WarrantyExpiringPrompt.php @@ -7,7 +7,6 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Attributes\Name; use Laravel\Mcp\Server\Attributes\Title; -use App\Mcp\Prompts\SnipePrompt; use Laravel\Mcp\Server\Prompts\Argument; #[Name('warranty_expiring')] diff --git a/app/Mcp/Tools/CreateSupplierTool.php b/app/Mcp/Tools/CreateSupplierTool.php index 35ae3af056a3..6fcb47dbef62 100644 --- a/app/Mcp/Tools/CreateSupplierTool.php +++ b/app/Mcp/Tools/CreateSupplierTool.php @@ -62,7 +62,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$supplier->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $supplier->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php index 226e836996a8..475b12b1934d 100644 --- a/app/Mcp/Tools/CreateUserTool.php +++ b/app/Mcp/Tools/CreateUserTool.php @@ -90,7 +90,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Create failed: '.$user->getErrors()->first())); + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $user->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/resources/lang/en-US/mcp.php b/resources/lang/en-US/mcp.php index 0748e0e17b3f..aa4aaa0e8dd5 100644 --- a/resources/lang/en-US/mcp.php +++ b/resources/lang/en-US/mcp.php @@ -5,241 +5,241 @@ // ----------------------------------------------------------------- // Generic errors // ----------------------------------------------------------------- - 'not_authenticated' => 'Not authenticated', - 'unauthorized' => 'Unauthorized', - 'id_or_name_required' => 'Either id or name is required', + 'not_authenticated' => 'Not authenticated', + 'unauthorized' => 'Unauthorized', + 'id_or_name_required' => 'Either id or name is required', 'id_username_or_email_required' => 'Please provide an id, username, or email', - 'provide_user_or_asset' => 'Please provide either assigned_to (user ID) or asset_id', + 'provide_user_or_asset' => 'Please provide either assigned_to (user ID) or asset_id', // ----------------------------------------------------------------- // "Not found" errors // ----------------------------------------------------------------- - 'asset_not_found' => 'Asset not found', - 'user_not_found' => 'User not found', - 'accessory_not_found' => 'Accessory not found', + 'asset_not_found' => 'Asset not found', + 'user_not_found' => 'User not found', + 'accessory_not_found' => 'Accessory not found', 'accessory_checkout_not_found' => 'Accessory checkout record not found', - 'component_not_found' => 'Component not found', + 'component_not_found' => 'Component not found', 'component_checkout_not_found' => 'Component checkout record not found', - 'consumable_not_found' => 'Consumable not found', - 'license_not_found' => 'License not found', - 'license_seat_not_found' => 'License seat not found', - 'department_not_found' => 'Department not found', - 'company_not_found' => 'Company not found', - 'category_not_found' => 'Category not found', - 'manufacturer_not_found' => 'Manufacturer not found', - 'supplier_not_found' => 'Supplier not found', - 'status_label_not_found' => 'Status label not found', - 'location_not_found' => 'Location not found', - 'asset_model_not_found' => 'Asset model not found', - 'depreciation_not_found' => 'Depreciation not found', - 'group_not_found' => 'Group not found', - 'checkout_target_not_found' => 'The specified :type was not found', + 'consumable_not_found' => 'Consumable not found', + 'license_not_found' => 'License not found', + 'license_seat_not_found' => 'License seat not found', + 'department_not_found' => 'Department not found', + 'company_not_found' => 'Company not found', + 'category_not_found' => 'Category not found', + 'manufacturer_not_found' => 'Manufacturer not found', + 'supplier_not_found' => 'Supplier not found', + 'status_label_not_found' => 'Status label not found', + 'location_not_found' => 'Location not found', + 'asset_model_not_found' => 'Asset model not found', + 'depreciation_not_found' => 'Depreciation not found', + 'group_not_found' => 'Group not found', + 'checkout_target_not_found' => 'The specified :type was not found', // ----------------------------------------------------------------- // Business rule errors // ----------------------------------------------------------------- - 'asset_not_available' => 'Asset :asset_tag is not available for checkout', - 'asset_not_checked_out' => 'Asset :asset_tag is not currently checked out', - 'asset_not_deleted' => 'Asset is not deleted', - 'user_not_deleted' => 'User is not deleted', - 'user_cannot_delete_self' => 'You cannot delete your own account', - 'user_has_items' => 'User has assigned items and cannot be deleted. Check in all items first.', - 'cannot_edit_auth_fields' => 'You do not have permission to edit auth fields (username, email, password, activated) for this user', - 'no_available_seats' => 'No available seats for this license', - 'no_free_seat' => 'No free seat found for this license', - 'seat_not_checked_out' => 'This seat is not currently checked out', - 'no_units_available' => 'No units of this accessory are available for checkout', - 'no_units_remaining' => 'No units remaining', - 'accessory_has_checkouts' => 'Accessory has units checked out and cannot be deleted. Check them in first.', - 'component_has_checkouts' => 'Component has units checked out and cannot be deleted. Check them in first.', - 'consumable_has_checkouts' => 'Consumable has items checked out and cannot be deleted', - 'license_has_seats_assigned' => 'License has seats currently assigned and cannot be deleted. Check in all seats first.', - 'department_has_users' => 'Department has users assigned and cannot be deleted. Reassign all users first.', + 'asset_not_available' => 'Asset :asset_tag is not available for checkout', + 'asset_not_checked_out' => 'Asset :asset_tag is not currently checked out', + 'asset_not_deleted' => 'Asset is not deleted', + 'user_not_deleted' => 'User is not deleted', + 'user_cannot_delete_self' => 'You cannot delete your own account', + 'user_has_items' => 'User has assigned items and cannot be deleted. Check in all items first.', + 'cannot_edit_auth_fields' => 'You do not have permission to edit auth fields (username, email, password, activated) for this user', + 'no_available_seats' => 'No available seats for this license', + 'no_free_seat' => 'No free seat found for this license', + 'seat_not_checked_out' => 'This seat is not currently checked out', + 'no_units_available' => 'No units of this accessory are available for checkout', + 'no_units_remaining' => 'No units remaining', + 'accessory_has_checkouts' => 'Accessory has units checked out and cannot be deleted. Check them in first.', + 'component_has_checkouts' => 'Component has units checked out and cannot be deleted. Check them in first.', + 'consumable_has_checkouts' => 'Consumable has items checked out and cannot be deleted', + 'license_has_seats_assigned' => 'License has seats currently assigned and cannot be deleted. Check in all seats first.', + 'department_has_users' => 'Department has users assigned and cannot be deleted. Reassign all users first.', 'location_has_child_locations' => 'Location has child locations and cannot be deleted', - 'location_has_users' => 'Location has users assigned and cannot be deleted', - 'status_label_has_assets' => 'Status label has assets assigned and cannot be deleted', - 'model_has_assets' => 'Model has assets and cannot be deleted', + 'location_has_users' => 'Location has users assigned and cannot be deleted', + 'status_label_has_assets' => 'Status label has assets assigned and cannot be deleted', + 'model_has_assets' => 'Model has assets and cannot be deleted', // ----------------------------------------------------------------- // Operation failures // ----------------------------------------------------------------- - 'create_failed' => 'Create failed: :error', - 'update_failed' => 'Update failed: :error', - 'delete_failed' => 'Delete failed', - 'delete_failed_error' => 'Delete failed: :error', - 'checkin_failed' => 'Checkin failed', - 'checkin_failed_error' => 'Checkin failed: :error', - 'checkout_failed' => 'Checkout failed', - 'audit_failed' => 'Audit failed: :error', - 'note_save_failed' => 'Failed to save note', + 'create_failed' => 'Create failed: :error', + 'update_failed' => 'Update failed: :error', + 'delete_failed' => 'Delete failed', + 'delete_failed_error' => 'Delete failed: :error', + 'checkin_failed' => 'Checkin failed', + 'checkin_failed_error' => 'Checkin failed: :error', + 'checkout_failed' => 'Checkout failed', + 'audit_failed' => 'Audit failed: :error', + 'note_save_failed' => 'Failed to save note', // ----------------------------------------------------------------- // Asset messages // ----------------------------------------------------------------- - 'asset_found' => 'Asset :asset_tag found', - 'asset_created' => 'Asset :asset_tag created successfully', - 'asset_updated' => 'Asset :asset_tag updated successfully', - 'asset_deleted' => 'Asset :asset_tag deleted successfully', - 'asset_restored' => 'Asset :asset_tag restored successfully', - 'asset_checked_out' => 'Asset :asset_tag checked out successfully', - 'asset_checked_in' => 'Asset :asset_tag checked in successfully', - 'asset_audited' => 'Audit recorded for asset :asset_tag', - 'note_added_to_asset' => 'Note added to asset :asset_tag', - 'note_added_successfully' => 'Note added successfully', + 'asset_found' => 'Asset :asset_tag found', + 'asset_created' => 'Asset :asset_tag created successfully', + 'asset_updated' => 'Asset :asset_tag updated successfully', + 'asset_deleted' => 'Asset :asset_tag deleted successfully', + 'asset_restored' => 'Asset :asset_tag restored successfully', + 'asset_checked_out' => 'Asset :asset_tag checked out successfully', + 'asset_checked_in' => 'Asset :asset_tag checked in successfully', + 'asset_audited' => 'Audit recorded for asset :asset_tag', + 'note_added_to_asset' => 'Note added to asset :asset_tag', + 'note_added_successfully' => 'Note added successfully', // ----------------------------------------------------------------- // User messages // ----------------------------------------------------------------- - 'current_user' => 'Current user: :username', - 'user_found' => 'User :username found', - 'user_created' => 'User :username created successfully', - 'user_updated' => 'User :username updated successfully', - 'user_deleted' => 'User :username deleted successfully', - 'user_restored' => 'User :username restored successfully', - 'two_factor_reset' => 'Two-factor authentication reset for :username', - 'user_assets_found' => 'Found :count assets for user :username', - 'profile_updated' => 'Profile updated successfully', + 'current_user' => 'Current user: :username', + 'user_found' => 'User :username found', + 'user_created' => 'User :username created successfully', + 'user_updated' => 'User :username updated successfully', + 'user_deleted' => 'User :username deleted successfully', + 'user_restored' => 'User :username restored successfully', + 'two_factor_reset' => 'Two-factor authentication reset for :username', + 'user_assets_found' => 'Found :count assets for user :username', + 'profile_updated' => 'Profile updated successfully', // ----------------------------------------------------------------- // Accessory messages // ----------------------------------------------------------------- - 'accessory_created' => 'Accessory :name created successfully', - 'accessory_updated' => 'Accessory :name updated successfully', - 'accessory_deleted' => 'Accessory :name deleted successfully', - 'accessory_checked_out' => 'Accessory :name checked out successfully', - 'accessory_checked_in' => 'Accessory :name checked in successfully', + 'accessory_created' => 'Accessory :name created successfully', + 'accessory_updated' => 'Accessory :name updated successfully', + 'accessory_deleted' => 'Accessory :name deleted successfully', + 'accessory_checked_out' => 'Accessory :name checked out successfully', + 'accessory_checked_in' => 'Accessory :name checked in successfully', // ----------------------------------------------------------------- // Component messages // ----------------------------------------------------------------- - 'component_created' => 'Component :name created successfully', - 'component_updated' => 'Component :name updated successfully', - 'component_deleted' => 'Component :name deleted successfully', - 'component_checked_out' => 'Component :name checked out to asset :asset_tag', - 'component_checked_in' => 'Component :name checked in successfully', + 'component_created' => 'Component :name created successfully', + 'component_updated' => 'Component :name updated successfully', + 'component_deleted' => 'Component :name deleted successfully', + 'component_checked_out' => 'Component :name checked out to asset :asset_tag', + 'component_checked_in' => 'Component :name checked in successfully', // ----------------------------------------------------------------- // Consumable messages // ----------------------------------------------------------------- - 'consumable_found' => 'Consumable :name found', - 'consumable_created' => 'Consumable :name created successfully', - 'consumable_updated' => 'Consumable :name updated successfully', - 'consumable_deleted' => 'Consumable :name deleted successfully', - 'consumable_checked_out' => 'Consumable :name checked out to :username', + 'consumable_found' => 'Consumable :name found', + 'consumable_created' => 'Consumable :name created successfully', + 'consumable_updated' => 'Consumable :name updated successfully', + 'consumable_deleted' => 'Consumable :name deleted successfully', + 'consumable_checked_out' => 'Consumable :name checked out to :username', // ----------------------------------------------------------------- // License messages // ----------------------------------------------------------------- - 'license_found' => 'License :name found', - 'license_created' => 'License :name created successfully', - 'license_updated' => 'License :name updated successfully', - 'license_deleted' => 'License :name deleted successfully', - 'license_seat_checked_out_user' => 'License seat checked out to user :username', + 'license_found' => 'License :name found', + 'license_created' => 'License :name created successfully', + 'license_updated' => 'License :name updated successfully', + 'license_deleted' => 'License :name deleted successfully', + 'license_seat_checked_out_user' => 'License seat checked out to user :username', 'license_seat_checked_out_asset' => 'License seat checked out to asset :asset_tag', - 'license_seat_checked_in' => 'License seat :id checked in successfully', + 'license_seat_checked_in' => 'License seat :id checked in successfully', // ----------------------------------------------------------------- // Department messages // ----------------------------------------------------------------- - 'department_created' => 'Department :name created successfully', - 'department_updated' => 'Department :name updated successfully', - 'department_deleted' => 'Department :name deleted successfully', + 'department_created' => 'Department :name created successfully', + 'department_updated' => 'Department :name updated successfully', + 'department_deleted' => 'Department :name deleted successfully', // ----------------------------------------------------------------- // Company messages // ----------------------------------------------------------------- - 'company_found' => 'Company :name found', - 'company_created' => 'Company :name created successfully', - 'company_updated' => 'Company :name updated successfully', - 'company_deleted' => 'Company :name deleted successfully', + 'company_found' => 'Company :name found', + 'company_created' => 'Company :name created successfully', + 'company_updated' => 'Company :name updated successfully', + 'company_deleted' => 'Company :name deleted successfully', // ----------------------------------------------------------------- // Category messages // ----------------------------------------------------------------- - 'category_found' => 'Category :name found', - 'category_created' => 'Category :name created successfully', - 'category_updated' => 'Category :name updated successfully', - 'category_deleted' => 'Category :name deleted successfully', - 'category_delete_failed' => 'Category cannot be deleted: :error', + 'category_found' => 'Category :name found', + 'category_created' => 'Category :name created successfully', + 'category_updated' => 'Category :name updated successfully', + 'category_deleted' => 'Category :name deleted successfully', + 'category_delete_failed' => 'Category cannot be deleted: :error', // ----------------------------------------------------------------- // Manufacturer messages // ----------------------------------------------------------------- - 'manufacturer_found' => 'Manufacturer :name found', - 'manufacturer_created' => 'Manufacturer :name created successfully', - 'manufacturer_updated' => 'Manufacturer :name updated successfully', - 'manufacturer_deleted' => 'Manufacturer :name deleted successfully', + 'manufacturer_found' => 'Manufacturer :name found', + 'manufacturer_created' => 'Manufacturer :name created successfully', + 'manufacturer_updated' => 'Manufacturer :name updated successfully', + 'manufacturer_deleted' => 'Manufacturer :name deleted successfully', // ----------------------------------------------------------------- // Supplier messages // ----------------------------------------------------------------- - 'supplier_found' => 'Supplier :name found', - 'supplier_created' => 'Supplier :name created successfully', - 'supplier_updated' => 'Supplier :name updated successfully', - 'supplier_deleted' => 'Supplier :name deleted successfully', + 'supplier_found' => 'Supplier :name found', + 'supplier_created' => 'Supplier :name created successfully', + 'supplier_updated' => 'Supplier :name updated successfully', + 'supplier_deleted' => 'Supplier :name deleted successfully', // ----------------------------------------------------------------- // Status label messages // ----------------------------------------------------------------- - 'status_label_found' => 'Status label :name found', - 'status_label_created' => 'Status label :name created successfully', - 'status_label_updated' => 'Status label :name updated successfully', - 'status_label_deleted' => 'Status label :name deleted successfully', + 'status_label_found' => 'Status label :name found', + 'status_label_created' => 'Status label :name created successfully', + 'status_label_updated' => 'Status label :name updated successfully', + 'status_label_deleted' => 'Status label :name deleted successfully', // ----------------------------------------------------------------- // Location messages // ----------------------------------------------------------------- - 'location_found' => 'Location :name found', - 'location_created' => 'Location :name created successfully', - 'location_updated' => 'Location :name updated successfully', - 'location_deleted' => 'Location :name deleted successfully', + 'location_found' => 'Location :name found', + 'location_created' => 'Location :name created successfully', + 'location_updated' => 'Location :name updated successfully', + 'location_deleted' => 'Location :name deleted successfully', // ----------------------------------------------------------------- // Asset model messages // ----------------------------------------------------------------- - 'asset_model_found' => 'Asset model :name found', - 'asset_model_created' => 'Asset model :name created successfully', - 'asset_model_updated' => 'Asset model :name updated successfully', - 'asset_model_deleted' => 'Asset model :name deleted successfully', + 'asset_model_found' => 'Asset model :name found', + 'asset_model_created' => 'Asset model :name created successfully', + 'asset_model_updated' => 'Asset model :name updated successfully', + 'asset_model_deleted' => 'Asset model :name deleted successfully', // ----------------------------------------------------------------- // Depreciation messages // ----------------------------------------------------------------- - 'depreciation_found' => 'Depreciation :name found', - 'depreciation_created' => 'Depreciation :name created successfully', - 'depreciation_updated' => 'Depreciation :name updated successfully', - 'depreciation_deleted' => 'Depreciation :name deleted successfully', + 'depreciation_found' => 'Depreciation :name found', + 'depreciation_created' => 'Depreciation :name created successfully', + 'depreciation_updated' => 'Depreciation :name updated successfully', + 'depreciation_deleted' => 'Depreciation :name deleted successfully', // ----------------------------------------------------------------- // Group messages // ----------------------------------------------------------------- - 'group_found' => 'Group :name found', - 'group_created' => 'Group :name created successfully', - 'group_updated' => 'Group :name updated successfully', - 'group_deleted' => 'Group :name deleted successfully', + 'group_found' => 'Group :name found', + 'group_created' => 'Group :name created successfully', + 'group_updated' => 'Group :name updated successfully', + 'group_deleted' => 'Group :name deleted successfully', // ----------------------------------------------------------------- // Maintenance messages // ----------------------------------------------------------------- - 'maintenance_created' => 'Maintenance :name created successfully', + 'maintenance_created' => 'Maintenance :name created successfully', // ----------------------------------------------------------------- // List messages // ----------------------------------------------------------------- - 'list_assets' => 'Found :total assets, returning :count', - 'list_users' => 'Found :total users, returning :count', - 'list_consumables' => 'Found :total consumables, returning :count', - 'list_licenses' => 'Found :total licenses, returning :count', - 'list_companies' => 'Found :total companies, returning :count', - 'list_categories' => 'Found :total categories, returning :count', - 'list_manufacturers' => 'Found :total manufacturers, returning :count', - 'list_suppliers' => 'Found :total suppliers, returning :count', - 'list_status_labels' => 'Found :total status labels, returning :count', - 'list_locations' => 'Found :total locations, returning :count', - 'list_asset_models' => 'Found :total asset models, returning :count', - 'list_depreciations' => 'Found :total depreciations, returning :count', - 'list_groups' => 'Found :total groups, returning :count', - 'list_maintenances' => 'Found :total maintenances, returning :count', - 'list_activity' => 'Found :total activity log entries, returning :count', + 'list_assets' => 'Found :total assets, returning :count', + 'list_users' => 'Found :total users, returning :count', + 'list_consumables' => 'Found :total consumables, returning :count', + 'list_licenses' => 'Found :total licenses, returning :count', + 'list_companies' => 'Found :total companies, returning :count', + 'list_categories' => 'Found :total categories, returning :count', + 'list_manufacturers' => 'Found :total manufacturers, returning :count', + 'list_suppliers' => 'Found :total suppliers, returning :count', + 'list_status_labels' => 'Found :total status labels, returning :count', + 'list_locations' => 'Found :total locations, returning :count', + 'list_asset_models' => 'Found :total asset models, returning :count', + 'list_depreciations' => 'Found :total depreciations, returning :count', + 'list_groups' => 'Found :total groups, returning :count', + 'list_maintenances' => 'Found :total maintenances, returning :count', + 'list_activity' => 'Found :total activity log entries, returning :count', ]; From 926afa6c286c3ca45489e6f962dfedbe2f5754c5 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:43:26 +0100 Subject: [PATCH 35/45] Added throttle --- routes/ai.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routes/ai.php b/routes/ai.php index 87b621766859..63c26a1ca3c1 100644 --- a/routes/ai.php +++ b/routes/ai.php @@ -4,4 +4,4 @@ use Laravel\Mcp\Facades\Mcp; Mcp::oauthRoutes(); -Mcp::web('/mcp/snipe-it', SnipeMCPServer::class)->middleware('auth:api'); +Mcp::web('/mcp/snipe-it', SnipeMCPServer::class)->middleware(['auth:api', 'api-throttle:api']); From 25a08faa6d5e48fc126feeec82569865241f1d31 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:51:45 +0100 Subject: [PATCH 36/45] Updated readme --- app/Mcp/README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/Mcp/README.md b/app/Mcp/README.md index c10732cee506..ae03692e330b 100644 --- a/app/Mcp/README.md +++ b/app/Mcp/README.md @@ -61,13 +61,11 @@ Claude Desktop will initiate the OAuth flow on first connection. Once authorised ### MCP Inspector -The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is useful for exploring and testing tools before integrating with a client: +The [MCP Inspector](https://laravel.com/docs/12.x/mcp#mcp-inspector) is useful for exploring and testing tools before +integrating with a client: -1. Run `npx @modelcontextprotocol/inspector` -2. Set the transport to **Streamable HTTP** -3. Enter the server URL: `https://your-snipeit-domain.com/mcp/snipe-it` -4. Click **Connect** and complete the OAuth flow -5. Browse tools and prompts from the sidebar +1. Run `php artisan mcp:inspector SnipeITMcpServer` in your terminal +2. Open the provided URL in your browser ### Cursor / VS Code / Other MCP Clients From b264e07327cd03f97f66c11c286bf2d6c6243d61 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:52:05 +0100 Subject: [PATCH 37/45] More localizations --- app/Mcp/Servers/SnipeMCPServer.php | 8 ++ app/Mcp/Tools/DeleteAccessoryTool.php | 10 +- app/Mcp/Tools/DeleteAssetModelTool.php | 10 +- app/Mcp/Tools/DeleteAssetTool.php | 8 +- app/Mcp/Tools/DeleteCategoryTool.php | 10 +- app/Mcp/Tools/DeleteCompanyTool.php | 8 +- app/Mcp/Tools/DeleteComponentTool.php | 10 +- app/Mcp/Tools/DeleteConsumableTool.php | 10 +- app/Mcp/Tools/DeleteDepartmentTool.php | 10 +- app/Mcp/Tools/DeleteDepreciationTool.php | 8 +- app/Mcp/Tools/DeleteGroupTool.php | 12 +- app/Mcp/Tools/DeleteLicenseTool.php | 10 +- app/Mcp/Tools/DeleteLocationTool.php | 12 +- app/Mcp/Tools/DeleteManufacturerTool.php | 8 +- app/Mcp/Tools/DeleteStatusLabelTool.php | 10 +- app/Mcp/Tools/DeleteSupplierTool.php | 8 +- app/Mcp/Tools/DeleteUserTool.php | 14 +-- app/Mcp/Tools/GetActivityLogTool.php | 4 +- app/Mcp/Tools/GetCurrentUserTool.php | 6 +- app/Mcp/Tools/GetUserAssetsTool.php | 8 +- app/Mcp/Tools/ListAssetModelsTool.php | 4 +- app/Mcp/Tools/ListAssetNotesTool.php | 116 +++++++++++++++++++ app/Mcp/Tools/ListAssetsTool.php | 4 +- app/Mcp/Tools/ListCategoriesTool.php | 4 +- app/Mcp/Tools/ListCompaniesTool.php | 4 +- app/Mcp/Tools/ListConsumablesTool.php | 4 +- app/Mcp/Tools/ListDepreciationsTool.php | 4 +- app/Mcp/Tools/ListGroupsTool.php | 4 +- app/Mcp/Tools/ListHistoryTool.php | 140 +++++++++++++++++++++++ app/Mcp/Tools/ListLicensesTool.php | 4 +- app/Mcp/Tools/ListLocationsTool.php | 4 +- app/Mcp/Tools/ListMaintenancesTool.php | 4 +- app/Mcp/Tools/ListManufacturersTool.php | 4 +- app/Mcp/Tools/ListStatusLabelsTool.php | 4 +- app/Mcp/Tools/ListSuppliersTool.php | 4 +- app/Mcp/Tools/ListUploadsTool.php | 130 +++++++++++++++++++++ app/Mcp/Tools/ListUsersTool.php | 4 +- app/Mcp/Tools/Reset2FATool.php | 8 +- app/Mcp/Tools/RestoreAssetTool.php | 10 +- app/Mcp/Tools/RestoreUserTool.php | 12 +- app/Mcp/Tools/ShowAssetModelTool.php | 8 +- app/Mcp/Tools/ShowAssetTool.php | 6 +- app/Mcp/Tools/ShowCategoryTool.php | 8 +- app/Mcp/Tools/ShowCompanyTool.php | 8 +- app/Mcp/Tools/ShowConsumableTool.php | 8 +- app/Mcp/Tools/ShowDepreciationTool.php | 8 +- app/Mcp/Tools/ShowGroupTool.php | 8 +- app/Mcp/Tools/ShowLicenseTool.php | 8 +- app/Mcp/Tools/ShowLocationTool.php | 8 +- app/Mcp/Tools/ShowManufacturerTool.php | 8 +- app/Mcp/Tools/ShowStatusLabelTool.php | 8 +- app/Mcp/Tools/ShowSupplierTool.php | 8 +- app/Mcp/Tools/ShowUserTool.php | 8 +- app/Mcp/Tools/UpdateAccessoryTool.php | 10 +- app/Mcp/Tools/UpdateAssetModelTool.php | 10 +- app/Mcp/Tools/UpdateAssetTool.php | 10 +- app/Mcp/Tools/UpdateCategoryTool.php | 10 +- app/Mcp/Tools/UpdateCompanyTool.php | 4 +- resources/lang/en-US/mcp.php | 4 + 59 files changed, 602 insertions(+), 204 deletions(-) create mode 100644 app/Mcp/Tools/ListAssetNotesTool.php create mode 100644 app/Mcp/Tools/ListHistoryTool.php create mode 100644 app/Mcp/Tools/ListUploadsTool.php diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index e7eeb464197f..8a786c3acdd8 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -59,18 +59,21 @@ use App\Mcp\Tools\GetCurrentUserTool; use App\Mcp\Tools\GetUserAssetsTool; use App\Mcp\Tools\ListAssetModelsTool; +use App\Mcp\Tools\ListAssetNotesTool; use App\Mcp\Tools\ListAssetsTool; use App\Mcp\Tools\ListCategoriesTool; use App\Mcp\Tools\ListCompaniesTool; use App\Mcp\Tools\ListConsumablesTool; use App\Mcp\Tools\ListDepreciationsTool; use App\Mcp\Tools\ListGroupsTool; +use App\Mcp\Tools\ListHistoryTool; use App\Mcp\Tools\ListLicensesTool; use App\Mcp\Tools\ListLocationsTool; use App\Mcp\Tools\ListMaintenancesTool; use App\Mcp\Tools\ListManufacturersTool; use App\Mcp\Tools\ListStatusLabelsTool; use App\Mcp\Tools\ListSuppliersTool; +use App\Mcp\Tools\ListUploadsTool; use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\Reset2FATool; use App\Mcp\Tools\RestoreAssetTool; @@ -127,6 +130,11 @@ class SnipeMCPServer extends Server CheckinAssetTool::class, AuditAssetTool::class, AddAssetNoteTool::class, + ListAssetNotesTool::class, + + // Cross-type tools + ListUploadsTool::class, + ListHistoryTool::class, // Users ListUsersTool::class, diff --git a/app/Mcp/Tools/DeleteAccessoryTool.php b/app/Mcp/Tools/DeleteAccessoryTool.php index 007ab3a43ddf..6dd3f5c0d6dc 100644 --- a/app/Mcp/Tools/DeleteAccessoryTool.php +++ b/app/Mcp/Tools/DeleteAccessoryTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $accessory = $this->resolveAccessory($request); if (! $accessory) { - return Response::make(Response::error('Accessory not found')); + return Response::make(Response::error(trans('mcp.accessory_not_found'))); } if (! Gate::allows('delete', $accessory)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($accessory->numCheckedOut() > 0) { - return Response::make(Response::error('Accessory has units checked out and cannot be deleted. Check them in first.')); + return Response::make(Response::error(trans('mcp.accessory_has_checkouts'))); } $name = $accessory->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $accessory->delete(); return Response::make( - Response::text('Accessory '.$name.' deleted successfully') + Response::text(trans('mcp.accessory_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Accessory deleted successfully', + 'message' => trans('mcp.accessory_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteAssetModelTool.php b/app/Mcp/Tools/DeleteAssetModelTool.php index f2a5d0d6f6c3..b57f0b0f4e71 100644 --- a/app/Mcp/Tools/DeleteAssetModelTool.php +++ b/app/Mcp/Tools/DeleteAssetModelTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $model = $this->resolveModel($request); if (! $model) { - return Response::make(Response::error('Asset model not found')); + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); } if (! Gate::allows('delete', $model)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($model->assets()->count() > 0) { - return Response::make(Response::error('Model has assets and cannot be deleted')); + return Response::make(Response::error(trans('mcp.model_has_assets'))); } $name = $model->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $model->delete(); return Response::make( - Response::text('Asset model '.$name.' deleted successfully') + Response::text(trans('mcp.asset_model_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset model deleted successfully', + 'message' => trans('mcp.asset_model_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteAssetTool.php b/app/Mcp/Tools/DeleteAssetTool.php index 3d51cf3ec0f8..644fcdee0449 100644 --- a/app/Mcp/Tools/DeleteAssetTool.php +++ b/app/Mcp/Tools/DeleteAssetTool.php @@ -31,11 +31,11 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('delete', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $assetTag = $asset->asset_tag; @@ -50,10 +50,10 @@ public function handle(Request $request): ResponseFactory $asset->delete(); return Response::make( - Response::text('Asset '.$assetTag.' deleted successfully') + Response::text(trans('mcp.asset_deleted', ['asset_tag' => $assetTag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset deleted successfully', + 'message' => trans('mcp.asset_deleted', ['asset_tag' => $assetTag]), 'asset_tag' => $assetTag, ]); } diff --git a/app/Mcp/Tools/DeleteCategoryTool.php b/app/Mcp/Tools/DeleteCategoryTool.php index a930366f5f9b..7d63781aec08 100644 --- a/app/Mcp/Tools/DeleteCategoryTool.php +++ b/app/Mcp/Tools/DeleteCategoryTool.php @@ -28,11 +28,11 @@ public function handle(Request $request): ResponseFactory $category = $this->resolveCategory($request); if (! $category) { - return Response::make(Response::error('Category not found')); + return Response::make(Response::error(trans('mcp.category_not_found'))); } if (! Gate::allows('delete', $category)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $name = $category->name; @@ -40,14 +40,14 @@ public function handle(Request $request): ResponseFactory try { $category->delete(); } catch (\Exception $e) { - return Response::make(Response::error('Category cannot be deleted: '.$e->getMessage())); + return Response::make(Response::error(trans('mcp.category_delete_failed', ['error' => $e->getMessage()]))); } return Response::make( - Response::text('Category '.$name.' deleted successfully') + Response::text(trans('mcp.category_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Category deleted successfully', + 'message' => trans('mcp.category_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteCompanyTool.php b/app/Mcp/Tools/DeleteCompanyTool.php index eb7fe3ff41a4..70ab41bad8dc 100644 --- a/app/Mcp/Tools/DeleteCompanyTool.php +++ b/app/Mcp/Tools/DeleteCompanyTool.php @@ -28,11 +28,11 @@ public function handle(Request $request): ResponseFactory $company = $this->resolveCompany($request); if (! $company) { - return Response::make(Response::error('Company not found')); + return Response::make(Response::error(trans('mcp.company_not_found'))); } if (! Gate::allows('delete', $company)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $name = $company->name; @@ -40,10 +40,10 @@ public function handle(Request $request): ResponseFactory $company->delete(); return Response::make( - Response::text('Company '.$name.' deleted successfully') + Response::text(trans('mcp.company_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Company deleted successfully', + 'message' => trans('mcp.company_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteComponentTool.php b/app/Mcp/Tools/DeleteComponentTool.php index 1190b68e1efd..dc4bd2564400 100644 --- a/app/Mcp/Tools/DeleteComponentTool.php +++ b/app/Mcp/Tools/DeleteComponentTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $component = $this->resolveComponent($request); if (! $component) { - return Response::make(Response::error('Component not found')); + return Response::make(Response::error(trans('mcp.component_not_found'))); } if (! Gate::allows('delete', $component)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($component->numCheckedOut() > 0) { - return Response::make(Response::error('Component has units checked out and cannot be deleted. Check them in first.')); + return Response::make(Response::error(trans('mcp.component_has_checkouts'))); } $name = $component->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $component->delete(); return Response::make( - Response::text('Component '.$name.' deleted successfully') + Response::text(trans('mcp.component_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Component deleted successfully', + 'message' => trans('mcp.component_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteConsumableTool.php b/app/Mcp/Tools/DeleteConsumableTool.php index 027f9d7958f5..c141a0990dff 100644 --- a/app/Mcp/Tools/DeleteConsumableTool.php +++ b/app/Mcp/Tools/DeleteConsumableTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $consumable = $this->resolveConsumable($request); if (! $consumable) { - return Response::make(Response::error('Consumable not found')); + return Response::make(Response::error(trans('mcp.consumable_not_found'))); } if (! Gate::allows('delete', $consumable)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($consumable->users()->count() > 0) { - return Response::make(Response::error('Consumable has items checked out and cannot be deleted')); + return Response::make(Response::error(trans('mcp.consumable_has_checkouts'))); } $name = $consumable->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $consumable->delete(); return Response::make( - Response::text('Consumable '.$name.' deleted successfully') + Response::text(trans('mcp.consumable_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Consumable deleted successfully', + 'message' => trans('mcp.consumable_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteDepartmentTool.php b/app/Mcp/Tools/DeleteDepartmentTool.php index 163ca6150a5f..8fb08b02829f 100644 --- a/app/Mcp/Tools/DeleteDepartmentTool.php +++ b/app/Mcp/Tools/DeleteDepartmentTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $department = $this->resolveDepartment($request); if (! $department) { - return Response::make(Response::error('Department not found')); + return Response::make(Response::error(trans('mcp.department_not_found'))); } if (! Gate::allows('delete', $department)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($department->users->count() > 0) { - return Response::make(Response::error('Department has users assigned and cannot be deleted. Reassign all users first.')); + return Response::make(Response::error(trans('mcp.department_has_users'))); } $name = $department->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $department->delete(); return Response::make( - Response::text('Department '.$name.' deleted successfully') + Response::text(trans('mcp.department_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Department deleted successfully', + 'message' => trans('mcp.department_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteDepreciationTool.php b/app/Mcp/Tools/DeleteDepreciationTool.php index 2573294f78b8..b07a86005c3a 100644 --- a/app/Mcp/Tools/DeleteDepreciationTool.php +++ b/app/Mcp/Tools/DeleteDepreciationTool.php @@ -28,11 +28,11 @@ public function handle(Request $request): ResponseFactory $dep = $this->resolveDepreciation($request); if (! $dep) { - return Response::make(Response::error('Depreciation not found')); + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); } if (! Gate::allows('delete', $dep)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $name = $dep->name; @@ -40,10 +40,10 @@ public function handle(Request $request): ResponseFactory $dep->delete(); return Response::make( - Response::text('Depreciation '.$name.' deleted successfully') + Response::text(trans('mcp.depreciation_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Depreciation deleted successfully', + 'message' => trans('mcp.depreciation_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteGroupTool.php b/app/Mcp/Tools/DeleteGroupTool.php index 9674f58ae219..a93586ec21c5 100644 --- a/app/Mcp/Tools/DeleteGroupTool.php +++ b/app/Mcp/Tools/DeleteGroupTool.php @@ -21,7 +21,7 @@ class DeleteGroupTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('superadmin')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -34,26 +34,26 @@ public function handle(Request $request): ResponseFactory } elseif ($request->filled('name')) { $group = Group::where('name', $request->get('name'))->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $group) { - return Response::make(Response::error('Group not found')); + return Response::make(Response::error(trans('mcp.group_not_found'))); } $groupName = $group->name; if ($group->delete()) { return Response::make( - Response::text('Group '.$groupName.' deleted successfully') + Response::text(trans('mcp.group_deleted', ['name' => $groupName])) )->withStructuredContent([ 'success' => true, - 'message' => 'Group deleted successfully', + 'message' => trans('mcp.group_deleted', ['name' => $groupName]), 'name' => $groupName, ]); } - return Response::make(Response::error('Delete failed')); + return Response::make(Response::error(trans('mcp.delete_failed'))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/DeleteLicenseTool.php b/app/Mcp/Tools/DeleteLicenseTool.php index 4c27d6b20fc6..128b7f8a9134 100644 --- a/app/Mcp/Tools/DeleteLicenseTool.php +++ b/app/Mcp/Tools/DeleteLicenseTool.php @@ -29,15 +29,15 @@ public function handle(Request $request): ResponseFactory $license = $this->resolveLicense($request); if (! $license) { - return Response::make(Response::error('License not found')); + return Response::make(Response::error(trans('mcp.license_not_found'))); } if (! Gate::allows('delete', $license)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($license->assignedCount()->count() > 0) { - return Response::make(Response::error('License has seats currently assigned and cannot be deleted. Check in all seats first.')); + return Response::make(Response::error(trans('mcp.license_has_seats_assigned'))); } $name = $license->name; @@ -50,10 +50,10 @@ public function handle(Request $request): ResponseFactory $license->delete(); return Response::make( - Response::text('License '.$name.' deleted successfully') + Response::text(trans('mcp.license_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'License deleted successfully', + 'message' => trans('mcp.license_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteLocationTool.php b/app/Mcp/Tools/DeleteLocationTool.php index 1fb09f3a9bce..fa915d325c9b 100644 --- a/app/Mcp/Tools/DeleteLocationTool.php +++ b/app/Mcp/Tools/DeleteLocationTool.php @@ -28,19 +28,19 @@ public function handle(Request $request): ResponseFactory $location = $this->resolveLocation($request); if (! $location) { - return Response::make(Response::error('Location not found')); + return Response::make(Response::error(trans('mcp.location_not_found'))); } if (! Gate::allows('delete', $location)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($location->users()->count() > 0) { - return Response::make(Response::error('Location has users assigned and cannot be deleted')); + return Response::make(Response::error(trans('mcp.location_has_users'))); } if ($location->children()->count() > 0) { - return Response::make(Response::error('Location has child locations and cannot be deleted')); + return Response::make(Response::error(trans('mcp.location_has_child_locations'))); } $name = $location->name; @@ -48,10 +48,10 @@ public function handle(Request $request): ResponseFactory $location->delete(); return Response::make( - Response::text('Location '.$name.' deleted successfully') + Response::text(trans('mcp.location_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Location deleted successfully', + 'message' => trans('mcp.location_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteManufacturerTool.php b/app/Mcp/Tools/DeleteManufacturerTool.php index 9466107b8e28..feb96383b6fe 100644 --- a/app/Mcp/Tools/DeleteManufacturerTool.php +++ b/app/Mcp/Tools/DeleteManufacturerTool.php @@ -28,11 +28,11 @@ public function handle(Request $request): ResponseFactory $manufacturer = $this->resolveManufacturer($request); if (! $manufacturer) { - return Response::make(Response::error('Manufacturer not found')); + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); } if (! Gate::allows('delete', $manufacturer)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $name = $manufacturer->name; @@ -40,10 +40,10 @@ public function handle(Request $request): ResponseFactory $manufacturer->delete(); return Response::make( - Response::text('Manufacturer '.$name.' deleted successfully') + Response::text(trans('mcp.manufacturer_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Manufacturer deleted successfully', + 'message' => trans('mcp.manufacturer_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteStatusLabelTool.php b/app/Mcp/Tools/DeleteStatusLabelTool.php index 19f41daff58b..a4670ff40c81 100644 --- a/app/Mcp/Tools/DeleteStatusLabelTool.php +++ b/app/Mcp/Tools/DeleteStatusLabelTool.php @@ -28,15 +28,15 @@ public function handle(Request $request): ResponseFactory $label = $this->resolveStatusLabel($request); if (! $label) { - return Response::make(Response::error('Status label not found')); + return Response::make(Response::error(trans('mcp.status_label_not_found'))); } if (! Gate::allows('delete', $label)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($label->assets()->count() > 0) { - return Response::make(Response::error('Status label has assets assigned and cannot be deleted')); + return Response::make(Response::error(trans('mcp.status_label_has_assets'))); } $name = $label->name; @@ -44,10 +44,10 @@ public function handle(Request $request): ResponseFactory $label->delete(); return Response::make( - Response::text('Status label '.$name.' deleted successfully') + Response::text(trans('mcp.status_label_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Status label deleted successfully', + 'message' => trans('mcp.status_label_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteSupplierTool.php b/app/Mcp/Tools/DeleteSupplierTool.php index 42cf3bfd19ab..bf3790d1815d 100644 --- a/app/Mcp/Tools/DeleteSupplierTool.php +++ b/app/Mcp/Tools/DeleteSupplierTool.php @@ -28,11 +28,11 @@ public function handle(Request $request): ResponseFactory $supplier = $this->resolveSupplier($request); if (! $supplier) { - return Response::make(Response::error('Supplier not found')); + return Response::make(Response::error(trans('mcp.supplier_not_found'))); } if (! Gate::allows('delete', $supplier)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $name = $supplier->name; @@ -40,10 +40,10 @@ public function handle(Request $request): ResponseFactory $supplier->delete(); return Response::make( - Response::text('Supplier '.$name.' deleted successfully') + Response::text(trans('mcp.supplier_deleted', ['name' => $name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Supplier deleted successfully', + 'message' => trans('mcp.supplier_deleted', ['name' => $name]), 'name' => $name, ]); } diff --git a/app/Mcp/Tools/DeleteUserTool.php b/app/Mcp/Tools/DeleteUserTool.php index df4ac8760719..cbaa4d8f6fe8 100644 --- a/app/Mcp/Tools/DeleteUserTool.php +++ b/app/Mcp/Tools/DeleteUserTool.php @@ -29,34 +29,34 @@ public function handle(Request $request): ResponseFactory $user = $this->resolveUser($request); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } if (! Gate::allows('delete', $user)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($user->id === auth()->id()) { - return Response::make(Response::error('You cannot delete your own account')); + return Response::make(Response::error(trans('mcp.user_cannot_delete_self'))); } if ($user->allAssignedCount() > 0) { - return Response::make(Response::error('User has assigned items and cannot be deleted. Check in all items first.')); + return Response::make(Response::error(trans('mcp.user_has_items'))); } $username = $user->username; if ($user->delete()) { return Response::make( - Response::text('User '.$username.' deleted successfully') + Response::text(trans('mcp.user_deleted', ['username' => $username])) )->withStructuredContent([ 'success' => true, - 'message' => 'User deleted successfully', + 'message' => trans('mcp.user_deleted', ['username' => $username]), 'username' => $username, ]); } - return Response::make(Response::error('Delete failed: '.$user->getErrors()->first())); + return Response::make(Response::error(trans('mcp.delete_failed_error', ['error' => $user->getErrors()->first()]))); } private function resolveUser(Request $request): ?User diff --git a/app/Mcp/Tools/GetActivityLogTool.php b/app/Mcp/Tools/GetActivityLogTool.php index d15baeb858b3..6876c25d5469 100644 --- a/app/Mcp/Tools/GetActivityLogTool.php +++ b/app/Mcp/Tools/GetActivityLogTool.php @@ -21,7 +21,7 @@ class GetActivityLogTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('activity.view')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -69,7 +69,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} activity log entries, returning ".count($activityData)) + Response::text(trans('mcp.list_activity', ['total' => $total, 'count' => count($activityData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/GetCurrentUserTool.php b/app/Mcp/Tools/GetCurrentUserTool.php index dac5fecd757b..06a11d91d3e0 100644 --- a/app/Mcp/Tools/GetCurrentUserTool.php +++ b/app/Mcp/Tools/GetCurrentUserTool.php @@ -20,17 +20,17 @@ class GetCurrentUserTool extends Tool public function handle(Request $request): ResponseFactory { if (! auth()->check()) { - return Response::make(Response::error('Not authenticated')); + return Response::make(Response::error(trans('mcp.not_authenticated'))); } $user = User::with('company', 'department', 'userloc')->find(auth()->id()); if (! $user) { - return Response::make(Response::error('Not authenticated')); + return Response::make(Response::error(trans('mcp.not_authenticated'))); } return Response::make( - Response::text('Current user: '.$user->username) + Response::text(trans('mcp.current_user', ['username' => $user->username])) )->withStructuredContent([ 'id' => $user->id, 'username' => $user->username, diff --git a/app/Mcp/Tools/GetUserAssetsTool.php b/app/Mcp/Tools/GetUserAssetsTool.php index 3e5eea9cf790..67812b157949 100644 --- a/app/Mcp/Tools/GetUserAssetsTool.php +++ b/app/Mcp/Tools/GetUserAssetsTool.php @@ -22,11 +22,11 @@ class GetUserAssetsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if (! Gate::allows('view', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -36,7 +36,7 @@ public function handle(Request $request): ResponseFactory $user = User::find($request->get('id')); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } $assets = Asset::where('assigned_to', $user->id) @@ -54,7 +54,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text('Found '.count($data).' assets for user '.$user->username) + Response::text(trans('mcp.user_assets_found', ['count' => count($data), 'username' => $user->username])) )->withStructuredContent([ 'user_id' => $user->id, 'username' => $user->username, diff --git a/app/Mcp/Tools/ListAssetModelsTool.php b/app/Mcp/Tools/ListAssetModelsTool.php index 289e54820815..b8e9cc4f8b51 100644 --- a/app/Mcp/Tools/ListAssetModelsTool.php +++ b/app/Mcp/Tools/ListAssetModelsTool.php @@ -21,7 +21,7 @@ class ListAssetModelsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', AssetModel::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -69,7 +69,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} asset models, returning ".count($modelsData)) + Response::text(trans('mcp.list_asset_models', ['total' => $total, 'count' => count($modelsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListAssetNotesTool.php b/app/Mcp/Tools/ListAssetNotesTool.php new file mode 100644 index 000000000000..beaa861ba747 --- /dev/null +++ b/app/Mcp/Tools/ListAssetNotesTool.php @@ -0,0 +1,116 @@ +validate([ + 'asset_tag' => 'nullable|string|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('view', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $query = Actionlog::with('adminuser:id,username') + ->where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->where('action_type', 'note added') + ->orderBy('created_at', 'desc'); + + $total = (clone $query)->count(); + $records = $query->skip($offset)->take($limit) + ->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'action_type']); + + $notes = $records->map(fn ($n) => [ + 'id' => $n->id, + 'created_at' => $n->created_at?->toISOString(), + 'note' => $n->note, + 'created_by_id' => $n->created_by, + 'created_by_username' => $n->adminuser?->username, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_asset_notes', [ + 'asset_tag' => $asset->asset_tag, + 'total' => $total, + 'count' => count($notes), + ])) + )->withStructuredContent([ + 'asset_id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'notes' => $notes, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'serial' => $schema->string()->description('Serial number of the asset'), + 'id' => $schema->number()->description('Numeric ID of the asset'), + 'limit' => $schema->number()->description('Number of notes to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of notes to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Numeric ID of the asset')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the asset')->required(), + 'total' => $schema->number()->description('Total number of notes on this asset')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'notes' => $schema->array()->description('List of notes'), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetsTool.php b/app/Mcp/Tools/ListAssetsTool.php index c2becca4f1e9..745ad0787618 100644 --- a/app/Mcp/Tools/ListAssetsTool.php +++ b/app/Mcp/Tools/ListAssetsTool.php @@ -21,7 +21,7 @@ class ListAssetsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('index', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -97,7 +97,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} assets, returning ".count($assetsData)) + Response::text(trans('mcp.list_assets', ['total' => $total, 'count' => count($assetsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListCategoriesTool.php b/app/Mcp/Tools/ListCategoriesTool.php index 8638c700ad44..2ca979d127bc 100644 --- a/app/Mcp/Tools/ListCategoriesTool.php +++ b/app/Mcp/Tools/ListCategoriesTool.php @@ -21,7 +21,7 @@ class ListCategoriesTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Category::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -67,7 +67,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} categories, returning ".count($categoriesData)) + Response::text(trans('mcp.list_categories', ['total' => $total, 'count' => count($categoriesData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListCompaniesTool.php b/app/Mcp/Tools/ListCompaniesTool.php index 27acb91d5374..dbdb319b1239 100644 --- a/app/Mcp/Tools/ListCompaniesTool.php +++ b/app/Mcp/Tools/ListCompaniesTool.php @@ -21,7 +21,7 @@ class ListCompaniesTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Company::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -58,7 +58,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} companies, returning ".count($companiesData)) + Response::text(trans('mcp.list_companies', ['total' => $total, 'count' => count($companiesData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListConsumablesTool.php b/app/Mcp/Tools/ListConsumablesTool.php index 192a37e3d954..95b84f0faa95 100644 --- a/app/Mcp/Tools/ListConsumablesTool.php +++ b/app/Mcp/Tools/ListConsumablesTool.php @@ -21,7 +21,7 @@ class ListConsumablesTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('index', Consumable::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -77,7 +77,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} consumables, returning ".count($consumablesData)) + Response::text(trans('mcp.list_consumables', ['total' => $total, 'count' => count($consumablesData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListDepreciationsTool.php b/app/Mcp/Tools/ListDepreciationsTool.php index d784b256277a..f2ee43a9f144 100644 --- a/app/Mcp/Tools/ListDepreciationsTool.php +++ b/app/Mcp/Tools/ListDepreciationsTool.php @@ -21,7 +21,7 @@ class ListDepreciationsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Depreciation::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -52,7 +52,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} depreciations, returning ".count($depreciationsData)) + Response::text(trans('mcp.list_depreciations', ['total' => $total, 'count' => count($depreciationsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListGroupsTool.php b/app/Mcp/Tools/ListGroupsTool.php index 80a478332764..dfc6fdfd52b4 100644 --- a/app/Mcp/Tools/ListGroupsTool.php +++ b/app/Mcp/Tools/ListGroupsTool.php @@ -21,7 +21,7 @@ class ListGroupsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('superadmin')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -51,7 +51,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} groups, returning ".count($groupsData)) + Response::text(trans('mcp.list_groups', ['total' => $total, 'count' => count($groupsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListHistoryTool.php b/app/Mcp/Tools/ListHistoryTool.php new file mode 100644 index 000000000000..c35223d06415 --- /dev/null +++ b/app/Mcp/Tools/ListHistoryTool.php @@ -0,0 +1,140 @@ + Accessory::class, + 'asset' => Asset::class, + 'asset_model' => AssetModel::class, + 'component' => Component::class, + 'consumable' => Consumable::class, + 'license' => License::class, + 'location' => Location::class, + 'maintenance' => Maintenance::class, + 'user' => User::class, + ]; + + public function handle(Request $request): ResponseFactory + { + $validTypes = implode(',', array_keys(self::TYPE_MAP)); + + $request->validate([ + 'object_type' => 'required|string|in:'.$validTypes, + 'id' => 'required|integer|min:1', + 'search' => 'nullable|string|max:255', + 'action_type' => 'nullable|string|max:100', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $objectType = $request->get('object_type'); + $modelClass = self::TYPE_MAP[$objectType]; + + $object = $modelClass::withTrashed()->find($request->get('id')); + + if (! $object) { + return Response::make(Response::error(trans('mcp.object_not_found', ['type' => $objectType]))); + } + + if (! Gate::allows('history', $object)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $history = $object->history(); + + if ($request->filled('search')) { + $history = $history->TextSearch(e($request->get('search'))); + } + + if ($request->filled('action_type')) { + $history = $history->where('action_type', $request->get('action_type')); + } + + $history = $history->orderBy('action_logs.created_at', 'desc'); + + $total = (clone $history)->count(); + $records = $history->skip($offset)->take($limit)->forApiHistory()->get(); + + $entries = $records->map(fn ($log) => [ + 'id' => $log->id, + 'action_type' => $log->action_type, + 'created_at' => $log->created_at?->toISOString(), + 'note' => $log->note, + 'created_by' => $log->adminuser ? [ + 'id' => $log->adminuser->id, + 'username' => $log->adminuser->username, + ] : null, + 'target' => $log->target ? [ + 'id' => $log->target->getKey(), + 'type' => class_basename($log->target_type), + 'name' => $log->target->present()->name() ?? null, + ] : null, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_history', [ + 'total' => $total, + 'count' => count($entries), + 'type' => $objectType, + ])) + )->withStructuredContent([ + 'object_type' => $objectType, + 'object_id' => $object->id, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'history' => $entries, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object: accessory, asset, asset_model, component, consumable, license, location, maintenance, user'), + 'id' => $schema->number()->description('Numeric ID of the object'), + 'search' => $schema->string()->description('Filter history by keyword'), + 'action_type' => $schema->string()->description('Filter by action type (e.g. checkout, checkin, update, note added, uploaded)'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object queried')->required(), + 'object_id' => $schema->number()->description('ID of the object queried')->required(), + 'total' => $schema->number()->description('Total number of history entries')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'history' => $schema->array()->description('List of history entries'), + ]; + } +} diff --git a/app/Mcp/Tools/ListLicensesTool.php b/app/Mcp/Tools/ListLicensesTool.php index 4f458f080ab5..507a27ab578c 100644 --- a/app/Mcp/Tools/ListLicensesTool.php +++ b/app/Mcp/Tools/ListLicensesTool.php @@ -21,7 +21,7 @@ class ListLicensesTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('index', License::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -80,7 +80,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} licenses, returning ".count($licensesData)) + Response::text(trans('mcp.list_licenses', ['total' => $total, 'count' => count($licensesData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListLocationsTool.php b/app/Mcp/Tools/ListLocationsTool.php index d5d452e80dfc..2b12ce4c7c74 100644 --- a/app/Mcp/Tools/ListLocationsTool.php +++ b/app/Mcp/Tools/ListLocationsTool.php @@ -21,7 +21,7 @@ class ListLocationsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Location::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -70,7 +70,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} locations, returning ".count($locationsData)) + Response::text(trans('mcp.list_locations', ['total' => $total, 'count' => count($locationsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListMaintenancesTool.php b/app/Mcp/Tools/ListMaintenancesTool.php index ac1243a145aa..5b27ce686469 100644 --- a/app/Mcp/Tools/ListMaintenancesTool.php +++ b/app/Mcp/Tools/ListMaintenancesTool.php @@ -22,7 +22,7 @@ class ListMaintenancesTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -59,7 +59,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} maintenances, returning ".count($maintenancesData)) + Response::text(trans('mcp.list_maintenances', ['total' => $total, 'count' => count($maintenancesData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListManufacturersTool.php b/app/Mcp/Tools/ListManufacturersTool.php index 139781e3c167..425bb08afda5 100644 --- a/app/Mcp/Tools/ListManufacturersTool.php +++ b/app/Mcp/Tools/ListManufacturersTool.php @@ -21,7 +21,7 @@ class ListManufacturersTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Manufacturer::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -63,7 +63,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} manufacturers, returning ".count($manufacturersData)) + Response::text(trans('mcp.list_manufacturers', ['total' => $total, 'count' => count($manufacturersData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListStatusLabelsTool.php b/app/Mcp/Tools/ListStatusLabelsTool.php index 4fe247f727b2..62da66cf7cff 100644 --- a/app/Mcp/Tools/ListStatusLabelsTool.php +++ b/app/Mcp/Tools/ListStatusLabelsTool.php @@ -21,7 +21,7 @@ class ListStatusLabelsTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Statuslabel::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -68,7 +68,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} status labels, returning ".count($labelsData)) + Response::text(trans('mcp.list_status_labels', ['total' => $total, 'count' => count($labelsData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListSuppliersTool.php b/app/Mcp/Tools/ListSuppliersTool.php index d2f75b1c0dfe..38d62557c20a 100644 --- a/app/Mcp/Tools/ListSuppliersTool.php +++ b/app/Mcp/Tools/ListSuppliersTool.php @@ -21,7 +21,7 @@ class ListSuppliersTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('view', Supplier::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -62,7 +62,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} suppliers, returning ".count($suppliersData)) + Response::text(trans('mcp.list_suppliers', ['total' => $total, 'count' => count($suppliersData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/ListUploadsTool.php b/app/Mcp/Tools/ListUploadsTool.php new file mode 100644 index 000000000000..479c8fd1c1b4 --- /dev/null +++ b/app/Mcp/Tools/ListUploadsTool.php @@ -0,0 +1,130 @@ + Accessory::class, + 'assets' => Asset::class, + 'companies' => Company::class, + 'components' => Component::class, + 'consumables' => Consumable::class, + 'departments' => Department::class, + 'licenses' => License::class, + 'locations' => Location::class, + 'maintenances' => Maintenance::class, + 'models' => AssetModel::class, + 'suppliers' => Supplier::class, + 'users' => User::class, + ]; + + public function handle(Request $request): ResponseFactory + { + $validTypes = implode(',', array_keys(self::TYPE_MAP)); + + $request->validate([ + 'object_type' => 'required|string|in:'.$validTypes, + 'id' => 'required|integer|min:1', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $objectType = $request->get('object_type'); + $modelClass = self::TYPE_MAP[$objectType]; + + $object = $modelClass::withTrashed()->find($request->get('id')); + + if (! $object) { + return Response::make(Response::error(trans('mcp.object_not_found', ['type' => $objectType]))); + } + + if (! Gate::allows('files', $object)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $query = $object->uploads()->with('adminuser'); + + $total = (clone $query)->count(); + $uploads = $query->skip($offset)->take($limit)->orderBy('created_at', 'desc')->get(); + + $files = $uploads->map(fn ($file) => [ + 'id' => $file->id, + 'filename' => $file->filename, + 'url' => $file->uploads_file_url(), + 'note' => $file->note, + 'created_by' => $file->adminuser ? [ + 'id' => $file->adminuser->id, + 'username' => $file->adminuser->username, + ] : null, + 'created_at' => $file->created_at?->toISOString(), + 'exists_on_disk' => Storage::exists($file->uploads_file_path()), + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_uploads', [ + 'total' => $total, + 'count' => count($files), + 'type' => $objectType, + ])) + )->withStructuredContent([ + 'object_type' => $objectType, + 'object_id' => $object->id, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'files' => $files, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object: accessories, assets, companies, components, consumables, departments, licenses, locations, maintenances, models, suppliers, users'), + 'id' => $schema->number()->description('Numeric ID of the object'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object queried')->required(), + 'object_id' => $schema->number()->description('ID of the object queried')->required(), + 'total' => $schema->number()->description('Total number of uploaded files')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'files' => $schema->array()->description('List of uploaded files'), + ]; + } +} diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php index b91b3de86397..ccdafa7248df 100644 --- a/app/Mcp/Tools/ListUsersTool.php +++ b/app/Mcp/Tools/ListUsersTool.php @@ -21,7 +21,7 @@ class ListUsersTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('index', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -82,7 +82,7 @@ public function handle(Request $request): ResponseFactory ])->values()->all(); return Response::make( - Response::text("Found {$total} users, returning ".count($usersData)) + Response::text(trans('mcp.list_users', ['total' => $total, 'count' => count($usersData)])) )->withStructuredContent([ 'total' => $total, 'offset' => $offset, diff --git a/app/Mcp/Tools/Reset2FATool.php b/app/Mcp/Tools/Reset2FATool.php index 6cecd54ba5e7..838ec5297d8d 100644 --- a/app/Mcp/Tools/Reset2FATool.php +++ b/app/Mcp/Tools/Reset2FATool.php @@ -21,7 +21,7 @@ class Reset2FATool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('update', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -31,7 +31,7 @@ public function handle(Request $request): ResponseFactory $user = User::find($request->get('id')); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } $user->two_factor_secret = null; @@ -40,10 +40,10 @@ public function handle(Request $request): ResponseFactory $user->save(); return Response::make( - Response::text('Two-factor authentication reset for '.$user->username) + Response::text(trans('mcp.two_factor_reset', ['username' => $user->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'Two-factor authentication reset successfully', + 'message' => trans('mcp.two_factor_reset', ['username' => $user->username]), 'id' => $user->id, 'username' => $user->username, ]); diff --git a/app/Mcp/Tools/RestoreAssetTool.php b/app/Mcp/Tools/RestoreAssetTool.php index 8a3a5c780d09..2e62ae99bda7 100644 --- a/app/Mcp/Tools/RestoreAssetTool.php +++ b/app/Mcp/Tools/RestoreAssetTool.php @@ -27,24 +27,24 @@ public function handle(Request $request): ResponseFactory $asset = Asset::withTrashed()->find($request->get('id')); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! $asset->deleted_at) { - return Response::make(Response::error('Asset is not deleted')); + return Response::make(Response::error(trans('mcp.asset_not_deleted'))); } if (! Gate::allows('delete', Asset::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $asset->restore(); return Response::make( - Response::text('Asset '.$asset->asset_tag.' restored successfully') + Response::text(trans('mcp.asset_restored', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset restored successfully', + 'message' => trans('mcp.asset_restored', ['asset_tag' => $asset->asset_tag]), 'id' => $asset->id, 'asset_tag' => $asset->asset_tag, ]); diff --git a/app/Mcp/Tools/RestoreUserTool.php b/app/Mcp/Tools/RestoreUserTool.php index 4bf2a2e11960..013a8df29eb2 100644 --- a/app/Mcp/Tools/RestoreUserTool.php +++ b/app/Mcp/Tools/RestoreUserTool.php @@ -21,7 +21,7 @@ class RestoreUserTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('delete', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -31,24 +31,24 @@ public function handle(Request $request): ResponseFactory $user = User::withTrashed()->find($request->get('id')); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } if (! $user->deleted_at) { - return Response::make(Response::error('User is not deleted')); + return Response::make(Response::error(trans('mcp.user_not_deleted'))); } if (! Gate::allows('delete', User::class)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $user->restore(); return Response::make( - Response::text('User '.$user->username.' restored successfully') + Response::text(trans('mcp.user_restored', ['username' => $user->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'User restored successfully', + 'message' => trans('mcp.user_restored', ['username' => $user->username]), 'id' => $user->id, 'username' => $user->username, ]); diff --git a/app/Mcp/Tools/ShowAssetModelTool.php b/app/Mcp/Tools/ShowAssetModelTool.php index 2e3b9c24f31d..690bfdc4c9df 100644 --- a/app/Mcp/Tools/ShowAssetModelTool.php +++ b/app/Mcp/Tools/ShowAssetModelTool.php @@ -28,21 +28,21 @@ public function handle(Request $request): ResponseFactory $model = $this->resolveModel($request); if ($model === false) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $model) { - return Response::make(Response::error('Asset model not found')); + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); } if (! Gate::allows('view', $model)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $model->loadCount('assets as assets_count'); return Response::make( - Response::text('Asset model '.$model->name.' found') + Response::text(trans('mcp.asset_model_found', ['name' => $model->name])) )->withStructuredContent([ 'id' => $model->id, 'name' => $model->name, diff --git a/app/Mcp/Tools/ShowAssetTool.php b/app/Mcp/Tools/ShowAssetTool.php index c7be342886df..3c96e1125013 100644 --- a/app/Mcp/Tools/ShowAssetTool.php +++ b/app/Mcp/Tools/ShowAssetTool.php @@ -39,16 +39,16 @@ public function handle(Request $request): ResponseFactory if (! $asset) { return Response::make( - Response::error('Asset not found') + Response::error(trans('mcp.asset_not_found')) ); } if (! Gate::allows('view', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } return Response::make( - Response::text('Asset '.$asset->asset_tag.' found') + Response::text(trans('mcp.asset_found', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent($this->formatAsset($asset)); } diff --git a/app/Mcp/Tools/ShowCategoryTool.php b/app/Mcp/Tools/ShowCategoryTool.php index 596c28c75443..4a02fc86fa8b 100644 --- a/app/Mcp/Tools/ShowCategoryTool.php +++ b/app/Mcp/Tools/ShowCategoryTool.php @@ -40,19 +40,19 @@ public function handle(Request $request): ResponseFactory } elseif ($request->filled('name')) { $category = Category::withCount($withCounts)->where('name', $request->get('name'))->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $category) { - return Response::make(Response::error('Category not found')); + return Response::make(Response::error(trans('mcp.category_not_found'))); } if (! Gate::allows('view', $category)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } return Response::make( - Response::text('Category '.$category->name.' found') + Response::text(trans('mcp.category_found', ['name' => $category->name])) )->withStructuredContent([ 'id' => $category->id, 'name' => $category->name, diff --git a/app/Mcp/Tools/ShowCompanyTool.php b/app/Mcp/Tools/ShowCompanyTool.php index 9003ebf3186e..be620f09ab08 100644 --- a/app/Mcp/Tools/ShowCompanyTool.php +++ b/app/Mcp/Tools/ShowCompanyTool.php @@ -36,19 +36,19 @@ public function handle(Request $request): ResponseFactory 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), ])->withCount('users as users_count')->where('name', $request->get('name'))->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $company) { - return Response::make(Response::error('Company not found')); + return Response::make(Response::error(trans('mcp.company_not_found'))); } if (! Gate::allows('view', $company)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } return Response::make( - Response::text('Company '.$company->name.' found') + Response::text(trans('mcp.company_found', ['name' => $company->name])) )->withStructuredContent([ 'id' => $company->id, 'name' => $company->name, diff --git a/app/Mcp/Tools/ShowConsumableTool.php b/app/Mcp/Tools/ShowConsumableTool.php index 7436d1251634..26a58527112e 100644 --- a/app/Mcp/Tools/ShowConsumableTool.php +++ b/app/Mcp/Tools/ShowConsumableTool.php @@ -32,21 +32,21 @@ public function handle(Request $request): ResponseFactory } elseif ($request->filled('name')) { $consumable = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location')->where('name', $request->get('name'))->first(); } else { - return Response::make(Response::error('Either id or name is required')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $consumable) { - return Response::make(Response::error('Consumable not found')); + return Response::make(Response::error(trans('mcp.consumable_not_found'))); } if (! Gate::allows('view', $consumable)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $usersCount = $consumable->users()->count(); return Response::make( - Response::text('Consumable '.$consumable->name.' found') + Response::text(trans('mcp.consumable_found', ['name' => $consumable->name])) )->withStructuredContent([ 'id' => $consumable->id, 'name' => $consumable->name, diff --git a/app/Mcp/Tools/ShowDepreciationTool.php b/app/Mcp/Tools/ShowDepreciationTool.php index 4af218731eed..65340929b177 100644 --- a/app/Mcp/Tools/ShowDepreciationTool.php +++ b/app/Mcp/Tools/ShowDepreciationTool.php @@ -28,21 +28,21 @@ public function handle(Request $request): ResponseFactory $depreciation = $this->resolveDepreciation($request); if ($depreciation === false) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $depreciation) { - return Response::make(Response::error('Depreciation not found')); + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); } if (! Gate::allows('view', $depreciation)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $depreciation->loadCount('models as models_count'); return Response::make( - Response::text('Depreciation '.$depreciation->name.' found') + Response::text(trans('mcp.depreciation_found', ['name' => $depreciation->name])) )->withStructuredContent([ 'id' => $depreciation->id, 'name' => $depreciation->name, diff --git a/app/Mcp/Tools/ShowGroupTool.php b/app/Mcp/Tools/ShowGroupTool.php index 9cbe386579d2..e75c08945594 100644 --- a/app/Mcp/Tools/ShowGroupTool.php +++ b/app/Mcp/Tools/ShowGroupTool.php @@ -21,7 +21,7 @@ class ShowGroupTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('superadmin')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $request->validate([ @@ -36,15 +36,15 @@ public function handle(Request $request): ResponseFactory ->where('name', $request->get('name')) ->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $group) { - return Response::make(Response::error('Group not found')); + return Response::make(Response::error(trans('mcp.group_not_found'))); } return Response::make( - Response::text('Group '.$group->name.' found') + Response::text(trans('mcp.group_found', ['name' => $group->name])) )->withStructuredContent([ 'id' => $group->id, 'name' => $group->name, diff --git a/app/Mcp/Tools/ShowLicenseTool.php b/app/Mcp/Tools/ShowLicenseTool.php index cbe529f37a6e..2aa648283b6c 100644 --- a/app/Mcp/Tools/ShowLicenseTool.php +++ b/app/Mcp/Tools/ShowLicenseTool.php @@ -35,21 +35,21 @@ public function handle(Request $request): ResponseFactory ->where('name', $request->get('name')) ->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $license) { - return Response::make(Response::error('License not found')); + return Response::make(Response::error(trans('mcp.license_not_found'))); } if (! Gate::allows('view', $license)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $assignedCount = $license->assignedCount()->count(); return Response::make( - Response::text('License '.$license->name.' found') + Response::text(trans('mcp.license_found', ['name' => $license->name])) )->withStructuredContent([ 'id' => $license->id, 'name' => $license->name, diff --git a/app/Mcp/Tools/ShowLocationTool.php b/app/Mcp/Tools/ShowLocationTool.php index b3cf1ce7465d..e5d0359b7cfb 100644 --- a/app/Mcp/Tools/ShowLocationTool.php +++ b/app/Mcp/Tools/ShowLocationTool.php @@ -28,21 +28,21 @@ public function handle(Request $request): ResponseFactory $location = $this->resolveLocation($request); if ($location === false) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $location) { - return Response::make(Response::error('Location not found')); + return Response::make(Response::error(trans('mcp.location_not_found'))); } if (! Gate::allows('view', $location)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $location->loadCount('assets as assets_count', 'users as users_count', 'children as children_count'); return Response::make( - Response::text('Location '.$location->name.' found') + Response::text(trans('mcp.location_found', ['name' => $location->name])) )->withStructuredContent([ 'id' => $location->id, 'name' => $location->name, diff --git a/app/Mcp/Tools/ShowManufacturerTool.php b/app/Mcp/Tools/ShowManufacturerTool.php index 671d06b63023..88344eb3327b 100644 --- a/app/Mcp/Tools/ShowManufacturerTool.php +++ b/app/Mcp/Tools/ShowManufacturerTool.php @@ -29,20 +29,20 @@ public function handle(Request $request): ResponseFactory if (! $manufacturer) { if (! $request->filled('id') && ! $request->filled('name')) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } - return Response::make(Response::error('Manufacturer not found')); + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); } if (! Gate::allows('view', $manufacturer)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $manufacturer->loadCount('assets as assets_count'); return Response::make( - Response::text('Manufacturer: '.$manufacturer->name) + Response::text(trans('mcp.manufacturer_found', ['name' => $manufacturer->name])) )->withStructuredContent([ 'id' => $manufacturer->id, 'name' => $manufacturer->name, diff --git a/app/Mcp/Tools/ShowStatusLabelTool.php b/app/Mcp/Tools/ShowStatusLabelTool.php index 08199ce7b35e..90953431d09b 100644 --- a/app/Mcp/Tools/ShowStatusLabelTool.php +++ b/app/Mcp/Tools/ShowStatusLabelTool.php @@ -29,20 +29,20 @@ public function handle(Request $request): ResponseFactory if (! $label) { if (! $request->filled('id') && ! $request->filled('name')) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } - return Response::make(Response::error('Status label not found')); + return Response::make(Response::error(trans('mcp.status_label_not_found'))); } if (! Gate::allows('view', $label)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $label->loadCount('assets as assets_count'); return Response::make( - Response::text('Status label: '.$label->name) + Response::text(trans('mcp.status_label_found', ['name' => $label->name])) )->withStructuredContent([ 'id' => $label->id, 'name' => $label->name, diff --git a/app/Mcp/Tools/ShowSupplierTool.php b/app/Mcp/Tools/ShowSupplierTool.php index 3ae06dedd90a..4871a568a1ac 100644 --- a/app/Mcp/Tools/ShowSupplierTool.php +++ b/app/Mcp/Tools/ShowSupplierTool.php @@ -29,20 +29,20 @@ public function handle(Request $request): ResponseFactory if (! $supplier) { if (! $request->filled('id') && ! $request->filled('name')) { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } - return Response::make(Response::error('Supplier not found')); + return Response::make(Response::error(trans('mcp.supplier_not_found'))); } if (! Gate::allows('view', $supplier)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $supplier->loadCount('assets as assets_count', 'licenses as licenses_count'); return Response::make( - Response::text('Supplier: '.$supplier->name) + Response::text(trans('mcp.supplier_found', ['name' => $supplier->name])) )->withStructuredContent([ 'id' => $supplier->id, 'name' => $supplier->name, diff --git a/app/Mcp/Tools/ShowUserTool.php b/app/Mcp/Tools/ShowUserTool.php index 1758aad24adf..bd320fb0ca4e 100644 --- a/app/Mcp/Tools/ShowUserTool.php +++ b/app/Mcp/Tools/ShowUserTool.php @@ -41,19 +41,19 @@ public function handle(Request $request): ResponseFactory ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) ->first(); } else { - return Response::make(Response::error('Please provide an id, username, or email')); + return Response::make(Response::error(trans('mcp.id_username_or_email_required'))); } if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } if (! Gate::allows('view', $user)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } return Response::make( - Response::text('User '.$user->username.' found') + Response::text(trans('mcp.user_found', ['username' => $user->username])) )->withStructuredContent([ 'id' => $user->id, 'first_name' => $user->first_name, diff --git a/app/Mcp/Tools/UpdateAccessoryTool.php b/app/Mcp/Tools/UpdateAccessoryTool.php index b941f39ee5f8..c462fbfaf5ac 100644 --- a/app/Mcp/Tools/UpdateAccessoryTool.php +++ b/app/Mcp/Tools/UpdateAccessoryTool.php @@ -43,11 +43,11 @@ public function handle(Request $request): ResponseFactory $accessory = $this->resolveAccessory($request); if (! $accessory) { - return Response::make(Response::error('Accessory not found')); + return Response::make(Response::error(trans('mcp.accessory_not_found'))); } if (! Gate::allows('update', $accessory)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -72,16 +72,16 @@ public function handle(Request $request): ResponseFactory if ($accessory->save()) { return Response::make( - Response::text('Accessory '.$accessory->name.' updated successfully') + Response::text(trans('mcp.accessory_updated', ['name' => $accessory->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Accessory updated successfully', + 'message' => trans('mcp.accessory_updated', ['name' => $accessory->name]), 'id' => $accessory->id, 'name' => $accessory->name, ]); } - return Response::make(Response::error('Update failed: '.$accessory->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $accessory->getErrors()->first()]))); } private function resolveAccessory(Request $request): ?Accessory diff --git a/app/Mcp/Tools/UpdateAssetModelTool.php b/app/Mcp/Tools/UpdateAssetModelTool.php index f40701def23e..cdb7a09214c2 100644 --- a/app/Mcp/Tools/UpdateAssetModelTool.php +++ b/app/Mcp/Tools/UpdateAssetModelTool.php @@ -38,11 +38,11 @@ public function handle(Request $request): ResponseFactory $model = $this->resolveModel($request); if (! $model) { - return Response::make(Response::error('Asset model not found')); + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); } if (! Gate::allows('update', $model)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -57,16 +57,16 @@ public function handle(Request $request): ResponseFactory if ($model->save()) { return Response::make( - Response::text('Asset model '.$model->name.' updated successfully') + Response::text(trans('mcp.asset_model_updated', ['name' => $model->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset model updated successfully', + 'message' => trans('mcp.asset_model_updated', ['name' => $model->name]), 'id' => $model->id, 'name' => $model->name, ]); } - return Response::make(Response::error('Update failed: '.$model->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $model->getErrors()->first()]))); } private function resolveModel(Request $request): ?AssetModel diff --git a/app/Mcp/Tools/UpdateAssetTool.php b/app/Mcp/Tools/UpdateAssetTool.php index 094b9c2f41ee..d693a5ea053f 100644 --- a/app/Mcp/Tools/UpdateAssetTool.php +++ b/app/Mcp/Tools/UpdateAssetTool.php @@ -47,11 +47,11 @@ public function handle(Request $request): ResponseFactory $asset = $this->resolveAsset($request); if (! $asset) { - return Response::make(Response::error('Asset not found')); + return Response::make(Response::error(trans('mcp.asset_not_found'))); } if (! Gate::allows('update', $asset)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -78,16 +78,16 @@ public function handle(Request $request): ResponseFactory if ($asset->save()) { return Response::make( - Response::text('Asset '.$asset->asset_tag.' updated successfully') + Response::text(trans('mcp.asset_updated', ['asset_tag' => $asset->asset_tag])) )->withStructuredContent([ 'success' => true, - 'message' => 'Asset updated successfully', + 'message' => trans('mcp.asset_updated', ['asset_tag' => $asset->asset_tag]), 'asset_tag' => $asset->asset_tag, 'id' => $asset->id, ]); } - return Response::make(Response::error('Update failed: '.$asset->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $asset->getErrors()->first()]))); } private function resolveAsset(Request $request): ?Asset diff --git a/app/Mcp/Tools/UpdateCategoryTool.php b/app/Mcp/Tools/UpdateCategoryTool.php index 048576393e56..42ea3c378930 100644 --- a/app/Mcp/Tools/UpdateCategoryTool.php +++ b/app/Mcp/Tools/UpdateCategoryTool.php @@ -34,11 +34,11 @@ public function handle(Request $request): ResponseFactory $category = $this->resolveCategory($request); if (! $category) { - return Response::make(Response::error('Category not found')); + return Response::make(Response::error(trans('mcp.category_not_found'))); } if (! Gate::allows('update', $category)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -53,17 +53,17 @@ public function handle(Request $request): ResponseFactory if ($category->save()) { return Response::make( - Response::text('Category '.$category->name.' updated successfully') + Response::text(trans('mcp.category_updated', ['name' => $category->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Category updated successfully', + 'message' => trans('mcp.category_updated', ['name' => $category->name]), 'id' => $category->id, 'name' => $category->name, 'category_type' => $category->category_type, ]); } - return Response::make(Response::error('Update failed: '.$category->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $category->getErrors()->first()]))); } private function resolveCategory(Request $request): ?Category diff --git a/app/Mcp/Tools/UpdateCompanyTool.php b/app/Mcp/Tools/UpdateCompanyTool.php index 0c7edec457a4..832549e32bd7 100644 --- a/app/Mcp/Tools/UpdateCompanyTool.php +++ b/app/Mcp/Tools/UpdateCompanyTool.php @@ -33,11 +33,11 @@ public function handle(Request $request): ResponseFactory $company = $this->resolveCompany($request); if (! $company) { - return Response::make(Response::error('Company not found')); + return Response::make(Response::error(trans('mcp.company_not_found'))); } if (! Gate::allows('update', $company)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { diff --git a/resources/lang/en-US/mcp.php b/resources/lang/en-US/mcp.php index aa4aaa0e8dd5..8252b0b5f29b 100644 --- a/resources/lang/en-US/mcp.php +++ b/resources/lang/en-US/mcp.php @@ -14,6 +14,7 @@ // ----------------------------------------------------------------- // "Not found" errors // ----------------------------------------------------------------- + 'object_not_found' => 'The specified :type was not found', 'asset_not_found' => 'Asset not found', 'user_not_found' => 'User not found', 'accessory_not_found' => 'Accessory not found', @@ -241,5 +242,8 @@ 'list_groups' => 'Found :total groups, returning :count', 'list_maintenances' => 'Found :total maintenances, returning :count', 'list_activity' => 'Found :total activity log entries, returning :count', + 'list_asset_notes' => 'Found :total notes for asset :asset_tag, returning :count', + 'list_uploads' => 'Found :total uploaded files for :type, returning :count', + 'list_history' => 'Found :total history entries for :type, returning :count', ]; From b8d2be6c3a986c13a31c576944b596e580469815 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:52:45 +0100 Subject: [PATCH 38/45] Added test --- tests/Feature/Mcp/ListAssetNotesToolTest.php | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Feature/Mcp/ListAssetNotesToolTest.php diff --git a/tests/Feature/Mcp/ListAssetNotesToolTest.php b/tests/Feature/Mcp/ListAssetNotesToolTest.php new file mode 100644 index 000000000000..06b5a9692610 --- /dev/null +++ b/tests/Feature/Mcp/ListAssetNotesToolTest.php @@ -0,0 +1,85 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListAssetNotesTool)->handle(new Request($args)); + } + + public function test_returns_notes_for_asset() + { + $asset = Asset::factory()->create(); + $author = User::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'Test note content', + 'created_by' => $author->id, + ]); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('Test note content', $content['notes'][0]['note']); + } + + public function test_returns_empty_list_when_no_notes() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertEmpty($content['notes']); + } + + public function test_resolves_asset_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => (string) $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_resolves_asset_by_serial() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['serial' => $asset->serial])->getStructuredContent(); + + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_view_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + } +} From e839d989ec4ced047266a58162a55dc6ae8858fc Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:53:01 +0100 Subject: [PATCH 39/45] Still more localizations --- app/Mcp/Tools/UpdateCompanyTool.php | 6 +++--- app/Mcp/Tools/UpdateComponentTool.php | 10 +++++----- app/Mcp/Tools/UpdateConsumableTool.php | 10 +++++----- app/Mcp/Tools/UpdateDepartmentTool.php | 4 ++-- tests/Feature/Mcp/ListAssetNotesToolTest.php | 8 ++++---- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/Mcp/Tools/UpdateCompanyTool.php b/app/Mcp/Tools/UpdateCompanyTool.php index 832549e32bd7..1a742f74a688 100644 --- a/app/Mcp/Tools/UpdateCompanyTool.php +++ b/app/Mcp/Tools/UpdateCompanyTool.php @@ -52,16 +52,16 @@ public function handle(Request $request): ResponseFactory if ($company->save()) { return Response::make( - Response::text('Company '.$company->name.' updated successfully') + Response::text(trans('mcp.company_updated', ['name' => $company->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Company updated successfully', + 'message' => trans('mcp.company_updated', ['name' => $company->name]), 'id' => $company->id, 'name' => $company->name, ]); } - return Response::make(Response::error('Update failed: '.$company->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $company->getErrors()->first()]))); } private function resolveCompany(Request $request): ?Company diff --git a/app/Mcp/Tools/UpdateComponentTool.php b/app/Mcp/Tools/UpdateComponentTool.php index 33bb11c43bcf..2ae0e3a827a6 100644 --- a/app/Mcp/Tools/UpdateComponentTool.php +++ b/app/Mcp/Tools/UpdateComponentTool.php @@ -43,11 +43,11 @@ public function handle(Request $request): ResponseFactory $component = $this->resolveComponent($request); if (! $component) { - return Response::make(Response::error('Component not found')); + return Response::make(Response::error(trans('mcp.component_not_found'))); } if (! Gate::allows('update', $component)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -72,16 +72,16 @@ public function handle(Request $request): ResponseFactory if ($component->save()) { return Response::make( - Response::text('Component '.$component->name.' updated successfully') + Response::text(trans('mcp.component_updated', ['name' => $component->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Component updated successfully', + 'message' => trans('mcp.component_updated', ['name' => $component->name]), 'id' => $component->id, 'name' => $component->name, ]); } - return Response::make(Response::error('Update failed: '.$component->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $component->getErrors()->first()]))); } private function resolveComponent(Request $request): ?Component diff --git a/app/Mcp/Tools/UpdateConsumableTool.php b/app/Mcp/Tools/UpdateConsumableTool.php index 62f70beb2f43..92d31c23cb8e 100644 --- a/app/Mcp/Tools/UpdateConsumableTool.php +++ b/app/Mcp/Tools/UpdateConsumableTool.php @@ -39,11 +39,11 @@ public function handle(Request $request): ResponseFactory $consumable = $this->resolveConsumable($request); if (! $consumable) { - return Response::make(Response::error('Consumable not found')); + return Response::make(Response::error(trans('mcp.consumable_not_found'))); } if (! Gate::allows('update', $consumable)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -63,17 +63,17 @@ public function handle(Request $request): ResponseFactory if ($consumable->save()) { return Response::make( - Response::text('Consumable '.$consumable->name.' updated successfully') + Response::text(trans('mcp.consumable_updated', ['name' => $consumable->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Consumable updated successfully', + 'message' => trans('mcp.consumable_updated', ['name' => $consumable->name]), 'id' => $consumable->id, 'name' => $consumable->name, 'qty' => $consumable->qty, ]); } - return Response::make(Response::error('Update failed: '.$consumable->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $consumable->getErrors()->first()]))); } private function resolveConsumable(Request $request): ?Consumable diff --git a/app/Mcp/Tools/UpdateDepartmentTool.php b/app/Mcp/Tools/UpdateDepartmentTool.php index af996003f59c..23f786d142be 100644 --- a/app/Mcp/Tools/UpdateDepartmentTool.php +++ b/app/Mcp/Tools/UpdateDepartmentTool.php @@ -36,11 +36,11 @@ public function handle(Request $request): ResponseFactory $department = $this->resolveDepartment($request); if (! $department) { - return Response::make(Response::error('Department not found')); + return Response::make(Response::error(trans('mcp.department_not_found'))); } if (! Gate::allows('update', $department)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = ['location_id', 'manager_id', 'phone', 'fax', 'notes']; diff --git a/tests/Feature/Mcp/ListAssetNotesToolTest.php b/tests/Feature/Mcp/ListAssetNotesToolTest.php index 06b5a9692610..b422b653753d 100644 --- a/tests/Feature/Mcp/ListAssetNotesToolTest.php +++ b/tests/Feature/Mcp/ListAssetNotesToolTest.php @@ -29,11 +29,11 @@ public function test_returns_notes_for_asset() $author = User::factory()->create(); Actionlog::factory()->create([ - 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'item_id' => $asset->id, 'action_type' => 'note added', - 'note' => 'Test note content', - 'created_by' => $author->id, + 'note' => 'Test note content', + 'created_by' => $author->id, ]); $content = $this->handle(['id' => $asset->id])->getStructuredContent(); From d83b64ff321b66af59bcba26ce94bd15673a5c3b Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:55:48 +0100 Subject: [PATCH 40/45] Added tests --- tests/Feature/Mcp/ListHistoryToolTest.php | 108 ++++++++++++++++++++++ tests/Feature/Mcp/ListUploadsToolTest.php | 79 ++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 tests/Feature/Mcp/ListHistoryToolTest.php create mode 100644 tests/Feature/Mcp/ListUploadsToolTest.php diff --git a/tests/Feature/Mcp/ListHistoryToolTest.php b/tests/Feature/Mcp/ListHistoryToolTest.php new file mode 100644 index 000000000000..82af3c497fb6 --- /dev/null +++ b/tests/Feature/Mcp/ListHistoryToolTest.php @@ -0,0 +1,108 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListHistoryTool)->handle(new Request($args)); + } + + public function test_returns_history_for_asset() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'update', + ]); + + $content = $this->handle(['object_type' => 'asset', 'id' => $asset->id])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + $this->assertEquals('asset', $content['object_type']); + $this->assertEquals($asset->id, $content['object_id']); + } + + public function test_returns_empty_history_for_new_asset() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['object_type' => 'asset', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals('asset', $content['object_type']); + $this->assertIsArray($content['history']); + } + + public function test_filters_by_action_type() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'update', + ]); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'A note', + ]); + + $content = $this->handle([ + 'object_type' => 'asset', + 'id' => $asset->id, + 'action_type' => 'note added', + ])->getStructuredContent(); + + foreach ($content['history'] as $entry) { + $this->assertEquals('note added', $entry['action_type']); + } + } + + public function test_returns_error_when_object_not_found() + { + $this->assertTrue( + $this->handle(['object_type' => 'asset', 'id' => 999999])->responses()->first()->isError() + ); + } + + public function test_returns_error_when_user_lacks_history_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue( + $this->handle(['object_type' => 'asset', 'id' => $asset->id])->responses()->first()->isError() + ); + } + + public function test_supports_user_object_type() + { + $this->actingAs(User::factory()->viewUsers()->create()); + $user = User::factory()->create(); + + $content = $this->handle(['object_type' => 'user', 'id' => $user->id])->getStructuredContent(); + + $this->assertEquals('user', $content['object_type']); + $this->assertEquals($user->id, $content['object_id']); + } +} diff --git a/tests/Feature/Mcp/ListUploadsToolTest.php b/tests/Feature/Mcp/ListUploadsToolTest.php new file mode 100644 index 000000000000..eec45812a6bf --- /dev/null +++ b/tests/Feature/Mcp/ListUploadsToolTest.php @@ -0,0 +1,79 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListUploadsTool)->handle(new Request($args)); + } + + public function test_returns_uploads_for_asset() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'uploaded', + 'filename' => 'test-document.pdf', + ]); + + $content = $this->handle(['object_type' => 'assets', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('test-document.pdf', $content['files'][0]['filename']); + } + + public function test_returns_empty_list_when_no_uploads() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['object_type' => 'assets', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertEmpty($content['files']); + } + + public function test_returns_error_when_object_not_found() + { + $this->assertTrue( + $this->handle(['object_type' => 'assets', 'id' => 999999])->responses()->first()->isError() + ); + } + + public function test_returns_error_when_user_lacks_files_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue( + $this->handle(['object_type' => 'assets', 'id' => $asset->id])->responses()->first()->isError() + ); + } + + public function test_supports_user_object_type() + { + $user = User::factory()->create(); + + $content = $this->handle(['object_type' => 'users', 'id' => $user->id])->getStructuredContent(); + + $this->assertEquals('users', $content['object_type']); + $this->assertEquals($user->id, $content['object_id']); + } +} From 19e58a8640e4597d0c197903ad94594bce70748d Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:56:03 +0100 Subject: [PATCH 41/45] Still more localization --- app/Mcp/Tools/ListHistoryTool.php | 20 ++++++++++++++++---- app/Mcp/Tools/UpdateDepartmentTool.php | 6 +++--- app/Mcp/Tools/UpdateDepreciationTool.php | 10 +++++----- app/Mcp/Tools/UpdateGroupTool.php | 12 ++++++------ app/Mcp/Tools/UpdateLicenseTool.php | 10 +++++----- app/Mcp/Tools/UpdateLocationTool.php | 10 +++++----- app/Mcp/Tools/UpdateManufacturerTool.php | 10 +++++----- tests/Feature/Mcp/ListHistoryToolTest.php | 16 ++++++++-------- tests/Feature/Mcp/ListUploadsToolTest.php | 6 +++--- 9 files changed, 56 insertions(+), 44 deletions(-) diff --git a/app/Mcp/Tools/ListHistoryTool.php b/app/Mcp/Tools/ListHistoryTool.php index c35223d06415..1943e20b87a6 100644 --- a/app/Mcp/Tools/ListHistoryTool.php +++ b/app/Mcp/Tools/ListHistoryTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Models\Accessory; +use App\Models\Actionlog; use App\Models\Asset; use App\Models\AssetModel; use App\Models\Component; @@ -67,17 +68,28 @@ public function handle(Request $request): ResponseFactory $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; - $history = $object->history(); + $modelClass = get_class($object); + $modelId = $object->getKey(); + + // Wrap the item/target OR in a subquery so additional filters apply to both sides. + $history = Actionlog::where(function ($q) use ($modelClass, $modelId) { + $q->where('item_type', $modelClass) + ->where('item_id', $modelId) + ->orWhere(function ($q2) use ($modelClass, $modelId) { + $q2->where('target_type', $modelClass) + ->where('target_id', $modelId); + }); + }); if ($request->filled('search')) { - $history = $history->TextSearch(e($request->get('search'))); + $history->TextSearch(e($request->get('search'))); } if ($request->filled('action_type')) { - $history = $history->where('action_type', $request->get('action_type')); + $history->where('action_type', $request->get('action_type')); } - $history = $history->orderBy('action_logs.created_at', 'desc'); + $history->orderBy('action_logs.created_at', 'desc'); $total = (clone $history)->count(); $records = $history->skip($offset)->take($limit)->forApiHistory()->get(); diff --git a/app/Mcp/Tools/UpdateDepartmentTool.php b/app/Mcp/Tools/UpdateDepartmentTool.php index 23f786d142be..75cdb77386bc 100644 --- a/app/Mcp/Tools/UpdateDepartmentTool.php +++ b/app/Mcp/Tools/UpdateDepartmentTool.php @@ -61,16 +61,16 @@ public function handle(Request $request): ResponseFactory if ($department->save()) { return Response::make( - Response::text('Department '.$department->name.' updated successfully') + Response::text(trans('mcp.department_updated', ['name' => $department->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Department updated successfully', + 'message' => trans('mcp.department_updated', ['name' => $department->name]), 'id' => $department->id, 'name' => $department->name, ]); } - return Response::make(Response::error('Update failed: '.$department->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $department->getErrors()->first()]))); } private function resolveDepartment(Request $request): ?Department diff --git a/app/Mcp/Tools/UpdateDepreciationTool.php b/app/Mcp/Tools/UpdateDepreciationTool.php index 1fb2ba22230d..53b3cade5453 100644 --- a/app/Mcp/Tools/UpdateDepreciationTool.php +++ b/app/Mcp/Tools/UpdateDepreciationTool.php @@ -30,11 +30,11 @@ public function handle(Request $request): ResponseFactory $dep = $this->resolveDepreciation($request); if (! $dep) { - return Response::make(Response::error('Depreciation not found')); + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); } if (! Gate::allows('update', $dep)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -47,17 +47,17 @@ public function handle(Request $request): ResponseFactory if ($dep->save()) { return Response::make( - Response::text('Depreciation '.$dep->name.' updated successfully') + Response::text(trans('mcp.depreciation_updated', ['name' => $dep->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Depreciation updated successfully', + 'message' => trans('mcp.depreciation_updated', ['name' => $dep->name]), 'id' => $dep->id, 'name' => $dep->name, 'months' => $dep->months, ]); } - return Response::make(Response::error('Update failed: '.$dep->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $dep->getErrors()->first()]))); } private function resolveDepreciation(Request $request): ?Depreciation diff --git a/app/Mcp/Tools/UpdateGroupTool.php b/app/Mcp/Tools/UpdateGroupTool.php index 286c367af26d..afee8c7d2971 100644 --- a/app/Mcp/Tools/UpdateGroupTool.php +++ b/app/Mcp/Tools/UpdateGroupTool.php @@ -22,7 +22,7 @@ class UpdateGroupTool extends Tool public function handle(Request $request): ResponseFactory { if (! Gate::allows('superadmin')) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } try { @@ -41,11 +41,11 @@ public function handle(Request $request): ResponseFactory } elseif ($request->filled('name')) { $group = Group::where('name', $request->get('name'))->first(); } else { - return Response::make(Response::error('Please provide an id or name')); + return Response::make(Response::error(trans('mcp.id_or_name_required'))); } if (! $group) { - return Response::make(Response::error('Group not found')); + return Response::make(Response::error(trans('mcp.group_not_found'))); } if ($request->filled('new_name')) { @@ -58,16 +58,16 @@ public function handle(Request $request): ResponseFactory if ($group->save()) { return Response::make( - Response::text('Group '.$group->name.' updated successfully') + Response::text(trans('mcp.group_updated', ['name' => $group->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Group updated successfully', + 'message' => trans('mcp.group_updated', ['name' => $group->name]), 'id' => $group->id, 'name' => $group->name, ]); } - return Response::make(Response::error('Update failed: '.$group->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $group->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/UpdateLicenseTool.php b/app/Mcp/Tools/UpdateLicenseTool.php index 9efb477a951a..fe8cdb9492c3 100644 --- a/app/Mcp/Tools/UpdateLicenseTool.php +++ b/app/Mcp/Tools/UpdateLicenseTool.php @@ -48,11 +48,11 @@ public function handle(Request $request): ResponseFactory $license = $this->resolveLicense($request); if (! $license) { - return Response::make(Response::error('License not found')); + return Response::make(Response::error(trans('mcp.license_not_found'))); } if (! Gate::allows('update', $license)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -78,17 +78,17 @@ public function handle(Request $request): ResponseFactory if ($license->save()) { return Response::make( - Response::text('License '.$license->name.' updated successfully') + Response::text(trans('mcp.license_updated', ['name' => $license->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'License updated successfully', + 'message' => trans('mcp.license_updated', ['name' => $license->name]), 'id' => $license->id, 'name' => $license->name, 'seats' => $license->seats, ]); } - return Response::make(Response::error('Update failed: '.$license->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $license->getErrors()->first()]))); } private function resolveLicense(Request $request): ?License diff --git a/app/Mcp/Tools/UpdateLocationTool.php b/app/Mcp/Tools/UpdateLocationTool.php index 27af3a14684e..8d891164798b 100644 --- a/app/Mcp/Tools/UpdateLocationTool.php +++ b/app/Mcp/Tools/UpdateLocationTool.php @@ -39,11 +39,11 @@ public function handle(Request $request): ResponseFactory $location = $this->resolveLocation($request); if (! $location) { - return Response::make(Response::error('Location not found')); + return Response::make(Response::error(trans('mcp.location_not_found'))); } if (! Gate::allows('update', $location)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -58,16 +58,16 @@ public function handle(Request $request): ResponseFactory if ($location->save()) { return Response::make( - Response::text('Location '.$location->name.' updated successfully') + Response::text(trans('mcp.location_updated', ['name' => $location->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Location updated successfully', + 'message' => trans('mcp.location_updated', ['name' => $location->name]), 'id' => $location->id, 'name' => $location->name, ]); } - return Response::make(Response::error('Update failed: '.$location->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $location->getErrors()->first()]))); } private function resolveLocation(Request $request): ?Location diff --git a/app/Mcp/Tools/UpdateManufacturerTool.php b/app/Mcp/Tools/UpdateManufacturerTool.php index 11a79272bb59..f2fce4085b8b 100644 --- a/app/Mcp/Tools/UpdateManufacturerTool.php +++ b/app/Mcp/Tools/UpdateManufacturerTool.php @@ -35,11 +35,11 @@ public function handle(Request $request): ResponseFactory $manufacturer = $this->resolveManufacturer($request); if (! $manufacturer) { - return Response::make(Response::error('Manufacturer not found')); + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); } if (! Gate::allows('update', $manufacturer)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -56,16 +56,16 @@ public function handle(Request $request): ResponseFactory if ($manufacturer->save()) { return Response::make( - Response::text('Manufacturer '.$manufacturer->name.' updated successfully') + Response::text(trans('mcp.manufacturer_updated', ['name' => $manufacturer->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Manufacturer updated successfully', + 'message' => trans('mcp.manufacturer_updated', ['name' => $manufacturer->name]), 'id' => $manufacturer->id, 'name' => $manufacturer->name, ]); } - return Response::make(Response::error('Update failed: '.$manufacturer->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $manufacturer->getErrors()->first()]))); } private function resolveManufacturer(Request $request): ?Manufacturer diff --git a/tests/Feature/Mcp/ListHistoryToolTest.php b/tests/Feature/Mcp/ListHistoryToolTest.php index 82af3c497fb6..91cab3b44e20 100644 --- a/tests/Feature/Mcp/ListHistoryToolTest.php +++ b/tests/Feature/Mcp/ListHistoryToolTest.php @@ -28,8 +28,8 @@ public function test_returns_history_for_asset() $asset = Asset::factory()->create(); Actionlog::factory()->create([ - 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'item_id' => $asset->id, 'action_type' => 'update', ]); @@ -55,21 +55,21 @@ public function test_filters_by_action_type() $asset = Asset::factory()->create(); Actionlog::factory()->create([ - 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'item_id' => $asset->id, 'action_type' => 'update', ]); Actionlog::factory()->create([ - 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'item_id' => $asset->id, 'action_type' => 'note added', - 'note' => 'A note', + 'note' => 'A note', ]); $content = $this->handle([ 'object_type' => 'asset', - 'id' => $asset->id, + 'id' => $asset->id, 'action_type' => 'note added', ])->getStructuredContent(); diff --git a/tests/Feature/Mcp/ListUploadsToolTest.php b/tests/Feature/Mcp/ListUploadsToolTest.php index eec45812a6bf..0a07ef362cc6 100644 --- a/tests/Feature/Mcp/ListUploadsToolTest.php +++ b/tests/Feature/Mcp/ListUploadsToolTest.php @@ -28,10 +28,10 @@ public function test_returns_uploads_for_asset() $asset = Asset::factory()->create(); Actionlog::factory()->create([ - 'item_type' => Asset::class, - 'item_id' => $asset->id, + 'item_type' => Asset::class, + 'item_id' => $asset->id, 'action_type' => 'uploaded', - 'filename' => 'test-document.pdf', + 'filename' => 'test-document.pdf', ]); $content = $this->handle(['object_type' => 'assets', 'id' => $asset->id])->getStructuredContent(); From a56426e6f4629b8ed272e44281d72723dd7e9706 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:59:00 +0100 Subject: [PATCH 42/45] And still more --- app/Mcp/Tools/UpdateProfileTool.php | 6 +++--- app/Mcp/Tools/UpdateStatusLabelTool.php | 10 +++++----- app/Mcp/Tools/UpdateSupplierTool.php | 10 +++++----- app/Mcp/Tools/UpdateUserTool.php | 10 +++++----- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/app/Mcp/Tools/UpdateProfileTool.php b/app/Mcp/Tools/UpdateProfileTool.php index fe91d48afada..dcdc32592518 100644 --- a/app/Mcp/Tools/UpdateProfileTool.php +++ b/app/Mcp/Tools/UpdateProfileTool.php @@ -60,10 +60,10 @@ public function handle(Request $request): ResponseFactory if ($user->save()) { return Response::make( - Response::text('Profile updated successfully') + Response::text(trans('mcp.profile_updated')) )->withStructuredContent([ 'success' => true, - 'message' => 'Profile updated successfully', + 'message' => trans('mcp.profile_updated'), 'first_name' => $user->first_name, 'last_name' => $user->last_name, 'phone' => $user->phone, @@ -73,7 +73,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Update failed: '.$user->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $user->getErrors()->first()]))); } public function schema(JsonSchema $schema): array diff --git a/app/Mcp/Tools/UpdateStatusLabelTool.php b/app/Mcp/Tools/UpdateStatusLabelTool.php index 66676a48610a..0b44ae82b910 100644 --- a/app/Mcp/Tools/UpdateStatusLabelTool.php +++ b/app/Mcp/Tools/UpdateStatusLabelTool.php @@ -34,11 +34,11 @@ public function handle(Request $request): ResponseFactory $label = $this->resolveStatusLabel($request); if (! $label) { - return Response::make(Response::error('Status label not found')); + return Response::make(Response::error(trans('mcp.status_label_not_found'))); } if (! Gate::allows('update', $label)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -70,17 +70,17 @@ public function handle(Request $request): ResponseFactory if ($label->save()) { return Response::make( - Response::text('Status label '.$label->name.' updated successfully') + Response::text(trans('mcp.status_label_updated', ['name' => $label->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Status label updated successfully', + 'message' => trans('mcp.status_label_updated', ['name' => $label->name]), 'id' => $label->id, 'name' => $label->name, 'type' => $label->getStatuslabelType(), ]); } - return Response::make(Response::error('Update failed: '.$label->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $label->getErrors()->first()]))); } private function resolveStatusLabel(Request $request): ?Statuslabel diff --git a/app/Mcp/Tools/UpdateSupplierTool.php b/app/Mcp/Tools/UpdateSupplierTool.php index ccfe22b214bb..bb845bac8404 100644 --- a/app/Mcp/Tools/UpdateSupplierTool.php +++ b/app/Mcp/Tools/UpdateSupplierTool.php @@ -41,11 +41,11 @@ public function handle(Request $request): ResponseFactory $supplier = $this->resolveSupplier($request); if (! $supplier) { - return Response::make(Response::error('Supplier not found')); + return Response::make(Response::error(trans('mcp.supplier_not_found'))); } if (! Gate::allows('update', $supplier)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } if ($request->filled('new_name')) { @@ -62,16 +62,16 @@ public function handle(Request $request): ResponseFactory if ($supplier->save()) { return Response::make( - Response::text('Supplier '.$supplier->name.' updated successfully') + Response::text(trans('mcp.supplier_updated', ['name' => $supplier->name])) )->withStructuredContent([ 'success' => true, - 'message' => 'Supplier updated successfully', + 'message' => trans('mcp.supplier_updated', ['name' => $supplier->name]), 'id' => $supplier->id, 'name' => $supplier->name, ]); } - return Response::make(Response::error('Update failed: '.$supplier->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $supplier->getErrors()->first()]))); } private function resolveSupplier(Request $request): ?Supplier diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php index 1115264c3227..fba57ba9489c 100644 --- a/app/Mcp/Tools/UpdateUserTool.php +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -55,11 +55,11 @@ public function handle(Request $request): ResponseFactory $user = $this->resolveUser($request); if (! $user) { - return Response::make(Response::error('User not found')); + return Response::make(Response::error(trans('mcp.user_not_found'))); } if (! Gate::allows('update', $user)) { - return Response::make(Response::error('Unauthorized')); + return Response::make(Response::error(trans('mcp.unauthorized'))); } $updatable = [ @@ -81,7 +81,7 @@ public function handle(Request $request): ResponseFactory if ($request->filled('new_username') || $request->filled('new_email') || $request->filled('password') || $request->has('activated')) { if (! $canEditAuthFields) { - return Response::make(Response::error('You do not have permission to edit auth fields (username, email, password, activated) for this user')); + return Response::make(Response::error(trans('mcp.cannot_edit_auth_fields'))); } if ($request->filled('new_username')) { @@ -107,10 +107,10 @@ public function handle(Request $request): ResponseFactory if ($user->save()) { return Response::make( - Response::text('User '.$user->username.' updated successfully') + Response::text(trans('mcp.user_updated', ['username' => $user->username])) )->withStructuredContent([ 'success' => true, - 'message' => 'User updated successfully', + 'message' => trans('mcp.user_updated', ['username' => $user->username]), 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, From 464db7f4738e92b8aed60c80973613e9824067ca Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 8 May 2026 15:59:37 +0100 Subject: [PATCH 43/45] Last one (I hope) --- app/Mcp/Tools/UpdateUserTool.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php index fba57ba9489c..19f2d9055409 100644 --- a/app/Mcp/Tools/UpdateUserTool.php +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -117,7 +117,7 @@ public function handle(Request $request): ResponseFactory ]); } - return Response::make(Response::error('Update failed: '.$user->getErrors()->first())); + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $user->getErrors()->first()]))); } private function resolveUser(Request $request): ?User From 256003b67514262017b98124683ebd248aabd16e Mon Sep 17 00:00:00 2001 From: snipe Date: Sat, 9 May 2026 12:20:40 +0100 Subject: [PATCH 44/45] Added password reset prompt --- app/Mcp/Prompts/OnboardEmployeePrompt.php | 11 +-- app/Mcp/README.md | 2 +- app/Mcp/Servers/SnipeMCPServer.php | 2 + app/Mcp/Tools/CreateGroupTool.php | 81 ++++++++++++++--- app/Mcp/Tools/CreateUserTool.php | 16 ++++ app/Mcp/Tools/SendPasswordResetTool.php | 105 ++++++++++++++++++++++ app/Mcp/Tools/UpdateGroupTool.php | 80 +++++++++++++---- app/Mcp/Tools/UpdateUserTool.php | 24 ++++- resources/lang/en-US/mcp.php | 10 +++ tests/Feature/Mcp/CreateGroupToolTest.php | 48 ++++++++++ tests/Feature/Mcp/CreateUserToolTest.php | 31 +++++++ tests/Feature/Mcp/UpdateGroupToolTest.php | 37 ++++++++ tests/Feature/Mcp/UpdateUserToolTest.php | 49 ++++++++++ 13 files changed, 456 insertions(+), 40 deletions(-) create mode 100644 app/Mcp/Tools/SendPasswordResetTool.php diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php index bb574e04ae81..dcf65c5bf57e 100644 --- a/app/Mcp/Prompts/OnboardEmployeePrompt.php +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -40,11 +40,12 @@ public function handle(Request $request): Response Please complete the following onboarding steps using the available tools: - 1. Create a new user account using first_name "{$firstName}" and last_name "{$lastName}" along with the details provided above. Ask for any missing required fields (email address, username) before proceeding. - 2. Search for available (undeployed) assets suitable for their role — typically a laptop and any other standard equipment for their department or location. - 3. Check out the selected assets to the new user. - 4. Check whether any software license seats are available that should be assigned (e.g. productivity suites, VPN, etc.) and assign them. - 5. Summarise what was set up: the user account created, assets checked out, and licenses assigned. + 1. Create a new user account using first_name "{$firstName}" and last_name "{$lastName}" along with the details provided above. Ask for any missing required fields (username and, optionally, email address) before proceeding. Do not ask for a password — one will be set automatically. + 2. If the new account has an email address, ask whether you should send them a password reset link so they can set their own password. Use send_password_reset if the answer is yes. + 3. Search for available (undeployed) assets suitable for their role — typically a laptop and any other standard equipment for their department or location. + 4. Check out the selected assets to the new user. + 5. Check whether any software license seats are available that should be assigned (e.g. productivity suites, VPN, etc.) and assign them. + 6. Summarise what was set up: the user account created, whether a password reset email was sent, assets checked out, and licenses assigned. TEXT; return Response::text(trim($prompt).$this->localeInstruction()); diff --git a/app/Mcp/README.md b/app/Mcp/README.md index ae03692e330b..d5bb1c520d38 100644 --- a/app/Mcp/README.md +++ b/app/Mcp/README.md @@ -47,7 +47,7 @@ Add the server to your `claude_desktop_config.json`: { "mcpServers": { "snipe-it": { - "url": "https://your-snipeit-domain.com/mcp/snipe-it" + "url": "https://your-snipeit-domain/mcp/snipe-it" } } } diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 8a786c3acdd8..96d559628467 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -76,6 +76,7 @@ use App\Mcp\Tools\ListUploadsTool; use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\Reset2FATool; +use App\Mcp\Tools\SendPasswordResetTool; use App\Mcp\Tools\RestoreAssetTool; use App\Mcp\Tools\RestoreUserTool; use App\Mcp\Tools\ShowAssetModelTool; @@ -147,6 +148,7 @@ class SnipeMCPServer extends Server UpdateProfileTool::class, GetUserAssetsTool::class, Reset2FATool::class, + SendPasswordResetTool::class, // Accessories CreateAccessoryTool::class, diff --git a/app/Mcp/Tools/CreateGroupTool.php b/app/Mcp/Tools/CreateGroupTool.php index b847a91c36b7..a7cd8e869c92 100644 --- a/app/Mcp/Tools/CreateGroupTool.php +++ b/app/Mcp/Tools/CreateGroupTool.php @@ -16,7 +16,7 @@ #[Name('create_group')] #[Title('Create Group')] -#[Description('Create a new Snipe-IT permission group')] +#[Description('Create a new Snipe-IT permission group. Requires superadmin. Permissions are a JSON object mapping permission keys to 1 (grant) or -1 (deny).')] class CreateGroupTool extends Tool { public function handle(Request $request): ResponseFactory @@ -27,15 +27,28 @@ public function handle(Request $request): ResponseFactory try { $request->validate([ - 'name' => 'required|string|max:255', - 'notes' => 'nullable|string', + 'name' => 'required|string|max:255', + 'permissions' => 'nullable|string', + 'notes' => 'nullable|string', ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); } + $permissions = null; + if ($request->filled('permissions')) { + $result = $this->parseAndValidatePermissions($request->get('permissions')); + if (is_string($result)) { + return Response::make(Response::error($result)); + } + $permissions = $result; + } + $group = new Group; $group->name = $request->get('name'); + if ($permissions !== null) { + $group->permissions = json_encode($permissions); + } if ($request->filled('notes')) { $group->notes = $request->get('notes'); } @@ -45,31 +58,73 @@ public function handle(Request $request): ResponseFactory return Response::make( Response::text(trans('mcp.group_created', ['name' => $group->name])) )->withStructuredContent([ - 'success' => true, - 'message' => trans('mcp.group_created', ['name' => $group->name]), - 'id' => $group->id, - 'name' => $group->name, + 'success' => true, + 'message' => trans('mcp.group_created', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, + 'permissions' => $group->decodePermissions(), ]); } return Response::make(Response::error(trans('mcp.create_failed', ['error' => $group->getErrors()->first()]))); } + /** + * Parse a JSON permissions string and validate all keys against config('permissions'). + * Returns the decoded array on success, or an error string on failure. + */ + private function parseAndValidatePermissions(string $raw): array|string + { + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return trans('mcp.invalid_permissions_format'); + } + + $validKeys = collect(config('permissions')) + ->flatMap(fn ($perms) => collect($perms)->pluck('permission')) + ->unique() + ->flip() + ->all(); + + foreach (array_keys($decoded) as $key) { + if (! isset($validKeys[$key])) { + return trans('mcp.invalid_permission_key', ['key' => $key]); + } + if (! in_array((int) $decoded[$key], [1, -1], true)) { + return trans('mcp.invalid_permission_value', ['key' => $key]); + } + } + + return array_map('intval', $decoded); + } + public function schema(JsonSchema $schema): array { return [ - 'name' => $schema->string()->description('Group name (required, must be unique)'), - 'notes' => $schema->string()->description('Notes about the group'), + 'name' => $schema->string()->description('Group name (required, must be unique)'), + 'permissions' => $schema->string()->description( + 'JSON object mapping permission keys to 1 (grant) or -1 (deny). '. + 'Valid keys include: superuser, admin, import, reports.view, '. + 'assets.view, assets.create, assets.edit, assets.delete, assets.checkout, assets.checkin, assets.audit, '. + 'users.view, users.create, users.edit, users.delete, '. + 'licenses.view, licenses.create, licenses.edit, licenses.delete, licenses.checkout, licenses.checkin, '. + 'accessories.view, accessories.create, accessories.edit, accessories.delete, accessories.checkout, accessories.checkin, '. + 'components.view, components.create, components.edit, components.delete, components.checkout, components.checkin, '. + 'consumables.view, consumables.create, consumables.edit, consumables.delete, consumables.checkout, '. + 'and many more. Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}' + ), + 'notes' => $schema->string()->description('Notes about the group'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the group was created'), - 'message' => $schema->string()->description('Human-readable result message')->required(), - 'id' => $schema->number()->description('Numeric ID of the new group'), - 'name' => $schema->string()->description('Name of the new group'), + 'success' => $schema->boolean()->description('True if the group was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new group'), + 'name' => $schema->string()->description('Name of the new group'), + 'permissions' => $schema->object()->description('Permissions set on the group'), ]; } } diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php index 475b12b1934d..19831be842d0 100644 --- a/app/Mcp/Tools/CreateUserTool.php +++ b/app/Mcp/Tools/CreateUserTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Models\Company; +use App\Models\Group; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Gate; @@ -53,11 +54,16 @@ public function handle(Request $request): ResponseFactory 'state' => 'nullable|string|max:191', 'country' => 'nullable|string|max:191', 'zip' => 'nullable|string|max:10', + 'group_ids' => 'nullable|array', ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); } + if (User::where('username', $request->get('username'))->exists()) { + return Response::make(Response::error(trans('mcp.username_taken', ['username' => $request->get('username')]))); + } + $user = new User; $user->fill($request->only([ 'first_name', 'last_name', 'username', 'email', 'employee_num', @@ -77,6 +83,14 @@ public function handle(Request $request): ResponseFactory } if ($user->save()) { + $groupIds = []; + if ($request->filled('group_ids') && auth()->user()->isSuperUser()) { + $groupIds = Group::whereIn('id', $request->get('group_ids'))->pluck('id')->all(); + $user->groups()->sync($groupIds); + } elseif ($request->filled('group_ids')) { + return Response::make(Response::error(trans('mcp.superadmin_required_for_groups'))); + } + return Response::make( Response::text(trans('mcp.user_created', ['username' => $user->username])) )->withStructuredContent([ @@ -87,6 +101,7 @@ public function handle(Request $request): ResponseFactory 'email' => $user->email, 'first_name' => $user->first_name, 'last_name' => $user->last_name, + 'group_ids' => $groupIds, ]); } @@ -121,6 +136,7 @@ public function schema(JsonSchema $schema): array 'state' => $schema->string()->description('State/province'), 'country' => $schema->string()->description('Country'), 'zip' => $schema->string()->description('Postal/ZIP code'), + 'group_ids' => $schema->array()->description('Array of permission group IDs to assign (requires superadmin). Example: [1, 3]'), ]; } diff --git a/app/Mcp/Tools/SendPasswordResetTool.php b/app/Mcp/Tools/SendPasswordResetTool.php new file mode 100644 index 000000000000..a07b38fa8285 --- /dev/null +++ b/app/Mcp/Tools/SendPasswordResetTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! Gate::allows('view', $user)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if (! $user->activated) { + return Response::make(Response::error(trans('mcp.password_reset_user_inactive', ['username' => $user->username]))); + } + + if (empty($user->email)) { + return Response::make(Response::error(trans('mcp.password_reset_no_email', ['username' => $user->username]))); + } + + if ($user->ldap_import) { + return Response::make(Response::error(trans('mcp.password_reset_ldap_user', ['username' => $user->username]))); + } + + try { + $result = Password::sendResetLink(['email' => trim($user->email)]); + } catch (\Exception $e) { + return Response::make(Response::error(trans('mcp.password_reset_send_failed', ['error' => $e->getMessage()]))); + } + + if ($result === Password::RESET_LINK_SENT) { + return Response::make( + Response::text(trans('mcp.password_reset_sent', ['email' => $user->email])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.password_reset_sent', ['email' => $user->email]), + 'username' => $user->username, + 'email' => $user->email, + ]); + } + + return Response::make(Response::error(trans('mcp.password_reset_send_failed', ['error' => $result]))); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user'), + 'username' => $schema->string()->description('Username of the user'), + 'email' => $schema->string()->description('Email address of the user'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the reset email was sent'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'username' => $schema->string()->description('Username of the user'), + 'email' => $schema->string()->description('Email address the reset link was sent to'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateGroupTool.php b/app/Mcp/Tools/UpdateGroupTool.php index afee8c7d2971..c13e8150e8b5 100644 --- a/app/Mcp/Tools/UpdateGroupTool.php +++ b/app/Mcp/Tools/UpdateGroupTool.php @@ -16,7 +16,7 @@ #[Name('update_group')] #[Title('Update Group')] -#[Description('Update an existing Snipe-IT permission group by ID or name')] +#[Description('Update an existing Snipe-IT permission group by ID or name. Requires superadmin. Supplying permissions replaces the group\'s entire permission set.')] class UpdateGroupTool extends Tool { public function handle(Request $request): ResponseFactory @@ -27,10 +27,11 @@ public function handle(Request $request): ResponseFactory try { $request->validate([ - 'id' => 'nullable|integer', - 'name' => 'nullable|string|max:255', - 'new_name' => 'nullable|string|max:255', - 'notes' => 'nullable|string', + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'permissions' => 'nullable|string', + 'notes' => 'nullable|string', ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); @@ -56,37 +57,82 @@ public function handle(Request $request): ResponseFactory $group->notes = $request->get('notes'); } + if ($request->filled('permissions')) { + $result = $this->parseAndValidatePermissions($request->get('permissions')); + if (is_string($result)) { + return Response::make(Response::error($result)); + } + $group->permissions = json_encode($result); + } + if ($group->save()) { return Response::make( Response::text(trans('mcp.group_updated', ['name' => $group->name])) )->withStructuredContent([ - 'success' => true, - 'message' => trans('mcp.group_updated', ['name' => $group->name]), - 'id' => $group->id, - 'name' => $group->name, + 'success' => true, + 'message' => trans('mcp.group_updated', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, + 'permissions' => $group->decodePermissions(), ]); } return Response::make(Response::error(trans('mcp.update_failed', ['error' => $group->getErrors()->first()]))); } + /** + * Parse a JSON permissions string and validate all keys against config('permissions'). + * Returns the decoded array on success, or an error string on failure. + */ + private function parseAndValidatePermissions(string $raw): array|string + { + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return trans('mcp.invalid_permissions_format'); + } + + $validKeys = collect(config('permissions')) + ->flatMap(fn ($perms) => collect($perms)->pluck('permission')) + ->unique() + ->flip() + ->all(); + + foreach (array_keys($decoded) as $key) { + if (! isset($validKeys[$key])) { + return trans('mcp.invalid_permission_key', ['key' => $key]); + } + if (! in_array((int) $decoded[$key], [1, -1], true)) { + return trans('mcp.invalid_permission_value', ['key' => $key]); + } + } + + return array_map('intval', $decoded); + } + public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->number()->description('Numeric group ID to update'), - 'name' => $schema->string()->description('Group name to look up for updating'), - 'new_name' => $schema->string()->description('New name to rename the group to'), - 'notes' => $schema->string()->description('Updated notes for the group'), + 'id' => $schema->number()->description('Numeric group ID to update'), + 'name' => $schema->string()->description('Group name to look up for updating'), + 'new_name' => $schema->string()->description('New name to rename the group to'), + 'permissions' => $schema->string()->description( + 'JSON object replacing the group\'s entire permission set. '. + 'Map permission keys to 1 (grant) or -1 (deny). '. + 'Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}. '. + 'Omit to leave existing permissions unchanged.' + ), + 'notes' => $schema->string()->description('Updated notes for the group'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the update succeeded'), - 'message' => $schema->string()->description('Human-readable result message')->required(), - 'id' => $schema->number()->description('Numeric ID of the updated group'), - 'name' => $schema->string()->description('Name of the updated group'), + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated group'), + 'name' => $schema->string()->description('Name of the updated group'), + 'permissions' => $schema->object()->description('Permissions now set on the group'), ]; } } diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php index 19f2d9055409..914c4bf7929d 100644 --- a/app/Mcp/Tools/UpdateUserTool.php +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -3,6 +3,7 @@ namespace App\Mcp\Tools; use App\Models\Company; +use App\Models\Group; use App\Models\User; use Illuminate\Contracts\JsonSchema\JsonSchema; use Illuminate\Support\Facades\Gate; @@ -50,6 +51,7 @@ public function handle(Request $request): ResponseFactory 'state' => 'nullable|string|max:191', 'country' => 'nullable|string|max:191', 'zip' => 'nullable|string|max:10', + 'group_ids' => 'nullable|array', ]); $user = $this->resolveUser($request); @@ -106,15 +108,28 @@ public function handle(Request $request): ResponseFactory } if ($user->save()) { - return Response::make( - Response::text(trans('mcp.user_updated', ['username' => $user->username])) - )->withStructuredContent([ + $groupIds = null; + if ($request->filled('group_ids') && auth()->user()->isSuperUser()) { + $groupIds = Group::whereIn('id', $request->get('group_ids'))->pluck('id')->all(); + $user->groups()->sync($groupIds); + } elseif ($request->filled('group_ids')) { + return Response::make(Response::error(trans('mcp.superadmin_required_for_groups'))); + } + + $content = [ 'success' => true, 'message' => trans('mcp.user_updated', ['username' => $user->username]), 'id' => $user->id, 'username' => $user->username, 'email' => $user->email, - ]); + ]; + if ($groupIds !== null) { + $content['group_ids'] = $groupIds; + } + + return Response::make( + Response::text(trans('mcp.user_updated', ['username' => $user->username])) + )->withStructuredContent($content); } return Response::make(Response::error(trans('mcp.update_failed', ['error' => $user->getErrors()->first()]))); @@ -166,6 +181,7 @@ public function schema(JsonSchema $schema): array 'state' => $schema->string()->description('State/province'), 'country' => $schema->string()->description('Country'), 'zip' => $schema->string()->description('Postal/ZIP code'), + 'group_ids' => $schema->array()->description('Array of permission group IDs to assign (requires superadmin). Replaces all existing group memberships. Example: [1, 3]'), ]; } diff --git a/resources/lang/en-US/mcp.php b/resources/lang/en-US/mcp.php index 8252b0b5f29b..8621d8b7eb10 100644 --- a/resources/lang/en-US/mcp.php +++ b/resources/lang/en-US/mcp.php @@ -46,6 +46,16 @@ 'user_cannot_delete_self' => 'You cannot delete your own account', 'user_has_items' => 'User has assigned items and cannot be deleted. Check in all items first.', 'cannot_edit_auth_fields' => 'You do not have permission to edit auth fields (username, email, password, activated) for this user', + 'username_taken' => 'Username :username is already taken. Please choose a different username.', + 'password_reset_sent' => 'Password reset link sent to :email', + 'password_reset_user_inactive' => 'User :username is inactive and cannot receive a password reset email', + 'password_reset_no_email' => 'User :username has no email address on file', + 'password_reset_ldap_user' => 'User :username is managed by LDAP and cannot use password reset', + 'password_reset_send_failed' => 'Failed to send password reset email: :error', + 'invalid_permissions_format' => 'Permissions must be a valid JSON object', + 'invalid_permission_key' => 'Invalid permission key: :key', + 'invalid_permission_value' => 'Permission value for :key must be 1 (grant) or -1 (deny)', + 'superadmin_required_for_groups' => 'Only superadmins can assign permission groups', 'no_available_seats' => 'No available seats for this license', 'no_free_seat' => 'No free seat found for this license', 'seat_not_checked_out' => 'This seat is not currently checked out', diff --git a/tests/Feature/Mcp/CreateGroupToolTest.php b/tests/Feature/Mcp/CreateGroupToolTest.php index 67fcd2b7ca88..b435f12294f2 100644 --- a/tests/Feature/Mcp/CreateGroupToolTest.php +++ b/tests/Feature/Mcp/CreateGroupToolTest.php @@ -57,4 +57,52 @@ public function test_returns_error_when_user_lacks_permission() $this->assertDatabaseMissing('permission_groups', ['name' => $name]); } + + public function test_creates_group_with_valid_permissions() + { + $name = 'Permissions Group '.uniqid(); + + $content = $this->handle([ + 'name' => $name, + 'permissions' => json_encode(['assets.view' => 1, 'assets.create' => -1]), + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertArrayHasKey('permissions', $content); + $group = \App\Models\Group::where('name', $name)->first(); + $this->assertNotNull($group); + $decoded = json_decode($group->permissions, true); + $this->assertEquals(1, $decoded['assets.view']); + $this->assertEquals(-1, $decoded['assets.create']); + } + + public function test_returns_error_for_invalid_permission_key() + { + $response = $this->handle([ + 'name' => 'Bad Perms Group '.uniqid(), + 'permissions' => json_encode(['not.a.real.key' => 1]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_for_invalid_permission_value() + { + $response = $this->handle([ + 'name' => 'Bad Value Group '.uniqid(), + 'permissions' => json_encode(['assets.view' => 0]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_for_malformed_permissions_json() + { + $response = $this->handle([ + 'name' => 'Malformed Group '.uniqid(), + 'permissions' => 'not valid json', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/CreateUserToolTest.php b/tests/Feature/Mcp/CreateUserToolTest.php index ea7dd6c728be..1deb7c561cdd 100644 --- a/tests/Feature/Mcp/CreateUserToolTest.php +++ b/tests/Feature/Mcp/CreateUserToolTest.php @@ -4,6 +4,7 @@ use App\Mcp\Tools\CreateUserTool; use App\Models\Department; +use App\Models\Group; use App\Models\Location; use App\Models\User; use Illuminate\Support\Facades\Hash; @@ -168,4 +169,34 @@ public function test_returns_error_when_user_lacks_permission() ])->responses()->first()->isError()); $this->assertDatabaseMissing('users', ['username' => 'blocked.user.mcp']); } + + public function test_superadmin_can_assign_group_ids() + { + $this->actingAs(User::factory()->superuser()->create()); + $group = Group::factory()->create(); + + $content = $this->handle([ + 'first_name' => 'GroupedUser', + 'username' => 'grouped.user.mcp', + 'group_ids' => [$group->id], + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $user = User::where('username', 'grouped.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertTrue($user->groups->contains($group->id)); + } + + public function test_non_superadmin_cannot_assign_group_ids() + { + $group = Group::factory()->create(); + + $response = $this->handle([ + 'first_name' => 'Denied', + 'username' => 'denied.groups.mcp', + 'group_ids' => [$group->id], + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } } diff --git a/tests/Feature/Mcp/UpdateGroupToolTest.php b/tests/Feature/Mcp/UpdateGroupToolTest.php index 78d6950e2164..51b9e6e8a159 100644 --- a/tests/Feature/Mcp/UpdateGroupToolTest.php +++ b/tests/Feature/Mcp/UpdateGroupToolTest.php @@ -54,4 +54,41 @@ public function test_returns_error_when_user_lacks_permission() $this->assertTrue($this->handle(['id' => $group->id, 'notes' => 'test'])->responses()->first()->isError()); } + + public function test_updates_permissions() + { + $group = Group::factory()->create(['permissions' => json_encode(['assets.view' => 1])]); + + $content = $this->handle([ + 'id' => $group->id, + 'permissions' => json_encode(['assets.create' => 1, 'assets.edit' => -1]), + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $decoded = json_decode($group->fresh()->permissions, true); + $this->assertArrayHasKey('assets.create', $decoded); + $this->assertArrayNotHasKey('assets.view', $decoded); + } + + public function test_returns_error_for_invalid_permission_key_on_update() + { + $group = Group::factory()->create(); + + $response = $this->handle([ + 'id' => $group->id, + 'permissions' => json_encode(['completely.fake.key' => 1]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_permissions_unchanged_when_not_provided() + { + $original = json_encode(['assets.view' => 1]); + $group = Group::factory()->create(['permissions' => $original]); + + $this->handle(['id' => $group->id, 'notes' => 'updated notes']); + + $this->assertEquals($original, $group->fresh()->permissions); + } } diff --git a/tests/Feature/Mcp/UpdateUserToolTest.php b/tests/Feature/Mcp/UpdateUserToolTest.php index e5e12b86ccb5..025d240ad00d 100644 --- a/tests/Feature/Mcp/UpdateUserToolTest.php +++ b/tests/Feature/Mcp/UpdateUserToolTest.php @@ -4,6 +4,7 @@ use App\Mcp\Tools\UpdateUserTool; use App\Models\Department; +use App\Models\Group; use App\Models\Location; use App\Models\User; use Illuminate\Support\Facades\Hash; @@ -233,4 +234,52 @@ public function test_returns_error_when_user_lacks_permission() 'first_name' => 'Should Not Update', ])->responses()->first()->isError()); } + + public function test_superadmin_can_assign_group_ids() + { + $this->actingAs(User::factory()->superuser()->create()); + $user = User::factory()->create(); + $group = Group::factory()->create(); + + $content = $this->handle([ + 'id' => $user->id, + 'group_ids' => [$group->id], + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertContains($group->id, $content['group_ids']); + $this->assertTrue($user->fresh()->groups->contains($group->id)); + } + + public function test_non_superadmin_cannot_assign_group_ids() + { + $user = User::factory()->create(); + $group = Group::factory()->create(); + + $response = $this->handle([ + 'id' => $user->id, + 'group_ids' => [$group->id], + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertFalse($user->fresh()->groups->contains($group->id)); + } + + public function test_group_ids_replaces_existing_groups() + { + $this->actingAs(User::factory()->superuser()->create()); + $user = User::factory()->create(); + $oldGroup = Group::factory()->create(); + $newGroup = Group::factory()->create(); + $user->groups()->sync([$oldGroup->id]); + + $this->handle([ + 'id' => $user->id, + 'group_ids' => [$newGroup->id], + ]); + + $freshGroups = $user->fresh()->groups->pluck('id'); + $this->assertContains($newGroup->id, $freshGroups); + $this->assertNotContains($oldGroup->id, $freshGroups); + } } From f697ef1d03f7f023a5c54bb63e3facc7ad6c699d Mon Sep 17 00:00:00 2001 From: snipe Date: Sat, 9 May 2026 12:20:45 +0100 Subject: [PATCH 45/45] Pint --- app/Mcp/Servers/SnipeMCPServer.php | 2 +- app/Mcp/Tools/CreateGroupTool.php | 24 ++++++++--------- app/Mcp/Tools/SendPasswordResetTool.php | 20 +++++++------- app/Mcp/Tools/UpdateGroupTool.php | 32 +++++++++++------------ tests/Feature/Mcp/CreateGroupToolTest.php | 3 ++- 5 files changed, 41 insertions(+), 40 deletions(-) diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php index 96d559628467..a2760245b7e9 100644 --- a/app/Mcp/Servers/SnipeMCPServer.php +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -76,9 +76,9 @@ use App\Mcp\Tools\ListUploadsTool; use App\Mcp\Tools\ListUsersTool; use App\Mcp\Tools\Reset2FATool; -use App\Mcp\Tools\SendPasswordResetTool; use App\Mcp\Tools\RestoreAssetTool; use App\Mcp\Tools\RestoreUserTool; +use App\Mcp\Tools\SendPasswordResetTool; use App\Mcp\Tools\ShowAssetModelTool; use App\Mcp\Tools\ShowAssetTool; use App\Mcp\Tools\ShowCategoryTool; diff --git a/app/Mcp/Tools/CreateGroupTool.php b/app/Mcp/Tools/CreateGroupTool.php index a7cd8e869c92..c932cf8ad3fa 100644 --- a/app/Mcp/Tools/CreateGroupTool.php +++ b/app/Mcp/Tools/CreateGroupTool.php @@ -27,9 +27,9 @@ public function handle(Request $request): ResponseFactory try { $request->validate([ - 'name' => 'required|string|max:255', + 'name' => 'required|string|max:255', 'permissions' => 'nullable|string', - 'notes' => 'nullable|string', + 'notes' => 'nullable|string', ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); @@ -58,10 +58,10 @@ public function handle(Request $request): ResponseFactory return Response::make( Response::text(trans('mcp.group_created', ['name' => $group->name])) )->withStructuredContent([ - 'success' => true, - 'message' => trans('mcp.group_created', ['name' => $group->name]), - 'id' => $group->id, - 'name' => $group->name, + 'success' => true, + 'message' => trans('mcp.group_created', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, 'permissions' => $group->decodePermissions(), ]); } @@ -101,7 +101,7 @@ private function parseAndValidatePermissions(string $raw): array|string public function schema(JsonSchema $schema): array { return [ - 'name' => $schema->string()->description('Group name (required, must be unique)'), + 'name' => $schema->string()->description('Group name (required, must be unique)'), 'permissions' => $schema->string()->description( 'JSON object mapping permission keys to 1 (grant) or -1 (deny). '. 'Valid keys include: superuser, admin, import, reports.view, '. @@ -113,17 +113,17 @@ public function schema(JsonSchema $schema): array 'consumables.view, consumables.create, consumables.edit, consumables.delete, consumables.checkout, '. 'and many more. Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}' ), - 'notes' => $schema->string()->description('Notes about the group'), + 'notes' => $schema->string()->description('Notes about the group'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the group was created'), - 'message' => $schema->string()->description('Human-readable result message')->required(), - 'id' => $schema->number()->description('Numeric ID of the new group'), - 'name' => $schema->string()->description('Name of the new group'), + 'success' => $schema->boolean()->description('True if the group was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new group'), + 'name' => $schema->string()->description('Name of the new group'), 'permissions' => $schema->object()->description('Permissions set on the group'), ]; } diff --git a/app/Mcp/Tools/SendPasswordResetTool.php b/app/Mcp/Tools/SendPasswordResetTool.php index a07b38fa8285..5e6e193a2c53 100644 --- a/app/Mcp/Tools/SendPasswordResetTool.php +++ b/app/Mcp/Tools/SendPasswordResetTool.php @@ -22,9 +22,9 @@ class SendPasswordResetTool extends Tool public function handle(Request $request): ResponseFactory { $request->validate([ - 'id' => 'nullable|integer', + 'id' => 'nullable|integer', 'username' => 'nullable|string|max:191', - 'email' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', ]); $user = $this->resolveUser($request); @@ -59,10 +59,10 @@ public function handle(Request $request): ResponseFactory return Response::make( Response::text(trans('mcp.password_reset_sent', ['email' => $user->email])) )->withStructuredContent([ - 'success' => true, - 'message' => trans('mcp.password_reset_sent', ['email' => $user->email]), + 'success' => true, + 'message' => trans('mcp.password_reset_sent', ['email' => $user->email]), 'username' => $user->username, - 'email' => $user->email, + 'email' => $user->email, ]); } @@ -87,19 +87,19 @@ private function resolveUser(Request $request): ?User public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->number()->description('Numeric ID of the user'), + 'id' => $schema->number()->description('Numeric ID of the user'), 'username' => $schema->string()->description('Username of the user'), - 'email' => $schema->string()->description('Email address of the user'), + 'email' => $schema->string()->description('Email address of the user'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the reset email was sent'), - 'message' => $schema->string()->description('Human-readable result message')->required(), + 'success' => $schema->boolean()->description('True if the reset email was sent'), + 'message' => $schema->string()->description('Human-readable result message')->required(), 'username' => $schema->string()->description('Username of the user'), - 'email' => $schema->string()->description('Email address the reset link was sent to'), + 'email' => $schema->string()->description('Email address the reset link was sent to'), ]; } } diff --git a/app/Mcp/Tools/UpdateGroupTool.php b/app/Mcp/Tools/UpdateGroupTool.php index c13e8150e8b5..c2bf538c3aca 100644 --- a/app/Mcp/Tools/UpdateGroupTool.php +++ b/app/Mcp/Tools/UpdateGroupTool.php @@ -27,11 +27,11 @@ public function handle(Request $request): ResponseFactory try { $request->validate([ - 'id' => 'nullable|integer', - 'name' => 'nullable|string|max:255', - 'new_name' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', 'permissions' => 'nullable|string', - 'notes' => 'nullable|string', + 'notes' => 'nullable|string', ]); } catch (ValidationException $e) { return Response::make(Response::error($e->validator->errors()->first())); @@ -69,10 +69,10 @@ public function handle(Request $request): ResponseFactory return Response::make( Response::text(trans('mcp.group_updated', ['name' => $group->name])) )->withStructuredContent([ - 'success' => true, - 'message' => trans('mcp.group_updated', ['name' => $group->name]), - 'id' => $group->id, - 'name' => $group->name, + 'success' => true, + 'message' => trans('mcp.group_updated', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, 'permissions' => $group->decodePermissions(), ]); } @@ -112,26 +112,26 @@ private function parseAndValidatePermissions(string $raw): array|string public function schema(JsonSchema $schema): array { return [ - 'id' => $schema->number()->description('Numeric group ID to update'), - 'name' => $schema->string()->description('Group name to look up for updating'), - 'new_name' => $schema->string()->description('New name to rename the group to'), + 'id' => $schema->number()->description('Numeric group ID to update'), + 'name' => $schema->string()->description('Group name to look up for updating'), + 'new_name' => $schema->string()->description('New name to rename the group to'), 'permissions' => $schema->string()->description( 'JSON object replacing the group\'s entire permission set. '. 'Map permission keys to 1 (grant) or -1 (deny). '. 'Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}. '. 'Omit to leave existing permissions unchanged.' ), - 'notes' => $schema->string()->description('Updated notes for the group'), + 'notes' => $schema->string()->description('Updated notes for the group'), ]; } public function outputSchema(JsonSchema $schema): array { return [ - 'success' => $schema->boolean()->description('True if the update succeeded'), - 'message' => $schema->string()->description('Human-readable result message')->required(), - 'id' => $schema->number()->description('Numeric ID of the updated group'), - 'name' => $schema->string()->description('Name of the updated group'), + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated group'), + 'name' => $schema->string()->description('Name of the updated group'), 'permissions' => $schema->object()->description('Permissions now set on the group'), ]; } diff --git a/tests/Feature/Mcp/CreateGroupToolTest.php b/tests/Feature/Mcp/CreateGroupToolTest.php index b435f12294f2..49c18dccbd76 100644 --- a/tests/Feature/Mcp/CreateGroupToolTest.php +++ b/tests/Feature/Mcp/CreateGroupToolTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Mcp; use App\Mcp\Tools\CreateGroupTool; +use App\Models\Group; use App\Models\User; use Laravel\Mcp\Request; use Laravel\Mcp\ResponseFactory; @@ -69,7 +70,7 @@ public function test_creates_group_with_valid_permissions() $this->assertTrue($content['success']); $this->assertArrayHasKey('permissions', $content); - $group = \App\Models\Group::where('name', $name)->first(); + $group = Group::where('name', $name)->first(); $this->assertNotNull($group); $decoded = json_decode($group->permissions, true); $this->assertEquals(1, $decoded['assets.view']);