<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ricardo Rodrigues</title>
    <description>The latest articles on DEV Community by Ricardo Rodrigues (@codemalasartes).</description>
    <link>https://dev.to/codemalasartes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F900488%2Ff2206bfb-c9eb-4f28-be82-666e0114d62e.jpeg</url>
      <title>DEV Community: Ricardo Rodrigues</title>
      <link>https://dev.to/codemalasartes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/codemalasartes"/>
    <language>en</language>
    <item>
      <title>An immutable audit trail for AI agent actions (FastAPI + async SQLAlchemy)</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Tue, 02 Jun 2026 11:39:49 +0000</pubDate>
      <link>https://dev.to/codemalasartes/an-immutable-audit-trail-for-ai-agent-actions-fastapi-async-sqlalchemy-4m4c</link>
      <guid>https://dev.to/codemalasartes/an-immutable-audit-trail-for-ai-agent-actions-fastapi-async-sqlalchemy-4m4c</guid>
      <description>&lt;h2&gt;
  
  
  Rule zero: the audit log is append-only
&lt;/h2&gt;

&lt;p&gt;The single most important design decision is a discipline, not a library: &lt;strong&gt;the audit table is never updated and never deleted from.&lt;/strong&gt; Not by a feature, not by a migration, not by an admin in a hurry. An event happened, so a row exists, forever.&lt;/p&gt;

&lt;p&gt;That sounds obvious until you realise how many ORMs make &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt; one method call away. So the rule has to be enforced by convention &lt;em&gt;and&lt;/em&gt; by review: in this codebase, the audit module exposes exactly one operation — &lt;code&gt;append&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JSON&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.orm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DeclarativeBase&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeclarativeBase&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Append-only. Never UPDATE, never DELETE.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;audit_logs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                    &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# "action.blocked", ...
&lt;/span&gt;    &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the indexes on &lt;code&gt;event_type&lt;/code&gt; and &lt;code&gt;timestamp&lt;/code&gt; — you &lt;em&gt;will&lt;/em&gt; query this by "what happened to this agent, in this window," and you don't want a sequential scan over millions of rows when an auditor is waiting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule one: writing the log must never break the agent's path
&lt;/h2&gt;

&lt;p&gt;If your audit write is in the hot path and the database hiccups, you've just taken down the agent over a &lt;em&gt;log line&lt;/em&gt;. That's backwards. Logging is critical for compliance but it must not be a single point of failure for execution.&lt;/p&gt;

&lt;p&gt;FastAPI's &lt;code&gt;BackgroundTasks&lt;/code&gt; is the simple, dependency-free way to defer the write until after the response is sent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;append_audit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;session_factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One operation, append-only. Runs after the response is returned.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;session_factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# type: AsyncSession
&lt;/span&gt;        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AuditLog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;submit_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# from the policy engine
&lt;/span&gt;
    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;append_audit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;session_factory&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;actor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A couple of async gotchas worth saying out loud, because they cost me time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use the &lt;strong&gt;&lt;code&gt;asyncpg&lt;/code&gt;&lt;/strong&gt; driver (&lt;code&gt;postgresql+asyncpg://...&lt;/code&gt;), never a sync driver, in an async app. Mixing sync DB calls into an async event loop is a classic way to block everything.&lt;/li&gt;
&lt;li&gt;If your &lt;code&gt;DATABASE_URL&lt;/code&gt; comes from an environment variable, &lt;code&gt;.strip()&lt;/code&gt; it. A trailing newline pasted into a dashboard env var produces connection errors that look like everything and nothing.
## Rule two: make it tamper-evident, not just append-only&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Append-only protects you from your own code. It does not, on its own, prove to a &lt;em&gt;third party&lt;/em&gt; that no one with database access quietly edited a row. For that you want each entry to be cryptographically chained to the one before it — the same idea behind a hash chain.&lt;/p&gt;

&lt;p&gt;Here's the technique. When you append an event, you hash its contents together with the hash of the previous event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hash_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Canonical serialization matters: same input must always produce same bytes.
&lt;/span&gt;    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;separators&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;prev_hash&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that hash on each row. Now, to verify the trail hasn't been altered, you recompute the chain from the start: if any single row was changed, deleted, or reordered, every hash after it breaks, and the final hash won't match. You don't have to trust the storage layer — you can &lt;em&gt;prove&lt;/em&gt; integrity by recomputation.&lt;/p&gt;

&lt;p&gt;(I'm being honest about state here: the append-only, non-blocking design above is what's running. The hash-chaining is the direction I'm adding next — if you build the same thing, do them in this order, because immutability-by-discipline is what makes the chaining meaningful.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the unglamorous core
&lt;/h2&gt;

&lt;p&gt;Nobody demos an audit table. But it's the thing that turns "we think the agent behaved" into "here is the record, in order, provably unaltered, exportable as evidence." In a bank, an insurer, a hospital — that distinction is the difference between an agent that stays a prototype and one that's allowed into production.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://horkos.eu" rel="noopener noreferrer"&gt;https://horkos.eu&lt;/a&gt; — a governance layer that sits in front of AI agents. If you've had to defend an automated system to an auditor, I'd love to know what they actually asked you for. That's the spec I'm trying to hit.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>python</category>
      <category>claude</category>
    </item>
    <item>
      <title>Should this AI agent be allowed to act? Building a policy gateway in FastAPI</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Tue, 02 Jun 2026 11:37:55 +0000</pubDate>
      <link>https://dev.to/codemalasartes/should-this-ai-agent-be-allowed-to-act-building-a-policy-gateway-in-fastapi-3580</link>
      <guid>https://dev.to/codemalasartes/should-this-ai-agent-be-allowed-to-act-building-a-policy-gateway-in-fastapi-3580</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo48sp94c2ofpl241mktm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo48sp94c2ofpl241mktm.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;AI agents have quietly crossed a line. They no longer just suggest text — they &lt;em&gt;act&lt;/em&gt;. They send emails, write to databases, call internal APIs, trigger refunds. In a toy project that's fine. In a company where one of those actions touches customer data or moves money, "the agent decided to" is not an answer anyone in security or risk will accept.&lt;/p&gt;

&lt;p&gt;The missing piece isn't a smarter model. It's a &lt;strong&gt;decision point in front of every action&lt;/strong&gt; — somewhere you can ask, before anything happens: &lt;em&gt;is this allowed?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been building exactly that as an open project called &lt;a href="https://horkos.eu" rel="noopener noreferrer"&gt;Horkos&lt;/a&gt;. This post walks through the core idea — a policy gateway that intercepts an agent's action and returns one of three outcomes: &lt;strong&gt;allow&lt;/strong&gt;, &lt;strong&gt;block&lt;/strong&gt;, or &lt;strong&gt;require human approval&lt;/strong&gt;. The code below is simplified from the real thing to keep it readable, but the shape is the shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The model: an action is a thing you evaluate before you run it
&lt;/h2&gt;

&lt;p&gt;Most agent frameworks execute a tool call and &lt;em&gt;then&lt;/em&gt; (maybe) log it. That ordering is the whole problem. By the time you have a log, the money already moved.&lt;/p&gt;

&lt;p&gt;So the first move is to treat every action as data that flows through a checkpoint &lt;em&gt;before&lt;/em&gt; execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent wants to act
        │
        ▼
  evaluate against policy
        │
   ┌────┴────┐
 BLOCKED   ALLOWED
   │         │
   │    requires approval?
   │      ┌──┴──┐
   │     YES    NO
   │      │      │
   │   pause   execute
   │      │      │
   └──────┴──────┴──► always write to the audit trail
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every path ends in the same place: a log. The decision is the interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal action payload
&lt;/h2&gt;

&lt;p&gt;The agent (or an SDK wrapper around it) describes what it wants to do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RiskLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;LOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;MEDIUM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;HIGH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;CRITICAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ActionRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db_query&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wire_transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RiskLevel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MEDIUM&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice there's no &lt;code&gt;output_data&lt;/code&gt; yet — the action hasn't run. We're deciding whether it's &lt;em&gt;allowed&lt;/em&gt; to.&lt;/p&gt;

&lt;h2&gt;
  
  
  The policy engine
&lt;/h2&gt;

&lt;p&gt;Policies are just data. Keeping them as JSON means a non-engineer can change what's blocked without a deploy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DEFAULT_POLICY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;# Substrings that should never reach a tool, regardless of action type.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block_patterns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DROP TABLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DELETE FROM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;UNION SELECT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore previous instructions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;disregard the system prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;# Action types that always need a human before they run.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval_for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;wire_transfer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create_admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;export_customer_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="c1"&gt;# Risk threshold above which we escalate to a human.
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval_above&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things are happening here, and they map to two real-world fears:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;block_patterns&lt;/code&gt;&lt;/strong&gt; catches the "the agent got prompt-injected / hallucinated a destructive command" case. A &lt;code&gt;DROP TABLE&lt;/code&gt; or an &lt;code&gt;ignore previous instructions&lt;/code&gt; in the input is a hard stop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;require_approval_for&lt;/code&gt;&lt;/strong&gt; catches the "this action is legitimate but too consequential to be fully autonomous" case. Moving money is allowed — by a human, this time.
Now the evaluator:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;               &lt;span class="c1"&gt;# "allow" | "block" | "require_approval"
&lt;/span&gt;    &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;


&lt;span class="n"&gt;_RISK_ORDER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;critical&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PolicyEngine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ActionRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;haystack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# 1. Hard blocks — destructive or injection-like input.
&lt;/span&gt;        &lt;span class="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block_patterns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;haystack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Input matched blocked patterns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# 2. Action types that always need a human.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval_for&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Action type &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action_type&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; requires approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# 3. Risk threshold escalation.
&lt;/span&gt;        &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_RISK_ORDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval_above&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_RISK_ORDER&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Risk &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;risk_level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; is at or above threshold&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Decision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allow&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@staticmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Turn nested input into one searchable string.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PolicyEngine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_flatten&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not magic. It's a checkpoint with rules you can read. That readability is a feature — when an auditor asks &lt;em&gt;"why was this blocked?"&lt;/em&gt;, the answer is a line in a JSON file, not a model's vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into a FastAPI endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PolicyEngine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DEFAULT_POLICY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/v1/actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;submit_action&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ActionRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;BackgroundTasks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;block&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# The action never runs. We record the attempt.
&lt;/span&gt;        &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_audit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;violations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;violations&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;require_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Pause. A human gets pinged (Slack, email, whatever).
&lt;/span&gt;        &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_audit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval.requested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notify_approver&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;awaiting_approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;# Allowed — the caller is cleared to execute.
&lt;/span&gt;    &lt;span class="n"&gt;background_tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_audit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action.allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;allowed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The audit write goes to a &lt;code&gt;BackgroundTasks&lt;/code&gt; so logging never slows down or breaks the agent's path. Whatever the decision, the attempt is recorded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three outcomes, one demo
&lt;/h2&gt;

&lt;p&gt;Same agent, three actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Routine notification&lt;/strong&gt; → &lt;code&gt;allowed&lt;/code&gt;, logged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A query containing &lt;code&gt;DROP TABLE users&lt;/code&gt;&lt;/strong&gt; → &lt;code&gt;blocked&lt;/code&gt; before it ever reaches the database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A €50,000 transfer&lt;/strong&gt; → &lt;code&gt;awaiting_approval&lt;/code&gt;; a human approves or denies, and the agent waits.
That third one is the part people underestimate. "Human-in-the-loop" gets said a lot; what it actually means in code is: the action &lt;em&gt;pauses&lt;/em&gt;, state is persisted, a human decision flips it, and the agent resumes or stops. The policy decides &lt;em&gt;which&lt;/em&gt; actions deserve that treatment — not the agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this is
&lt;/h2&gt;

&lt;p&gt;Horkos it's live at &lt;a href="https://horkos.eu" rel="noopener noreferrer"&gt;horkos.eu&lt;/a&gt; with a Python SDK that wraps an agent in a few lines. I'm a platform engineer working on infrastructure in a regulated industry, and this is the layer I kept wishing existed before signing off on anything autonomous.&lt;/p&gt;

&lt;p&gt;If you're putting agents anywhere near production, I'd genuinely like to hear where your security or compliance team draws the line. That's the design input I care about most right now — drop it in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>python</category>
      <category>claude</category>
    </item>
    <item>
      <title>The Governance Layer AI Agents Are Missing</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Mon, 01 Jun 2026 22:11:14 +0000</pubDate>
      <link>https://dev.to/codemalasartes/the-governance-layer-ai-agents-are-missing-2e3i</link>
      <guid>https://dev.to/codemalasartes/the-governance-layer-ai-agents-are-missing-2e3i</guid>
      <description>&lt;p&gt;Enterprises learned to govern data. Tool governance is the parallel layer almost no one has built yet.&lt;/p&gt;

&lt;p&gt;Over the last decade, enterprises built a real discipline around data. Not just storing it — governing it. Cataloging what exists, defining who owns it, controlling who can access it, and proving where it came from and where it went. The question "who is using this data, how, and was it allowed?" went from unanswerable to routine.&lt;/p&gt;

&lt;p&gt;The Model Context Protocol just recreated that question one layer up. And almost no one has the answer yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  From assistants to agents
&lt;/h3&gt;

&lt;p&gt;MCP did something quietly significant: it turned AI clients into agents. Through it, tools like Claude, Cursor, and Windsurf can reach real systems — query databases, read repositories, call internal APIs, trigger deployment workflows. The protocol itself is clean and well-designed. The governance around it is where every enterprise will eventually get stuck.&lt;/p&gt;

&lt;p&gt;MCP works beautifully for an individual developer. It becomes a problem the moment a team adopts it.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it looks like inside most teams
&lt;/h3&gt;

&lt;p&gt;Right now, MCP adoption inside organizations tends to look like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero governance.&lt;/strong&gt; Developers install servers ad hoc. Security has no approved list, no process, and no visibility into which tools are connected to AI clients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared access.&lt;/strong&gt; Teams pass around the same tokens and config files. Everyone effectively gets the same access, regardless of role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local chaos.&lt;/strong&gt; Many MCP servers run as local stdio processes on laptops. They can't be centrally monitored, shared, revoked, or audited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No forensics.&lt;/strong&gt; When an agent queries a production database or triggers a workflow, no one can reconstruct who initiated it, when, and whether it was permitted.
The buyer pain here is not discovery. There are plenty of directories to find MCP servers. The pain is &lt;em&gt;safe operationalization&lt;/em&gt; — adopting MCP without creating shadow AI tooling, uncontrolled credentials, and untraceable agent activity.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The parallel layer
&lt;/h3&gt;

&lt;p&gt;This is the part worth sitting with, especially if you work in data or AI governance.&lt;/p&gt;

&lt;p&gt;Data governance answers questions about the &lt;strong&gt;asset&lt;/strong&gt;: what is this dataset, who owns it, what is its sensitivity, who is allowed to use it, where did it come from.&lt;/p&gt;

&lt;p&gt;Tool governance has to answer questions about the &lt;strong&gt;action&lt;/strong&gt;: which tool did the agent invoke, who was the agent acting for, was that invocation allowed, and what happened.&lt;/p&gt;

&lt;p&gt;These are not competing categories. They're parallel layers in the same enterprise stack, bought by the same people — platform engineering, security, and the emerging AI enablement function. And critically, each has a blind spot the other fills.&lt;/p&gt;

&lt;p&gt;When a developer uses an AI client and an MCP server to query a governed table, the data platform sees the table but not the agent's tool call. A tool-governance layer sees the tool call but not the data's classification. Connected, the two produce something neither has alone: end-to-end lineage from a business data asset all the way to the AI agent invocation that touched it.&lt;/p&gt;

&lt;p&gt;Put simply: &lt;strong&gt;data governance explains the asset. Tool governance explains the agent action.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What the tool-governance layer actually needs
&lt;/h3&gt;

&lt;p&gt;If you accept the framing, the requirements fall out naturally. They mirror what an identity provider brought to applications, applied to AI tool calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A single governed endpoint&lt;/strong&gt; per team, so AI clients connect to one controlled surface instead of dozens of ungoverned servers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-member identity&lt;/strong&gt; — individual credentials, so every tool call can be attributed to a person and revoked independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool-level allowlists&lt;/strong&gt; — least-privilege control over exactly which tools each member can call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A protocol-level audit trail&lt;/strong&gt; — an immutable operational record of every invocation: who, what, when, and the outcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted, governable infrastructure&lt;/strong&gt; — a way to take local stdio servers and run them as monitored, controlled services instead of processes on a laptop.
None of this is exotic. It's the same governance discipline enterprises already apply elsewhere. It just hasn't been applied to AI tool usage yet, because the usage itself is only a year or two old.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Why now
&lt;/h3&gt;

&lt;p&gt;The reason this matters now and not later is timing. MCP adoption is moving faster than internal security processes. By the time most organizations notice the gap, they'll already have unmanaged AI tool access spread across teams. Governance is far cheaper to introduce before broad rollout than to retrofit after.&lt;/p&gt;

&lt;p&gt;This is the layer we're building at &lt;a href="https://mcpnest.io" rel="noopener noreferrer"&gt;mcpnest.io&lt;/a&gt; — a governed MCP gateway, per-member access control, hosted infrastructure, and a protocol audit log for AI tool usage. If you want the full thesis, the architecture, and how it complements existing data and AI governance platforms, I put together an enterprise overview:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://mcpnest.io/mcpnest-enterprise-overview.pdf" rel="noopener noreferrer"&gt;Read the mcpnest.io enterprise overview (PDF)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you lead platform, security, or data governance and you're thinking about how AI tool adoption gets controlled in your organization, I'd value your perspective on where this framing holds and where it breaks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>cursor</category>
    </item>
    <item>
      <title>The Day Your AI Agent Has the Keys to Everything</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Sun, 24 May 2026 13:06:47 +0000</pubDate>
      <link>https://dev.to/codemalasartes/the-day-your-ai-agent-has-the-keys-to-everything-78k</link>
      <guid>https://dev.to/codemalasartes/the-day-your-ai-agent-has-the-keys-to-everything-78k</guid>
      <description>&lt;p&gt;There's a moment coming in your company, if it hasn't arrived already.&lt;/p&gt;

&lt;p&gt;A developer wires up an AI agent — Claude, Cursor, whatever your team uses — to a Model Context Protocol server. Suddenly the agent can query your production database, read your private repos, hit internal APIs, trigger deployments. Not &lt;em&gt;suggest&lt;/em&gt; doing those things. &lt;em&gt;Do&lt;/em&gt; them. Autonomously, in response to natural language, often without a human approving each action.&lt;/p&gt;

&lt;p&gt;On a laptop, that's a productivity miracle. In production, it's a question nobody in the room can answer: &lt;strong&gt;who is allowed to do what, and how would we ever know what happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That question is the one this piece is about. Because the answer, for most companies adopting MCP today, is: &lt;em&gt;we have no idea.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern we've seen before
&lt;/h2&gt;

&lt;p&gt;If you've been in infrastructure long enough, this rhymes with something.&lt;/p&gt;

&lt;p&gt;A decade ago, microservices arrived the same way. Teams split monoliths apart, called the pieces "services," and shipped. The architecture was exciting; the governance was an afterthought. We spent the &lt;em&gt;next&lt;/em&gt; five years retrofitting service meshes, API gateways, identity, and observability onto systems that were never designed to be governed. The cleanup cost more than the migration.&lt;/p&gt;

&lt;p&gt;MCP is at the exact same stage microservices were in 2014 — explosive adoption, no governance layer, and a collective decision to worry about security later.&lt;/p&gt;

&lt;p&gt;"Later" has a way of arriving as an incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap, stated plainly
&lt;/h2&gt;

&lt;p&gt;Here is what MCP adoption actually looks like inside most teams right now:&lt;/p&gt;

&lt;p&gt;Every developer installs their own MCP servers, with their own credentials, on their own machine. Security has no approved list and no visibility into what's connected. Many of those servers run as local processes — meaning they can't be centrally monitored, shared, revoked, or audited. And when an agent does something — queries a customer table, posts to a channel, runs a command — there is no record of who initiated it, through which identity, or whether it should have been allowed.&lt;/p&gt;

&lt;p&gt;This isn't hypothetical. Security researchers recently found roughly 1,800 MCP servers exposed to the public internet. Of the sample they analyzed, every single one accepted unauthenticated requests. Some of those servers connect directly to internal systems that would otherwise never be reachable from outside.&lt;/p&gt;

&lt;p&gt;Read that again: tools that can act on internal infrastructure, reachable by anyone, authenticating no one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worse than a normal security gap
&lt;/h2&gt;

&lt;p&gt;A normal vulnerability is a door someone might pick. This is different, and worse, in two specific ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the agent is unpredictable by design.&lt;/strong&gt; A traditional application does what it was coded to do. An AI agent does what it's &lt;em&gt;persuaded&lt;/em&gt; to do. If a developer points an agent at an untrusted web page or an unvetted codebase, an attacker can embed instructions in that content — and the agent, trying to be helpful, may call a real tool with real credentials. The infrastructure did nothing wrong. The agent was simply talked into it. Your firewall has no concept of "the model was tricked."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, there's no chain of custody.&lt;/strong&gt; When the incident review happens — and at scale, it always eventually happens — the question will be: &lt;em&gt;which agent, acting for which person, called which tool, against which system, and was that permitted?&lt;/em&gt; If your MCP setup is a pile of local configs, you cannot answer that. Not because you didn't log enough. Because the activity never passed through anywhere that &lt;em&gt;could&lt;/em&gt; log it.&lt;/p&gt;

&lt;p&gt;This is the part that turns a quiet adoption decision into a board-level risk: by the time you need the audit trail, it's too late to have started keeping one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What governing this actually requires
&lt;/h2&gt;

&lt;p&gt;Strip away the noise and the requirements are not exotic. They're the same disciplines identity providers brought to applications fifteen years ago — applied, now, to tool calls made by AI.&lt;/p&gt;

&lt;p&gt;You need a single point every tool call passes through, instead of dozens of direct, ungoverned connections. You need per-person identity, so every action is attributable and individually revocable — not a shared token that gives everyone the same keys. You need tool-level permissions, so a support engineer's agent can read a ticket but not delete a database. And you need an audit trail of every call — who, what, when, outcome — that exists &lt;em&gt;by default&lt;/em&gt;, not as something you scramble to bolt on after the first scare.&lt;/p&gt;

&lt;p&gt;Notice what governs this can't be the individual server, and can't be the client. A local server can harden itself but can't prove custody across a team. The client can't reason about what other members are doing. The governance has to live at the layer all the traffic passes through. That's not a feature you add to MCP. It's a layer you put in front of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this goes
&lt;/h2&gt;

&lt;p&gt;The companies that get this right won't be the ones with the most AI agents. They'll be the ones who can answer, on any given Tuesday, exactly what their agents did and whether it was allowed. That capability is about to separate the teams that scale AI safely from the ones that quietly accumulate risk until it surfaces.&lt;/p&gt;

&lt;p&gt;This is the layer we build at &lt;a href="https://mcpnest.io" rel="noopener noreferrer"&gt;mcpnest.io&lt;/a&gt; — a governed gateway for MCP, with per-member access, tool-level permissions, hosted infrastructure, and a protocol-level audit log that stores metadata, never payloads, so it's EU-resident and clean by construction. One endpoint your team connects through. Everything attributable. Everything revocable. Everything recorded.&lt;/p&gt;

&lt;p&gt;If you're adopting MCP and the questions in this piece made you slightly uncomfortable, that discomfort is the signal. You can scan your own MCP configuration for these exact risks, free and entirely in your browser, at &lt;a href="https://mcpnest.io/scan" rel="noopener noreferrer"&gt;mcpnest.io/scan&lt;/a&gt; — nothing leaves your machine.&lt;/p&gt;

&lt;p&gt;The microservices generation learned the cost of governing too late. The MCP generation doesn't have to.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>claude</category>
    </item>
    <item>
      <title>Why Enterprises Will Struggle With MCP — And What to Do About It</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Wed, 20 May 2026 14:35:16 +0000</pubDate>
      <link>https://dev.to/codemalasartes/why-enterprises-will-struggle-with-mcp-and-what-to-do-about-it-1mbf</link>
      <guid>https://dev.to/codemalasartes/why-enterprises-will-struggle-with-mcp-and-what-to-do-about-it-1mbf</guid>
      <description>&lt;h2&gt;
  
  
  What Enterprise IT Asks First
&lt;/h2&gt;

&lt;p&gt;When any new technology enters an enterprise, four questions must be answered before broad deployment:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Who has access to what?&lt;/li&gt;
&lt;li&gt;What happened, and when?&lt;/li&gt;
&lt;li&gt;How do we remove access when someone leaves?&lt;/li&gt;
&lt;li&gt;Can we prove compliance to an auditor?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For MCP today, in a default setup, the honest answers are: everyone has access to everything, we do not know what happened, we cannot remove access without breaking the whole team, and no we cannot prove compliance.&lt;/p&gt;

&lt;p&gt;This is not a reason to avoid MCP. It is a reason to build the governance layer before the rollout, not after.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Three Phases Every Enterprise Goes Through
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Phase 1 — Exploration&lt;/strong&gt;&lt;br&gt;
A few developers install MCP servers individually. Cursor with GitHub integration. Claude Desktop with a database connector. Productivity improves visibly. Word spreads internally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 2 — Team Adoption&lt;/strong&gt;&lt;br&gt;
Teams start sharing configs. Someone creates a recommended MCP stack document. A workspace token gets shared in Slack. Now the entire team is using the same token with the same access to the same tools. Nobody is logging anything. Security has no visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phase 3 — The Incident&lt;/strong&gt;&lt;br&gt;
Something unexpected happens. A production query runs at an unexpected time. A file gets modified that should not have been. An internal API gets called by an agent nobody authorised. Now the question is: "what happened, and who did it?" Nobody can answer it.&lt;/p&gt;

&lt;p&gt;This is the moment governance becomes urgent. The problem is that retrofitting governance into an existing MCP deployment is significantly harder than building it correctly from the start — because you have no historical data, no baseline, and no audit trail from before the governance layer existed.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Seven Capabilities Enterprise MCP Requires
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Centralised endpoint&lt;/strong&gt;&lt;br&gt;
One authenticated URL per team. Not dozens of individual JSON configs across dozens of developer machines. A single point of control, logging, and revocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Per-member identity&lt;/strong&gt;&lt;br&gt;
Every tool call attributable to a specific person. Not "the workspace called something." Individual tokens per member, so the audit trail captures who did what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Tool-level access control&lt;/strong&gt;&lt;br&gt;
The ability to say "Alice can use these tools, Bob can use those." Enforced at the protocol level. The principle of least privilege applied to AI tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Isolated server deployment&lt;/strong&gt;&lt;br&gt;
MCP servers running in defined, auditable environments — not on developer laptops. Containers with clean lifecycle management: deployable, monitorable, and terminatable centrally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Quality and trust signals&lt;/strong&gt;&lt;br&gt;
A trust signal per server that goes beyond GitHub stars. Maintenance velocity, last commit date, publisher verification, config completeness — visible before installation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Instant revocation&lt;/strong&gt;&lt;br&gt;
When someone leaves the team, their access gone in seconds. Without rotating the workspace token. Without reconfiguring every AI client on the team. Individual member revocation with no blast radius.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Protocol-level audit log&lt;/strong&gt;&lt;br&gt;
A record of every tool call — who called it, which tool, which server, when, and whether it succeeded. Not application-level logging. Protocol-level logging that captures everything that flows through the gateway.&lt;/p&gt;

&lt;p&gt;None of these are optional for enterprise. All of them are absent from a default MCP setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Retrofit Problem
&lt;/h2&gt;

&lt;p&gt;The most expensive version of this problem is the one teams build toward without realising it.&lt;/p&gt;

&lt;p&gt;A team that deploys MCP without governance for six months has six months of tool calls with no attribution, no audit trail, and no access history. When an auditor asks "what did your AI agents have access to between January and June?", the answer is "we do not know."&lt;/p&gt;

&lt;p&gt;That answer is not acceptable in regulated industries. It is barely acceptable in any enterprise context where AI agents have access to production systems.&lt;/p&gt;

&lt;p&gt;The teams that establish governance now will have a clean audit trail from the first tool call. The teams that wait will be unable to reconstruct the past.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Window Is Now
&lt;/h2&gt;

&lt;p&gt;MCP adoption in enterprise is in Phase 1 and early Phase 2 for most organisations. The exploration is happening. The team adoption is beginning.&lt;/p&gt;

&lt;p&gt;The governance layer needs to arrive before Phase 3.&lt;/p&gt;

&lt;p&gt;Building it after an incident is possible. It is just significantly more expensive — in time, in credibility, and in the irreversible absence of historical audit data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;MCPNest is the enterprise governance layer for MCP servers — Gateway, per-member access control, hosted infrastructure, and audit logging for AI engineering teams.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;mcpnest.io&lt;/em&gt;&lt;/p&gt;







&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm49w91mbb7nw1tcxtgck.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm49w91mbb7nw1tcxtgck.png" alt=" " width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>mcp</category>
      <category>cursor</category>
    </item>
    <item>
      <title>The MCP Security Gap No One Is Talking About</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Sun, 17 May 2026 17:11:34 +0000</pubDate>
      <link>https://dev.to/codemalasartes/the-mcp-security-gap-no-one-is-talking-about-5h30</link>
      <guid>https://dev.to/codemalasartes/the-mcp-security-gap-no-one-is-talking-about-5h30</guid>
      <description>&lt;p&gt;The MCP ecosystem is growing fast. Thousands of servers, dozens of clients, and teams across the industry moving from personal experimentation to production deployments. But there is a security architecture problem baked into how most teams are using MCP today — and most of them do not know it yet.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem: One Token, Everyone
&lt;/h2&gt;

&lt;p&gt;When you set up an MCP workspace for your team, you generate one Bearer token. That token goes into every team member's AI client configuration. Claude Desktop, Cursor, Windsurf — they all use the same token to authenticate against your MCP servers.&lt;/p&gt;

&lt;p&gt;This means every team member has identical access to every MCP tool in your workspace.&lt;/p&gt;

&lt;p&gt;Your junior developer has the same permissions as your principal engineer. Your intern can call the deployment tools. Your contractor can query the production database. There is no way to say "Alice can use the database tools but not the CI/CD tools" — not without managing separate workspaces per person, which defeats the purpose of a shared workspace entirely.&lt;/p&gt;

&lt;p&gt;This is not a preference problem. It is a security architecture problem. And it gets worse.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Revocation Problem
&lt;/h2&gt;

&lt;p&gt;Imagine someone leaves the team. To revoke their access, you rotate the workspace token. Now you have to update the configuration of every AI client on the team. Every developer. Every machine. Every client app. Simultaneously.&lt;/p&gt;

&lt;p&gt;In a team of ten people, this is painful. In a team of fifty, it is a production incident.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Proper Access Control Looks Like
&lt;/h2&gt;

&lt;p&gt;Enterprise security operates on the principle of least privilege. Every actor gets access to exactly what they need to do their job — and nothing else. When someone leaves, you revoke their access specifically, without affecting anyone else.&lt;/p&gt;

&lt;p&gt;For MCP, this means per-member tokens and per-member tool allowlists.&lt;/p&gt;




&lt;h2&gt;
  
  
  How MCPNest Gateway Layer 2 Works
&lt;/h2&gt;

&lt;p&gt;We built Gateway Layer 2 to solve this at the infrastructure level, not the application level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-member Bearer tokens.&lt;/strong&gt; Every workspace member gets their own token. The workspace token still exists for system-level access, but individual members authenticate with their own credentials. The audit trail captures &lt;code&gt;member_id&lt;/code&gt; on every call — you know exactly who called what tool and when.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool allowlists per member.&lt;/strong&gt; Admins define exactly which tools each member is permitted to call. Alice gets the database tools. Bob gets the search and retrieval tools. The deployment tools are restricted to the engineers who should have them. These allowlists are configured in the Security tab of the workspace UI — no code required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enforcement toggle.&lt;/strong&gt; The workspace owner enables or disables allowlist enforcement with a single toggle. You define the allowlists first, verify they are correct, and then enable enforcement when ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instant revocation.&lt;/strong&gt; When someone leaves the team, you delete their token. Their access is gone in seconds. Nobody else is affected. No workspace token rotation. No client reconfiguration across the team.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Audit Trail
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;tools/call&lt;/code&gt; through the MCPNest Gateway is logged with:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;workspace_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;member_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which team member&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;tool_name&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which tool was called&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;latency_ms&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How long the call took&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;status&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Success or failure&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is not workspace-level logging. This is tool-level forensics. When something goes wrong — and in production, something always goes wrong — you know exactly what happened, who triggered it, and when.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters Now
&lt;/h2&gt;

&lt;p&gt;The MCP ecosystem is at the same inflection point that OAuth was at in 2010. Teams are moving from "I set this up on my laptop" to "we are running this in production for the whole engineering org." The security model needs to evolve at the same pace.&lt;/p&gt;

&lt;p&gt;Right now, most teams are one credential leak away from full workspace exposure. One misconfigured client. One developer who stored their token in a public dotfile. One contractor whose access should have been revoked three months ago.&lt;/p&gt;

&lt;p&gt;Per-member access control is not a nice-to-have for enterprise MCP deployments. It is the minimum viable security posture for any team taking this seriously.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;MCPNest is the governance layer for MCP servers — Gateway, Layer 2 access control, hosted MCP infrastructure, and a marketplace of 7,554+ servers.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://mcpnest.io" rel="noopener noreferrer"&gt;mcpnest.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Ricardo Rodrigues is the founder of MCPNest and a Platform Engineer at a large financial institution in Portugal.&lt;/em&gt;&lt;/p&gt;







</description>
      <category>ai</category>
      <category>claude</category>
      <category>mcp</category>
      <category>cursor</category>
    </item>
    <item>
      <title>Why MCP Needs a Governance Layer</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Thu, 14 May 2026 12:53:58 +0000</pubDate>
      <link>https://dev.to/codemalasartes/why-mcp-needs-a-governance-layer-16p8</link>
      <guid>https://dev.to/codemalasartes/why-mcp-needs-a-governance-layer-16p8</guid>
      <description>&lt;p&gt;The MCP ecosystem is at an inflection point. What started as a protocol for connecting AI assistants to external tools has become the default integration layer for a generation of AI-powered engineering tools — Claude, Cursor, Windsurf, GitHub Copilot. Thousands of MCP servers exist. Tens of thousands of developers have installed them.&lt;/p&gt;

&lt;p&gt;Almost none of them have thought about what happens when this goes to production.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Individual Problem Is Solved
&lt;/h2&gt;

&lt;p&gt;For a solo developer, MCP is close to perfect. You find a server, copy a JSON snippet, paste it into your config file, restart your AI client, and you have a new capability. GitHub integration, database access, Slack messaging, web search — all available in natural language. The friction is low enough that exploration is easy.&lt;/p&gt;

&lt;p&gt;The MCP ecosystem solved the individual problem well.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Team Problem Is Not
&lt;/h2&gt;

&lt;p&gt;The moment a second person joins, the model breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication.&lt;/strong&gt; You have a workspace token. You share it with your team. Now everyone has the same access to every tool. You cannot differentiate between what Alice is allowed to do and what Bob is allowed to do. You cannot revoke Bob's access without rotating the token — which breaks every AI client on the team simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment.&lt;/strong&gt; Most MCP servers run as local stdio processes via npx. They exist only on the machine where they were installed. They cannot be shared across a team. They cannot be put behind a gateway. They cannot be monitored or audited. When a developer leaves, their MCP servers leave with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visibility.&lt;/strong&gt; When something goes wrong — when a production database gets queried unexpectedly, when a CI/CD pipeline is triggered by an agent, when sensitive data appears in a context where it should not — you cannot answer the most basic post-incident question: "which agent called which tool, and when?" There is no log. There is no audit trail. There is no answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Quality.&lt;/strong&gt; 7,500+ MCP servers exist. GitHub stars measure historical interest, not current health. A server with 3,000 stars may not have had a commit in 18 months. There is no quality signal. There is no trust layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Matters Now
&lt;/h2&gt;

&lt;p&gt;AI agents are moving toward production infrastructure. They are not just answering questions — they are writing code, querying databases, triggering deployments, sending messages. The tools they use via MCP are real tools with real access to real systems.&lt;/p&gt;

&lt;p&gt;The governance problem is not theoretical. It is the same problem that made API keys dangerous before OAuth, that made server access chaotic before centralised identity providers, that made network access ungovernable before Zero Trust. Every time a powerful capability becomes accessible to teams, governance follows — because it has to.&lt;/p&gt;

&lt;p&gt;MCP is at the API key moment. The capability exists. The governance does not.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Governance for MCP Looks Like
&lt;/h2&gt;

&lt;p&gt;A governance layer for MCP needs to answer four questions consistently:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Who is using which tools?&lt;/strong&gt;&lt;br&gt;
Every tool call must be attributable to a specific person. Not "the workspace called something" — "Alice called the database query tool." Member-level attribution requires per-member tokens and protocol-level logging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. What is each person allowed to do?&lt;/strong&gt;&lt;br&gt;
Access control must operate at the tool level, not the workspace level. The security engineer should not have the same MCP permissions as the junior developer. Tool allowlists per member enforce least privilege at the protocol layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. How do you revoke access instantly?&lt;/strong&gt;&lt;br&gt;
When someone leaves the team, their access must be gone in seconds. Without touching anyone else's configuration. Without rotating credentials that break the whole team. Per-member tokens make this possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Where do the servers run?&lt;/strong&gt;&lt;br&gt;
Local stdio processes are ungovernable by design. MCP servers need to run in isolated environments with defined lifecycles — deployable, monitorable, and terminatable without touching developer machines.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Category That Does Not Exist Yet
&lt;/h2&gt;

&lt;p&gt;Enterprise MCP governance is not a product category yet. It will be.&lt;/p&gt;

&lt;p&gt;The same pattern has played out in every previous infrastructure layer. API management was chaos before control planes emerged. Identity was chaos before centralised providers. Network access was chaos before Zero Trust made it manageable.&lt;/p&gt;

&lt;p&gt;MCP is the next layer. The governance problem is structural, not optional. And the window to define this category is open now — before the major platforms build their own solutions, before the ecosystem consolidates, before the standard emerges.&lt;/p&gt;

&lt;p&gt;The teams that govern MCP today will not be scrambling to retrofit security into production deployments tomorrow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ricardo Rodrigues is the founder of MCPNest.io and a Platform Engineer at a large financial institution in Portugal. MCPNest.io is the enterprise governance layer for MCP servers — Gateway, per-member access control, hosted infrastructure, and audit logging.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;mcpnest.io&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
    </item>
    <item>
      <title>From Developer Laptops to Isolated Containers — Enterprise MCP Infrastructure with MCPNest</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Sat, 09 May 2026 22:56:00 +0000</pubDate>
      <link>https://dev.to/codemalasartes/from-developer-laptops-to-isolated-containers-enterprise-mcp-infrastructure-with-mcpnest-3phj</link>
      <guid>https://dev.to/codemalasartes/from-developer-laptops-to-isolated-containers-enterprise-mcp-infrastructure-with-mcpnest-3phj</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The MCP ecosystem is growing fast. Anthropic, Microsoft, Google, AWS, and Cloudflare are all publishing official MCP servers. Developers are connecting AI tools — Claude, Cursor, Windsurf — to databases, internal APIs, GitHub, and business systems.&lt;/p&gt;

&lt;p&gt;The infrastructure for doing this at an individual level is mature. The infrastructure for doing this at an enterprise level does not exist.&lt;/p&gt;

&lt;p&gt;Today, at most engineering teams, MCP servers run on developer laptops via npx. This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credentials stored in JSON files on individual machines&lt;/li&gt;
&lt;li&gt;No isolation between the MCP server and the host system&lt;/li&gt;
&lt;li&gt;No central visibility into what is running&lt;/li&gt;
&lt;li&gt;No clean offboarding when a developer leaves&lt;/li&gt;
&lt;li&gt;No audit trail of what tools were called or what data was accessed
This is not a theoretical risk. It is the default state of every engineering team that has adopted MCP tooling without a governance layer.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What We Built
&lt;/h2&gt;

&lt;p&gt;The MCPNest Orchestrator is the infrastructure layer that fixes this.&lt;/p&gt;

&lt;p&gt;Instead of running MCP servers locally, the Orchestrator manages isolated Docker containers on central infrastructure. Developers deploy from the workspace dashboard. The AI client connects to the MCPNest Gateway, which authenticates the request, checks the tool allowlist, and proxies to the hosted container.&lt;/p&gt;

&lt;p&gt;Nothing changes in the developer workflow. Everything changes in visibility and control.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude / Cursor / Windsurf
  ↓ Bearer mng_xxx (per-member token)
MCPNest Gateway (Next.js on Vercel)
  ↓ Auth + allowlist check + audit log
MCPNest Orchestrator (FastAPI on Hetzner, Nuremberg EU)
  ↓ Docker socket
MCP Bridge Container
  ↓ stdio
MCP Server (npx package)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Gateway handles authentication and authorisation. The Orchestrator handles the container lifecycle. The Bridge handles protocol translation between HTTP and stdio.&lt;/p&gt;




&lt;h2&gt;
  
  
  The MCP Bridge
&lt;/h2&gt;

&lt;p&gt;Most MCP servers speak stdio — they expect to be launched as a subprocess and communicate via stdin/stdout. The Gateway speaks HTTP.&lt;/p&gt;

&lt;p&gt;The Bridge is a FastAPI application that wraps any stdio MCP server as an HTTP endpoint. It launches the MCP server as a subprocess on startup, performs the MCP handshake, and exposes two HTTP endpoints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /tools/list&lt;/code&gt; — returns the server tool definitions&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /tools/call&lt;/code&gt; — proxies a tool call to the subprocess
The Bridge image pre-installs 12 MCP server packages to avoid cold-start delays:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@modelcontextprotocol/server-filesystem
@modelcontextprotocol/server-github
@modelcontextprotocol/server-postgres
@modelcontextprotocol/server-memory
@modelcontextprotocol/server-everything
@modelcontextprotocol/server-sequential-thinking
@modelcontextprotocol/server-brave-search
@modelcontextprotocol/server-slack
@modelcontextprotocol/server-puppeteer
@upstash/context7-mcp
@notionhq/notion-mcp-server
mcp-server-sqlite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server to run is passed via the &lt;code&gt;MCP_COMMAND&lt;/code&gt; environment variable. The Bridge reads it on startup using &lt;code&gt;shlex.split&lt;/code&gt;, launches the subprocess, performs the MCP initialize handshake, and begins listening on port 8080.&lt;/p&gt;

&lt;p&gt;If the subprocess closes stdout before the handshake completes — which happens when a server requires credentials that were not provided — the Bridge raises a &lt;code&gt;RuntimeError&lt;/code&gt; and the container exits. This is by design: servers that require credentials must have them configured before deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Container Security
&lt;/h2&gt;

&lt;p&gt;Every container the Orchestrator starts is hardened at the Docker level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;cap_drop ALL&lt;/strong&gt;&lt;br&gt;
Removes all Linux capabilities from the container. The MCP server process has zero elevated permissions. It cannot bind to privileged ports, modify network interfaces, or perform any operation that requires elevated access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;no-new-privileges&lt;/strong&gt;&lt;br&gt;
Prevents the process from gaining additional privileges via setuid binaries or file capabilities. Even if the MCP server package contains a setuid binary, it cannot use it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resource limits&lt;/strong&gt;&lt;br&gt;
CPU and memory limits are enforced per container based on the resource profile (small / medium / large). This prevents runaway processes and resource exhaustion that could affect other containers on the same host.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dedicated Docker network&lt;/strong&gt;&lt;br&gt;
All hosted containers run on a dedicated Docker network (&lt;code&gt;mcpnest_hosted&lt;/code&gt;), isolated from the host network. Containers cannot reach the host network directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-root user&lt;/strong&gt;&lt;br&gt;
The Bridge runs as a non-root &lt;code&gt;bridge&lt;/code&gt; user inside the container. The MCP server subprocess inherits this user context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential isolation&lt;/strong&gt;&lt;br&gt;
Credentials are passed as environment variables to the container at deploy time. They are never logged by the Orchestrator (explicitly excluded from logs), never stored in plain text in the database, and never visible to developers after submission.&lt;/p&gt;


&lt;h2&gt;
  
  
  Credential Management
&lt;/h2&gt;

&lt;p&gt;Not all MCP servers start cleanly without configuration. Servers like the GitHub MCP server require a Personal Access Token. The PostgreSQL server requires a connection string. Slack requires a bot token and team ID.&lt;/p&gt;

&lt;p&gt;Without credential management, these servers fail silently — the subprocess closes stdout before the handshake completes and the container exits with an error that is difficult to diagnose.&lt;/p&gt;

&lt;p&gt;We solved this with a &lt;code&gt;required_env_vars&lt;/code&gt; column in the &lt;code&gt;mcp_allowed_images&lt;/code&gt; table. Each entry defines the credentials a server needs, with a key, label, type (text or password), and help text.&lt;/p&gt;

&lt;p&gt;When a developer clicks Deploy on a server that has required credentials, a modal opens before the deploy request is sent. The modal collects the values — clearly labelled, with help text, password inputs masked. Only when all required fields are filled can the developer proceed.&lt;/p&gt;

&lt;p&gt;The values are passed directly to the Orchestrator in the deploy request body and injected as environment variables into the container. They are never written to disk on the host and never appear in Orchestrator logs.&lt;/p&gt;

&lt;p&gt;When a developer's access is revoked, their Bearer token is invalidated at the Gateway level. The hosted containers continue running for other team members unaffected. The credentials inside the containers are not exposed.&lt;/p&gt;


&lt;h2&gt;
  
  
  Deploy Flow
&lt;/h2&gt;

&lt;p&gt;The full flow from click to running container:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Developer clicks Deploy in the workspace Hosted tab&lt;/li&gt;
&lt;li&gt;If the server has &lt;code&gt;required_env_vars&lt;/code&gt;, a modal collects the credentials before the request is sent&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;POST /api/hosted&lt;/code&gt; request goes to the Next.js API route with the server slug and credentials&lt;/li&gt;
&lt;li&gt;The API route creates an instance record in Supabase with status &lt;code&gt;starting&lt;/code&gt; and forwards the deploy request to the Orchestrator&lt;/li&gt;
&lt;li&gt;The Orchestrator checks if the Bridge image is already present locally using &lt;code&gt;docker.images.get()&lt;/code&gt; — skips pull if found, pulls only if not cached&lt;/li&gt;
&lt;li&gt;The Orchestrator starts the container with &lt;code&gt;cap_drop ALL&lt;/code&gt;, &lt;code&gt;no-new-privileges&lt;/code&gt;, resource limits, and the credentials as environment variables&lt;/li&gt;
&lt;li&gt;The Orchestrator updates the instance record in Supabase with status &lt;code&gt;running&lt;/code&gt; and the assigned host port&lt;/li&gt;
&lt;li&gt;The Next.js API route returns the &lt;code&gt;instance_id&lt;/code&gt; to the client&lt;/li&gt;
&lt;li&gt;The workspace UI opens a terminal modal that polls &lt;code&gt;/api/hosted/{id}/logs&lt;/code&gt; every 3 seconds&lt;/li&gt;
&lt;li&gt;When the container reaches RUNNING + HEALTHY state (Docker health check passing), the modal updates automatically and stops polling
The real-time deploy console shows the container logs as they stream. The developer sees the MCP server startup output — including any errors — in real time. There is no need to SSH into the server or use Docker CLI.&lt;/li&gt;
&lt;/ol&gt;


&lt;h2&gt;
  
  
  The Pull-Skip Optimisation
&lt;/h2&gt;

&lt;p&gt;Early in development, every deploy attempt pulled the Bridge image from the registry, even though the image is local-only and was never pushed to Docker Hub. This caused every deploy to fail with a &lt;code&gt;pull access denied&lt;/code&gt; error.&lt;/p&gt;

&lt;p&gt;The fix was simple but important: check if the image exists locally before attempting a pull.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Image %s already present locally, skipping pull&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_event_loop&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;run_in_executor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cli&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to pull image &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="si"&gt;!r}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This also makes deploys faster — there is no network round-trip for images that are already cached on the host.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Gives Enterprise Teams
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Isolation&lt;/strong&gt;&lt;br&gt;
MCP servers run in sandboxed containers. A misconfigured or compromised server cannot access the host system or interfere with other containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Central credential management&lt;/strong&gt;&lt;br&gt;
No credentials on developer machines. No JSON files with GitHub tokens or database connection strings on laptops. Clean offboarding — when a developer leaves, their Gateway token is revoked and their access is gone immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit trail&lt;/strong&gt;&lt;br&gt;
Every tool call is logged at the Gateway level — member, server, tool name, latency, HTTP status, timestamp. No inputs or outputs are stored. GDPR safe by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consistent infrastructure&lt;/strong&gt;&lt;br&gt;
Every developer on the team deploys from the same verified catalog. No drift between machines, no manual setup, no "works on my machine" issues with MCP server configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visibility&lt;/strong&gt;&lt;br&gt;
Real-time deploy logs during startup. Per-instance log viewer for ongoing debugging. Health monitoring with automatic RUNNING + HEALTHY detection. Terminate with full cleanup — container removed, database record updated.&lt;/p&gt;




&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;The Orchestrator runs on a dedicated Hetzner server in Nuremberg, EU. All state is managed in Supabase (Frankfurt, EU). The Gateway runs on Vercel's edge network.&lt;/p&gt;

&lt;p&gt;12 verified MCP servers are available in the hosted catalog today: filesystem, GitHub, PostgreSQL, Notion, Context7, Slack, SQLite, Brave Search, Puppeteer, Memory, Sequential Thinking, Everything.&lt;/p&gt;

&lt;p&gt;SOC 2 Type 1 is planned. Self-host via Docker is available for teams that require on-premise deployment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The MCP ecosystem is at the same inflection point that npm was in 2012 — growing fast, with tooling that works well for individuals and not yet for enterprises.&lt;/p&gt;

&lt;p&gt;The gap is not in the protocol. The protocol is solid. The gap is in infrastructure, governance, and auditability.&lt;/p&gt;

&lt;p&gt;Running MCP servers on developer laptops is the path of least resistance. It works until it doesn't — until a developer leaves with credentials, until a server is misconfigured and accesses something it shouldn't, until a security team asks what tools the AI has been calling and nobody can answer.&lt;/p&gt;

&lt;p&gt;The MCPNest Orchestrator is the infrastructure layer that closes that gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mcpnest.io&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>cursor</category>
    </item>
    <item>
      <title>MCPNest — One Month. The Problem, The Solution, Every Feature Explained.</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Tue, 05 May 2026 23:27:57 +0000</pubDate>
      <link>https://dev.to/codemalasartes/mcpnest-one-month-the-problem-the-solution-every-feature-explained-2ad8</link>
      <guid>https://dev.to/codemalasartes/mcpnest-one-month-the-problem-the-solution-every-feature-explained-2ad8</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The MCP ecosystem is expanding at an extraordinary pace. Anthropic, Microsoft, Google, AWS, and Cloudflare are all publishing official MCP servers. Hundreds of open source servers exist for every conceivable integration. Developers are connecting AI tools — Claude, Cursor, Windsurf — to internal databases, codebases, APIs, and filesystems.&lt;/p&gt;

&lt;p&gt;The infrastructure for doing this exists. The governance layer does not.&lt;/p&gt;

&lt;p&gt;Today, at most engineering teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every developer runs MCP servers on their own machine&lt;/li&gt;
&lt;li&gt;There is no central record of what servers are active&lt;/li&gt;
&lt;li&gt;There is no audit trail of what tools were called, by whom, or when&lt;/li&gt;
&lt;li&gt;Credentials — GitHub personal access tokens, database connection strings, API keys — are stored in JSON files on developer laptops&lt;/li&gt;
&lt;li&gt;There is no approval process for which servers developers can use&lt;/li&gt;
&lt;li&gt;There is no isolation — MCP servers run with the full permissions of the local user&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the gap MCPNest fills.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Platform
&lt;/h2&gt;

&lt;p&gt;MCPNest is the governance and infrastructure platform for MCP servers. It operates across three layers: a marketplace for discovery, a gateway for control, and a hosted infrastructure layer for isolation and centralisation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1 — Marketplace
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A searchable catalogue of 7,500+ MCP servers indexed from the official Anthropic registry and GitHub. Every server has a quality score, compatibility matrix, publisher profile, and install configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What problem it solves&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without a central catalogue, developers find MCP servers through GitHub searches, Reddit posts, and blog articles. There is no quality signal, no verification, no compatibility information. Teams end up with inconsistent tooling and no visibility into what is actually being used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;7,500+ servers indexed with quality scores and compatibility filters&lt;/li&gt;
&lt;li&gt;One-click install for Cursor and VS Code&lt;/li&gt;
&lt;li&gt;Publisher profiles with verified badges&lt;/li&gt;
&lt;li&gt;Server of the Week editorial picks&lt;/li&gt;
&lt;li&gt;Collections and curated starter packs&lt;/li&gt;
&lt;li&gt;Config Validator — validates syntax, endpoints, and arguments before installation&lt;/li&gt;
&lt;li&gt;MCP Composer — build multi-server configurations and share via link&lt;/li&gt;
&lt;li&gt;Trending servers with weekly install data&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer 2 — Gateway
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A single authenticated HTTPS endpoint per workspace. Every developer on the team points their AI client at the same Gateway URL. The Gateway authenticates the request, checks the tool allowlist, proxies to the correct upstream server, and logs the call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What problem it solves&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without a gateway, every developer maintains their own local configuration. When a server changes, everyone updates manually. There is no central authentication, no audit, and no way to enforce which tools developers can use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single endpoint per workspace&lt;/strong&gt;&lt;br&gt;
One URL replaces individual configurations across every developer machine. When the admin adds or removes a server, the change is immediate for the entire team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bearer token authentication (SHA-256)&lt;/strong&gt;&lt;br&gt;
Every request is authenticated. Tokens are stored as SHA-256 hashes with timing-safe comparison. No plain text secrets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-member tokens&lt;/strong&gt;&lt;br&gt;
Each developer has their own Bearer token. This means every tool call is attributable to a specific individual, not just to the workspace. Tokens can be revoked instantly for any member without affecting the rest of the team.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool allowlists per member&lt;/strong&gt;&lt;br&gt;
Admins define which tools each developer can call at the protocol level. A developer with access to the GitHub MCP server can be restricted to specific tools — for example, read-only operations only. Workspace-wide enforcement toggle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Full audit log per tool call&lt;/strong&gt;&lt;br&gt;
Every call is logged with: workspace ID, member ID, server, tool name, HTTP status, latency, timestamp, and error code where applicable. No inputs or outputs are stored — only metadata. GDPR safe by design. Logs are exportable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tool namespacing&lt;/strong&gt;&lt;br&gt;
Automatic conflict resolution when multiple servers expose tools with the same name. No manual configuration required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workspace RBAC&lt;/strong&gt;&lt;br&gt;
Three roles: Owner, Admin, Member. Only Admins can approve servers for the workspace. Separation of duties between team management and tool usage.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 3 — Hosted Infrastructure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;MCP servers running in isolated Docker containers on central infrastructure, managed by the MCPNest Orchestrator. Developers deploy servers from a catalogue via the workspace dashboard. The AI client connects to the Gateway, which proxies to the hosted container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What problem it solves&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Running MCP servers locally means no isolation, no central credential management, no shared infrastructure, and no visibility into what is actually running. When a developer's laptop is lost or stolen, every credential in every local config file is exposed. When a developer leaves the company, there is no clean offboarding process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12 verified servers available for one-click deploy&lt;/strong&gt;&lt;br&gt;
Filesystem, GitHub, PostgreSQL, Notion, Context7, Slack, SQLite, Brave Search, Puppeteer, Memory, Sequential Thinking, Everything. All pre-validated and running on the MCPNest Bridge image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container isolation&lt;/strong&gt;&lt;br&gt;
Every container runs with: cap_drop ALL (zero Linux capabilities), no-new-privileges flag, CPU and memory resource limits enforced, dedicated Docker network per workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credential management&lt;/strong&gt;&lt;br&gt;
Servers that require credentials — GitHub personal access tokens, database connection strings, Slack bot tokens, API keys — prompt for them via a modal before deploy. Credentials are encrypted and never logged. Developers never see or store them locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Real-time deploy console&lt;/strong&gt;&lt;br&gt;
A terminal modal streams container logs during startup. The system auto-detects RUNNING + HEALTHY state and closes automatically. No manual refresh required.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-instance log viewer&lt;/strong&gt;&lt;br&gt;
Every running instance has a Logs button that shows the last 50 lines of container output. Debugging without SSH access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terminate with cleanup&lt;/strong&gt;&lt;br&gt;
Stopping a server removes the container and cleans the database record. No orphaned containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCP Bridge&lt;/strong&gt;&lt;br&gt;
A stdio-to-HTTP adapter that wraps any npx-based MCP server into an HTTP endpoint compatible with the Gateway. Enables hosting of any MCP server without modifying its source.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security and Compliance
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Infrastructure&lt;/strong&gt;&lt;br&gt;
All infrastructure is EU-based. Supabase (Frankfurt) for the database. Hetzner (Nuremberg) for the orchestrator and hosted containers. Vercel edge network for the application layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token security&lt;/strong&gt;&lt;br&gt;
Bearer tokens are stored as SHA-256 hashes. Timing-safe comparison on every request. Per-member tokens enable individual audit trails and instant revocation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data handling&lt;/strong&gt;&lt;br&gt;
No inputs or outputs from tool calls are stored. Only metadata is logged (who, when, which tool, what status). GDPR safe by design.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Container security&lt;/strong&gt;&lt;br&gt;
cap_drop ALL removes all Linux capabilities from containers. no-new-privileges prevents privilege escalation. Resource limits prevent noisy neighbour and runaway processes. Dedicated Docker network per workspace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-host&lt;/strong&gt;&lt;br&gt;
The full MCPNest stack is available for self-hosted deployment via Docker for teams that require on-premise infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;One month. 14 versions shipped. 7,500+ MCP servers indexed. Enterprise Gateway live. 12 hosted servers operational. Partnerships with Grafana, RailPush, and Context7 confirmed.&lt;/p&gt;

&lt;p&gt;The MCP ecosystem needed a governance layer. MCPNest is it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;mcpnest.io&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx75mo5p2m6p3ninmqz3o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fx75mo5p2m6p3ninmqz3o.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>openai</category>
    </item>
    <item>
      <title>Hosting MCP Servers at Scale: The Orchestrator</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Wed, 29 Apr 2026 20:39:35 +0000</pubDate>
      <link>https://dev.to/codemalasartes/hosting-mcp-servers-at-scale-the-orchestrator-517o</link>
      <guid>https://dev.to/codemalasartes/hosting-mcp-servers-at-scale-the-orchestrator-517o</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadnaa4tyqdj8x2viqkcz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fadnaa4tyqdj8x2viqkcz.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;How MCPNest deploys isolated Docker containers for MCP servers and routes tool calls intelligently across your workspace.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;MCP servers have a deployment problem.&lt;/p&gt;

&lt;p&gt;Most servers are stdio-based — they run as a subprocess via &lt;code&gt;npx&lt;/code&gt; and communicate over stdin/stdout. That works fine on a developer's laptop. It does not work for a team.&lt;/p&gt;

&lt;p&gt;You cannot share a stdio process across machines. You cannot health-check it remotely. You cannot restart it when it crashes without someone noticing. You cannot give five engineers access to the same instance running on one person's laptop.&lt;/p&gt;

&lt;p&gt;The standard solution is to wrap stdio servers in an HTTP adapter and deploy them to a server. The problem: that is significant infrastructure work for every MCP server you want to use. Dockerfile, networking, health checks, restart policies, resource limits — multiply that by every server your team needs.&lt;/p&gt;

&lt;p&gt;We built MCPNest Hosted Servers to handle all of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  How hosted MCP servers work
&lt;/h2&gt;

&lt;p&gt;When you click Deploy in your workspace Hosted tab, MCPNest:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Creates an instance record in the database (&lt;code&gt;status: pending&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Sends a deploy request to the MCPNest Orchestrator service&lt;/li&gt;
&lt;li&gt;Orchestrator pulls the verified image from our allowlist&lt;/li&gt;
&lt;li&gt;Orchestrator starts the container with security hardening applied&lt;/li&gt;
&lt;li&gt;Orchestrator polls health until the container responds&lt;/li&gt;
&lt;li&gt;Updates the instance to &lt;code&gt;status: running, health_status: healthy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The server automatically appears in your Gateway &lt;code&gt;tools/list&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total time measured in production today: &lt;strong&gt;6 seconds from click to RUNNING + HEALTHY&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Bridge layer
&lt;/h2&gt;

&lt;p&gt;Inside each hosted container runs MCPNest Bridge — a FastAPI server on port 8080 that translates between HTTP (external) and stdio JSON-RPC (internal).&lt;/p&gt;

&lt;p&gt;Bridge handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP protocol handshake (&lt;code&gt;initialize&lt;/code&gt; → &lt;code&gt;notifications/initialized&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Managing the stdio subprocess lifecycle&lt;/li&gt;
&lt;li&gt;Translating &lt;code&gt;POST /tools/list&lt;/code&gt; → &lt;code&gt;tools/list&lt;/code&gt; JSON-RPC request → response&lt;/li&gt;
&lt;li&gt;Translating &lt;code&gt;POST /tools/call&lt;/code&gt; → &lt;code&gt;tools/call&lt;/code&gt; JSON-RPC request → response&lt;/li&gt;
&lt;li&gt;Draining stderr in the background so it never blocks the main process&lt;/li&gt;
&lt;li&gt;asyncio.Lock per request to prevent concurrent stdio corruption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The subprocess command is configurable per image via the &lt;code&gt;mcp_allowed_images&lt;/code&gt; table. For &lt;code&gt;node:20-alpine&lt;/code&gt; with &lt;code&gt;@modelcontextprotocol/server-filesystem&lt;/code&gt;, the command is &lt;code&gt;npx -y @modelcontextprotocol/server-filesystem /workspace&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security model
&lt;/h2&gt;

&lt;p&gt;Every container runs with these constraints — no exceptions, not configurable by the user:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Process isolation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;no-new-privileges: true&lt;/code&gt; — no privilege escalation&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cap_drop: ALL&lt;/code&gt; — all Linux capabilities dropped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cap_add: [CHOWN, SETUID, SETGID]&lt;/code&gt; — only minimum needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Network isolation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Containers bind to &lt;code&gt;127.0.0.1&lt;/code&gt; only — never exposed on a public interface&lt;/li&gt;
&lt;li&gt;Traffic flows: Gateway → Orchestrator → container (all internal)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Filesystem:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/tmp&lt;/code&gt; mounted as tmpfs with &lt;code&gt;noexec, nosuid, nodev&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;No host filesystem mounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Resources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CPU and memory caps enforced at container creation by plan profile&lt;/li&gt;
&lt;li&gt;Small: 0.25 vCPU, 256 MB RAM&lt;/li&gt;
&lt;li&gt;Medium: 0.5 vCPU, 512 MB RAM&lt;/li&gt;
&lt;li&gt;Large: 1.0 vCPU, 1 GB RAM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Image allowlist:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only MCPNest-verified images can be deployed&lt;/li&gt;
&lt;li&gt;Images are validated against a regex pattern before pull&lt;/li&gt;
&lt;li&gt;The DB allowlist is the primary gate; the regex is defence-in-depth&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Orchestrator
&lt;/h2&gt;

&lt;p&gt;Above the containers sits the MCPNest Orchestrator — a FastAPI service that aggregates tools from all running instances in a workspace and routes tool calls to the correct container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;tools/list aggregation:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When your Gateway receives a &lt;code&gt;tools/list&lt;/code&gt; request and &lt;code&gt;orchestrator_enabled&lt;/code&gt; is true, it calls the Orchestrator. The Orchestrator:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Queries the database for all running instances in the workspace&lt;/li&gt;
&lt;li&gt;Fans out &lt;code&gt;POST /tools/list&lt;/code&gt; to each container in parallel&lt;/li&gt;
&lt;li&gt;Collects results, detects naming conflicts&lt;/li&gt;
&lt;li&gt;Applies namespacing: if two servers expose a tool called &lt;code&gt;query&lt;/code&gt;, they become &lt;code&gt;postgres_mcp__query&lt;/code&gt; and &lt;code&gt;mysql_mcp__query&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Caches the result for 30 seconds&lt;/li&gt;
&lt;li&gt;Returns a unified tool list&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;tools/call routing:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a &lt;code&gt;tools/call&lt;/code&gt; arrives, the Orchestrator:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Finds the ToolInfo record for the requested tool name (from cache)&lt;/li&gt;
&lt;li&gt;Looks up the live endpoint URL from the database&lt;/li&gt;
&lt;li&gt;Strips the namespace prefix if present (&lt;code&gt;postgres_mcp__query&lt;/code&gt; → &lt;code&gt;query&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Forwards the call to the correct container&lt;/li&gt;
&lt;li&gt;Logs the call to &lt;code&gt;mcp_tool_calls&lt;/code&gt; (best-effort, non-blocking)&lt;/li&gt;
&lt;li&gt;Returns the result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Fallback behaviour:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the Orchestrator is unreachable, or if a workspace has no running hosted instances, the Gateway automatically falls back to direct fan-out to remote HTTP servers. No downtime. No manual intervention.&lt;/p&gt;




&lt;h2&gt;
  
  
  Current state
&lt;/h2&gt;

&lt;p&gt;Three verified images are available today:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;filesystem-mcp&lt;/td&gt;
&lt;td&gt;node:20-alpine + @modelcontextprotocol/server-filesystem&lt;/td&gt;
&lt;td&gt;read_file, write_file, list_directory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;github-mcp&lt;/td&gt;
&lt;td&gt;ghcr.io/modelcontextprotocol/servers:github&lt;/td&gt;
&lt;td&gt;create_issue, list_prs, search_code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres-mcp&lt;/td&gt;
&lt;td&gt;ghcr.io/modelcontextprotocol/servers:postgres&lt;/td&gt;
&lt;td&gt;query, list_tables, describe_table&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plan limits: Team plan supports 3 running instances per workspace. Enterprise supports 20.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Auth headers per server (bring your own API keys — fixes degraded status on auth-required servers)&lt;/li&gt;
&lt;li&gt;More verified images: Slack, Notion, Linear&lt;/li&gt;
&lt;li&gt;Usage metering per instance for billing&lt;/li&gt;
&lt;li&gt;Auto-deploy via workspace registry URL (&lt;code&gt;mcpnest-registry-client&lt;/code&gt; npm package)&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Ricardo Rodrigues — Platform Engineer @ BCP, Founder @ MCPNest&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Porto, Portugal&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCPNest — The App Store for MCP Servers&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://mcpnest.io/workspace" rel="noopener noreferrer"&gt;mcpnest.io/workspace&lt;/a&gt; → Hosted tab&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>cursor</category>
    </item>
    <item>
      <title>Enterprise MCP Governance: Gateway + Layer 2</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Wed, 29 Apr 2026 20:38:30 +0000</pubDate>
      <link>https://dev.to/codemalasartes/enterprise-mcp-governance-gateway-layer-2-18ea</link>
      <guid>https://dev.to/codemalasartes/enterprise-mcp-governance-gateway-layer-2-18ea</guid>
      <description>&lt;p&gt;&lt;em&gt;One endpoint. Per-member tokens. Full audit trail. How MCPNest solves the team coordination problem for MCP.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;When a team of engineers all use Claude with MCP servers, you have a coordination problem.&lt;/p&gt;

&lt;p&gt;Each person runs their own &lt;code&gt;npx&lt;/code&gt; command. Each person has their own &lt;code&gt;claude_desktop_config.json&lt;/code&gt;. When a server URL changes, someone has to update every config. There is no audit trail. No access control. No way to know who called what tool, when, and with what arguments.&lt;/p&gt;

&lt;p&gt;This is the current state of enterprise MCP usage in 2026. We built two layers to fix it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Layer 1: The Gateway
&lt;/h2&gt;

&lt;p&gt;MCPNest Gateway gives every workspace a single endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://mcpnest.io/api/gw/{workspace-slug}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind that endpoint: all the MCP servers your team has approved. One Bearer token. All tools. Every call proxied and logged.&lt;/p&gt;

&lt;p&gt;The Gateway speaks JSON-RPC 2.0 — the MCP standard. Claude, Cursor, and Windsurf connect to it exactly as they would to any MCP server. Your team updates one config file once. When you add or remove a server from your workspace, every connected client sees the change automatically on the next &lt;code&gt;tools/list&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Technical implementation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Token format: &lt;code&gt;mng_&lt;/code&gt; prefix + 48 hex characters (192 bits of entropy)&lt;/li&gt;
&lt;li&gt;Storage: SHA-256 hash only — the raw token is never stored in plaintext&lt;/li&gt;
&lt;li&gt;Verification: &lt;code&gt;timingSafeEqual()&lt;/code&gt; to prevent timing side-channel attacks&lt;/li&gt;
&lt;li&gt;Rate limiting: 200 requests per minute per IP per workspace slug&lt;/li&gt;
&lt;li&gt;SSE support: streaming responses for MCP servers that use Server-Sent Events&lt;/li&gt;
&lt;li&gt;Tool prefix: configurable per server to avoid naming conflicts (&lt;code&gt;github_create_issue&lt;/code&gt; vs &lt;code&gt;gitlab_create_issue&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Layer 2: Per-Member Access Control
&lt;/h2&gt;

&lt;p&gt;The workspace token is admin-level — full access to all tools. For team members, you create individual Bearer tokens with the same format but different scope.&lt;/p&gt;

&lt;p&gt;Each member token can have an allowlist: &lt;code&gt;server_slug : tool_name&lt;/code&gt; pairs that define exactly which tools the member can call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Owner creates a member token in the workspace Security tab&lt;/li&gt;
&lt;li&gt;Owner defines allowlist rules: &lt;code&gt;github-mcp:list_issues&lt;/code&gt;, &lt;code&gt;grafana:get_dashboard&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Owner enables enforcement&lt;/li&gt;
&lt;li&gt;Member uses their token — Gateway checks allowlist on every &lt;code&gt;tools/call&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Blocked calls return error code &lt;code&gt;-32003: Tool not allowed for this user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Every call is logged with user identity to the audit trail&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The rule logic:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No rules defined: member has full access (open by default)&lt;/li&gt;
&lt;li&gt;Rules defined + enforcement on: member can only call listed tools&lt;/li&gt;
&lt;li&gt;Workspace admin token: always full access regardless of allowlist&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;A DevOps team has three MCP servers in their workspace: GitHub, Grafana, and a custom internal deployment server.&lt;/p&gt;

&lt;p&gt;Junior engineers get member tokens with allowlists restricted to read-only operations: &lt;code&gt;github-mcp:list_issues&lt;/code&gt;, &lt;code&gt;grafana:get_dashboard&lt;/code&gt;. They can query and report but not write.&lt;/p&gt;

&lt;p&gt;Senior engineers get tokens with no allowlist restrictions. The deployment server is restricted to the team lead and the CI system.&lt;/p&gt;

&lt;p&gt;All of this configured in the workspace UI. No code changes. No config files to distribute to each team member.&lt;/p&gt;




&lt;h2&gt;
  
  
  The audit log
&lt;/h2&gt;

&lt;p&gt;Every action in a workspace is logged: server added, member invited, tool called, health check run. For tool calls, the log records which user (by member token identity), which server, which tool, and the response latency.&lt;/p&gt;

&lt;p&gt;This is the audit trail your security team asks for. It exists out of the box.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ricardo Rodrigues — Platform Engineer @ BCP, Founder @ MCPNest&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Porto, Portugal&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MCPNest — The App Store for MCP Servers&lt;/strong&gt;&lt;br&gt;
&lt;a href="https://mcpnest.io/workspace" rel="noopener noreferrer"&gt;mcpnest.io/workspace&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>claude</category>
      <category>cursor</category>
    </item>
    <item>
      <title>The MCP Discovery Problem</title>
      <dc:creator>Ricardo Rodrigues</dc:creator>
      <pubDate>Wed, 29 Apr 2026 20:29:01 +0000</pubDate>
      <link>https://dev.to/codemalasartes/the-mcp-discovery-problem-3map</link>
      <guid>https://dev.to/codemalasartes/the-mcp-discovery-problem-3map</guid>
      <description>&lt;p&gt;In November 2024, Anthropic released the Model Context Protocol — &lt;br&gt;
a standard that lets AI agents call external tools. Within months, &lt;br&gt;
hundreds of MCP servers appeared on GitHub. Stripe, Grafana, &lt;br&gt;
Cloudflare, AWS — all publishing official servers.&lt;/p&gt;

&lt;p&gt;The problem: there was nowhere to find them.&lt;/p&gt;

&lt;p&gt;Developers were digging through GitHub search, Reddit threads, &lt;br&gt;
and scattered READMEs to find servers that might already exist. &lt;br&gt;
The official Anthropic registry had an API but no UI. &lt;br&gt;
The most starred community lists had tens of thousands of &lt;br&gt;
GitHub stars but weren't searchable or filterable.&lt;/p&gt;

&lt;p&gt;This is the npm problem from 2012. A growing ecosystem &lt;br&gt;
with no discovery layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built
&lt;/h2&gt;

&lt;p&gt;MCPNest indexes MCP servers from the official Anthropic registry &lt;br&gt;
and GitHub. 7,554 servers as of today, with quality scores, &lt;br&gt;
install counts, compatibility flags per client, and one-click &lt;br&gt;
install configs for Claude Desktop, Cursor, VS Code, and Windsurf.&lt;/p&gt;

&lt;p&gt;Every server page renders the GitHub README, shows the install &lt;br&gt;
config for each client, and tracks installs via a copy button &lt;br&gt;
that increments a counter in Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quality scoring
&lt;/h2&gt;

&lt;p&gt;Not all MCP servers are equal. We score each server 0-100 based on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has a valid install config&lt;/li&gt;
&lt;li&gt;Has a description&lt;/li&gt;
&lt;li&gt;Is verified from the official registry&lt;/li&gt;
&lt;li&gt;GitHub stars&lt;/li&gt;
&lt;li&gt;Install count&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The score maps to an A-F grade shown on each server card.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Discovery is the easy part. Finding a server is step one. &lt;br&gt;
The harder problems are: running it reliably, giving your team &lt;br&gt;
access to it securely, and managing it at scale.&lt;/p&gt;

&lt;p&gt;That's what we built next.&lt;/p&gt;

&lt;p&gt;→ mcpnest.io&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe46pib9wymkd5z7g2ub8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fe46pib9wymkd5z7g2ub8.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>mcp</category>
      <category>tooling</category>
    </item>
  </channel>
</rss>
