Search by

catidegla / laravel-agent-kit

catidegla

Expose Eloquent models to an AI agent without handing it your database. Field allowlists, per-record policy checks, result ceilings and an audit trail, all failing closed.

Package info

github.com/catidegla/laravel-agent-kit

pkg:composer/catidegla/laravel-agent-kit

Statistics

Installs: 16

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.0 2026-09-08 22:05 UTC

This package is auto-updated.

Last update: 2026-09-08 22:07:04 UTC


README

Laravel Agent Kit

Expose Eloquent models to an AI agent without handing it your database.

Field allowlists, per-record policy checks, result ceilings. Every one of them fails closed.

Tests PHP Laravel License

laravel/mcp has 34 million installs and gives you the primitive: define a tool, an agent can call it. What it does not give you is any reason to believe the tool is safe to expose.

The usual first version looks like this:

class TicketTool extends Tool
{
    public function handle(Request $request)
    {
        return Ticket::find($request->id);   // three problems, none visible
    }
}

It returns every column, including the ones nothing renders. It has no idea who is asking. And nothing stops an agent iterating ids until it has the table.

This package makes those three the default rather than the thing you remember to add.

#[AgentResource(
    fields: ['id', 'subject', 'status'],
    searchable: ['subject'],
    filterable: ['status'],
    description: 'Support tickets belonging to the signed in user.',
    maxResults: 25,
)]
class Ticket extends Model {}

That is the whole configuration. What follows is what it buys.

Undeclared columns never leave

Projection works from the declared field list, never from the model. Adding a column to the table cannot widen what an agent sees, which matters because the migration that adds internal_notes is written months after the tool and by somebody else.

$result = $resource->list($user);
// ['id' => 4, 'subject' => 'Payment failed', 'status' => 'open']
// internal_notes and card_last_four are on the table. They are not here.

Anything that is not a scalar is replaced with [not exposed] rather than serialised, because a cast returning a structured value would otherwise ship whatever it contains without any of it having been declared.

Every record is authorized individually

The agent acts as a signed in user, never as the application. An agent with application-level access reads every tenant's data the moment a prompt talks it into asking, and no amount of careful tool descriptions prevents that.

$result = $resource->list($alice);
// ['rows' => [...alice's only...], 'denied' => 1, 'truncated' => false]

denied is reported rather than silently dropped. A tool that quietly removes rows makes an agent believe a list is complete when it is not, and that produces confidently wrong answers instead of errors.

Filtering the query by user_id is not a substitute for this. It works until the next person adds a scope or a relation, and then it does not, quietly.

It fails closed, everywhere

Situation What happens
Model has no #[AgentResource] Unreachable
Model has the attribute but no policy Refused, not published
Model has both but is not in the config list Unreachable
Field not in fields Never returned
Field not in filterable Cannot be filtered on
Field not in fields but listed in searchable Caught by verify()
Agent is unauthenticated Sees nothing

The second row is the important one. Forgetting to write a policy must not silently publish a table, so a model annotated without one throws rather than being exposed openly.

The last one is subtle: a field you can search but cannot read still leaks, one query at a time. verify() treats that as a configuration error.

Ids cannot be enumerated

A denied record and a missing one answer identically:

$resource->get($alice, $bobsTicketId);  // null
$resource->get($alice, 999999);         // null

Distinguishing them turns the tool into an oracle for which ids exist.

An agent cannot page out your table

maxResults is a ceiling, not a default. Whatever an agent asks for, it gets at most what the resource declares, and truncation is reported rather than hidden:

$resource->list($user, [], 1000);
// ['rows' => [...5 rows...], 'denied' => 0, 'truncated' => true]

One row beyond the limit is fetched purely so truncated can be honest. A tool that returns exactly the limit with no signal makes an agent believe it has seen everything.

Relations do not widen exposure

An agent can ask for a relation to be expanded, and only the ones you declared:

#[AgentResource(
    fields: ['id', 'subject', 'status'],
    relations: ['comments', 'author'],
)]
class Ticket extends Model {}
$resource->get($user, $id, include: ['comments']);

The target's own rules apply, not this one's. Expanding comments returns exactly what Comment declares in its own attribute, checked against Comment's own policy, for the same signed in user. A relation composes two exposures that already existed. It never creates a third.

So a relation cannot reach a model you never exposed. If the target is not a registered resource the call is refused, because there would be no field list and no policy to apply:

Ticket declares "notes" as traversable but PrivateNote is not a registered
resource. A relation is not a way to reach a model that was never exposed.

To make a model reachable through relations without also publishing a tool that can enumerate it, register it with no abilities of its own:

#[AgentResource(fields: ['id', 'name'], abilities: [])]
class User extends Authenticatable {}

One level, never two. An expanded record does not itself expand relations. The next hop is another tool call, separately authorized and separately recorded. Walking the object graph inside a single call is how one question becomes a full export.

A to-many relation is capped by the target's own ceiling and reports truncation rather than trimming quietly. A to-one the viewer may not see comes back as null. Relations are eager loaded, so expanding across a page of results costs one extra query rather than one per row, and there is a test asserting that.

Verify before you deploy

$problems = app(Registry::class)->verify();
// [
//   'Ticket is marked #[AgentResource] but has no registered policy...',
//   'Leaky marks internal_notes searchable but does not expose it, which would leak the field one query at a time',
// ]

Every problem in one pass, not the first one. Fixing a configuration should not be a game of whack-a-mole where each run reveals one more thing.

Put it in a test and a misconfigured model fails the build rather than being discovered when an agent asks for it in production:

public function test_nothing_is_over_exposed(): void
{
    $this->assertSame([], app(Registry::class)->verify());
}

Exposure is an explicit list

// config/agent-kit.php
'models' => [
    App\Models\Ticket::class,
],

Deliberately not a scan for the attribute. With a scan, adding an attribute anywhere in the codebase publishes a table, and the reviewer of that pull request sees one line in a model rather than a change to the application's exposed surface.

What the agent read

The first question anyone asks after an incident, and it cannot be answered later if nothing was written down at the time. Every call is recorded, before its result is returned.

agent 41 ticket.get -> denied (0 returned, 1 denied)

Identifiers and field names, never field values. A trail that stored the rows would become a second copy of every record an agent ever read, in a table nobody wrote a policy for, and the log would be a softer target than the data it was meant to protect. To see values, read those ids from the source, where the policy still applies.

The exception is the arguments, which are what the agent supplied rather than what the database returned. A search term is kept, because what it was looking for is half of any incident. Read it back as untrusted text; a prompt can put anything there.

A denied read and a missing one are recorded apart, although the agent still cannot tell them apart. get returns null for both, so ids cannot be enumerated. The trail says which it was, because the operator needs to know and the agent never sees the record.

Refused calls are recorded too. One filter on an unexposed field is a mistake. A run of them is probing.

It fails closed here as well

A sink that refuses takes the read down with it:

AuditFailedException: The audit sink refused to record ticket.get, so the
result was withheld rather than served unrecorded.

This is the least popular decision in the package, so here is the reasoning. A trail with silent gaps cannot answer the question it exists for, and the moment a gap opens is exactly the moment an attacker would choose to be reading. If availability matters more to you than a complete record, that is a legitimate trade and it is yours to make:

'audit' => ['strict' => false],

Where it goes

The default writes to a log channel, so it works on a fresh install with no migration and no table to forget. A log file rotates, so bind your own store when "what did the agent read three months ago" becomes a query you need to run:

$this->app->bind(AuditSink::class, YourDatabaseSink::class);

ArraySink ships for your own test suite, so you can assert that a tool call produced the record you expected.

Generating the tools

You do not have to write the tool classes. Generate them from the attribute:

php artisan agent-kit:mcp

One class per declared ability, written to app/Mcp/Tools, and the command prints the block to paste into your server:

protected array $tools = [
    TicketListTool::class,
    TicketGetTool::class,
    TicketSearchTool::class,
];

The bodies delegate to the resource and do nothing else, which the generated file says at the top:

public function handle(Request $request, Registry $registry): Response
{
    $row = $registry->resource(Ticket::class)->get(
        $request->user(),
        $request->get('id'),
        $request->array('include'),
    );

    if ($row === null) {
        return Response::error('No such record, or it is not yours to read.');
    }

    return Response::structured($row);
}

That is the whole handler, on purpose. The field allowlist, the policy check, the ceiling, the relation rules and the audit trail all live in the resource, so anything added to a tool runs outside every one of them. Change the attribute and run the command again rather than editing the class.

The input schema is generated from the same attribute, so an agent sees which fields are filterable, which relations it may expand, and the ceiling it will be held to.

Nothing is generated from an exposure that does not hold together. The command runs verify() first and writes no files if it reports anything, because a tool built on a resource with no policy fails the first time an agent calls it, which is the wrong place to find out.

An existing file is left alone unless you pass --force. Somebody will have edited a generated class, and losing that quietly is worse than making them ask.

--path and --namespace are there if your application does not use the defaults. laravel/mcp is not a dependency of this package; it is only needed by the code the generator writes.

Where this sits

The Laravel MCP ecosystem is busy, and most of it is solving a different problem. It sorts by who the agent is working for.

Protocol and transport. laravel/mcp (34 million installs, and official), php-mcp/laravel (225k), opgginc/laravel-mcp-server (71k), kirschbaum-development/laravel-loop (20k). These carry the tool call. What the tool hands back is left to you, and that is the gap this package fills. It is deliberately not an MCP server itself: wire a resource into a tool and let one of those handle the protocol.

Developer tooling. anilcancakir/laravel-agent-mcp (44k) and onelearningcommunity/laravel-model-explorer (65k) point your own coding agent at your own application. The first says so plainly in its own description: no Sanctum, no user table, no write access. That is the right design for what it does, and it is the opposite of the problem here. When the only person on the other end is you, on your machine, there is no authorization boundary to get wrong.

Production exposure, with a boundary. The case this package is for: the agent answers on behalf of a signed in user and must not return another user's rows. The closest neighbour is mattiasgeniar/filament-mcp, which does per-record CRUD with token auth and policy-aware access for Filament resources. If you are already on Filament, look at that first. This one works against plain Eloquent, needs no admin panel, and refuses rather than publishes when a model has no policy.

Figures checked on Packagist on 8 September 2026.

Install

composer require catidegla/laravel-agent-kit
php artisan vendor:publish --tag=agent-kit-config

Requires PHP 8.2 and Laravel 12.

Scope

This is the authorization and exposure layer, and nothing else. It is deliberately not an MCP server: laravel/mcp already is one and is very good at it, so the generator emits tools for it rather than competing with it.

Testing

composer install
vendor/bin/phpunit

58 tests. They assert the security properties directly rather than describing them: that internal_notes is absent from a payload, that Bob's ticket is not in Alice's list, that a model without a policy throws, that a denied get is indistinguishable from a missing one, that no field value ever reaches the audit record, that a relation cannot reach a model nobody exposed, and that a generated tool class loads and serialises through laravel/mcp itself rather than merely looking right.

License

MIT