Skip to main content
BlogSecurity

Enterprise MCP Reference Architecture with Cloudflare Portals

A layered enterprise MCP stack on Cloudflare: Access OAuth, one portal per audience, Code Mode, Gateway DLP and the limits the docs list.

Matthews Wong

Software Engineer

October 202612 min read
Enterprise MCP Reference Architecture with Cloudflare Portals

Key Takeaway

An enterprise MCP reference architecture on Cloudflare has five layers: remote servers with Access as the OAuth provider, a portal for discovery and per-group tool policy, Code Mode to shrink tool definitions, Gateway DLP on portal traffic, and a WAF for public servers. Default-deny writes sit at the server layer.

The number that made me read Cloudflare's enterprise MCP post twice was not about security. It was 52 tools costing about 9,400 tokens of context before an employee had typed a word. Four internal MCP servers, one portal, and the model was already carrying a small manual it did not need.

This is a close reading of that post (published 14 April 2026, modified 15 July 2026) against the current Cloudflare documentation for MCP server portals, the Gateway tutorial on detecting MCP traffic, and the AI Security for Apps page. I have not deployed this stack myself. Every number below is Cloudflare's, every limit is quoted from the docs, and where the two sources disagree I say so.

Why Cloudflare banned local MCP servers before anything else

The architecture starts with a refusal. Cloudflare judged local MCP servers an early security liability for three stated reasons: unvetted software sources and versions (a supply chain problem), tool injection or tool poisoning, and no IT or security administration at all, because every employee decides for themselves. The fix was to make remote servers the only easy path.

A central team runs a shared MCP platform inside the monorepo. An employee who wants to expose an internal resource gets approval from the AI governance team, copies a template, writes tool definitions and deploys. The template brings default-deny write controls, audit logging, an auto-generated CI/CD pipeline and secrets management. The post says this takes minutes of scaffolding, and that the governance being baked into the platform is what let adoption spread so quickly. I read that as the real design principle: the secure path has to be the cheapest path, or people route around it.

The five layers and what each one is responsible for

The post is easier to use if you separate the layers, because each one answers a different question and each one has a gap the next one covers. This table is my own summary of the post and the docs, not a Cloudflare diagram.

LayerCloudflare productQuestion it answersWhat it cannot do alone
ServerWorkers with the monorepo templateWhat can this tool do, and is a write allowed?Nothing outside its own tools; a direct URL can bypass portal policy
IdentityAccess as OAuth providerWho is this, on what device, from where?Does not decide which tools of a server a person sees
Discovery and policyMCP server portalWhich servers and tools does this group get?Does not inspect payloads unless Gateway routing is on
DataGateway HTTP policy with DLPIs sensitive data leaving or returning?Scans only the first 1,024 bytes of a POST body
Public serversWAF with AI Security for AppsIs inbound traffic a prompt injection or a PII leak?Detection fields are a paid add-on; JSON requests only

Read the last column as the argument for having all five. Access alone cannot see tool-level intent, a portal alone cannot see request bodies, and Gateway alone cannot tell you who is allowed to call a write tool. The post also places AI Gateway between the MCP client and the model for provider switching and per-employee token limits, but gives no configuration for it, so I treat it as a design note rather than something to copy.

Layer one: Access as the OAuth provider for your own servers

Public servers such as the Cloudflare documentation MCP and the Radar MCP are open to anyone. Private ones are not. The monorepo template wires Cloudflare Access in as the OAuth provider, so Access runs the login flow, issues the token and applies SSO, MFA and contextual checks such as IP address, location and device certificates. The server author writes tools and never touches an identity system.

Registering the server with the portal is a separate step. In the dashboard it is Zero Trust, Access controls, MCP Portals, MCP servers. Through the API the auth_type field takes oauth, bearer or unauthenticated. For a server that cannot do Dynamic Client Registration you supply a pre-registered client and send the client secret as its own field; Cloudflare stores it encrypted and never returns it.

# Register an upstream server that cannot do Dynamic Client Registration.
# The client secret is a SIBLING field, never inside auth_credentials.
# Cloudflare encrypts it and never returns it from read, create or update calls.
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/access/ai-controls/mcp/servers" \
  --request POST \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --json '{
    "id": "github",
    "name": "GitHub MCP Server",
    "hostname": "https://github-mcp.example.com/mcp",
    "auth_type": "oauth",
    "is_shared_oauth_callback_enabled": true,
    "auth_credentials": "{\"auth_mode\":\"manual\",\"config\":{\"authorization_endpoint\":\"https://github.com/login/oauth/authorize\",\"token_endpoint\":\"https://github.com/login/oauth/access_token\"},\"registration_info\":{\"client_id\":\"<client-id>\",\"token_endpoint_auth_method\":\"client_secret_basic\",\"scope\":\"repo read:user\"}}",
    "client_secret": "<client-secret>"
  }'

# The server id becomes the tool prefix: list_issues is exposed as github_list_issues.
# Server ids cannot contain underscores (the portal splits on the first one). Use hyphens.

Cloudflare's own caution: users blocked by an Access policy at the portal can still reach a server by its direct URL. Hiding a server in a portal is not enforcement. The docs say to configure Access as the server's OAuth provider if you want authentication enforced at the server itself.

Layer two: a portal per audience, with tools allowed rather than hidden

A portal puts many MCP servers behind one HTTP endpoint at the path /mcp on your own domain. Users sign in once through Access and then authenticate separately to each upstream that needs OAuth. The post's worked example is the cleanest statement of the model: a finance portal limited to the finance group that exposes only read-only tools of the code repository server, and an engineering portal limited to engineers on corporate laptops that exposes read and write tools of the same server. One server, two portals, two tool lists.

The detail that makes this safe is the allowlist. By default every tool and prompt of an added server is visible. Setting default_disabled to true on the server-to-portal mapping inverts that: only tools you list in updated_tools are exposed. That is where default-deny writes become a portal property as well as a server one, and it is also where the first trap sits. A portal update that includes servers replaces the whole mapping, so omitting a server removes it and omitting a field resets it to its default.

# Finance portal: PUT replaces the WHOLE servers array, so list every server
# you want to keep and repeat its existing mapping fields. Omit one and it is removed.
curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/access/ai-controls/mcp/portals/$PORTAL_ID" \
  --request PUT \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  --json '{
    "servers": [
      {
        "server_id": "github",
        "default_disabled": true,
        "updated_tools": [
          { "name": "search_documents", "enabled": true },
          { "name": "list_projects",    "enabled": true }
        ]
      }
    ]
  }'
# Every other tool and every prompt of this server is now hidden from this portal.
# Terraform creates the portal but NOT its DNS record. Without the proxied CNAME
# the portal hostname answers 522.
resource "cloudflare_zero_trust_access_mcp_server_portal" "engineering" {
  account_id = var.cloudflare_account_id
  name       = "Engineering Portal"
  hostname   = "mcp.example.com"
}

resource "cloudflare_dns_record" "mcp_portal" {
  zone_id = var.cloudflare_zone_id
  name    = "mcp"
  content = "gateway.agents.cloudflare.com"
  type    = "CNAME"
  proxied = true
}

Tools are namespaced as the server ID, an underscore, then the tool name, so github plus list_issues becomes github_list_issues. The portal splits on the first underscore, which means server IDs cannot contain underscores; use hyphens. If you create a portal through the API or Terraform, the docs are explicit that no DNS record is created for you: add a proxied CNAME to gateway.agents.cloudflare.com or the portal answers with a 522.

Layer three: Code Mode and what 94 percent actually means

Cloudflare's internal portal fronts four MCP servers. As plain tools that was 52 definitions at about 9,400 tokens. With Code Mode the model sees two portal tools, portal_codemode_search and portal_codemode_execute, at about 600 tokens. The post calls that a 94 percent reduction; 600 over 9,400 is 6.4 percent, so the saving is closer to 93.6 and the rounding is fair. The more important sentence is that the cost stays fixed as servers are added. Cloudflare's separate post on its own API server reports around 1,000 tokens for two tools versus an estimated 1.17 million without Code Mode.

Code Mode is a policy on each portal, not a client habit. The default is opt-in. A portal can also be forced on, which matters if you want the token saving to be a platform guarantee rather than something each engineer has to remember.

Policy (API value)Default behaviourClient override
Off (off)Code Mode unavailableQuery parameters ignored
Opt-in (opt_in)Code Mode offAdd codemode=search_and_execute to turn it on
On by default (default_on)Code Mode onAdd codemode=off to turn it off
Enforced (enforced)Code Mode onQuery parameters ignored
// Opt-in portal: the client asks for Code Mode in the URL.
// The portal then advertises portal_codemode_search and portal_codemode_execute
// instead of every upstream tool definition.
{
  "mcpServers": {
    "example-portal": {
      "command": "npx",
      "args": [
        "-y",
        "mcp-remote@latest",
        "https://mcp.example.com/mcp?codemode=search_and_execute"
      ]
    }
  }
}

// Gentler alternative that keeps tools visible by name only:
//   https://mcp.example.com/mcp?optimize_context=minimize_tools
// Use command and args as above. The docs advise against the serverURL parameter.

There is a second, gentler option. The optimize_context parameter offers minimize_tools, which strips descriptions and schemas so only names remain, with the docs claiming up to 5x savings, and search_and_execute, which hides upstream tools entirely. The docs warn that the agent then depends fully on the query tool to discover anything. Execution runs in an isolated Dynamic Worker, so credentials and environment variables stay out of the model context.

Pick enforced only after checking your clients. The docs say portals do not support upstream servers that run their own Code Mode, and suggest opt-in or on by default when some clients bring their own implementation, to avoid nested code execution.

Layer four: Gateway DLP on portal traffic, and what it can read

Access logs each tool call through a portal, but it cannot read the payload. To get DLP you turn on Route traffic through Cloudflare Gateway, either for the whole portal or for one server. The docs say portal traffic is decrypted automatically, so you do not need the account-wide TLS decryption setting, but a Do Not Inspect HTTP policy that matches your MCP server still exempts it and DLP then sees nothing.

# Gateway HTTP policy for portal traffic (Cloudflare's documented example).
# Target the UPSTREAM hostname, not the portal hostname.
Selector      Operator  Value                                          Logic  Action
Host          in        example-mcp-server.example.workers.dev        And    Block
DLP Profile   in        Credentials and Secrets, Financial Information

# DLP regex for MCP JSON-RPC from the detection tutorial (Rust regex syntax).
# Scans only the first 1,024 bytes of a POST body.
"method"\s{0,5}:\s{0,5}"tools/call"

The policy must target the upstream server's hostname, not the portal's. Cloudflare's example blocks when the Host is the Workers hostname and the DLP profile is Credentials and Secrets or Financial Information. A block works in both directions: a request carrying matching data is refused, and a response carrying matching data is replaced with an error. That is the mechanism behind the post's example of a DLP rule that keeps PII away from certain servers.

Two limits matter. Gateway routing supports Streamable HTTP only, so an SSE-only upstream fails when it is on. And DLP AI prompt profiles do not apply to portal traffic because they target specific web client API paths; use standard profiles. For traffic that bypasses portals, the Gateway tutorial detects MCP by hostname containing mcp, the paths /mcp and /sse, and DLP regexes over JSON-RPC methods such as tools/call. It also states that DLP scans only the first 1,024 bytes of POST bodies, and the post itself notes that responses and notifications may not carry a method field, so treat it as a heuristic.

Where this design is weaker than the diagram suggests

The portal documentation lists limits that the blog post does not dwell on. These are the ones I would put in a risk register before a rollout.

  • Independent MFA, purpose justification and temporary authentication are not enforced when a server is authorised through a portal. A policy that demands step-up MFA on a server will not prompt after the portal login. Selectors such as emails, groups, country and device posture are still enforced.
  • Admin OAuth tokens can expire silently. The server then shows Error or Sync Required, disappears from the portal for end users, and the docs say admins are not notified. Someone has to watch server status.
  • A tool alias is removed on the next sync if the upstream server renames the tool, and background sync runs about every two hours for servers using Dynamic Client Registration.
  • Only remote HTTP servers work. Stdio-only servers must be self-hosted behind an HTTP endpoint with a bearer token or custom headers, some servers reject proxy-based clients with a 403, and a portal holds up to 80 servers.
  • Detection for public servers is not free: AI Security for Apps scans endpoints labelled cf-llm, handles only application/json, and its detection fields and Log Mode ruleset are a paid add-on available on Enterprise; endpoint discovery is on every plan.

None of this breaks the architecture. It means the portal is an access and routing layer, not a substitute for authorisation inside each server. The default-deny write control still has to live in the server template, because that is the only layer that knows what a tool really does.

What to do on Monday

If you already run MCP servers, you do not need to rebuild anything to get most of this. The order below puts the cheapest visibility first.

  1. Measure what you have: scan Gateway HTTP logs for MCP traffic using the query below, and list every destination that is allowed rather than blocked.
  2. Put the servers you want to keep behind one portal per audience, with default_disabled true and an explicit list of read tools for the group that only reads.
  3. Make Access the OAuth provider on each server you control, so the direct URL stops being a bypass.
  4. Turn on Code Mode as opt-in, compare the tool-definition tokens before and after on your own servers, and only then consider enforcing it.
  5. Route the portal through Gateway, add a Host plus DLP policy aimed at each upstream hostname, and check that no Do Not Inspect rule covers those hosts.

The scan is the Cloudflare tutorial's query, trimmed. It needs an API token with Zero Trust read permission, and the dataset covers up to 30 days.

// Step 1: which MCP-looking destinations are allowed, and for whom?
// Dataset: gatewayHttpRequestsAdaptiveGroups, up to 30 days of history.
const query = [
  "query MCPTrafficScan($accountTag: string, $since: string, $until: string) {",
  "  viewer { accounts(filter: { accountTag: $accountTag }) {",
  "    gatewayHttpRequestsAdaptiveGroups(",
  "      filter: { datetime_geq: $since, datetime_leq: $until,",
  "        OR: [ { httpHost_like: \"%mcp%\" },",
  "              { httpRequestURI_like: \"%/mcp%\" },",
  "              { httpRequestURI_like: \"%/sse%\" } ] }",
  "      limit: 10000",
  "    ) { dimensions { httpHost action users } count }",
  "  } }",
  "}",
].join("\n");

const res = await fetch("https://api.cloudflare.com/client/v4/graphql", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.CLOUDFLARE_API_TOKEN,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    query,
    variables: { accountTag: process.env.ACCOUNT_ID, since: "2026-09-17T00:00:00Z", until: "2026-10-17T00:00:00Z" },
  }),
});
const groups = (await res.json()).data?.viewer?.accounts?.[0]?.gatewayHttpRequestsAdaptiveGroups ?? [];

// action "allow" means an active MCP connection nobody has blocked or governed.
const open = groups.filter((g) => g.dimensions.action === "allow");
console.table(open.map((g) => ({ host: g.dimensions.httpHost, count: g.count, users: g.dimensions.users })));

The rule I take from this: put governance in the platform that makes servers cheap to build, then add layers that each cover what the one below cannot see. Cloudflare's 94 percent token figure is the headline, but the durable ideas are default-deny writes at the server, one portal per audience, and DLP aimed at the upstream host. Verify the limits on your own plan before you promise any of it.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is an MCP server portal?

An MCP server portal puts several MCP servers behind one HTTP endpoint at the path /mcp on your own domain. Users sign in once through Cloudflare Access, and admins choose which servers and tools each portal exposes. It also centralises logging and can route traffic through Gateway for DLP.

How much does Code Mode reduce MCP tool tokens?

In Cloudflare's own internal portal, four MCP servers exposed 52 tools using about 9,400 tokens. With Code Mode the model saw two portal tools using about 600 tokens, which Cloudflare reports as a 94 percent reduction. These are Cloudflare's figures for its own servers, so measure yours before relying on them.

Can I make MCP tools read-only for one team and writable for another?

Yes, by using one portal per audience. Cloudflare's example gives a finance group read-only tools of a repository server and gives engineers on corporate laptops read and write tools of the same server. Setting default_disabled to true makes the portal expose only the tools you explicitly list.

Does a portal stop users from reaching an MCP server directly?

No. Cloudflare's docs caution that users blocked at the portal can still connect through the server's direct URL. To enforce authentication at the server, configure Cloudflare Access as that server's OAuth provider.

Can Cloudflare Gateway block sensitive data sent to an MCP server?

Yes, when Route traffic through Cloudflare Gateway is on for the portal or server. You create an HTTP policy that matches the upstream server's hostname and a DLP profile, and a block applies to both requests and responses. DLP scans only the first 1,024 bytes of POST bodies, so treat it as a guardrail, not a guarantee.