Compare commits

..

156 Commits

Author SHA1 Message Date
snipe 8b1e312292 Normalize purchase_cost 2026-05-14 15:33:42 +01:00
snipe 9004211a59 Added text string 2026-05-14 15:04:48 +01:00
snipe 053eb91457 Update bulk asset controller 2026-05-14 15:03:55 +01:00
snipe 3810513224 Updated HTML fields back to text from number 2026-05-14 14:30:09 +01:00
snipe e50e0f0e34 Updated tests 2026-05-14 14:29:51 +01:00
snipe cdf73f9c89 Merge pull request #19028 from grokability/move-and-rename-trait
Move and rename CheckInOutRequest to CheckInOutTrait
2026-05-14 13:13:50 +01:00
snipe bc808cbe46 Pint 2026-05-14 12:50:31 +01:00
snipe fdc65fb1b2 Moved and renamed CheckInOutRequest 2026-05-14 12:50:23 +01:00
snipe 3db9a15dd3 Merge pull request #19027 from grokability/add-fields-to-user-export
Fixed #18659 - added more data to user export
2026-05-14 12:32:24 +01:00
snipe 42bf43d68d Fixed #18659 - added more data to user export 2026-05-14 12:24:08 +01:00
snipe 4548ed8a45 Fixed flappy test 2026-05-14 11:45:20 +01:00
snipe 407e2d0246 Merge pull request #19026 from grokability/scim-typo
Fixed SCIMConfig typo "string_starts_with"
2026-05-14 11:12:52 +01:00
snipe 2af7367480 Pint 2026-05-14 11:00:58 +01:00
snipe 29b9a78f54 Fixed typo “string_starts_with” 2026-05-14 11:00:25 +01:00
snipe 382a164b9d Updated composer.lock 2026-05-14 10:40:41 +01:00
snipe 9216a7550f Merge pull request #19019 from grokability/adding-normal-pagination-to-api-responses
Added page number support in API, added `per_page`, `total_pages`, and `current_page` to API response
2026-05-13 22:16:40 +01:00
snipe 4f9ba7c6cc Updated to use fullUrlWithQuery 2026-05-13 22:08:27 +01:00
snipe afb7c69ac3 Merge pull request #19021 from marcusmoore/fixes/21081-missing-asset-in-maintenance
Fixed asset maintenances page not rendering with missing asset
2026-05-13 22:00:24 +01:00
snipe 5f232c0584 Merge pull request #19024 from grokability/api-disallow-own-priv-changes
Tighten permission changes and UI, fixed #18831
2026-05-13 21:59:15 +01:00
snipe ed931d497a Hide admin/superadmin sections if the user is not an admin/superadmin 2026-05-13 21:45:56 +01:00
snipe 59278c3f70 Missed a spot 2026-05-13 21:39:46 +01:00
snipe 179d031bb2 Fixed #18831 - better spacing for buttons 2026-05-13 21:32:36 +01:00
snipe dc1410aa70 Tweaked logic around messaging 2026-05-13 21:27:25 +01:00
snipe 0f595a8854 Updated preserve permissions action 2026-05-13 21:06:04 +01:00
snipe 70e1dcf1b4 Disallow self-editing of privs on user model 2026-05-13 20:06:51 +01:00
snipe 780e3e1cd9 Merge pull request #19023 from marcusmoore/fixes/users-export
Fixed duplicate headers in users csv export
2026-05-13 19:41:55 +01:00
Marcus Moore 339c93ebbf Add documentation for assertion 2026-05-13 11:27:26 -07:00
Marcus Moore b4bb1556be Add assertions 2026-05-13 11:26:04 -07:00
Marcus Moore ba96aa5a61 Write assertion implementation 2026-05-13 11:25:52 -07:00
snipe 2171556ec4 Merge pull request #19022 from marcusmoore/fixes/disable-debugbar-for-unaccepted-assets-report
Disabled debugbar on acceptance report
2026-05-13 19:18:53 +01:00
Marcus Moore 2d33368063 WIP: better assertions 2026-05-13 11:10:45 -07:00
Marcus Moore 93a2f74f9e Disable debugbar on acceptance report 2026-05-13 10:46:58 -07:00
Marcus Moore ee61084ac8 Ensure maintenance has asset before attempting to render 2026-05-13 10:11:50 -07:00
snipe 8d8a1889cd Drop the limit from the next/prev links unless they were specifically sent in the URL parameters 2026-05-13 17:17:14 +01:00
snipe f275cb6928 Pint 2026-05-13 17:10:41 +01:00
snipe db8de1f794 Codacy fixes 2026-05-13 17:10:34 +01:00
snipe d901e821cc Include next and previous urls in the payload 2026-05-13 16:55:55 +01:00
snipe 34a533b2d6 Added page number support in API, added per_page, total_pages, and current_page 2026-05-13 16:48:39 +01:00
snipe d3d37c70ab Merge pull request #19017 from grokability/FD-52058-importer-updated-at-timestamp
Fixed importer `created_at` timestamp getting weird on large imports
2026-05-13 12:18:01 +01:00
snipe 475e674fc6 Bumped laravel version in README 2026-05-13 12:17:44 +01:00
snipe 01436d0532 Import the model in test 2026-05-13 12:06:39 +01:00
snipe 96bf7d0c2b Fixed FD-52058 - Importer using wrong created_at date 2026-05-13 12:06:13 +01:00
snipe 529973aa77 Updated EULA PDF filename to include username 2026-05-13 10:32:17 +01:00
snipe f4cd090ac6 Merge pull request #19012 from marcusmoore/fixes/activity-report-link
Fixed anchor tag on report index page
2026-05-13 02:41:35 +01:00
Marcus Moore 6d5e68274d Only write headers once 2026-05-12 16:36:26 -07:00
Marcus Moore 3e002cb940 Implement test and remove trailing comma in group column 2026-05-12 16:33:53 -07:00
Marcus Moore b7ea9a959c Scaffold some tests 2026-05-12 16:25:53 -07:00
Marcus Moore dc3a16c437 Update test macros 2026-05-12 16:25:00 -07:00
Marcus Moore 608af84253 Fix anchor tag 2026-05-12 15:13:29 -07:00
snipe 161d7e1c2b Dev assets 2026-05-12 19:42:20 +01:00
snipe 8627032c4f Bumped version to 8.5.0 2026-05-12 19:41:10 +01:00
snipe 5bead4fbcc Pint :( 2026-05-12 19:30:54 +01:00
snipe ef44ba5f97 Updated languages 2026-05-12 19:29:57 +01:00
snipe 2dc0ec9e7e Bumped php_max_major_minor for new 8.5 support 2026-05-12 19:27:50 +01:00
snipe afd435e895 Bumped php max requirement 2026-05-12 15:23:15 +01:00
snipe 80d1bf6a7a Better manufacturer check again :( 2026-05-12 15:12:38 +01:00
snipe 737f3ef3db Skip lookup URL if manufacturer was deleted 2026-05-12 15:07:55 +01:00
snipe d179f47274 Merge pull request #19007 from uberbrady/reintroduce_scim_logging
Change branch of home-forked SCIM server to re-introduce logging
2026-05-12 14:51:11 +01:00
snipe 1832d95371 Merge pull request #19009 from grokability/bulk-delete-licenses
🎥 Added bulk deletion of licenses
2026-05-12 13:55:37 +01:00
snipe a614f986f0 Added test for the fix I made in the prior commit 2026-05-12 13:07:16 +01:00
snipe f398a59d26 Fixed bug on API delete 2026-05-12 13:05:15 +01:00
snipe c8bd104268 Added comment for clarity 2026-05-12 13:00:51 +01:00
snipe 7f01bd4c56 Merge pull request #19008 from uberbrady/more_exception_handling_in_scim
Widen exception scope in emails; similar treatment for phones
2026-05-12 12:57:06 +01:00
Brady Wetherington 6c3c7fdf49 Widen exception scope in emails; similar treatment for phones 2026-05-12 12:43:53 +01:00
snipe bdc8fc8d4a Eager load license seat relations to avoid n+1 2026-05-12 12:41:45 +01:00
snipe 41be127489 Added bulk license action 2026-05-12 12:41:30 +01:00
snipe fdfae9593d Use isDeletable for delete ability status 2026-05-12 12:41:08 +01:00
snipe 6a21eb53c9 Fixed return type 2026-05-12 12:40:29 +01:00
snipe efde2b4672 Added checknbox to presneter 2026-05-12 12:40:08 +01:00
snipe daaa26cbf4 Added new text strings 2026-05-12 12:34:40 +01:00
snipe 3e7441562c Added bulk action menu 2026-05-12 12:34:31 +01:00
snipe 7b53fa5245 Added bulk delete route 2026-05-12 12:34:16 +01:00
snipe d35d46f5b4 Added tests 2026-05-12 12:34:03 +01:00
Brady Wetherington f4772a9cad Change branch of home-forked SCIM server to re-introduce logging 2026-05-12 12:26:37 +01:00
snipe 4c1bb7e0ac Merge remote-tracking branch 'origin/master' into develop 2026-05-11 19:45:01 +01:00
snipe 762ea9b4db One more try I guess 2026-05-11 19:44:41 +01:00
snipe b16970a61e Merge pull request #18629 from Godmartinz/update-print-invtentory-view-with-assigned2assets
Update print inventory view with indirect assignments table
2026-05-11 18:27:25 +01:00
snipe dadb9bd81e Merge remote-tracking branch 'origin/develop' 2026-05-11 16:12:03 +01:00
snipe 13dc7de660 Add enter to submit advanced search modal 2026-05-11 16:11:51 +01:00
snipe 003ea36e18 Merge remote-tracking branch 'origin/develop' 2026-05-11 16:04:10 +01:00
snipe f4bd2a68c9 Fix for compound is:not_null 2026-05-11 16:03:59 +01:00
snipe be4e75d4f7 Merge remote-tracking branch 'origin/develop' 2026-05-11 15:25:56 +01:00
snipe 538c21ce1e Merge pull request #19002 from grokability/fixed-crash-on-checkout-outside-of-company-id
Fixed crash on checkout outside of company via API
2026-05-11 15:25:27 +01:00
snipe 626cd6cb2e Fixed #18989 - better wrapping for auth self.profile checks 2026-05-11 15:25:03 +01:00
snipe 2a56f6573d Wrap the checkout in a transaction and add tests 2026-05-11 15:07:25 +01:00
snipe 6ee2dc1cd6 Merge pull request #18998 from uberbrady/fix_scim_error_email
Don't 500 on malformed emails input
2026-05-11 14:59:56 +01:00
snipe 3fcde8bd16 Prevent crash when trying to check out a component from another company if FMCS is on 2026-05-11 14:43:54 +01:00
snipe e2ff7a7bc7 Merge pull request #19000 from grokability/reports-index
🎥 Added reports index
2026-05-11 14:37:14 +01:00
snipe c7efd16517 Fixed progressbar color 2026-05-11 14:25:25 +01:00
snipe f2907f04d9 Added nicer border 2026-05-11 14:19:10 +01:00
snipe 7d98c267d5 More formatting tweaks 2026-05-11 14:13:16 +01:00
snipe 5bc6330c13 Messed with the boxes 2026-05-11 14:05:11 +01:00
snipe 1706ed597d Updated text 2026-05-11 14:05:03 +01:00
snipe 6e9ba28ef7 Tightened up query string 2026-05-11 13:26:35 +01:00
snipe 554d1a44de More shifting 2026-05-11 13:21:33 +01:00
snipe c0a8f4c1a4 Include withrashed() 2026-05-11 13:19:49 +01:00
snipe 08be9aac6d Added users chart 2026-05-11 13:08:23 +01:00
Brady Wetherington a51b17fb53 Don't 500 on malformed emails input 2026-05-11 13:07:16 +01:00
snipe 66d5618d60 Fixed links in summary box 2026-05-11 12:59:53 +01:00
snipe e16c2384fd Fixed some dark/light mode stuff 2026-05-11 12:55:25 +01:00
snipe b3323f08a0 More b0xen 2026-05-11 12:29:58 +01:00
snipe 7e63c2ef92 CSS is hard :( 2026-05-11 12:25:38 +01:00
snipe 7f65b6d598 Shifting stuff around again 2026-05-11 12:25:32 +01:00
snipe 8fb8f0a4d2 Edited queries in ReportsController 2026-05-11 12:24:05 +01:00
snipe 637dbc8d2a New strings 2026-05-11 12:23:47 +01:00
snipe 978990fdff Added progressbar 2026-05-11 12:23:41 +01:00
snipe 52a058e511 Breaking everything.. whee 2026-05-11 12:16:15 +01:00
snipe 64bea202c5 Switched layout, added chart 2026-05-11 12:01:45 +01:00
snipe 37f60993ca Added charts with date range picker 2026-05-11 11:45:34 +01:00
snipe 32717c67c7 Added reports index to sidenav 2026-05-11 11:45:20 +01:00
snipe 3681e3f025 Removed extranneous div 2026-05-11 11:45:08 +01:00
snipe 1d0f055349 Added new strings 2026-05-11 11:44:58 +01:00
snipe fb3024ca9c Added controller methods for reports 2026-05-11 11:44:52 +01:00
snipe 005c0ea9f6 Pint 2026-05-11 11:44:37 +01:00
snipe 7c3f1f3a84 Added routes 2026-05-11 11:44:29 +01:00
snipe 900e5209d9 Added claude.md :( 2026-05-11 10:21:30 +01:00
snipe 4fbf416d16 Merge remote-tracking branch 'origin/develop' 2026-05-11 09:56:31 +01:00
snipe 7b7d2c87fb Added League\Csv\EscapeFormula for a few more reports 2026-05-11 09:54:55 +01:00
snipe 6debb3a65d Added is_not: as search modifier 2026-05-11 09:46:05 +01:00
snipe 315ba49a1d Larger tag size 2026-05-11 09:43:42 +01:00
snipe ff57855038 Added EthicalCheck
Giving this a test drive
2026-05-08 17:10:40 +01:00
snipe da6e837578 Merge pull request #18991 from uberbrady/better_scim_errors
Fixed #18987 - fix SCIM error on mismapped fields
2026-05-08 14:25:02 +01:00
snipe a2d8f89162 Merge remote-tracking branch 'origin/develop' 2026-05-08 14:21:20 +01:00
snipe e36d65e695 Use carbon instead 2026-05-08 14:21:07 +01:00
snipe 34abf14cbe Merge remote-tracking branch 'origin/develop' 2026-05-08 14:12:29 +01:00
snipe dda7a4f22f Format dates in custom report 2026-05-08 14:12:15 +01:00
Brady Wetherington 283a885196 Get rid of 'setCode' and just use the constructor parameter instead 2026-05-08 13:15:37 +01:00
snipe d44aa3f16e Merge remote-tracking branch 'origin/develop' 2026-05-07 12:42:15 +01:00
snipe afb37981bf Merge remote-tracking branch 'origin/develop' 2026-05-07 12:07:46 +01:00
snipe 2b6518427a Merge remote-tracking branch 'origin/develop' 2026-05-07 11:11:16 +01:00
snipe 185e0073b3 Merge remote-tracking branch 'origin/develop' 2026-05-07 10:40:10 +01:00
snipe d0794ba71c Merge remote-tracking branch 'origin/develop' 2026-05-07 10:37:15 +01:00
snipe 1b42e2e138 Merge remote-tracking branch 'origin/develop' 2026-05-06 17:50:59 +01:00
snipe b4efabe82e Merge remote-tracking branch 'origin/develop' 2026-05-06 16:38:06 +01:00
snipe 9b37e95b58 Merge remote-tracking branch 'origin/develop' 2026-05-05 22:00:13 +01:00
snipe a92d8eeaab Merge remote-tracking branch 'origin/develop' 2026-05-05 20:37:03 +01:00
snipe e8dbb12ccc Merge remote-tracking branch 'origin/develop' 2026-05-05 13:22:59 +01:00
snipe 8a2cd19ea6 Merge remote-tracking branch 'origin/develop' 2026-05-05 10:58:55 +01:00
snipe afdf86ad0d Merge remote-tracking branch 'origin/develop' 2026-05-04 21:47:15 +01:00
snipe a5dae3f222 Merge remote-tracking branch 'origin/develop' 2026-05-04 20:55:35 +01:00
snipe 97765c08b1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:58:48 +01:00
snipe 6ad92556a1 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:36 +01:00
snipe e2465ca2a7 Merge remote-tracking branch 'origin/develop' 2026-05-04 19:47:03 +01:00
snipe f5644928a8 Prod assets 2026-05-04 13:24:50 +01:00
Godfrey M 8747ff32dd Merge branch 'develop' into update-print-invtentory-view-with-assigned2assets
# Conflicts:
#	app/Http/Controllers/ProfileController.php
#	app/Http/Controllers/Users/UsersController.php
2026-03-17 16:11:16 -07:00
Godfrey M 4ddd2f1cf8 change indirect Asset name 2026-03-10 12:49:37 -07:00
Godfrey M 11c8fd4d4c update scope for directLicense.category" 2026-03-10 12:41:19 -07:00
Godfrey M ab04f3de93 use inventory scope, add quantity to print blade 2026-03-10 12:35:34 -07:00
Godfrey M 4c16796256 reduce query count to 52 2026-03-10 10:59:59 -07:00
Godfrey M 516771d948 update profile Controller print inventory 2026-03-10 09:50:00 -07:00
Godfrey M e25ea465c5 add ternary on variables in asset count" 2026-03-04 10:25:59 -08:00
Godfrey M 30ac3d1a26 fix display name of item" 2026-03-03 16:11:46 -08:00
Godfrey M e47c772230 cleaned up other tables in print view 2026-03-03 16:04:13 -08:00
Godfrey M 706b623d95 adds assets to indirect assignment table 2026-03-03 15:51:47 -08:00
Godfrey M a908a76f53 adds components to indirect assignment table 2026-03-03 15:15:45 -08:00
Godfrey M a2ec707f79 add licenses to indirect assignedment table 2026-03-03 12:53:37 -08:00
883 changed files with 6909 additions and 24068 deletions
+2 -4
View File
@@ -1,7 +1,6 @@
# GitHub Copilot Custom Instructions for Snipe-IT
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.
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.
## ✅ General Coding Standards
@@ -23,7 +22,7 @@ software engineering principles, and industry best practices to improve software
- Adopt **final classes** where extension is not intended.
- Use **Named Arguments** for improved clarity when calling functions with multiple parameters.
## ✅ Laravel 12 Project Structure & Conventions
## ✅ Laravel 11 Project Structure & Conventions
- Follow the official Laravel project structure:
- `app/Http/Controllers` - Controllers
@@ -33,7 +32,6 @@ software engineering principles, and industry best practices to improve software
- `app/Enums` - Enums
- `app/Actions` - Single-responsibility action classes
- `app/Policies` - Authorization logic
- `app/Models/Builders` - Query scoping logic
- Controllers must:
- Use dependency injection.
+69
View File
@@ -0,0 +1,69 @@
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
# EthicalCheck provides the industrys only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
# Know your API and Applications are secure with EthicalCheck our free & automated API security testing service.
# How EthicalCheck works?
# EthicalCheck functions in the following simple steps.
# 1. Security Testing.
# Provide your OpenAPI specification or start with a public Postman collection URL.
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
# 2. Reporting.
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
# This is a starter workflow to help you get started with EthicalCheck Actions
name: EthicalCheck-Workflow
# Controls when the workflow will run
on:
# Triggers the workflow on push or pull request events but only for the "master" branch
# Customize trigger events based on your DevSecOps processes.
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
schedule:
- cron: '35 17 * * 6'
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
permissions:
contents: read
jobs:
Trigger_EthicalCheck:
permissions:
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
runs-on: ubuntu-latest
steps:
- name: EthicalCheck Free & Automated API Security Testing Service
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
with:
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
# The email address to which the penetration test report will be sent.
email: "snipe@snipe.net"
sarif-result-file: "ethicalcheck-results.sarif"
- name: Upload sarif file to repository
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: ./ethicalcheck-results.sarif
+8 -8
View File
@@ -1,10 +1,10 @@
{
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.4",
"php_max_wontwork": "8.5.0",
"current_snipeit_version": "8.0"
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
"DOC4": "You should really just ignore it and run upgrade.php. Really",
"php_min_version": "8.2.0",
"php_max_major_minor": "8.5",
"php_max_wontwork": "8.6.0",
"current_snipeit_version": "8.0"
}
+110
View File
@@ -0,0 +1,110 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Stack
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
## Common Commands
```bash
# Run all tests
php artisan test
# or
vendor/bin/phpunit
# Run a single test file
php artisan test tests/Feature/Assets/AssetsTest.php
# Run a specific test method
php artisan test --filter testSomeMethod
# Build frontend assets (dev)
npm run dev
# Build for production
npm run prod
# Laravel Mix watch
npm run watch
# Tinker / REPL
php artisan tinker
# Clear caches after config/route changes
php artisan optimize:clear
```
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
## Architecture
### Controllers
Two parallel controller trees:
- `app/Http/Controllers/` — web/UI controllers (Blade views)
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
### API Pattern
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
```php
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
```
### Authorization
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
### FMCS (Full Multiple Company Support)
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
```php
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$query->where('table.company_id', $request->input('companyId'));
}
```
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
### Select2 AJAX Dropdowns
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
### Routes
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
### Translations
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
### Checkout Redirect Flow
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
- Set `redirect_option=target` in the form
- Set `checkout_to_type=user` in the form
- Set `assigned_user={{ $user->id }}` in the form
### Key Helper Methods (`app/Helpers/Helper.php`)
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
- `Helper::defaultChartColors()` — 10-color palette used in charts
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
### Global View Variables
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
## Testing
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
+1 -1
View File
@@ -7,7 +7,7 @@
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
It is built on [Laravel 11](http://laravel.com).
It is built on [Laravel 12](http://laravel.com).
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
@@ -13,8 +13,13 @@ final class PreserveUnauthorizedPrivilegedPermissionsAction
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): array
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
{
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
return $originalPermissions;
}
if (! $authenticatedUser->isSuperUser()) {
if (array_key_exists('superuser', $originalPermissions)) {
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
+3
View File
@@ -78,6 +78,7 @@ class IconHelper
case 'angle-right':
return 'fas fa-angle-right';
case 'warning':
case 'alert':
return 'fas fa-exclamation-triangle';
case 'kits':
return 'fas fa-object-group';
@@ -126,6 +127,7 @@ class IconHelper
case 'dashboard':
return 'fas fa-tachometer-alt';
case 'info-circle':
case 'info':
return 'fas fa-info-circle';
case 'caret-right':
return 'fa fa-caret-right';
@@ -156,6 +158,7 @@ class IconHelper
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'help':
case 'support':
return 'far fa-life-ring';
case 'plus':
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
@@ -18,7 +18,7 @@ use Illuminate\Http\Request;
class AccessoryCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Return the form to checkout an Accessory to a user.
@@ -149,6 +149,9 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
$username_slug = Str::slug($assignedUser->username);
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
// If signatures are required, make sure we have one
if ($requiresSignature) {
@@ -234,7 +237,7 @@ class AcceptanceController extends Controller
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
@@ -4,26 +4,28 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Company;
use App\Models\Setting;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
class AccessoriesController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display a listing of the resource.
@@ -300,40 +302,49 @@ class AccessoriesController extends Controller
{
$this->authorize('checkout', $accessory);
$target = $this->determineCheckoutTarget();
$accessory->checkout_qty = $request->input('checkout_qty', 1);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Set this value to be able to pass the qty through to the event
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
$accessory->checkout_qty = $request->input('checkout_qty', 1);
$payload = null;
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
$accessory_checkout = new AccessoryCheckout([
'accessory_id' => $accessory->id,
'created_at' => Carbon::now(),
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
]);
$accessory_checkout->created_by = auth()->id();
$accessory_checkout->save();
$payload = [
'accessory_id' => $accessory->id,
'assigned_to' => $target->id,
'assigned_type' => $target::class,
'note' => $request->input('note'),
'created_by' => auth()->id(),
'pivot' => $accessory_checkout->id,
];
}
// Set this value to be able to pass the qty through to the event.
event(new CheckoutableCheckedOut(
$accessory,
$target,
auth()->user(),
$request->input('note'),
[],
$accessory->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
+119 -24
View File
@@ -706,18 +706,35 @@ class AssetsController extends Controller
}
}
if ($asset->save()) {
if ($request->input('assigned_user')) {
$target = User::find(request('assigned_user'));
} elseif ($request->input('assigned_asset')) {
$target = Asset::find(request('assigned_asset'));
} elseif ($request->input('assigned_location')) {
$target = Location::find(request('assigned_location'));
$target = $this->resolveCheckoutTargetForAssetMutation($request);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if ($requestedCheckout) {
// Keep create + optional checkout side effects atomic.
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
}
return true;
});
if ($stored) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -792,25 +809,54 @@ class AssetsController extends Controller
}
}
}
if ($asset->save()) {
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
$location = $target->location_id;
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
$location = $target->location_id;
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
$location = $target->id;
if ($requestedCheckout && (! $target)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
if ($requestedCheckout) {
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
if ($companyMismatchResponse) {
return $companyMismatchResponse;
}
}
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
if (! $asset->save()) {
return false;
}
if (isset($target)) {
if ($requestedCheckout) {
// Using `->has` preserves the asset name if the name parameter was not included in request.
$asset_name = request()->has('name') ? request('name') : $asset->name;
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
$location = null;
if ($request->filled('assigned_user')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_asset')) {
$location = $target->location_id;
} elseif ($request->filled('assigned_location')) {
$location = $target->id;
}
// Keep update + optional checkout side effects atomic.
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
return false;
}
if ($request->filled('assigned_asset')) {
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
->update(['location_id' => $target->location_id]);
}
}
return true;
});
if ($updated) {
if ($asset->image) {
$asset->image = $asset->getImageUrl();
}
@@ -829,6 +875,36 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
}
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
{
if ($request->filled('assigned_user')) {
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
}
if ($request->filled('assigned_asset')) {
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
}
if ($request->filled('assigned_location')) {
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
}
return null;
}
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
{
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($target->company_id))
&& ((int) $asset->company_id !== (int) $target->company_id)
) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
return null;
}
/**
* Delete a given asset (mark as deleted).
*
@@ -905,6 +981,7 @@ class AssetsController extends Controller
*/
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
{
// Use the same hardened checkout path as ID-based checkout.
if ($asset = Asset::where('asset_tag', $tag)->first()) {
return $this->checkout($request, $asset->id);
}
@@ -940,19 +1017,22 @@ class AssetsController extends Controller
// This item is checked out to a location
if (request('checkout_to_type') == 'location') {
$target = Location::find(request('assigned_location'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
$asset->location_id = ($target) ? $target->id : '';
$error_payload['target_id'] = $request->input('assigned_location');
$error_payload['target_type'] = 'location';
} elseif (request('checkout_to_type') == 'asset') {
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
// Override with the asset's location_id if it has one
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_asset');
$error_payload['target_type'] = 'asset';
} elseif (request('checkout_to_type') == 'user') {
// Fetch the target and set the asset's new location_id
$target = User::find(request('assigned_user'));
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
$error_payload['target_id'] = $request->input('assigned_user');
$error_payload['target_type'] = 'user';
@@ -971,6 +1051,16 @@ class AssetsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
}
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
$targetCompanyId = data_get($target, 'company_id');
if ((Setting::getSettings()->full_multiple_companies_support == '1')
&& (! is_null($asset->company_id))
&& (! is_null($targetCompanyId))
&& ((int) $asset->company_id !== (int) $targetCompanyId)
) {
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
}
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
$expected_checkin = request('expected_checkin', null);
$note = request('note', null);
@@ -985,7 +1075,12 @@ class AssetsController extends Controller
// $asset->location_id = $target->rtd_location_id;
// }
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
// Keep checkout mutation + checkout logging/event side effects atomic.
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
});
if ($wasCheckedOut) {
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
}
@@ -11,6 +11,7 @@ use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Component;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
}
if ($component->numRemaining() >= $request->input('assigned_qty')) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company records and lead to partial writes.
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
$asset = Asset::find($request->input('assigned_to'));
$component->assigned_to = $request->input('assigned_to');
if (! $asset) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
}
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
DB::transaction(function () use ($component, $request, $asset): void {
$component->assigned_to = $request->input('assigned_to');
$component->assets()->attach($component->id, [
'component_id' => $component->id,
'created_at' => Carbon::now(),
'assigned_qty' => $request->input('assigned_qty', 1),
'created_by' => auth()->id(),
'asset_id' => $request->input('assigned_to'),
'note' => $request->input('note'),
]);
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
}
@@ -13,9 +13,11 @@ use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ConsumablesController extends Controller
{
@@ -306,34 +308,42 @@ class ConsumablesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
}
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
if (! $user = User::find($request->input('assigned_to'))) {
// Resolve the raw target first, then enforce FMCS explicitly.
// Scoped lookup can hide cross-company users and make failures ambiguous.
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
// Return error message
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
// Update the consumable data
$consumable->assigned_to = $request->input('assigned_to');
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
DB::transaction(function () use ($consumable, $request, $user): void {
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
$consumable->users()->attach($consumable->id,
[
'consumable_id' => $consumable->id,
'created_by' => $user->id,
'assigned_to' => $request->input('assigned_to'),
'note' => $request->input('note'),
]
);
}
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
event(new CheckoutableCheckedOut(
$consumable,
$user,
auth()->user(),
$request->input('note'),
[],
$consumable->checkout_qty,
));
});
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class LicenseSeatsController extends Controller
{
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
'prohibits:asset_id',
// must be a valid user or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected assigned_to is invalid.');
}
},
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
'prohibits:assigned_to',
// must be a valid asset or null to unassign
function ($attribute, $value, $fail) {
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
// Validate existence without company scopes; FMCS checks happen explicitly below.
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
$fail('The selected asset_id is invalid.');
}
},
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
}
$targetUser = null;
if (! is_null($request->input('assigned_to'))) {
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
if (! $targetUser) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$targetAsset = null;
if (! is_null($request->input('asset_id'))) {
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
if (! $targetAsset) {
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
}
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
}
}
$oldUser = $licenseSeat->user;
$oldAsset = $licenseSeat->asset;
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
// the logging functions expect only one "target". if both asset and user are present in the request,
// we simply let assets take precedence over users...
if ($licenseSeat->isDirty('assigned_to')) {
$target = $is_checkin ? $oldUser : User::find($licenseSeat->assigned_to);
$target = $is_checkin ? $oldUser : $targetUser;
}
if ($licenseSeat->isDirty('asset_id')) {
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
$target = $is_checkin ? $oldAsset : $targetAsset;
}
if ($assignmentTouched && is_null($target)) {
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
}
}
if ($licenseSeat->save()) {
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
if (! $licenseSeat->save()) {
return false;
}
if ($assignmentTouched) {
if ($is_checkin) {
if (! $licenseSeat->license->reassignable) {
$licenseSeat->unreassignable_seat = true;
$licenseSeat->save();
if (! $licenseSeat->save()) {
return false;
}
}
// todo: skip if target is null?
$licenseSeat->logCheckin($target, $licenseSeat->notes);
} else {
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
}
}
return true;
});
if ($updated) {
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
}
@@ -28,7 +28,7 @@ class LicensesController extends Controller
{
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status') == 'inactive') {
@@ -247,7 +247,7 @@ class LicensesController extends Controller
if ($license->assigned_seats_count == 0) {
// Delete the license and the associated license seats
DB::table('license_seats')
->where('id', $license->id)
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$licenseSeats = $license->licenseseats();
@@ -38,6 +38,7 @@ class MaintenancesController extends Controller
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->whereHas('asset')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
@@ -6,8 +6,18 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
@@ -125,4 +135,141 @@ class ReportsController extends Controller
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
/**
* Returns time-series data for the reports overview charts.
*
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
* Also returns the immediately preceding period of equal length for comparison lines.
*/
public function activityChart(Request $request): JsonResponse
{
$this->authorize('reports.view');
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
if ($request->filled('start_date') && $request->filled('end_date')) {
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
if ($curEnd->lt($curStart)) {
[$curStart, $curEnd] = [$curEnd, $curStart];
}
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
} else {
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
$curEnd = Carbon::today()->endOfDay();
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
}
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
$buildDates = function (Carbon $start, Carbon $end): array {
$dates = [];
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
$dates[] = $d->toDateString();
}
return $dates;
};
$curDates = $buildDates($curStart, $curEnd);
$prevDates = $buildDates($prevStart, $prevEnd);
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
return $modelClass::withTrashed()
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Maintenance has no company_id column and no CompanyableTrait, so scope through
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
return Maintenance::withTrashed()
->whereHas('asset')
->whereBetween('maintenances.created_at', [$start, $end])
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Filters by both action_type and item_type for per-category checkout/checkin counts.
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::where('action_type', $actionType)
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
return User::withTrashed()
->whereNotNull('deleted_at')
->whereBetween('deleted_at', [$start, $end])
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
// Catches both 'checkin' and 'checkin from' action types used across different item types.
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
->where('item_type', $modelClass)
->whereBetween('created_at', [$start, $end])
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
->groupBy('date')
->pluck('count', 'date')
->toArray();
};
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
$datasets = [];
foreach ([
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
] as $key => $query) {
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
}
return response()->json(array_merge([
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
'prev_label' => $prevStart->format('M j').' '.$prevEnd->format('M j'),
], $datasets));
}
}
@@ -569,6 +569,7 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
targetUser: $user,
));
}
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
use App\Exceptions\CheckoutNotAllowed;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
@@ -17,7 +17,7 @@ use Illuminate\Http\RedirectResponse;
class AssetCheckoutController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Returns a view that presents a form to check an asset out to a
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
use App\Events\CheckoutablesCheckedOutInBulk;
use App\Helpers\Helper;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
@@ -27,7 +27,7 @@ use Illuminate\Support\Facades\Log;
class BulkAssetsController extends Controller
{
use CheckInOutRequest;
use CheckInOutTrait;
/**
* Display the bulk edit page.
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Components;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreComponentRequest;
use App\Http\Requests\UpdateComponentRequest;
use App\Models\Company;
use App\Models\Component;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
* This class controls all actions related to Components for
@@ -74,7 +74,7 @@ class ComponentsController extends Controller
*
* @throws AuthorizationException
*/
public function store(ImageUploadRequest $request)
public function store(StoreComponentRequest $request)
{
$this->authorize('create', Component::class);
$component = new Component;
@@ -148,21 +148,10 @@ class ComponentsController extends Controller
*
* @since [v3.0]
*/
public function update(ImageUploadRequest $request, Component $component)
public function update(UpdateComponentRequest $request, Component $component)
{
$min = $component->numCheckedOut();
$validator = Validator::make($request->all(), [
'qty' => "required|numeric|min:$min",
]);
if ($validator->fails()) {
return redirect()->back()
->withErrors($validator)
->withInput();
}
$this->authorize('update', $component);
// Update the component data
$component->name = $request->input('name');
$component->category_id = $request->input('category_id');
@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Kits;
use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Traits\CheckInOutTrait;
use App\Models\Asset;
use App\Models\PredefinedKit;
use App\Models\User;
@@ -23,7 +23,7 @@ class CheckoutKitController extends Controller
{
public $kitService;
use CheckInOutRequest;
use CheckInOutTrait;
public function __construct(PredefinedKitCheckoutService $kitService)
{
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Licenses;
use App\Http\Controllers\Controller;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
class BulkLicensesController extends Controller
{
public function destroy(Request $request)
{
$this->authorize('delete', License::class);
$errors = [];
$success_count = 0;
foreach ($request->ids as $id) {
$license = License::find($id);
if (is_null($license)) {
$errors[] = trans('admin/licenses/message.does_not_exist');
continue;
}
if (! Gate::allows('delete', $license)) {
$errors[] = trans('general.insufficient_permissions');
continue;
}
if ($license->assigned_seats_count > 0) {
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
continue;
}
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
DB::table('license_seats')
->where('license_id', $license->id)
->update(['assigned_to' => null, 'asset_id' => null]);
$license->licenseseats()->delete();
$license->delete();
$success_count++;
}
if (count($errors) > 0) {
if ($success_count > 0) {
return redirect()->route('licenses.index')
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
}
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
}
}
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -388,6 +389,8 @@ class LicensesController extends Controller
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($licenses as $license) {
// Add a new row with data
$values = [
@@ -419,7 +422,14 @@ class LicensesController extends Controller
$license->created_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
+12 -7
View File
@@ -211,14 +211,19 @@ class ProfileController extends Controller
*/
public function printInventory(): View
{
$show_users = User::where('id', auth()->user()->id)->get();
$userId = auth()->id();
return view('users/print')
->with('assets', auth()->user()->assets())
->with('licenses', auth()->user()->licenses()->get())
->with('accessories', auth()->user()->accessories()->get())
->with('consumables', auth()->user()->consumables()->get())
->with('users', $show_users)
$show_user = User::withInventoryRelations($userId)->first();
$indirectItemsCount =
$show_user->assets->flatMap->assignedAssets->count()
+ $show_user->assets->flatMap->components->count()
+ $show_user->assets->flatMap->licenses->count()
+ $show_user->assets->flatMap->assignedAccessories->count();
return view('users.print')
->with('users', [$show_user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
+42 -3
View File
@@ -56,6 +56,31 @@ class ReportsController extends Controller
parent::__construct();
}
public function index(): View
{
$this->authorize('reports.view');
$settings = Setting::getSettings();
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
// CheckoutAcceptance has no company_id column; scope through the checkoutable
// relationship so each type's CompanyableTrait global scope is applied.
$pending_acceptance_count = CheckoutAcceptance::pending()
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
->count();
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
->get()
->filter(fn ($l) => $l->free_seats_count <= 0)
->count();
return view('reports/index', compact(
'audit_alert_count',
'checkin_alert_count',
'pending_acceptance_count',
'licenses_low_count',
));
}
/**
* Returns a view that displays the accessories report.
*
@@ -252,6 +277,7 @@ class ReportsController extends Controller
$response = new StreamedResponse(function () {
Log::debug('Starting streamed response');
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
// Open output stream
$handle = fopen('php://output', 'w');
@@ -287,6 +313,8 @@ class ReportsController extends Controller
Log::debug('Walking results: '.$executionTime);
$count = 0;
$formatter = new EscapeFormula('`');
foreach ($actionlogs as $actionlog) {
$count++;
$target_name = '';
@@ -317,7 +345,15 @@ class ReportsController extends Controller
$actionlog->action_source,
$actionlog->log_meta,
];
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $row);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($row));
}
}
});
@@ -852,7 +888,7 @@ class ReportsController extends Controller
}
if ($request->filled('purchase_date')) {
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
}
if ($request->filled('purchase_cost')) {
@@ -860,7 +896,7 @@ class ReportsController extends Controller
}
if ($request->filled('eol')) {
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
}
if ($request->filled('warranty')) {
@@ -1200,6 +1236,9 @@ class ReportsController extends Controller
public function getAssetAcceptanceReport($deleted = false): View
{
$this->authorize('reports.view');
$this->disableDebugbar();
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::Pending()
+99 -57
View File
@@ -26,6 +26,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use League\Csv\EscapeFormula;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
@@ -314,6 +315,7 @@ class UsersController extends Controller
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
targetUser: $user,
));
// Only save groups if the user is a superuser
@@ -533,52 +535,76 @@ class UsersController extends Controller
// Open output stream
$handle = fopen('php://output', 'w');
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.display_name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.phone'),
trans('admin/users/table.mobile'),
trans('general.website'),
trans('general.address'),
trans('general.city'),
trans('general.state'),
trans('general.country'),
trans('general.zip'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
trans('general.importer.vip'),
trans('admin/users/general.remote'),
trans('general.language'),
trans('general.autoassign_licenses'),
trans('general.ldap_sync'),
trans('admin/users/general.two_factor_enrolled'),
trans('admin/users/general.two_factor_active'),
trans('admin/users/table.managed_users'),
trans('admin/users/table.managed_locations'),
trans('admin/users/general.department_manager'),
trans('general.created_by'),
trans('general.updated_at'),
trans('general.start_date'),
trans('general.end_date'),
trans('admin/users/table.last_login'),
trans('admin/licenses/table.deleted_at'),
];
fputcsv($handle, $headers);
$users = User::with(
'assets',
'accessories',
'consumables',
'department',
'department.manager',
'licenses',
'manager',
'groups',
'userloc',
'company'
)->orderBy('created_at', 'DESC')
'company',
'createdBy'
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
->orderBy('created_at', 'DESC')
->chunk(500, function ($users) use ($handle) {
$headers = [
// strtolower to prevent Excel from trying to open it as a SYLK file
strtolower(trans('general.id')),
trans('admin/companies/table.title'),
trans('admin/users/table.title'),
trans('general.employee_number'),
trans('admin/users/table.first_name'),
trans('admin/users/table.last_name'),
trans('admin/users/table.name'),
trans('admin/users/table.username'),
trans('admin/users/table.email'),
trans('admin/users/table.manager'),
trans('admin/users/table.location'),
trans('general.department'),
trans('general.assets'),
trans('general.licenses'),
trans('general.accessories'),
trans('general.consumables'),
trans('general.groups'),
trans('general.permissions'),
trans('general.notes'),
trans('admin/users/table.activated'),
trans('general.created_at'),
];
fputcsv($handle, $headers);
$formatter = new EscapeFormula('`');
foreach ($users as $user) {
$user_groups = '';
foreach ($user->groups as $user_group) {
$user_groups .= $user_group->name.', ';
}
$permissionstring = '';
if ($user->isSuperUser()) {
@@ -597,9 +623,18 @@ class UsersController extends Controller
$user->employee_num,
$user->first_name,
$user->last_name,
$user->display_name,
$user->getFullNameAttribute(),
$user->getRawOriginal('display_name'),
$user->username,
$user->email,
$user->phone,
$user->mobile,
$user->website,
$user->address,
$user->city,
$user->state,
$user->country,
$user->zip,
($user->manager) ? $user->manager->display_name : '',
($user->userloc) ? $user->userloc->name : '',
($user->department) ? $user->department->name : '',
@@ -607,14 +642,37 @@ class UsersController extends Controller
$user->licenses->count(),
$user->accessories->count(),
$user->consumables->count(),
$user_groups,
$user->groups->pluck('name')->implode(', '),
$permissionstring,
$user->notes,
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
$user->created_at,
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
$user->locale,
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
$user->manages_users_count,
$user->manages_locations_count,
($user->department && $user->department->manager) ? $user->department->manager->display_name : '',
($user->createdBy) ? $user->createdBy->display_name : '',
$user->updated_at,
$user->start_date,
$user->end_date,
$user->last_login,
$user->deleted_at,
];
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to false in the .env
if (config('app.escape_formulas') === false) {
fputcsv($handle, $values);
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
} else {
fputcsv($handle, $formatter->escapeRecord($values));
}
}
});
@@ -639,32 +697,16 @@ class UsersController extends Controller
{
$this->authorize('view', User::class);
$user = User::where('id', $id)
->with([
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'assets.assignedAssets.defaultLoc',
'assets.assignedAssets.location',
'assets.assignedAssets.model.category',
'assets.defaultLoc',
'assets.location',
'assets.model.category',
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'accessories.category',
'accessories.manufacturer',
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
'consumables.category',
'consumables.manufacturer',
'licenses.category',
])
->withTrashed()
->first();
$user = User::withInventoryRelations($id)->first();
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
if ($user) {
$this->authorize('view', $user);
return view('users.print')
->with('users', [$user])
->with('indirectItemsCount', $indirectItemsCount)
->with('settings', Setting::getSettings());
}
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\AssetModel;
@@ -26,6 +27,10 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Accessory;
use App\Models\Category;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreAccessoryRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreAccessoryRequest extends ImageUploadRequest
]);
}
}
}
/**
+4 -26
View File
@@ -2,10 +2,10 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Company;
use App\Models\Setting;
use App\Rules\AssetCannotBeCheckedOutToNondeployableStatus;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;
@@ -39,6 +39,9 @@ class StoreAssetRequest extends ImageUploadRequest
$this->merge([
'asset_tag' => $this->asset_tag ?? Asset::autoincrement_asset(),
'company_id' => $idForCurrentUser,
'purchase_cost' => $this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))
? Helper::ParseCurrency($this->input('purchase_cost'))
: $this->input('purchase_cost'),
]);
}
@@ -49,15 +52,6 @@ class StoreAssetRequest extends ImageUploadRequest
{
$modelRules = (new Asset)->getRules();
if (Setting::getSettings()->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
// If purchase_cost was submitted as a string with a comma separator
// then we need to ignore the normal numeric rules.
// Since the original rules still live on the model they will be run
// right before saving (and after purchase_cost has been
// converted to a float via setPurchaseCostAttribute).
$modelRules = $this->removeNumericRulesFromPurchaseCost($modelRules);
}
return array_merge(
$modelRules,
['status_id' => [new AssetCannotBeCheckedOutToNondeployableStatus]],
@@ -81,20 +75,4 @@ class StoreAssetRequest extends ImageUploadRequest
}
}
}
private function removeNumericRulesFromPurchaseCost(array $rules): array
{
$purchaseCost = $rules['purchase_cost'];
// If rule is in "|" format then turn it into an array
if (is_string($purchaseCost)) {
$purchaseCost = explode('|', $purchaseCost);
}
$rules['purchase_cost'] = array_filter($purchaseCost, function ($rule) {
return $rule !== 'numeric' && $rule !== 'gte:0';
});
return $rules;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Component;
use Illuminate\Support\Facades\Gate;
class StoreComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('create', Component::class);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
+5 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Models\Category;
use App\Models\Consumable;
use Illuminate\Contracts\Validation\ValidationRule;
@@ -21,6 +22,10 @@ class StoreConsumableRequest extends ImageUploadRequest
{
parent::prepareForValidation();
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
if ($this->category_id) {
if ($category = Category::find($this->category_id)) {
$this->merge([
@@ -28,7 +33,6 @@ class StoreConsumableRequest extends ImageUploadRequest
]);
}
}
}
/**
+8 -6
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Helpers\Helper;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use App\Models\Setting;
@@ -22,6 +23,13 @@ class UpdateAssetRequest extends ImageUploadRequest
return Gate::allows('update', $this->asset);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
/**
* Get the validation rules that apply to the request.
*
@@ -51,12 +59,6 @@ class UpdateAssetRequest extends ImageUploadRequest
],
);
// if the purchase cost is passed in as a string **and** the digit_separator is ',' (as is common in the EU)
// then we tweak the purchase_cost rule to make it a string
if ($setting->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
$rules['purchase_cost'] = ['nullable', 'string'];
}
return $rules;
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests;
use App\Helpers\Helper;
use Illuminate\Support\Facades\Gate;
class UpdateComponentRequest extends ImageUploadRequest
{
public function authorize(): bool
{
return Gate::allows('update', $this->component);
}
public function prepareForValidation(): void
{
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
}
}
public function rules(): array
{
$min = $this->component->numCheckedOut();
return array_merge(parent::rules(), [
'qty' => "required|numeric|min:{$min}",
]);
}
public function response(array $errors)
{
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers;
namespace App\Http\Traits;
use App\Models\Asset;
use App\Models\Location;
use App\Models\SnipeModel;
use App\Models\User;
trait CheckInOutRequest
trait CheckInOutTrait
{
/**
* Find target for checkout
@@ -9,8 +9,24 @@ class DatatablesTransformer
**/
public function transformDatatables($objects, $total = null)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
$current_page = app('api_current_page');
$limit = (int) app('api_limit_value');
$total_pages = $limit > 0 ? (int) ceil($objects_array['total'] / $limit) : 1;
$objects_array['current_page'] = $current_page;
$objects_array['per_page'] = $limit;
$objects_array['total_pages'] = $total_pages;
$objects_array['prev_page_url'] = $current_page > 1
? request()->fullUrlWithQuery(['page' => $current_page - 1])
: null;
$objects_array['next_page_url'] = $current_page < $total_pages
? request()->fullUrlWithQuery(['page' => $current_page + 1])
: null;
return $objects_array;
}
@@ -20,8 +36,10 @@ class DatatablesTransformer
**/
public function transformBulkResponseWithStatusAndObjects($objects, $total)
{
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
$objects_array['rows'] = $objects;
$objects_array = [
'total' => $total ?? count($objects),
'rows' => $objects,
];
return $objects_array;
}
@@ -75,7 +75,10 @@ class LicensesTransformer
'checkin' => Gate::allows('checkin', License::class),
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
'delete' => $license->isDeletable(),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
];
$array += $permissions_array;
+2 -1
View File
@@ -37,7 +37,8 @@ class AccessoryImporter extends ItemImporter
$this->log('Updating Accessory');
$this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number'));
$accessory->update($this->sanitizeItemForUpdating($accessory));
$accessory->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$accessory->setImported(true);
return;
}
+28 -8
View File
@@ -176,35 +176,55 @@ class AssetImporter extends ItemImporter
if ($editingAsset) {
$asset->update($item);
$asset->setImported(true);
} else {
$asset->fill($item);
$asset->setImported(true);
}
// If we're updating, we don't want to overwrite old fields.
// Apply custom fields to asset attributes if they exist
$customFieldsToSave = [];
if (array_key_exists('custom_fields', $this->item)) {
foreach ($this->item['custom_fields'] as $custom_field => $val) {
$asset->{$custom_field} = $val;
$customFieldsToSave[$custom_field] = $val;
}
}
// This sets an attribute on the Loggable trait for the action log
$asset->setImported(true);
// For existing assets that have custom fields, update them.
// This avoids the issue of calling save() twice with Model::unguard() active.
if ($editingAsset && ! empty($customFieldsToSave)) {
$asset->update($customFieldsToSave);
$success = true;
} elseif (! $editingAsset) {
// For new assets, save with all changes (custom fields included via direct attribute assignment above)
$success = $asset->save();
} else {
// For existing assets without custom fields, update() already saved everything
$success = true;
}
if ($asset->save()) {
if ($success) {
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated');
// If we have a target to checkout to, lets do so.
// -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by
// -- the class that needs to use it (command importer or GUI importer inside the project).
if (isset($target) && ($target !== false)) {
if (! is_null($asset->assigned_to)) {
if ($asset->assigned_to != $target->id) {
$asset = $asset->fresh();
$targetType = get_class($target);
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
// Skip duplicate checkout noise when update mode keeps the same assignment target.
if (! $alreadyCheckedOutToTarget) {
if (! is_null($asset->assigned_to)) {
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
}
}
$asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
}
}
return;
+2 -1
View File
@@ -42,7 +42,8 @@ class ComponentImporter extends ItemImporter
}
$this->log('Updating Component');
$component->update($this->sanitizeItemForUpdating($component));
$component->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$component->setImported(true);
return;
}
+2 -1
View File
@@ -38,7 +38,8 @@ class ConsumableImporter extends ItemImporter
}
$this->log('Updating Consumable');
$consumable->update($this->sanitizeItemForUpdating($consumable));
$consumable->save();
// update() already saves the model, no need to call save() again while Model::unguard() is active
$consumable->setImported(true);
return;
}
+6 -2
View File
@@ -88,8 +88,12 @@ class LicenseImporter extends ItemImporter
// This sets an attribute on the Loggable trait for the action log
$license->setImported(true);
if ($license->save()) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
// For new licenses we need to save, for existing ones update() already saved
$licenseWasSaved = $editingLicense || $license->save();
if ($licenseWasSaved) {
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated');
// Lets try to checkout seats if the fields exist and we have seats.
if ($license->seats > 0) {
-45
View File
@@ -1,45 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->get('location');
$prompt = <<<TEXT
You are conducting an asset audit for location: {$location}.
Please complete the following steps using the available tools:
1. Find the location record for "{$location}" (search by name if needed).
2. List all assets currently assigned to or located at that location.
3. Identify any assets with overdue audit dates (next_audit_date is in the past).
4. Flag any assets with unexpected status labels (e.g. archived, pending, or out-for-repair assets that appear to still be at this location).
5. Note any assets that have been at this location longer than expected without a check-in or audit event.
6. Produce a summary report with: total asset count, assets requiring audit, assets with status anomalies, and any recommended actions.
Present the findings clearly so they can be acted on or exported.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('location', 'Name or ID of the location to audit', required: true),
];
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$department = $request->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 = <<<TEXT
You are conducting an end-of-life and depreciation review. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets that have passed their asset_eol_date (end-of-life date is in the past).
2. List assets that are fully depreciated based on their depreciation schedule and purchase date.
3. For each identified asset, show: asset tag, name, model, assigned user or location, EOL date, purchase date, and current status.
4. Group findings by category for easier review.
5. Recommend disposition for each group: retire and replace, redeploy to a lower-demand role, send for repair, or archive.
6. Provide a cost summary if purchase cost data is available total value of end-of-life assets.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('department', 'Limit review to a specific department', required: false),
new Argument('category', 'Limit review to a specific asset category', required: false),
];
}
}
@@ -1,43 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 30));
$prompt = <<<TEXT
You are reviewing software license health across the organisation. Focus on licenses expiring within {$days} days.
Please complete the following steps using the available tools:
1. List all licenses in the system.
2. Identify licenses whose expiration date falls within the next {$days} days.
3. For each expiring license, show: license name, total seats, seats in use, seats free, and the expiration date.
4. Flag any licenses that are over-deployed (more seats checked out than purchased).
5. Flag any licenses that are under-used (many free seats that may indicate unused subscriptions worth cancelling).
6. Produce a prioritised action list: renewals needed urgently, over-deployments to resolve, and possible cancellations.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for expiring licenses (default: 30)', required: false),
];
}
}
@@ -1,56 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$category = $request->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 = <<<TEXT
You need to find an available (undeployed) asset matching {$assetDescription}.
Please complete the following steps using the available tools:
1. Search for assets with a Ready-to-Deploy status that match the requested {$assetDescription}.
2. If multiple options are available, list them with their asset tags, serial numbers, and any relevant details so the best one can be selected.
3. {$assignLine}
4. Confirm the final asset tag, serial number, and checkout status once complete.
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).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('category', 'Asset category to search (e.g. Laptop, Monitor)', required: false),
new Argument('model', 'Specific model name to search for', required: false),
new Argument('assign_to', 'Username to check the asset out to once found', required: false),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$location = $request->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 = <<<TEXT
You are generating an inventory summary report. {$scopeLine}
Please complete the following steps using the available tools:
1. List assets (filtered by the scope above if provided) and tally counts by status: Deployed, Ready to Deploy, Archived, Pending, Out for Repair.
2. Break the deployed count down by asset category (laptops, monitors, phones, etc.).
3. List the top 5 models by total quantity.
4. Show total purchase value of the inventory if cost data is available.
5. Highlight any categories with zero available (Ready to Deploy) assets potential stock-out risk.
6. Present the results as a concise executive summary with a supporting breakdown table.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('location', 'Limit report to a specific location', required: false),
new Argument('department', 'Limit report to a specific department', required: false),
];
}
}
@@ -1,45 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are helping offboard a departing employee with username: {$username}.
Please complete the following offboarding steps using the available tools:
1. Look up the user account for {$username} and display a summary of everything currently assigned to them (assets, licenses, accessories, consumables).
2. Check in all assigned assets from this user.
3. Check in all assigned accessories from this user.
4. Revoke or check in any license seats assigned to this user.
5. Deactivate the user account.
6. Provide a final summary of all items that were checked in and confirm the account has been deactivated.
If any items cannot be checked in automatically, flag them for manual follow-up.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the departing employee', required: true),
];
}
}
-64
View File
@@ -1,64 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$firstName = $request->get('first_name');
$lastName = $request->get('last_name');
$department = $request->get('department');
$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,
])->filter()->implode("\n");
$prompt = <<<TEXT
You are helping onboard a new employee.
Employee details:
- First name: {$firstName}
- Last name: {$lastName}
{$context}
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 (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());
}
public function arguments(): array
{
return [
new Argument('first_name', 'First name of the new employee', required: true),
new Argument('last_name', 'Last name of the new employee', required: false),
new Argument('department', 'Department the employee will join', required: false),
new Argument('location', 'Primary office location', required: false),
new Argument('title', 'Job title', required: false),
];
}
}
-24
View File
@@ -1,24 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Server\Prompt;
abstract class SnipePrompt extends Prompt
{
/**
* Returns a trailing instruction telling the model which language to respond in,
* derived from the authenticated user's locale setting. Returns an empty string
* for English locales so the prompt text is unchanged for the majority of users.
*/
protected function localeInstruction(): string
{
$locale = auth()->user()?->locale ?? app()->getLocale();
if (str_starts_with($locale, 'en')) {
return '';
}
return "\n\nPlease respond in the language that corresponds to locale: {$locale}.";
}
}
-44
View File
@@ -1,44 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$username = $request->get('username');
$prompt = <<<TEXT
You are pulling a complete inventory of everything assigned to the user: {$username}.
Please complete the following steps using the available tools:
1. Look up the user account for {$username} and display their basic info (name, department, location, job title).
2. List all assets currently checked out to this user (asset tag, name, model, serial, status).
3. List all accessories checked out to this user.
4. List all license seats assigned to this user.
5. List any consumables that have been checked out to this user.
6. Calculate the total purchase value of all assigned assets if cost data is available.
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).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('username', 'Username of the user to review', required: true),
];
}
}
@@ -1,42 +0,0 @@
<?php
namespace App\Mcp\Prompts;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
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 SnipePrompt
{
public function handle(Request $request): Response
{
$days = (int) ($request->get('days', 90));
$prompt = <<<TEXT
You are reviewing assets whose warranty is expiring soon. Focus on assets expiring within {$days} days.
Please complete the following steps using the available tools:
1. List assets and filter for those whose warranty expiration date (calculated from purchase_date + warranty_months) falls within the next {$days} days.
2. For each asset, show: asset tag, name, model, assigned user or location, purchase date, warranty months, and calculated warranty end date.
3. Group by urgency: expiring within 30 days, 3160 days, and 61{$days} days.
4. Flag any assets that are deployed to critical roles or users where warranty coverage is especially important.
5. Recommend actions: extend warranty, schedule replacement, or note as acceptable risk.
TEXT;
return Response::text(trim($prompt).$this->localeInstruction());
}
public function arguments(): array
{
return [
new Argument('days', 'Number of days ahead to check for warranty expiry (default: 90)', required: false),
];
}
}
-1066
View File
File diff suppressed because it is too large Load Diff
-275
View File
@@ -1,275 +0,0 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Prompts\AuditLocationPrompt;
use App\Mcp\Prompts\EndOfLifeReviewPrompt;
use App\Mcp\Prompts\ExpiringLicensesPrompt;
use App\Mcp\Prompts\FindAvailableAssetPrompt;
use App\Mcp\Prompts\InventorySummaryPrompt;
use App\Mcp\Prompts\OffboardEmployeePrompt;
use App\Mcp\Prompts\OnboardEmployeePrompt;
use App\Mcp\Prompts\UserInventoryPrompt;
use App\Mcp\Prompts\WarrantyExpiringPrompt;
use App\Mcp\Tools\AddAssetNoteTool;
use App\Mcp\Tools\AuditAssetTool;
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\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\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;
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;
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\UpdateProfileTool;
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;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Snipe-IT MCP Server')]
#[Version('0.0.1')]
#[Instructions('This server allows you to interact with the Snipe-IT asset management database. You can list, view, check out, and check in assets.')]
class SnipeMCPServer extends Server
{
protected array $tools = [
// Assets
ShowAssetTool::class,
ListAssetsTool::class,
CreateAssetTool::class,
UpdateAssetTool::class,
DeleteAssetTool::class,
RestoreAssetTool::class,
CheckoutAssetTool::class,
CheckinAssetTool::class,
AuditAssetTool::class,
AddAssetNoteTool::class,
ListAssetNotesTool::class,
// Cross-type tools
ListUploadsTool::class,
ListHistoryTool::class,
// Users
ListUsersTool::class,
ShowUserTool::class,
CreateUserTool::class,
UpdateUserTool::class,
DeleteUserTool::class,
RestoreUserTool::class,
GetCurrentUserTool::class,
UpdateProfileTool::class,
GetUserAssetsTool::class,
Reset2FATool::class,
SendPasswordResetTool::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,
UpdateLicenseTool::class,
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 = [
//
];
protected array $prompts = [
OnboardEmployeePrompt::class,
OffboardEmployeePrompt::class,
AuditLocationPrompt::class,
FindAvailableAssetPrompt::class,
ExpiringLicensesPrompt::class,
EndOfLifeReviewPrompt::class,
WarrantyExpiringPrompt::class,
InventorySummaryPrompt::class,
UserInventoryPrompt::class,
];
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Actionlog;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('add_asset_note')]
#[Title('Add Asset Note')]
#[Description('Add a manual note to a Snipe-IT asset identified by asset tag, serial number, or numeric ID')]
class AddAssetNoteTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.asset_not_found')));
}
if (! Gate::allows('update', $asset)) {
return Response::make(Response::error(trans('mcp.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(trans('mcp.note_added_to_asset', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.note_added_successfully'),
'asset_tag' => $asset->asset_tag,
'asset_id' => $asset->id,
'note' => $logaction->note,
]);
}
return Response::make(Response::error(trans('mcp.note_save_failed')));
}
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'),
];
}
}
-120
View File
@@ -1,120 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('audit_asset')]
#[Title('Audit Asset')]
#[Description('Record an audit for a Snipe-IT asset, updating the last audit date and optionally the location')]
class AuditAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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::error(trans('mcp.asset_not_found')));
}
if (! Gate::allows('audit', $asset)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$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(trans('mcp.asset_audited', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'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,
'location' => $asset->location?->name,
]);
}
return Response::make(Response::error(trans('mcp.audit_failed', ['error' => $asset->getErrors()->first()])));
}
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'),
];
}
}
-82
View File
@@ -1,82 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_accessory')]
#[Title('Checkin Accessory')]
#[Description('Check in a Snipe-IT accessory checkout record by its checkout ID')]
class CheckinAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'checkout_id' => 'required|integer',
'note' => 'nullable|string|max:65535',
]);
$checkout = AccessoryCheckout::find($request->get('checkout_id'));
if (! $checkout) {
return Response::make(Response::error(trans('mcp.accessory_checkout_not_found')));
}
$accessory = Accessory::find($checkout->accessory_id);
if (! $accessory) {
return Response::make(Response::error(trans('mcp.accessory_not_found')));
}
if (! Gate::allows('checkin', $accessory)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$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(trans('mcp.accessory_checked_in', ['name' => $accessory->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.accessory_checked_in', ['name' => $accessory->name]),
'accessory_id' => $accessory->id,
'accessory_name' => $accessory->name,
]);
}
return Response::make(Response::error(trans('mcp.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'),
];
}
}
-110
View File
@@ -1,110 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_asset')]
#[Title('Check In Asset')]
#[Description('Check a currently checked-out Snipe-IT asset back in')]
class CheckinAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'asset_tag' => 'nullable|max:100',
'id' => 'nullable|integer',
'note' => 'nullable|string|max:1000',
]);
$asset = $this->resolveAsset($request);
if (! $asset) {
return Response::make(Response::error(trans('mcp.asset_not_found')));
}
if (! Gate::allows('checkin', $asset)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$target = $asset->assignedTo;
if (is_null($target)) {
return Response::make(Response::error(trans('mcp.asset_not_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(trans('mcp.asset_checked_in', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'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(trans('mcp.checkin_failed_error', ['error' => $asset->getErrors()->first()])));
}
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'),
];
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\Component;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_component')]
#[Title('Checkin Component')]
#[Description('Check in one or more units of a Snipe-IT component from an asset using the checkout record ID')]
class CheckinComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.component_checkout_not_found')));
}
$component = Component::find($componentAsset->component_id);
if (! $component) {
return Response::make(Response::error(trans('mcp.component_not_found')));
}
if (! Gate::allows('checkin', $component)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$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(trans('mcp.component_checked_in', ['name' => $component->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.component_checked_in', ['name' => $component->name]),
'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)'),
];
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\License;
use App\Models\LicenseSeat;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkin_license')]
#[Title('Checkin License')]
#[Description('Check in a Snipe-IT license seat by its seat ID, returning it to the available pool')]
class CheckinLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.license_seat_not_found')));
}
if (is_null($seat->assigned_to) && is_null($seat->asset_id)) {
return Response::make(Response::error(trans('mcp.seat_not_checked_out')));
}
$license = $seat->license;
if (! $license) {
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(trans('mcp.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(trans('mcp.license_seat_checked_in', ['id' => $seat->id]))
)->withStructuredContent([
'success' => true,
'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(trans('mcp.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'),
];
}
}
-134
View File
@@ -1,134 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\Location;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_accessory')]
#[Title('Checkout Accessory')]
#[Description('Check out a Snipe-IT accessory to a user, location, or asset')]
class CheckoutAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.accessory_not_found')));
}
if (! Gate::allows('checkout', $accessory)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($accessory->numRemaining() < 1) {
return Response::make(Response::error(trans('mcp.no_units_available')));
}
$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(trans('mcp.checkout_target_not_found', ['type' => $checkoutType])));
}
$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(trans('mcp.accessory_checked_out', ['name' => $accessory->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.accessory_checked_out', ['name' => $accessory->name]),
'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'),
];
}
}
-145
View File
@@ -1,145 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_asset')]
#[Title('Checkout Asset')]
#[Description('Check out a Snipe-IT asset to a user, location, or another asset')]
class CheckoutAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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::error(trans('mcp.asset_not_found')));
}
if (! Gate::allows('checkout', $asset)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if (! $asset->availableForCheckout()) {
return Response::make(Response::error(trans('mcp.asset_not_available', ['asset_tag' => $asset->asset_tag])));
}
$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::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');
$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(trans('mcp.asset_checked_out', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'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(trans('mcp.checkout_failed')));
}
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'),
];
}
}
-121
View File
@@ -1,121 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
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;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_component')]
#[Title('Checkout Component')]
#[Description('Check out one or more units of a Snipe-IT component to an asset')]
class CheckoutComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
try {
$request->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(trans('mcp.component_not_found')));
}
if (! Gate::allows('checkout', $component)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$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(trans('mcp.component_checked_out', ['name' => $component->name, 'asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'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,
'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)'),
];
}
}
-113
View File
@@ -1,113 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Consumable;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_consumable')]
#[Title('Checkout Consumable')]
#[Description('Check out a Snipe-IT consumable to a user')]
class CheckoutConsumableTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.consumable_not_found')));
}
if (! Gate::allows('checkout', $consumable)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($consumable->numRemaining() <= 0) {
return Response::make(Response::error(trans('mcp.no_units_remaining')));
}
$user = User::find($request->get('assigned_to'));
if (! $user) {
return Response::make(Response::error(trans('mcp.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(trans('mcp.consumable_checked_out', ['name' => $consumable->name, 'username' => $user->username]))
)->withStructuredContent([
'success' => true,
'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,
'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'),
];
}
}
-149
View File
@@ -1,149 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedOut;
use App\Models\Asset;
use App\Models\License;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('checkout_license')]
#[Title('Checkout License')]
#[Description('Check out an available license seat to a user or asset')]
class CheckoutLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.license_not_found')));
}
if (! Gate::allows('checkout', $license)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($license->numRemaining() < 1) {
return Response::make(Response::error(trans('mcp.no_available_seats')));
}
if (! $request->filled('assigned_to') && ! $request->filled('asset_id')) {
return Response::make(Response::error(trans('mcp.provide_user_or_asset')));
}
$seat = $license->freeSeat();
if (! $seat) {
return Response::make(Response::error(trans('mcp.no_free_seat')));
}
$note = $request->get('note');
if ($request->filled('assigned_to')) {
$target = User::find($request->get('assigned_to'));
if (! $target) {
return Response::make(Response::error(trans('mcp.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(trans('mcp.license_seat_checked_out_user', ['username' => $target->username]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.license_seat_checked_out_user', ['username' => $target->username]),
'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(trans('mcp.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(trans('mcp.license_seat_checked_out_asset', ['asset_tag' => $target->asset_tag]))
)->withStructuredContent([
'success' => true,
'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,
'assigned_to_type' => 'asset',
'assigned_to_id' => $target->id,
]);
}
}
return Response::make(Response::error(trans('mcp.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'),
];
}
}
-107
View File
@@ -1,107 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_accessory')]
#[Title('Create Accessory')]
#[Description('Create a new Snipe-IT accessory')]
class CreateAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Accessory::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.accessory_created', ['name' => $accessory->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.accessory_created', ['name' => $accessory->name]),
'id' => $accessory->id,
'name' => $accessory->name,
'qty' => $accessory->qty,
'category_id' => $accessory->category_id,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AssetModel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_asset_model')]
#[Title('Create Asset Model')]
#[Description('Create a new Snipe-IT asset model')]
class CreateAssetModelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', AssetModel::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.asset_model_created', ['name' => $assetModel->name]))
)->withStructuredContent([
'success' => true,
'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(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-108
View File
@@ -1,108 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_asset')]
#[Title('Create Asset')]
#[Description('Create a new Snipe-IT asset')]
class CreateAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Asset::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.asset_created', ['asset_tag' => $asset->asset_tag]))
)->withStructuredContent([
'success' => true,
'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(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-89
View File
@@ -1,89 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_category')]
#[Title('Create Category')]
#[Description('Create a new Snipe-IT category')]
class CreateCategoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Category::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.category_created', ['name' => $category->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.category_created', ['name' => $category->name]),
'id' => $category->id,
'name' => $category->name,
'category_type' => $category->category_type,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-90
View File
@@ -1,90 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_company')]
#[Title('Create Company')]
#[Description('Create a new Snipe-IT company')]
class CreateCompanyTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Company::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.company_created', ['name' => $company->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.company_created', ['name' => $company->name]),
'id' => $company->id,
'name' => $company->name,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-107
View File
@@ -1,107 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_component')]
#[Title('Create Component')]
#[Description('Create a new Snipe-IT component')]
class CreateComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Component::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.component_created', ['name' => $component->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.component_created', ['name' => $component->name]),
'id' => $component->id,
'name' => $component->name,
'qty' => $component->qty,
'category_id' => $component->category_id,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-106
View File
@@ -1,106 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Consumable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_consumable')]
#[Title('Create Consumable')]
#[Description('Create a new Snipe-IT consumable')]
class CreateConsumableTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Consumable::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.consumable_created', ['name' => $consumable->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.consumable_created', ['name' => $consumable->name]),
'id' => $consumable->id,
'name' => $consumable->name,
'qty' => $consumable->qty,
'category_id' => $consumable->category_id,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-87
View File
@@ -1,87 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Company;
use App\Models\Department;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_department')]
#[Title('Create Department')]
#[Description('Create a new Snipe-IT department')]
class CreateDepartmentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Department::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.department_created', ['name' => $department->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.department_created', ['name' => $department->name]),
'id' => $department->id,
'name' => $department->name,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-74
View File
@@ -1,74 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Depreciation;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_depreciation')]
#[Title('Create Depreciation')]
#[Description('Create a new Snipe-IT depreciation schedule')]
class CreateDepreciationTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Depreciation::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.depreciation_created', ['name' => $depreciation->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.depreciation_created', ['name' => $depreciation->name]),
'id' => $depreciation->id,
'name' => $depreciation->name,
'months' => $depreciation->months,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-130
View File
@@ -1,130 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Group;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_group')]
#[Title('Create 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
{
if (! Gate::allows('superadmin')) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->validate([
'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');
}
$group->created_by = auth()->id();
if ($group->save()) {
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,
'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)'),
'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'),
'permissions' => $schema->object()->description('Permissions set on the group'),
];
}
}
-119
View File
@@ -1,119 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Company;
use App\Models\License;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_license')]
#[Title('Create License')]
#[Description('Create a new Snipe-IT software license')]
class CreateLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', License::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.license_created', ['name' => $license->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.license_created', ['name' => $license->name]),
'id' => $license->id,
'name' => $license->name,
'seats' => $license->seats,
'category_id' => $license->category_id,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-97
View File
@@ -1,97 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Location;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_location')]
#[Title('Create Location')]
#[Description('Create a new Snipe-IT location')]
class CreateLocationTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Location::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.location_created', ['name' => $location->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.location_created', ['name' => $location->name]),
'id' => $location->id,
'name' => $location->name,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-105
View File
@@ -1,105 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Asset;
use App\Models\Maintenance;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_maintenance')]
#[Title('Create Maintenance')]
#[Description('Create a new asset maintenance record')]
class CreateMaintenanceTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('update', Asset::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.maintenance_created', ['name' => $maintenance->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.maintenance_created', ['name' => $maintenance->name]),
'id' => $maintenance->id,
'title' => $maintenance->name,
'asset_id' => $maintenance->asset_id,
'asset_tag' => $maintenance->asset?->asset_tag,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Manufacturer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_manufacturer')]
#[Title('Create Manufacturer')]
#[Description('Create a new Snipe-IT manufacturer')]
class CreateManufacturerTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Manufacturer::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.manufacturer_created', ['name' => $manufacturer->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.manufacturer_created', ['name' => $manufacturer->name]),
'id' => $manufacturer->id,
'name' => $manufacturer->name,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-95
View File
@@ -1,95 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Statuslabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_status_label')]
#[Title('Create Status Label')]
#[Description('Create a new Snipe-IT status label')]
class CreateStatusLabelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Statuslabel::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.status_label_created', ['name' => $statuslabel->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.status_label_created', ['name' => $statuslabel->name]),
'id' => $statuslabel->id,
'name' => $statuslabel->name,
'type' => $statuslabel->getStatuslabelType(),
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-96
View File
@@ -1,96 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Supplier;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_supplier')]
#[Title('Create Supplier')]
#[Description('Create a new Snipe-IT supplier')]
class CreateSupplierTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', Supplier::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
try {
$request->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(trans('mcp.supplier_created', ['name' => $supplier->name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.supplier_created', ['name' => $supplier->name]),
'id' => $supplier->id,
'name' => $supplier->name,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
];
}
}
-155
View File
@@ -1,155 +0,0 @@
<?php
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;
use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('create_user')]
#[Title('Create User')]
#[Description('Create a new Snipe-IT user account')]
class CreateUserTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('create', User::class)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
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',
'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',
'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()) {
$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([
'success' => true,
'message' => trans('mcp.user_created', ['username' => $user->username]),
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'group_ids' => $groupIds,
]);
}
return Response::make(Response::error(trans('mcp.create_failed', ['error' => $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'),
'group_ids' => $schema->array()->description('Array of permission group IDs to assign (requires superadmin). Example: [1, 3]'),
];
}
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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_accessory')]
#[Title('Delete Accessory')]
#[Description('Soft-delete a Snipe-IT accessory. The accessory must have no units currently checked out.')]
class DeleteAccessoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$accessory = $this->resolveAccessory($request);
if (! $accessory) {
return Response::make(Response::error(trans('mcp.accessory_not_found')));
}
if (! Gate::allows('delete', $accessory)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($accessory->numCheckedOut() > 0) {
return Response::make(Response::error(trans('mcp.accessory_has_checkouts')));
}
$name = $accessory->name;
$accessory->delete();
return Response::make(
Response::text(trans('mcp.accessory_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.accessory_deleted', ['name' => $name]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\AssetModel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_asset_model')]
#[Title('Delete Asset Model')]
#[Description('Soft-delete a Snipe-IT asset model by numeric ID or name')]
class DeleteAssetModelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$model = $this->resolveModel($request);
if (! $model) {
return Response::make(Response::error(trans('mcp.asset_model_not_found')));
}
if (! Gate::allows('delete', $model)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($model->assets()->count() > 0) {
return Response::make(Response::error(trans('mcp.model_has_assets')));
}
$name = $model->name;
$model->delete();
return Response::make(
Response::text(trans('mcp.asset_model_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.asset_model_deleted', ['name' => $name]),
'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'),
];
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Events\CheckoutableCheckedIn;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_asset')]
#[Title('Delete Asset')]
#[Description('Soft-delete a Snipe-IT asset. If the asset is currently checked out it will be checked in first.')]
class DeleteAssetTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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(trans('mcp.asset_not_found')));
}
if (! Gate::allows('delete', $asset)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$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(trans('mcp.asset_deleted', ['asset_tag' => $assetTag]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.asset_deleted', ['asset_tag' => $assetTag]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Category;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_category')]
#[Title('Delete Category')]
#[Description('Soft-delete a Snipe-IT category. The category must have no items assigned to it.')]
class DeleteCategoryTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$category = $this->resolveCategory($request);
if (! $category) {
return Response::make(Response::error(trans('mcp.category_not_found')));
}
if (! Gate::allows('delete', $category)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$name = $category->name;
try {
$category->delete();
} catch (\Exception $e) {
return Response::make(Response::error(trans('mcp.category_delete_failed', ['error' => $e->getMessage()])));
}
return Response::make(
Response::text(trans('mcp.category_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.category_deleted', ['name' => $name]),
'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'),
];
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_company')]
#[Title('Delete Company')]
#[Description('Soft-delete a Snipe-IT company by numeric ID or name')]
class DeleteCompanyTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$company = $this->resolveCompany($request);
if (! $company) {
return Response::make(Response::error(trans('mcp.company_not_found')));
}
if (! Gate::allows('delete', $company)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$name = $company->name;
$company->delete();
return Response::make(
Response::text(trans('mcp.company_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.company_deleted', ['name' => $name]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_component')]
#[Title('Delete Component')]
#[Description('Soft-delete a Snipe-IT component. The component must have no units currently checked out to assets.')]
class DeleteComponentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:191',
]);
$component = $this->resolveComponent($request);
if (! $component) {
return Response::make(Response::error(trans('mcp.component_not_found')));
}
if (! Gate::allows('delete', $component)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($component->numCheckedOut() > 0) {
return Response::make(Response::error(trans('mcp.component_has_checkouts')));
}
$name = $component->name;
$component->delete();
return Response::make(
Response::text(trans('mcp.component_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.component_deleted', ['name' => $name]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Consumable;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_consumable')]
#[Title('Delete Consumable')]
#[Description('Soft-delete a Snipe-IT consumable. The consumable must have no units currently checked out.')]
class DeleteConsumableTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$consumable = $this->resolveConsumable($request);
if (! $consumable) {
return Response::make(Response::error(trans('mcp.consumable_not_found')));
}
if (! Gate::allows('delete', $consumable)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($consumable->users()->count() > 0) {
return Response::make(Response::error(trans('mcp.consumable_has_checkouts')));
}
$name = $consumable->name;
$consumable->delete();
return Response::make(
Response::text(trans('mcp.consumable_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.consumable_deleted', ['name' => $name]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Department;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_department')]
#[Title('Delete Department')]
#[Description('Soft-delete a Snipe-IT department. The department must have no users assigned to it.')]
class DeleteDepartmentTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$department = $this->resolveDepartment($request);
if (! $department) {
return Response::make(Response::error(trans('mcp.department_not_found')));
}
if (! Gate::allows('delete', $department)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($department->users->count() > 0) {
return Response::make(Response::error(trans('mcp.department_has_users')));
}
$name = $department->name;
$department->delete();
return Response::make(
Response::text(trans('mcp.department_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.department_deleted', ['name' => $name]),
'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'),
];
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Depreciation;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_depreciation')]
#[Title('Delete Depreciation')]
#[Description('Soft-delete a Snipe-IT depreciation schedule by numeric ID or name')]
class DeleteDepreciationTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$dep = $this->resolveDepreciation($request);
if (! $dep) {
return Response::make(Response::error(trans('mcp.depreciation_not_found')));
}
if (! Gate::allows('delete', $dep)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$name = $dep->name;
$dep->delete();
return Response::make(
Response::text(trans('mcp.depreciation_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.depreciation_deleted', ['name' => $name]),
'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'),
];
}
}
-75
View File
@@ -1,75 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Group;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_group')]
#[Title('Delete Group')]
#[Description('Delete a Snipe-IT permission group by ID or name. The group must have no users assigned.')]
class DeleteGroupTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('superadmin')) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$request->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(trans('mcp.id_or_name_required')));
}
if (! $group) {
return Response::make(Response::error(trans('mcp.group_not_found')));
}
$groupName = $group->name;
if ($group->delete()) {
return Response::make(
Response::text(trans('mcp.group_deleted', ['name' => $groupName]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.group_deleted', ['name' => $groupName]),
'name' => $groupName,
]);
}
return Response::make(Response::error(trans('mcp.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'),
];
}
}
-89
View File
@@ -1,89 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\License;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_license')]
#[Title('Delete License')]
#[Description('Soft-delete a Snipe-IT license. The license must have no seats currently assigned to users or assets.')]
class DeleteLicenseTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$license = $this->resolveLicense($request);
if (! $license) {
return Response::make(Response::error(trans('mcp.license_not_found')));
}
if (! Gate::allows('delete', $license)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($license->assignedCount()->count() > 0) {
return Response::make(Response::error(trans('mcp.license_has_seats_assigned')));
}
$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(trans('mcp.license_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.license_deleted', ['name' => $name]),
'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'),
];
}
}
-87
View File
@@ -1,87 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Location;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_location')]
#[Title('Delete Location')]
#[Description('Soft-delete a Snipe-IT location by numeric ID or name')]
class DeleteLocationTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$location = $this->resolveLocation($request);
if (! $location) {
return Response::make(Response::error(trans('mcp.location_not_found')));
}
if (! Gate::allows('delete', $location)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($location->users()->count() > 0) {
return Response::make(Response::error(trans('mcp.location_has_users')));
}
if ($location->children()->count() > 0) {
return Response::make(Response::error(trans('mcp.location_has_child_locations')));
}
$name = $location->name;
$location->delete();
return Response::make(
Response::text(trans('mcp.location_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.location_deleted', ['name' => $name]),
'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'),
];
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Manufacturer;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_manufacturer')]
#[Title('Delete Manufacturer')]
#[Description('Soft-delete a Snipe-IT manufacturer identified by numeric ID or name')]
class DeleteManufacturerTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$manufacturer = $this->resolveManufacturer($request);
if (! $manufacturer) {
return Response::make(Response::error(trans('mcp.manufacturer_not_found')));
}
if (! Gate::allows('delete', $manufacturer)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$name = $manufacturer->name;
$manufacturer->delete();
return Response::make(
Response::text(trans('mcp.manufacturer_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.manufacturer_deleted', ['name' => $name]),
'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'),
];
}
}
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Statuslabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_status_label')]
#[Title('Delete Status Label')]
#[Description('Soft-delete a Snipe-IT status label identified by numeric ID or name')]
class DeleteStatusLabelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$label = $this->resolveStatusLabel($request);
if (! $label) {
return Response::make(Response::error(trans('mcp.status_label_not_found')));
}
if (! Gate::allows('delete', $label)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($label->assets()->count() > 0) {
return Response::make(Response::error(trans('mcp.status_label_has_assets')));
}
$name = $label->name;
$label->delete();
return Response::make(
Response::text(trans('mcp.status_label_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.status_label_deleted', ['name' => $name]),
'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'),
];
}
}
-79
View File
@@ -1,79 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Supplier;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_supplier')]
#[Title('Delete Supplier')]
#[Description('Soft-delete a Snipe-IT supplier identified by numeric ID or name')]
class DeleteSupplierTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->validate([
'id' => 'nullable|integer',
'name' => 'nullable|string|max:255',
]);
$supplier = $this->resolveSupplier($request);
if (! $supplier) {
return Response::make(Response::error(trans('mcp.supplier_not_found')));
}
if (! Gate::allows('delete', $supplier)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$name = $supplier->name;
$supplier->delete();
return Response::make(
Response::text(trans('mcp.supplier_deleted', ['name' => $name]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.supplier_deleted', ['name' => $name]),
'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'),
];
}
}
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace App\Mcp\Tools;
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;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('delete_user')]
#[Title('Delete User')]
#[Description('Soft-delete a Snipe-IT user. The user must have no assets, licenses, accessories, or consumables assigned.')]
class DeleteUserTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$request->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('delete', $user)) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
if ($user->id === auth()->id()) {
return Response::make(Response::error(trans('mcp.user_cannot_delete_self')));
}
if ($user->allAssignedCount() > 0) {
return Response::make(Response::error(trans('mcp.user_has_items')));
}
$username = $user->username;
if ($user->delete()) {
return Response::make(
Response::text(trans('mcp.user_deleted', ['username' => $username]))
)->withStructuredContent([
'success' => true,
'message' => trans('mcp.user_deleted', ['username' => $username]),
'username' => $username,
]);
}
return Response::make(Response::error(trans('mcp.delete_failed_error', ['error' => $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'),
];
}
}
-102
View File
@@ -1,102 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\Actionlog;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('get_activity_log')]
#[Title('Get Activity Log')]
#[Description('Retrieve the Snipe-IT activity log with optional filtering by item type, item ID, user, and action type')]
class GetActivityLogTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! Gate::allows('activity.view')) {
return Response::make(Response::error(trans('mcp.unauthorized')));
}
$request->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(trans('mcp.list_activity', ['total' => $total, 'count' => 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(),
];
}
}
-72
View File
@@ -1,72 +0,0 @@
<?php
namespace App\Mcp\Tools;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Title;
use Laravel\Mcp\Server\Tool;
#[Name('get_current_user')]
#[Title('Get Current User')]
#[Description('Return information about the currently authenticated Snipe-IT user')]
class GetCurrentUserTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
if (! auth()->check()) {
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(trans('mcp.not_authenticated')));
}
return Response::make(
Response::text(trans('mcp.current_user', ['username' => $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'),
];
}
}

Some files were not shown because too many files have changed in this diff Show More