Dev Log: 2026-06-24 — agent guardrails and runtime LDAP config A developer built MCP tools for an AI agent to drive an events platform, enforcing organization-scoped access and reusing existing permission strings to prevent privilege escalation. They also made LDAP connection settings fully editable from an admin UI, implementing a cache-backed settings layer that overrides deploy-time config and restoring integer keys from JSON to ensure compatibility with ldap_set_option. 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.