This is the first MCP tool nearly everyone writes:
class TicketTool extends Tool
{
public function handle(Request $request): Response
{
return Response::structured(Ticket::find($request->get('id')));
}
}
It works, and it contains three separate security failures that are not visible in the code.
One: it returns every column
Ticket::find() gives you the model. Serialising the model gives you every attribute on the row.
That includes internal_notes. It includes the card_last_four somebody added for a support screen. It includes whatever column was added last week for a feature that has not shipped, and the admin_flag that nothing in your UI renders.
This is the quietest of the three because nothing looks wrong. Your Blade templates render four fields, so you think of the model as having four fields. The agent gets all of them, and the model happily explains their contents to whoever asked.
Two: it has no idea who is asking
There is no user in that code. The query is unscoped, so the tool will fetch any ticket by id, for anyone who can reach the tool.
Adding ->where('user_id', auth()->id()) fixes the immediate case and creates a subtler one. The filter now lives in the tool instead of in your policies, so when someone adds a second tool, or a relation, or a scope, the rule has to be remembered again. It will not be.
The check belongs where every other authorization check in the application lives.
Three: nothing stops enumeration
find($id) with an incrementing id is an invitation. An agent that can call the tool can call it with 1, 2, 3, and keep going. Even with the policy check added, the shape of the failure leaks: a missing record and a forbidden one usually answer differently, and that difference maps out which ids exist.
What laravel/mcp is and is not
None of this is a criticism of laravel/mcp. It has 34.8 million installs, it is official, and it does its job well. Its job is the protocol: define a tool, let an agent call it, handle transport and schemas.
What it deliberately does not do is decide what your tool is allowed to hand back. That is an application concern, and it would be wrong for a protocol package to guess at it.
It is also a concern with a consistent shape across every application, which makes it a good candidate for a package instead of something each team reinvents at 4pm on a Friday.
Declaring the exposure instead
What I ended up with is an attribute on the model:
#[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, and each argument closes one of the three failures.
fields is an allowlist. Projection works from that list only and never inspects the model to decide what to include, so adding a column cannot widen exposure. Anything that is not a scalar after casting is refused, because a nested array arriving from a cast would return contents nobody declared.
maxResults is a ceiling applied on top of whatever the agent asks for, with a second global ceiling in config so no single attribute can raise the limit for the whole application.
Authorization runs through your existing policies, per record, not once per query.
The part I would defend hardest
Every one of these fails closed.
A model with no policy throws. Forgetting to write a policy is the most likely mistake in the whole flow, so the default has to be refusal.
A denied get is indistinguishable from a missing one. Both return null, so the tool cannot be used to discover which ids exist.
Denied rows in a list are reported, not dropped. This one took the longest to get right. A tool that silently removes rows the caller cannot see leaves the agent believing the list is complete, and it then answers questions about it with confidence. So the count comes back:
['rows' => [...], 'denied' => 2, 'truncated' => false]
Truncation is reported for the same reason. The query fetches one row over the limit so the response can say whether there were more.
Exposure is a list, not a scan
One deliberate non-feature: the package does not scan your codebase for the attribute. Exposed models are listed explicitly in config.
With a scan, adding an attribute anywhere publishes a table, and the reviewer of that pull request sees one line in a model file. With a list, they see a change to the application's exposed surface, which is what it is.
Not writing the tools at all
The attribute already describes everything a tool needs, so the tools are generated:
php artisan agent-kit:mcp
One laravel/mcp tool class per declared ability, with the input schema derived from the same attribute, so the agent sees which fields are filterable and what ceiling it will be held to. The generated handler delegates to the resource and does nothing else, which the generated file says at the top, because every guarantee lives in the resource and anything added to a tool runs outside all of them.
It refuses to generate from an exposure that fails verification, and it will not overwrite an existing file without --force.
composer require catidegla/laravel-agent-kit
58 tests across PHP 8.2, 8.3 and 8.4, including one that loads a generated tool class and runs it through laravel/mcp's own serialiser. Repo here.
Top comments (0)