shaxzodbek-uzb / laravel-model-mcp
Auto-expose Eloquent models as policy-enforced MCP tools. Safe-by-default CRUD over the Model Context Protocol, gated by your Laravel Policies, scoped to your tenant, and audited.
Package info
github.com/shaxzodbek-uzb/laravel-model-mcp
pkg:composer/shaxzodbek-uzb/laravel-model-mcp
Requires
- php: ^8.2
- illuminate/console: ^12.41 || ^13.0
- illuminate/contracts: ^12.41 || ^13.0
- illuminate/database: ^12.41 || ^13.0
- illuminate/support: ^12.41 || ^13.0
- laravel/mcp: ^0.8
- spatie/laravel-package-tools: ^1.16
- symfony/finder: ^7.0 || ^8.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- pestphp/pest: ^3.7 || ^4.0
- pestphp/pest-plugin-laravel: ^3.0 || ^4.0
README
Expose your Eloquent models to AI agents as MCP tools — without handing them the keys to your database.
laravel-model-mcp turns any Eloquent model into a full set of Model Context
Protocol tools (list, view, create,
update, delete, search) on top of the official
laravel/mcp package — and every single call
is checked against your Laravel Policies, scoped to the current tenant,
and audited. Safe by default, no boilerplate.
The problem
The official laravel/mcp package is excellent, but it makes you hand-write one
tool class per operation, and authorization is a manual if inside each handler:
class UpdatePostTool extends Tool { public function handle(Request $request): Response { $post = Post::findOrFail($request->get('id')); // You have to remember this, on every tool, for every model. if (! $request->user()->can('update', $post)) { return Response::error('Forbidden.'); } $post->update($request->validate([...])); return Response::json($post); } public function schema(JsonSchema $schema): array { // ...and hand-maintain a schema that mirrors your migration. } }
Multiply that by 6 operations × every model you want to expose. Forget the
can() check on one of them, and an agent can now edit anything.
The solution
List the models. Register one server. Done — policy-enforced CRUD for all of them:
// config/model-mcp.php 'models' => [ App\Models\Post::class, App\Models\Comment::class, ],
// routes/ai.php use Blaze\ModelMcp\Server\ModelMcpServer; use Laravel\Mcp\Facades\Mcp; Mcp::web('/mcp/models', ModelMcpServer::class)->middleware(['auth:sanctum']);
That's it. You now have list_posts, get_post, create_post, update_post,
delete_post, search_posts (and the same for comment) — each one running
PostPolicy@viewAny, @view, @create, @update, @delete for the
authenticated user before it touches a row.
Why this package
laravel/mcp alone |
laravel-model-mcp |
|
|---|---|---|
| Eloquent → MCP CRUD tools | hand-written | auto-generated |
| Laravel Policy enforced per call | manual can() in each handler |
built in, fail-closed |
| Multi-tenant row scoping | DIY | built in |
| Audit log of every tool call | DIY | built in |
| Token-safe pagination & field limits | DIY | built in |
| JSON Schema from casts/columns | hand-written | generated |
This package builds on laravel/mcp — it does not replace it. Transport,
OAuth, and the protocol stay with the official package; this layer adds the
opinionated, safe-by-default model exposure on top.
Installation
composer require shaxzodbek-uzb/laravel-model-mcp
Requires PHP 8.2+ and Laravel 12.41+ / 13.x (it relies on laravel/mcp's
JSON Schema builder). If you haven't set up laravel/mcp yet:
php artisan vendor:publish --tag=ai-routes # creates routes/ai.php
Optionally publish the config:
php artisan vendor:publish --tag=model-mcp-config
Quickstart
1. Add a policy for the model you want to expose (standard Laravel — nothing special):
class PostPolicy { public function viewAny(User $user): bool { return true; } public function view(User $user, Post $post): bool { return true; } public function create(User $user): bool { return $user->can_write; } public function update(User $user, Post $post): bool { return $user->id === $post->user_id; } public function delete(User $user, Post $post): bool { return $user->id === $post->user_id; } }
2. Expose the model:
// config/model-mcp.php 'models' => [ App\Models\Post::class, ],
3. Register the server in routes/ai.php behind your auth middleware:
Mcp::web('/mcp/models', \Blaze\ModelMcp\Server\ModelMcpServer::class) ->middleware(['auth:sanctum']);
4. See exactly what you exposed:
php artisan model-mcp:list # Tool Model Operation # list_posts App\Models\Post list # get_post App\Models\Post view # create_post App\Models\Post create # update_post App\Models\Post update # delete_post App\Models\Post delete # search_posts App\Models\Post search
Point any MCP client (Claude, your agent, php artisan mcp:inspector) at the
server and the tools are live — each one acting as the authenticated user.
The security model
This is the whole point, so it's worth being explicit. By default:
-
Nothing is exposed implicitly. Only models in
model-mcp.models(or tagged with#[McpModel]) become tools. -
Every operation enforces the matching policy ability for the authenticated MCP user, before any read or write:
Operation Policy ability list,searchviewAnyviewviewcreatecreateupdateupdatedeletedelete -
Fail-closed. No policy for the model → every operation is denied. No authenticated user → denied. (Both configurable, both default to safe.)
-
Tenant scope is applied to the query before the policy runs, so even a missing or over-permissive policy can't leak another tenant's rows. If tenancy is enabled and no tenant resolves, the request fails closed.
-
Writes are limited to
$fillable; reads honor$hidden; andfields.always_hiddenis a hard block on top (e.g.password).
Denials and errors come back as MCP isError results with safe messages — the
agent can recover, and your internals never leak. Every call (allowed, denied,
errored) is recorded by the audit log.
See SECURITY.md for the full model and how to report issues.
Configuration
The published config/model-mcp.php is fully documented. The essentials:
return [ // Explicit allow-list. Bare class, or Class => [overrides]. 'models' => [ App\Models\Post::class, App\Models\Invoice::class => [ 'operations' => ['list', 'view'], // read-only 'tenant_column' => 'team_id', 'policy' => App\Policies\InvoicePolicy::class, 'name' => 'invoice', // tool name stem ], ], // Operations exposed by default for an opted-in model. 'operations' => ['list', 'view', 'create', 'update', 'delete', 'search'], 'authorization' => [ 'enabled' => true, 'deny_without_policy' => true, // fail closed 'require_authentication' => true, ], 'tenancy' => [ 'enabled' => false, // rely on your global scopes by default 'column' => 'tenant_id', 'resolver' => Blaze\ModelMcp\Tenancy\AuthUserTenantResolver::class, 'fail_closed' => true, ], 'pagination' => ['default_per_page' => 25, 'max_per_page' => 100], 'audit' => ['enabled' => true, 'logger' => Blaze\ModelMcp\Audit\LogAuditor::class], ];
Read-only or partial exposure
Expose only the operations you want, per model:
'models' => [ App\Models\AuditEntry::class => ['operations' => ['list', 'view', 'search']], ],
Or flip a single global kill-switch so no model can ever be mutated — only
list / view / search tools are generated, regardless of per-model settings:
'read_only' => true,
Multi-tenancy
If your app already scopes models with global scopes (a BelongsToTenant
trait, #[ScopedBy], stancl/tenancy, spatie/laravel-multitenancy), you need
to do nothing — every query runs through Model::query(), so your scopes
apply transparently and are never stripped.
Turn on the package's own explicit scoping only when a global scope alone won't
filter (e.g. you want a hard where(tenant_column, id) regardless):
'tenancy' => [ 'enabled' => true, 'column' => 'tenant_id', // default; override per model via 'tenant_column' ],
The default AuthUserTenantResolver reads the column off the authenticated user
($user->tenant_id). Provide your own by binding
Blaze\ModelMcp\Contracts\TenantResolver.
Audit log
Every tool call is recorded with the acting user, the model, the operation, and
the outcome (allowed / denied / error). The default LogAuditor writes to
your log channel; swap in your own to persist to a table:
use Blaze\ModelMcp\Contracts\ToolAuditor; use Blaze\ModelMcp\Audit\ToolCallEvent; class DatabaseAuditor implements ToolAuditor { public function record(ToolCallEvent $event): void { McpAuditLog::create($event->toArray()); } }
// config/model-mcp.php 'audit' => ['enabled' => true, 'logger' => App\Mcp\DatabaseAuditor::class],
Attribute discovery (optional)
Prefer to opt in from the model itself? Enable discovery and tag your models — it's off by default so nothing is ever exposed by accident:
use Blaze\ModelMcp\Attributes\McpModel; #[McpModel(operations: ['list', 'view'], tenantColumn: 'team_id')] class Post extends Model { /* ... */ }
'discovery' => ['enabled' => true, 'paths' => [app_path('Models')]],
Extending
- Custom tools alongside generated ones — subclass
ModelMcpServerand add your hand-written tools to the$toolsarray; the generated ones are merged in. - Custom policies — point a model's
policyoption at any class, or rely on Laravel's normal policy resolution. - Custom auditor / tenant resolver — implement the contract and bind it.
Requirements
- PHP 8.2+
- Laravel 12.41+ or 13.x
laravel/mcp^0.8
Testing
composer test # Pest composer analyse # PHPStan / Larastan (level 6) composer lint # Pint
Credits
Built by Blaze. Stands on the shoulders of the
laravel/mcp team.
License
The MIT License (MIT). See LICENSE.