Originally published at hafiz.dev
There are about six good tutorials on building a Laravel MCP server. Every one of them ends at the same place: you make a tool, you connect Claude, the agent calls your tool, everyone claps. Then the tutorial stops.
That's the exact moment the interesting part starts. Because what you just built is an HTTP endpoint that lets a language model run your code. Mcp::web('/mcp/orders', OrderServer::class) is a public route by default, and the tool behind it might refund a payment, delete a record, or read a customer's data. The getting-started guides walk you right up to that door and then wave goodbye before anyone locks it.
This post is the lock. Authentication, per-tool authorization, the annotation trap that looks like a safety feature and isn't, rate limiting an endpoint an AI can call in a loop, and how to test that all of it actually holds. If you've read the Laravel MCP getting-started guide or built the app-friendly version I covered in making your Laravel app AI-agent-friendly, this is the next step you actually need before any of it goes near production.
What you're actually exposing
Start by being honest about the threat model, because it's different from a normal API.
A normal REST endpoint is called by code you wrote or a client you documented. An MCP tool is called by a language model interpreting natural language, sometimes from a user you've never met, sometimes with arguments the model invented to fit what it thought you meant. The caller is non-deterministic by design. That's the whole point of the protocol, and it's also the whole problem.
So three things are true at once. The endpoint is public unless you protect it. The arguments are model-generated, so they can be malformed or adversarial in ways a normal client never would be. And the tool descriptions you write are read by the model to decide what to call, which means a badly scoped tool gets invoked in situations you didn't picture. A deleteRecords tool with a vague description is a loaded gun with a helpful label.
None of this means MCP is unsafe. It means the safety is your job, and Laravel gives you every piece you need. The pieces just aren't assembled anywhere, so let's assemble them.
Layer one: authentication, and why local is not exempt
A web server registered with Mcp::web() is reachable by anyone who finds the URL. Authenticate it. The docs give you two real options and one trap.
Sanctum is the pragmatic choice for most apps. Add the middleware and require a bearer token:
use App\Mcp\Servers\OrderServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp/orders', OrderServer::class)
->middleware('auth:sanctum');
Every request now needs Authorization: Bearer <token>, and inside your tools $request->user() resolves to the token's owner. For an internal server, or one your own product's agents call, this is enough.
OAuth via Passport is what the MCP spec actually standardizes on, and it's the right call when third-party MCP clients (someone else's Claude, a tool you don't control) need to connect:
Mcp::oauthRoutes();
Mcp::web('/mcp/orders', OrderServer::class)
->middleware('auth:api');
Here's the part worth internalizing before you rely on it: Laravel MCP uses OAuth as a translation layer to your authenticatable model, and it advertises a single mcp:use scope. Custom scopes aren't supported. So OAuth authenticates who the agent is acting as, but it does not carve up what they can do. If your mental model of OAuth includes fine-grained scopes gating individual tools, drop it here. Authentication tells you the user. Authorization is still entirely on you, and that's the next layer.
The trap is thinking local servers don't need any of this. A local server runs as an Artisan command for agents on the same machine, which feels safe. But it runs with your application's full privileges, every binding in your container, your database, your filesystem. The security boundary for a local server is whatever can start that process. If that's a coding agent executing on your dev machine with your credentials, the blast radius is your entire local environment. Treat the machine boundary as the auth boundary, and don't register a local server that does anything you wouldn't let a shell script do unattended.
Layer two: authorization, per tool, inside handle
Authentication gets you a user. Now decide what that user can do, and do it in two places, because they guard different things.
shouldRegister controls whether a tool is visible in the server's tool list:
public function shouldRegister(Request $request): bool
{
return $request->user()?->can('refund-orders') ?? false;
}
This is good practice. It keeps tools a user can't use out of the list the model sees, which means the model won't try to call them and won't hallucinate around their existence. But understand exactly what it does: it hides the tool. Hiding is not blocking. A caller who knows the tool name can still attempt to invoke it, and shouldRegister returning false is not a guaranteed rejection at the point of execution the way an authorization check is.
So the actual gate goes inside handle(), every time, on every tool that does anything sensitive:
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
public function handle(Request $request): Response
{
if (! $request->user()->can('refund-orders')) {
return Response::error('You are not authorized to issue refunds.');
}
$validated = $request->validate([
'order_id' => ['required', 'integer', 'exists:orders,id'],
'amount' => ['required', 'integer', 'min:1', 'max:500000'],
]);
// ...
}
Two things are happening there and both matter. The can() check is your real authorization boundary, the one that actually stops execution. And the validation is not optional politeness, it's your defense against model-generated arguments. The exists rule stops a refund against an order that isn't there. The max stops the model refunding fifty thousand euros because it misread "500" as cents-or-not. Model input is untrusted input. Validate it exactly as hard as you'd validate a public form, because functionally that's what it is.
The pattern I hold to: shouldRegister for visibility, can() for the gate, validate() for the arguments. Skip any one of them and you've left a hole that the other two don't cover. This is the same principle behind stopping an AI agent from destroying your Laravel app, applied at the protocol boundary instead of the SDK. If you want the review-before-execution version of the same instinct, the AI SDK's human-in-the-loop tool approval pauses a call for a person; this is the authorization layer underneath it.
The annotation trap
Laravel MCP lets you tag tools with annotations: #[IsReadOnly], #[IsDestructive], #[IsIdempotent], #[IsOpenWorld]. They look like access controls. They are not.
These are hints to the client. They travel to the AI client as metadata so it can make smarter decisions, like warning a user before calling a destructive tool or preferring a read-only one. That's useful for a well-behaved client. But nothing in your server enforces them. A client can ignore #[IsReadOnly] completely, and a compromised or hostile client absolutely will. Marking a tool #[IsReadOnly] does not prevent it writing if its handle() writes.
So use annotations, they improve the experience with honest clients and they're good documentation of intent. Just never let one stand in for a check. If a tool must not modify data, the guarantee lives in what handle() does and what can() allows, not in an attribute the client is free to disregard. The annotation describes the tool's behavior; it doesn't constrain it.
This is the single most likely place for a false sense of security in the whole feature, because the attribute reads like a policy and sits right there in the class looking authoritative.
Layer three: rate limiting an endpoint a robot can loop
A human hits your API at human speed. An agent in a retry loop, or a streaming tool processing a batch, can hit it as fast as the network allows. And MCP tools can return generators that hold an SSE stream open, which is a different resource profile from a normal request-response.
Throttle the server route like you'd throttle a login endpoint:
Mcp::web('/mcp/orders', OrderServer::class)
->middleware(['auth:sanctum', 'throttle:mcp']);
Then define the limiter against the authenticated user, not the IP, because many agents sit behind shared egress addresses:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('mcp', function (Request $request) {
return $request->user()
? Limit::perMinute(60)->by($request->user()->id)
: Limit::perMinute(10)->by($request->ip());
});
Sixty a minute is a starting point, not a recommendation. Set it from what a legitimate session actually needs, then add headroom. The point is that an unbounded MCP endpoint is a cost and availability risk the moment a model decides to call your tool in a loop, and models do decide that. An agent will happily call the same lookup tool a dozen times in one turn, each result making it think of a new question. Cap it.
For anything expensive behind a tool, the throttle is the first line, not the only one. Push the heavy work to a queue and return a job reference, the same way you would for any slow endpoint, which I went through in processing thousands of queued jobs without breaking. A tool that kicks off a job and returns immediately can't hold a worker hostage.
Testing that the locks hold
The reason this feature survives contact with production is that all of it is testable without a live model or a network call. Laravel MCP ships test helpers that let you invoke a tool as a specific user and assert on the result.
The tests that matter here aren't the happy path. They're the negative cases, the ones that prove your gates actually reject:
it('refuses refunds for unauthorized users', function () {
$user = User::factory()->create(); // no refund permission
$response = OrderServer::actingAs($user)
->tool(RefundOrderTool::class, [
'order_id' => 42,
'amount' => 5000,
]);
$response->assertHasErrors();
});
it('rejects an amount above the ceiling', function () {
$manager = User::factory()->refundManager()->create();
$response = OrderServer::actingAs($manager)
->tool(RefundOrderTool::class, [
'order_id' => 42,
'amount' => 99999999,
]);
$response->assertHasErrors();
});
Write the "unauthorized user is refused" test for every sensitive tool. Write the "bad arguments are rejected" test for every tool that takes model input, which is all of them. If you set up your suite the way I described in the Pest testing guide, these drop straight into it. A green suite here means the model can throw whatever it wants at your server and the boundaries hold.
A quick word on tool descriptions as an attack surface
One thing that isn't obvious until it bites you: the description you write on a tool is instructions to the model. A vague or overreaching description causes the model to call the tool in situations you didn't intend, which is a security issue dressed as a copywriting issue.
"Manages orders" invites the model to reach for that tool for anything order-shaped. "Issues a refund against a specific order, for a specific amount, when a customer reports a defect" tells the model exactly when this fires and when it doesn't. Narrow descriptions are narrow attack surfaces. Write them like you're briefing an over-eager junior who takes every instruction literally, because you are.
My take
The rule I'd publish on the team wiki: authenticate the server, authorize inside every sensitive handle(), validate every argument, throttle the route, and never trust an annotation as a control. Five things, all of them boring, all of them enforced in code you can test.
The one I'd emphasize hardest is that authentication and authorization are different jobs and MCP only hands you the first one cleanly. The single mcp:use scope means the framework knows who is calling but has no opinion on what they may do. Every "what" decision is a can() check you write. Miss that distinction and you'll ship a server that's authenticated and wide open, which is arguably worse than one with no auth at all, because it looks secure in the code review.
I'll also say the honest thing: MCP is young, the security patterns around it are younger, and the most dangerous tools are the ones that felt harmless in isolation. A read tool that exposes one customer's data is a read tool that exposes every customer's data if the authorization is wrong. Start with your least dangerous tool, get the five layers right on it, and use it as the template for everything else. Don't expose the refund tool until the lookup tool's tests are green.
Build the server. Then lock it before anyone, human or model, walks through the door.
FAQ
Does a local MCP server need authentication?
Not in the HTTP sense, since it runs as an Artisan command rather than a route. But it runs with your full application privileges, so the security boundary becomes whatever can start the process. On a dev machine driven by a coding agent, that's your entire local environment. Treat the machine access as the auth boundary.
Can I use OAuth scopes to control which tools an agent can call?
Not with Laravel MCP's built-in OAuth. It uses OAuth as a translation layer to your authenticatable model and advertises a single mcp:use scope; custom scopes aren't supported. Per-tool authorization is done with can() checks inside each tool's handle() method, not with scopes.
Do the #[IsReadOnly] and #[IsDestructive] annotations prevent a tool from doing damage?
No. They're advisory metadata sent to the AI client to help it make decisions. Your server doesn't enforce them, and a client can ignore them. A tool is only read-only if its handle() method doesn't write. Never rely on an annotation as an access control.
How do I stop an AI agent from calling my tool in a loop?
Apply Laravel's throttle middleware to the MCP route and define a rate limiter keyed by the authenticated user rather than the IP, since agents often share egress addresses. For expensive operations, push the work to a queue and return a job reference so a single call can't tie up a worker.
Is validation really necessary if I've defined an input schema?
Yes. The JSON schema gives the model a shape to aim for, but it isn't enforcement, and model-generated arguments can still be malformed or adversarial. Validate inside handle() with Laravel's validator exactly as you would for a public form, including existence and range checks.
Wrapping up
Building an MCP server is a solved problem with six good tutorials. Securing one isn't, because the docs hand you the pieces (Sanctum, Passport, can(), shouldRegister, annotations, throttling, test helpers) without assembling them into a threat model. The model is simple once you see it: the endpoint is public, the caller is non-deterministic, the arguments are untrusted, and the annotations are hints. Everything else follows.
Authenticate the route, authorize inside handle(), validate every argument, throttle the endpoint, and test the negative cases. Do that and you've got an MCP server you'd actually put on the internet.
Top comments (0)