There's a moment in every app's life where someone asks: "can an AI assistant just talk to this thing?" Not scrape the UI, not reverse-engineer the REST API — actually call typed tools that the app exposes on purpose, with the same permission rules a human user would hit. That's the problem the Model Context Protocol (MCP) solves, and today's work was wiring a proper MCP server straight into a Laravel application.
I'm going to keep this teachable and generic — the patterns here apply to any Laravel app, not the specific product I was working in. Let me walk through the decisions, because the interesting parts weren't "how do I register a tool" (that's the easy bit) — they were auth, authorization, and where the seams go.
The analogy: an MCP server is a reception desk, not a back door
Think of your app as an office building. The web UI is the front lobby for humans. The REST API is the staff entrance for other systems. An MCP server is a reception desk built specifically for AI agents: the agent walks up, shows ID, and asks the receptionist to perform a named task — "list the participants for this event", "get this user's details". The receptionist doesn't hand over the keys to the building. They check who you are, check what you're allowed to do, perform exactly that one task, and hand back a structured answer.
The mental shift that makes MCP click: you're not exposing your database or your internals. You're exposing a menu of intentions — discrete, named, typed tools — and everything else stays behind the desk.
One endpoint, not a forest of routes
The first real decision was collapsing everything to a single /mcp endpoint. Early on it's tempting to sprawl — a route here, a settings page there, a token thing somewhere else. I pulled it all back to one MCP endpoint that the protocol speaks to, and kept the human-facing settings under the admin area where the rest of the system config lives.
The reasoning is the same reason you don't expose fifteen different webhook URLs: one well-defined surface is easier to secure, document, and reason about than a scattered set. The protocol already gives you tool discovery — the client asks the server "what can you do?" and gets the list — so you don't need separate routes per capability. The tools register themselves; the endpoint stays singular.
Reach for a package rather than hand-rolling the protocol plumbing. I used Laravel's MCP support together with my own cleaniquecoders/laravel-mcp-kit to get the quick-start scaffolding — server registration, tool base classes, the token tables — so the work could focus on auth and the actual tools instead of protocol mechanics.
Dual-auth: Sanctum and OAuth 2.1
Here's the part I want to dwell on, because it's where most "just bolt on an AI endpoint" attempts get sloppy.
An MCP server has two very different kinds of caller:
- A first-party client — your own background job, a CLI, an internal integration. For these, a personal access token (Sanctum) is the right tool. Simple, revocable, scoped.
- A third-party agent acting on behalf of a user — the AI client a user connects from outside. For these, you want a real delegated-authorization flow, which is what OAuth 2.1 is for. The user consents, the agent gets a token bound to that user, and you can revoke it without touching anything else.
Rather than pick one and annoy half your callers, the server accepts both. The trick is to keep the resolution uniform: whichever path authenticated the request, by the time you're inside a tool you have one thing — an authenticated user — and the tool code doesn't care how they got there.
A driver-style guard resolver makes this clean. Conceptually:
interface ResolvesMcpIdentity
{
public function resolve(Request $request): ?Authenticatable;
}
You implement one resolver per scheme (a Sanctum one, an OAuth one), try them in order, and the first that succeeds wins. New scheme later? Add a driver, register it, done — the tools never change. This is the same swappable-backend pattern I lean on everywhere: program against the contract, vary the implementation.
Authorization is not authentication — map abilities to tools
Authenticating the caller tells you who. It says nothing about what they may do. The mistake I see constantly: an app carefully authenticates the MCP connection and then lets any authenticated caller invoke any tool. That's a back door wearing a reception-desk costume.
So every tool maps to an ability, and the same RBAC rules the web UI enforces get enforced here too. The cleanest way to express it is to let each tool declare what it needs:
final class ListEventParticipantsTool extends Tool
{
public function ability(): string
{
return 'participants.view';
}
public function handle(Request $request): array
{
// $request->user() is already resolved by the guard layer.
// Authorization is checked centrally before we ever get here.
return Participant::query()
->where('event_id', $request->validated('event_id'))
->paginate()
->through(fn ($p) => $p->only(['uuid', 'name', 'status']))
->toArray();
}
}
Notice the tool returns a deliberately narrowed projection — UUID public id, name, status — not the full Eloquent model. Tools are an external surface; treat their output like an API resource, not an internal dump. Same discipline as never returning $model->toArray() straight out of a controller.
The ability check lives in one place — a middleware or a base-tool hook — that reads ability() and runs it through the existing gate. One choke point, every tool covered, no per-tool if soup.
The directory tools, conceptually
The actual tools I shipped were a small "directory" set — list participants for an event, list an organization's users, look up a single user, list all users. Boring on purpose. That's the point: an MCP tool should be a thin, well-named, well-authorized verb over data you already expose elsewhere, not a clever new pathway with its own rules. Each one validates its inputs through a form request, checks its ability, and returns a trimmed projection. If a tool starts wanting special-case access that the rest of the app doesn't grant, that's a smell — the answer is to fix the permission model, not to give the tool a secret tunnel.
Test the boundary, not the happy path
The valuable tests here aren't "does the tool return data" — they're "does the tool refuse when it should." Pest makes the negative cases readable:
it('rejects an authenticated user who lacks the ability', function () {
$user = User::factory()->create(); // no participants.view
$this->actingAs($user)
->postJson('/mcp', mcpCall('list_event_participants', ['event_id' => $event->uuid]))
->assertForbidden();
});
it('allows a user with the ability and trims the projection', function () {
$user = User::factory()->withAbility('participants.view')->create();
$response = $this->actingAs($user)
->postJson('/mcp', mcpCall('list_event_participants', ['event_id' => $event->uuid]))
->assertOk();
expect($response->json('result.0'))
->toHaveKeys(['uuid', 'name', 'status'])
->not->toHaveKey('email'); // projection must not leak extra fields
});
The not->toHaveKey('email') assertion is the one I care about most. It's a regression fence: the day someone "helpfully" returns the whole model from a tool, this test goes red before it ships. When your surface is consumed by an autonomous agent, the test suite is the thing standing between a sloppy projection and a data leak.
A token + settings UI, because revocation has to be human-speed
Last piece: a small admin UI to toggle the MCP server on or off and to manage the tokens. This sounds like a nicety; it's actually part of the security model. If the only way to cut off a misbehaving agent is to SSH in and run a tinker command, you've built a fire alarm with no button. A human needs to be able to see "these tokens exist, this one looks wrong, kill it" in a couple of clicks. Put the kill switch where a person under pressure can find it.
Takeaway
Bolting an MCP server onto a Laravel app is easy. Doing it responsibly comes down to three boundaries: authenticate every caller (and accept that first-party and third-party callers want different schemes), authorize every tool against the permission model you already have, and treat every tool's output like a public API resource. Get those three right and the AI assistant becomes a well-behaved guest at the reception desk — not someone who found a window.
What's next: tightening the consent screen on the OAuth path and expanding the tool menu carefully, one well-authorized verb at a time.
Top comments (0)