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.
Thread 1 — MCP tools that respect tenancy and permissions
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();
});
Thread 2 — making LDAP config editable at runtime, safely
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:
- The bind password is held encrypted at rest and is never placed in the cache as plaintext. Secrets don't get to be convenient.
- The JSON fields are validated to actually decode to an object before they're persisted — a malformed paste fails at save time, not at connection time when someone's locked out.
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.
The thread between the threads
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.