Giving an AI agent the keys without giving it the building: RBAC + org-scoped MCP tools in Laravel A developer wired up MCP tools for a multi-tenant Laravel app, implementing explicit organization-scoped filtering and authorization to prevent data leaks when AI agents access the system. The solution uses a trait that resolves events by UUID with a named query scope, avoiding reliance on ambient global scopes that could leak data across tenants. Exposing your app to an AI agent over MCP is basically handing someone a master keyring and trusting them to only open the doors they're supposed to. That trust is a bug waiting to happen. This week I wired up a batch of MCP tools over a multi-tenant Laravel app, and the whole exercise was really about one question: how do I let an agent drive the app without letting it drive someone else's data? Here's the thing about MCP tools — each one is an endpoint. An agent calls list events , publish event , check in participant , and your server runs code on the caller's behalf. The moment you have more than one tenant, every single tool needs to answer two questions before it does anything: are you allowed to do this , and are you allowed to do it here . Authorization and scope. Skip either and you've built a confused deputy. In a normal web request, multi-tenancy is comfortable. You've got a logged-in user, a global scope on the model that quietly appends where organization id = ? , and you mostly forget it's there. Everything Just Works because there's an ambient "current organization" sitting in the session. MCP tools don't have that. The caller authenticates with a token, there's no session, no middleware stack that set up a current-tenant context. If you lean on a global OrganizationScope that reads "the current org" from somewhere, it reads nothing — and a query you assumed was fenced returns every tenant's rows. That's the kind of bug that doesn't throw an error; it just silently leaks. So the rule I settled on: under token auth, never rely on ambient scope. Filter explicitly, every time, in one place. That "one place" is a small trait every event-scoped tool pulls in: trait ResolvesOrgEvents { protected function resolveOrgEvent Authenticatable $user, string $uuid : ?Event { if empty $user- organization id { return null; } return Event::query - withOrganization $user- organization id - where 'uuid', $uuid - first ; } } Nothing clever — and that's the point. The org filter isn't a global scope you hope is active; it's a named query scope withOrganization applied by hand, living in exactly one trait. Every tool that resolves an event by UUID goes through this. If the resolution returns null , the tool answers "not found in your organization" and stops. An agent poking at a UUID from another tenant gets the same response as a UUID that doesn't exist — no oracle, no leak. Notice the lookup is by UUID, not auto-increment ID . Public identifiers should be unguessable. An agent or a prompt-injected one shouldn't be able to enumerate event/1 , event/2 , event/3 . The internal numeric key never leaves the database. Scope keeps you in your tenant. Authorization decides what you can do within it. I gave every tool a single declared ability: Name 'event readiness check' Description 'Check whether an event is ready to publish. Returns ready=true/false and blocking issues.' IsReadOnly class EventReadinessCheckTool extends McpKitTool { use ResolvesOrgEvents; protected function ability : string { return 'events.view.details'; } public function handle Request $request : Response { $user = $this- authorizedUser $request ; if $user === null { return $this- unauthorized ; } $validated = $request- validate 'event' = 'required', 'string', 'max:36' , ; $event = $this- resolveOrgEvent $user, $validated 'event' ; if $event === null { return Response::error 'Event not found in your organization.' ; } // ... do the read-only work } } A few deliberate choices here. The ability method is the tool's contract — it says, in one line, "you need this permission to call me." The base McpKitTool does the gate-check in authorizedUser , so the permission logic isn't copy-pasted into every handle . And crucially, it's the same ability string the web app and the underlying action already use . The readiness check leans on events.view.details ; the publish flow leans on the same gate the lifecycle action enforces. One permission model, three entry points web, action, MCP . I'm not maintaining a second, parallel "what can the agent do" matrix that drifts out of sync with the real one — that drift is exactly how an agent ends up more privileged than the human behind it. The IsReadOnly annotation is a small honesty signal. Read tools and write tools get marked differently, so a client can reason about which calls have side effects. list events and event readiness check are read-only; publish event is not. It's cheap to annotate and it makes the destructive surface explicit. And the input still goes through $request- validate . The agent is an untrusted client like any other — max:36 on a UUID field isn't paranoia, it's the same hygiene you'd apply to a public form request. Once a couple of tools existed, the pattern crystallized and the rest were almost mechanical: list events is the entry point — it's the only tool that doesn't take a UUID, because it's how the agent resolveOrgEvent . The fence lives in one file.That uniformity matters more than any single tool. When fifteen tools all follow the same four rules, you can audit the rules instead of auditing fifteen handle methods. The org-fencing is the kind of thing that's easy to get right today and quietly break in six months when someone "optimizes" a query. So it gets a Pest test that asserts the boundary directly — not "does the happy path work" but "does the wrong tenant get nothing": it 'never resolves an event from another organization', function { $mine = Event::factory - for $orgA - create ; $theirs = Event::factory - for $orgB - create ; $user = User::factory - for $orgA - create ; expect resolveOrgEvent $user, $mine- uuid - not- toBeNull - and resolveOrgEvent $user, $theirs- uuid - toBeNull ; } ; it 'returns null when the user has no organization', function { $user = User::factory - create 'organization id' = null ; expect resolveOrgEvent $user, Event::factory - create - uuid - toBeNull ; } ; The second case — a user with no org context — is the one people forget. Under token auth you can absolutely end up with an authenticated principal that isn't attached to a tenant, and "no org" must mean "no access," not "all access by accident." MCP makes it genuinely easy to expose your app to an agent — maybe a little too easy. The mechanics of registering a tool are trivial; the discipline is all in the boundaries. Treat every tool as an untrusted endpoint: explicit tenant scope never ambient, under token auth , one declared ability per tool that reuses your existing permission model, UUIDs over enumerable IDs, and read/write honesty baked in. Put the fence in one trait so there's a single place to get it right — and a single place to test. An agent should be able to do everything the human behind it can do, in exactly the tenant they belong to, and nothing more. That's not an AI problem. It's the same multi-tenancy and authorization discipline we've always needed — MCP just removes every excuse for being sloppy about it.