Two threads today, and they rhyme more than I expected: both are about who gets to do what, and how you keep that boundary honest. One was exposing an app to an AI agent safely; the other was moving config out of deploy-time files and into a settings UI without losing the guardrails. Different problems, same instinct — make the boundary explicit and put it in one place.
I spent most of the day building out a set of MCP tools so an AI agent can drive an events platform: list events, check readiness, publish, run the lifecycle. The interesting part isn't the tools — it's the fencing around them.
MCP tools authenticate with a token, which means there's no ambient "current tenant" context the way a session-backed web request has. If you lean on a global tenant scope, it reads nothing and your "fenced" query quietly returns everyone's rows. So the rule became: under token auth, filter by organization explicitly, every time, in one shared trait — never a global scope you hope is active.
On top of scope, every tool declares a single ability()
— and it reuses the same permission strings the web app already enforces, so the agent can never be more privileged than the human behind it. Read tools get an #[IsReadOnly]
annotation so destructive calls are visible at a glance. Lookups are by UUID, never the enumerable auto-increment ID.
I wrote this thread up properly as its own post (RBAC + org-scoped MCP tools) because the pattern generalizes well beyond this app. Short version of the lesson: treat every MCP tool as an untrusted endpoint, and put the boundary logic somewhere you can test it in one shot.
it('never resolves a record 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();
});
The other change was on an identity/AD portal: making the LDAP connection settings fully editable from an admin UI, instead of being frozen in config/ldap.php
at deploy time. Previously only the obvious fields were exposed — host, port, base DN, the bind credentials, SSL/TLS, search attribute. Now the awkward ones are too: connection timeout, SASL toggle and options, and the raw ldap_set_option
map.
That last one is a nice little gotcha worth sharing. The options map uses integer keys that are LDAP option constants (e.g. 17
is LDAP_OPT_PROTOCOL_VERSION
). But the moment you store that map as JSON and decode it back, those keys come back as strings — and the underlying ldap_set_option
calls won't match on string keys. So the settings accessor has to restore the integer keys after decoding before the values are usable:
// JSON round-trips integer keys to strings; restore them so the
// values pass straight to ldap_set_option / LdapRecord.
$options = collect(json_decode($raw, true))
->mapWithKeys(fn ($value, $key) => [
is_numeric($key) ? (int) $key : $key => $value,
])
->all();
The pattern underneath is a cache-backed settings layer that overrides deploy-time config() at boot: a service provider reads the stored values and hydrates
config()
(and the LDAP container) before anything uses them. Two guardrails make it safe to hand to an admin:And a small design win: the same connectionConfig()
assembler backs both Save and Test connection, so the connection you test is byte-for-byte the connection you save. No "works in test, fails live" gap because two code paths built the config slightly differently.
Both changes are really the same move: take something that was implicit or frozen — ambient tenancy, deploy-time config — make it explicit and runtime-controllable, then wrap it in guardrails you can point at. Explicit org filter in one trait. Encrypted secret that never hits the cache. Validation at the boundary. One assembler for save-and-test. The feature is the easy part; the guardrail is the engineering.
What's next: more lifecycle tools on the agent side, and probably a "test connection" surfaced directly in the settings UI for every LDAP field, not just the core ones.