Governing AI Tool Access: Building an Enterprise MCP Gateway
When we started connecting AI agents to production systems, we realized the hard part isn’t the integration — it’s everything that happens…
Governing AI Tool Access: Building an Enterprise MCP Gateway
When we started connecting AI agents to production systems, we realized the hard part isn’t the integration — it’s everything that happens between the agent and the tool.

We deploy AI coding assistants (Cursor, Claude Desktop) across engineering, finance, and operations teams. These assistants connect to internal tools — Jira, Confluence, Slack, Google Workspace, CRM systems — via the Model Context Protocol (MCP). It works well until you ask the obvious questions:
Who called delete_all_records? Did the AI just read a customer’s SSN and paste it into a Slack message? Why does the intern’s agent have the same tool access as the CTO?
We built an MCP Gateway Registry to answer those questions. It sits between every AI client and every upstream MCP server, and it enforces identity, policy, data protection, and auditability on every single tool call.
This is what we learned building it.
The Architecture in One Paragraph
A Python gateway process receives MCP traffic from AI clients. It verifies the caller’s identity via token introspection against our OAuth2/SAML auth server. It evaluates access policies from a PostgreSQL-backed admin UI. It runs PII detection on both inputs and outputs using OpenAI’s Privacy Filter. It tracks data flows between tools to catch exfiltration. It classifies tools as read/write/destructive and gates dangerous operations with user prompts. Then — and only then — it forwards the call to the upstream MCP server using the caller’s own OAuth credentials.
A Next.js admin UI manages everything: gateway composition, server registration, policy rules, approval workflows, audit logs, health monitoring, and DLP (Data Loss Protection) configuration.


Per-User OAuth: No More Shared Credentials
This was a non-negotiable design decision. Every tool call executes with the caller’s own permissions — not a shared service account, not an admin token.
When a user’s AI agent calls a Jira tool for the first time, the gateway interrupts: “Please authenticate with Jira.” The user clicks through an OAuth flow (PKCE, dynamic client registration per RFC 7591), and the gateway stores their token in memory for the session. From that point, every Jira call runs as that user.
The admin token that the gateway uses to discover available tools? It’s completely isolated from the execution path. If it expires, existing tools keep working — you just won’t see newly-added tools until an admin refreshes it. We burned time figuring this out the hard way: an expired admin token was causing discovery to return empty, which initially removed all tools from the client. Now the gateway preserves existing registrations when discovery returns nothing.
The separation matters for audit. When the trail says “user X called delete_thread at 14:32,” that’s not “the gateway service account called delete_thread on behalf of someone.” It’s that specific user’s token, with that user’s permissions, hitting that endpoint.

Policy Engine: Priority-Based, Glob-Matching
Policies are rules with four dimensions: subject (who), server (where), tool pattern (what), and action (allow/deny/require approval). Each policy has a priority number, and highest priority wins. At equal priority, DENY beats everything else.
Priority 20: alice@co.com + oracle + * → DENY
Priority 10: * + * + * → ALLOW
Priority 5: * + * + *_customers → REQUIRE_APPROVAL
Alice is locked out of all Oracle tools. Everyone else gets in. But anyone calling a tool ending in _customers needs to click “Approve” first.
Glob patterns (send_*, *_delete) and regex support make it practical to write rules without enumerating every tool individually. We expose 50–70 tools per upstream server — nobody is writing per-tool policies for all of them.
Each policy also carries a DLP mode setting (full, input only, output only, audit, or disabled), so data protection is scoped to the same granularity as access control. The finance gateway scans everything. The internal wiki gateway runs in audit mode. The Slack gateway only scans outbound messages.


PII Protection That Actually Works on API Responses
We use OpenAI’s Privacy Filter — a 1.5B sparse MoE model running on CPU. It detects person names, emails, phone numbers, addresses, account numbers, secrets, and private URLs. It runs as a shared service process (one model instance serving all gateways) because loading it costs 2.6GB of RAM.
Input scanning is straightforward: extract string fields from the tool arguments, scan them, redact content fields (body, message, subject) while leaving routing fields (to, from, email, id) untouched. If you’re sending an email, the body gets scrubbed but the recipient address stays intact — otherwise the tool breaks.
Output scanning is where we hit problems. Jira returns 7KB JSON blobs. Confluence returns 50KB pages wrapped in API metadata. The model has a 2048-token context window. It was trained on natural language, not JSON full of UUIDs and avatar URLs.
We tried scanning the raw JSON — the model missed real PII and false-flagged API URLs. We tried chunking — 5 chunks at 3–5 seconds each meant 15–25 second latency on a tool call.
What worked: parse the JSON, recursively extract values from known human-readable keys (description, body, summary, comment, content, text), and scan only those. For a 7KB Jira response, we end up scanning 800 characters of actual human text. The model finds all 5 PII entities in 3 seconds with zero false positives. Then we string-replace the findings back into the full response.
The DLP system runs with a circuit breaker — three failures in 60 seconds and it trips. In redaction-required mode, that means tool calls get blocked (fail-closed). In audit mode, calls proceed without scanning (fail-open).

Cross-Tool Exfiltration Prevention
This is the feature that doesn’t exist anywhere else we looked.
Standard DLP treats each tool call independently. It sees “a phone number going into a Slack message” — fine, maybe the user typed it. It doesn’t know the agent read that exact phone number from a CRM tool 30 seconds ago.
Our taint store tracks this. When the DLP engine finds PII in a tool’s output, we normalize it, hash it (SHA-256), and record which tool it came from. On every subsequent input scan, we check: did any of these values come from a different server?
If yes, the gateway intervenes based on severity:
-
SSN / account numbers → auto-block. No override. The agent cannot forward these across tool boundaries.
-
Phone numbers / addresses → prompt the user. “This phone number was read from ‘read_contacts’ on the CRM server. Allow it to be sent via ‘send_email’?”
-
Names / dates → log only. Low-value signals, would cause too much friction.
Same-server flows don’t trigger alerts. Reading a Jira ticket and updating the same Jira ticket is normal. Reading a Jira ticket and dumping its content into a Slack message is not.
The whole thing runs in-memory with O(1) hash lookups. No database writes on the hot path. ~50MB worst case at 1000 concurrent users.

Destructive Action Guard
MCP tool annotations are optional and self-reported. We don’t trust them.
Instead, we classify tools ourselves in two phases.
Phase 1: call the tool with empty arguments. If it returns data, it’s a read operation. If the response says “cleared” or “deleted,” it’s destructive. If it returns a validation error (needs arguments), move to Phase 2.
Phase 2: analyze the tool name (delete_* → destructive, send_* → communication, get_* → read), its description (regex for “delete”, “remove”, “destroy”), and its schema (has to and subject fields? → communication).
At runtime, when an agent calls a tool classified as DESTRUCTIVE, the user sees: “This action permanently deletes data and cannot be undone. [Allow once] [Block]” — rendered as an interactive prompt right inside Cursor or Claude Desktop.


The Department Gateway Pattern
The deployment model that made this practical for 500+ users: IT pre-configures a gateway per department via the admin UI’s “Canvas” composer.
The finance gateway exposes SAP, Bloomberg, and Excel tools. Policies require approval for journal entries. DLP runs in full mode with account number redaction. The engineering gateway exposes Jira, GitHub, and CI tools. DLP runs in audit mode only. The HR gateway exposes the HRIS and payroll tools with heavy taint tracking because that data should never cross boundaries.
End users do nothing. They open their AI client, connect to localhost:8020 (or whichever port their department gateway runs on), and see exactly the tools their team needs with all the guardrails pre-applied. The gateway auto-registers itself in ~/.cursor/mcp.json on startup and deregisters on shutdown.
No per-user configuration. No “please install these 12 MCP servers.” No “make sure you don’t paste the customer’s SSN into Slack.” It’s all handled at the infrastructure layer.

Hot Reload and Process Lifecycle
Adding or removing a server doesn’t require a gateway restart. The admin UI pushes config changes, the gateway picks them up, mirrors new tools, and sends tools/list_changed notifications to connected clients. Cursor updates its tool list within seconds.
The process manager handles lifecycle: auto-restart (up to 3 times within 60 seconds with exponential backoff), health checks via /ping, PID persistence across admin UI restarts, port conflict detection, and orphan process recovery.
If a gateway crashes at 3 AM, it’s back up before the first engineer opens their laptop. If a port is stuck in TIME_WAIT, the manager waits or reassigns.

What the Audit Trail Looks Like
Every tool call produces an immutable audit record: who called what, when, from which gateway, with what arguments (post-redaction), what the policy decision was, whether DLP found anything, whether taint tracking flagged something, and the outcome.
The admin UI renders this as a searchable, filterable log with per-gateway attribution. Security teams can answer “show me every destructive action Bob’s agent took last week” or “which tools triggered PII detections in the last 24 hours” without digging through raw logs.
Audit records also feed back into the system. Approval workflows reference them — when a user approves a tool call, that decision is logged and can be configured to persist for 30 days, so they don’t get prompted again for the same tool.


Things That Surprised Us
Token lifecycle is harder than token validation. The OAuth dance works fine. The edge cases are: what happens when the upstream server revokes the token mid-session? When the refresh token expires during a long-running task? When two concurrent tool calls both detect an expired token and both try to refresh simultaneously? We ended up with reactive 401 handling that clears the cached token and re-prompts authentication, plus distributed locking for refresh operations.
DLP false positives matter more than false negatives in practice. A single false positive on a frequently-used tool creates prompt fatigue. Users start clicking “Allow” reflexively, which defeats the purpose. We spent more time tuning the false-positive filter than improving detection rates.
Tool classification needs human review. The two-phase automated classification is accurate enough to bootstrap, but someone needs to confirm that update_user_role is DESTRUCTIVE (not WRITE) and archive_thread should be treated as DESTRUCTIVE (not READ just because it returns a success message). The admin UI stores classifications in the database with an “admin confirmed” flag.
Multi-gateway is not optional for enterprises. Different teams have different risk profiles, different tool sets, and different compliance requirements. Running one gateway for everyone means the policy space explodes. Department-scoped gateways with scoped policies, scoped DLP modes, and scoped taint rules keep configuration manageable.
The Stack
-
Gateway: Python, FastMCP, asyncio. Stateless (restart-safe) except for in-memory taint store and token cache.
-
Admin UI: Next.js, PostgreSQL, Tailwind. Handles all CRUD, audit, analytics, process management.
-
Auth Server: Python. OAuth2 authorization server bridging SAML 2.0 (JumpCloud, Okta) or OIDC (Auth0) to bearer tokens.
-
DLP Service: Python, OpenAI Privacy Filter (1.5B MoE). Shared process with thread-pool workers and circuit breaker.
-
Deployment: Docker Compose. PostgreSQL for state, the gateway and admin UI as services, auth server auto-started by the admin UI.
Where We’re Headed
The current bottleneck is the single-instance architecture. Taint store and token cache live in memory, so horizontal scaling requires externalizing state to Redis. Session pooling to upstream servers (reusing MCP connections instead of creating one per tool call) would cut per-call overhead from 30ms to under 1ms. Prometheus metrics and structured JSON logging are needed before anyone can operate this in production with confidence.
But the core governance model — identity-aware policy evaluation, bidirectional DLP, taint-based exfiltration detection, automated tool classification — that’s working in production today. Fifty tools, multiple upstream servers, real users doing real work, with the guardrails they don’t even notice until something actually dangerous happens.
That’s the goal. Not “security that gets in the way.” Security that’s invisible until it matters.
메타데이터
- post_id
- cd1adb8d48aa
- slug
- governing-ai-tool-access-building-an-enterprise-mcp-gateway-cd1adb8d48aa
- url
- https://medium.com/@djajafer/governing-ai-tool-access-building-an-enterprise-mcp-gateway-cd1adb8d48aa
- canonical_url
- https://medium.com/@djajafer/governing-ai-tool-access-building-an-enterprise-mcp-gateway-cd1adb8d48aa
- author_url
- https://medium.com/@djajafer
- status
- ok
- fetched_at
- 2026-06-20 20:29:01