DEV Community

Christ-loisele Atidegla
Christ-loisele Atidegla

Posted on

Your Laravel MCP tool returns every column, and three lines of code hide it

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')));
    }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)