I spent today wiring an MCP server into a Laravel app that manages a Kong API gateway. The interesting part wasn't "make the AI talk to the app" — that's the easy bit now that there's a first-party package for it. The interesting part was making sure the MCP layer is just another UI over the same rules, and never a quiet little backdoor that skips authorization.
Here's how I think about it, and the patterns that kept it honest.
MCP is a third front-end, not a new set of powers
The app already has two ways in: a web UI and an HTTP API. Both go through the same authorization, the same action classes, the same approval workflow. When you bolt on an MCP server, the temptation is to let the tools "just query the database" because it's faster. That's exactly how you end up with an AI agent that can do things a logged-in user never could.
So the rule I set for myself: every MCP tool maps to a permission the human already has, and every write goes through the same action class the web UI calls. MCP gets zero special privileges.
Tool base class: gate first, work second
With laravel/mcp, a tool is a class extending Laravel\Mcp\Server\Tool. Instead of letting each tool reinvent the auth check, I push it into an abstract base. Each concrete tool just declares the permission it needs; the base decides whether the caller is allowed.
abstract class GatewayTool extends Tool
{
/** The permission this tool requires, e.g. "gateway.manage.services". */
abstract protected function permission(): string;
protected function authorizedUser(Request $request): ?User
{
$user = $request->user();
if (! $user instanceof User || ! $user->can($this->permission())) {
return null;
}
return $user;
}
protected function unauthorized(): Response
{
return Response::error(
"Unauthorized — this tool requires the '{$this->permission()}' permission."
);
}
}
The $request->user() here is the token holder — I issue scoped bearer tokens with Sanctum, so the MCP session is authenticated as a real user with real permissions. Superadmin still bypasses through the usual Gate::before, so I don't have to special-case it.
One small thing that matters more than it looks: an unauthorized tool returns an error, not partial data. No "here's what you're allowed to see" half-answers. If you can't call it, you get a clean refusal and nothing leaks.
class ListServicesTool extends GatewayTool
{
protected function permission(): string
{
return 'gateway.view.services';
}
public function handle(Request $request): Response
{
if (! $user = $this->authorizedUser($request)) {
return $this->unauthorized();
}
// ...paginated, uuid/code only — never the internal id
}
}
Note the identifiers: tools take and return uuid or a human code, never the auto-increment primary key. The internal id stays internal — same convention I use everywhere, and it matters doubly when an LLM is the one passing arguments around.
Tiered capabilities via a driver-based contract
Not every deployment has the same data behind it. Aggregated metrics might come from gateway snapshots on a small install, from PostgreSQL request logs on a bigger one, or from Elasticsearch on the heavy tier. I didn't want analytics tools to care which.
So the analytics tools depend on a contract, and the container binds the right implementation based on a configured tier:
interface UsageMetricsProvider
{
public function usageSummary(Carbon $from, Carbon $to): array;
public function getProviderName(): string;
}
// Only the richer tiers implement this marker contract.
interface DetailedUsageMetricsProvider extends UsageMetricsProvider
{
public function errorBreakdown(Carbon $from, Carbon $to): array;
public function slowestEndpoints(Carbon $from, Carbon $to): array;
}
The base analytics tool resolves the detailed provider only when it's actually available, and degrades gracefully when it isn't:
abstract class AnalyticsTool extends GatewayTool
{
public function __construct(protected UsageMetricsProvider $metrics) {}
protected function permission(): string
{
return 'gateway.view.usage-report';
}
protected function detailedMetrics(): ?DetailedUsageMetricsProvider
{
return $this->metrics instanceof DetailedUsageMetricsProvider
? $this->metrics
: null;
}
}
A detailed tool checks detailedMetrics() and, if it's null, tells the caller why — "not available on this reporting tier, switch to the log-backed tier" — instead of throwing a confusing error or returning empty arrays. The marker-interface trick (a sub-interface that only the capable drivers implement) is a clean way to do capability detection without a pile of if ($tier >= 2) checks scattered around.
Write tools reuse the action classes — no shortcuts
This is the part I care about most. The write tools (approve a service, expire a subscription, configure a plugin) don't touch models directly. They call the exact same invokable action classes the web controllers call. Which means:
- A new service still starts as
draftand must be submitted, then approved by someone other than the owner, before it syncs to the gateway. - Changing a plugin on an approved service still reverts it to
pending_approval. - Expiring a subscription still revokes gateway access; renewing still restores it.
The MCP tool is a thin adapter: parse input, resolve the resource by uuid, call the action, format the response. All the business rules live in one place and the AI can't route around them. If I ever change the approval flow, the MCP server inherits it for free.
I also deliberately left some things out. Destructive deletion isn't exposed over MCP at all — that stays a web-only, human-driven request/approve flow. Not everything needs an AI-accessible tool, and "can I" shouldn't decide "should I".
Two servers, two audiences
I ended up splitting into two MCP servers sharing the same tool conventions: an ops server (manage services, routes, plugins, run audits, read full analytics) and a consumer-facing catalogue server (browse available APIs, request a subscription, check my own usage). Same Sanctum tokens, different permission sets decide which endpoint a token can even reach. The catalogue server simply can't see the management tools — it's not a UI toggle, it's a different tool list bound to different access.
Testing the gate
The thing worth a Pest test isn't the happy path — it's the refusal. The most important assertion is "a user without the permission gets an error and zero data":
it('refuses a tool when the token user lacks the permission', function () {
$user = User::factory()->create(); // no gateway permissions
$response = ListServicesTool::make()
->handle(Request::for(['page' => 1], actingAs: $user));
expect($response->isError())->toBeTrue()
->and($response->content())->toContain('requires')
->and($response->content())->not->toContain('uuid');
});
it('returns data once the permission is granted', function () {
$user = User::factory()->create();
$user->givePermissionTo('gateway.view.services');
$response = ListServicesTool::make()
->handle(Request::for(['page' => 1], actingAs: $user));
expect($response->isError())->toBeFalse();
});
Test the deny path first. It's the one a refactor is most likely to quietly break, and it's the one with the worst blast radius if it does.
Takeaway
An MCP server is a powerful new surface, and that's exactly why it should be the least privileged one — never more capable than the human whose token it carries. Three habits made that easy to hold: gate every tool through a base class against an existing permission, lean on a driver-based contract so capabilities degrade honestly instead of lying, and make write tools call the same action classes as the rest of the app so the workflow can't be bypassed. The AI gets to be useful without getting to be sneaky.
Next up: tightening the tool instructions so the model picks the right tool the first time — turns out a good #[Instructions] block is half the battle, but that's a post for another day.
Top comments (2)
“MCP is a third front-end, not a new set of powers” is the right mental model. The dangerous version of MCP is when tools become a parallel backend with softer rules. Mapping each tool to an existing permission and forcing writes through the same action classes keeps the agent path boring, which is exactly what you want for anything that can mutate infrastructure.
For me, the rules, policy, authorisation, access control should maintain as it is despite of accessing through UI, API, Console / Terminal or MCP.