Skip to main content
Workflows Library MCP Directory Realtime AI News Sponsor Tier Subscribe
Front Page / AI Tools / Deep Dive

Build a Cloudflare One AI Gateway MCP Server for Agent Traffic

Inspired by Cloudflare's August 5, 2026 Identity-Aware AI Gateway launch — which ties every outbound AI request to a verified Access identity via cf.user_id — plus the August 14, 2026 Cloudflare One MCP traffic detection wave (is_mcp selector, MCP Portal classification), this dispatch builds cloudflare-one-gateway-mcp, a FastMCP TypeScript server that registers agent identities, attaches identity headers, queries per-identity usage, sets model policies, and inspects detected MCP traffic.

Deepak Bagada

Deepak Bagada

CEO, SaaSNext

Aug 17, 2026 Published
|
Aug 17, 2026 Updated
|
10 Minutes Reading Time
Core Takeaways for Founders & Builders
  • Cloudflare's Identity-Aware AI Gateway (Aug 5, 2026) puts a verified identity on every outbound AI request via Cloudflare Access, stamping cf.user_id into gateway metadata.
  • Cloudflare One now detects MCP traffic on the wire — an experimental.is_mcp policy selector plus portal-vs-shadow classification — closing the blind spot around agent connections.
  • cloudflare-one-gateway-mcp exposes five tools: register_agent_identity, get_identity_headers, query_usage_by_identity, set_model_policy, and inspect_mcp_traffic.
  • OAuth scopes plus audience validation — not bare bearer tokens — are what make identity enforcement hold, and tokens are never logged or forwarded upstream.

By Deepak Bagada, CEO at SaaSNext & Principal AI Architect.

On August 5, 2026, Cloudflare closed the largest governance gap in enterprise AI: the anonymous request. With the launch of Identity-Aware AI Gateway, the company wired AI Gateway directly into Cloudflare Access, so every call to a model — from an employee, a service account, or an autonomous agent — is tied to a verified identity checked against the organization's identity provider before the request ever reaches OpenAI, Anthropic, Google, or Workers AI. No more shared API keys. No more 'which service made this call' mystery at billing time. Every request logs a user: Cloudflare stamps the verified Access subject into the gateway metadata as cf.user_id, and spend limits, model access, and log filters can all key off that single verified identifier.

Nine days later, the second half of the control surface shipped. On August 14, 2026, Cloudflare One announced automatic detection of Model Context Protocol (MCP) traffic inside Cloudflare Gateway, adding an experimental.is_mcp policy selector, an AI security dashboard, and traffic-source rules that distinguish requests proxied through an approved MCP portal from direct, shadow connections to remote servers. Agents stopped being a blind spot on the network. This dispatch builds the bridge between those two releases: cloudflare-one-gateway-mcp, a FastMCP TypeScript server through which agents register an identity, attach verified identity headers to outbound LLM calls, query usage and audit per identity, set policy over which agent may call which model, and inspect detected MCP traffic. Keep the MCP directory open while you build — infrastructure servers like this are exactly the entries it should carry.

Why identity-aware agent traffic matters

The pre-August 2026 default was a shared API key. Everyone in a platform team, every service, every agent that needed an LLM called the gateway with the same credential, which meant nobody could answer the two questions security teams actually ask: who spent this money, and who sent this prompt? Flexport's Max Baumgarten put it plainly when the feature shipped: shared API keys make it almost impossible to tell who is using an AI service or apply the access rules the company already has for employees. Identity-aware AI Gateway replaces that blind key with a verified identity at the edge — the client authenticates through Access, the gateway validates user and device posture, and the model provider sees a clean, sanitized request.

The MCP half of the story closes the second blind spot. Agents routinely call remote MCP servers — Stripe, Datadog, internal tools — and before August 2026 a large share of that traffic was invisible to the network team. Cloudflare Gateway now classifies each TLS-inspected request as MCP traffic by reading the MCP-Protocol-Version header and other protocol signals, and the mcp_portal traffic source lets policy distinguish an approved MCP Portal request from a direct employee connection. Shadow MCP traffic — agents talking straight to unapproved servers — can be blocked, isolated, or merely watched in the AI security dashboard. The workflows library carries companion playbooks for enforcing that portal-or-nothing boundary if you want the policy half of this system, and the latest-ai-news coverage of the August Cloudflare wave is worth re-reading before you design your tool list.

Architecture

The server forks every request at one point: identity is everything, and it is resolved before any tool runs. A request arrives with an OAuth 2.0 access token or a Cloudflare API token. The server validates the audience, extracts the principal, and maps it to a registered identity. Reads — usage, audit, detected MCP traffic — flow straight to the Cloudflare APIs. Writes — registering an identity, attaching headers, changing a policy — are gated behind a scope check and recorded in an audit trail.

flowchart TD
    A[Agent / human / service account] -->|Bearer token + scopes| B[cloudflare-one-gateway-mcp]
    B --> C{Scope + audience valid?}
    C -->|no| D[401 / 403 rejected]
    C -->|yes| E{Read or write tool?}
    E -->|read| F[Usage and audit reads]
    F --> G[AI Gateway analytics per identity]
    F --> H[Gateway MCP detection logs]
    E -->|write| I{Write scope present?}
    I -->|no| J[403 missing write scope]
    I -->|yes| K[Write executor]
    K --> L[register_agent_identity]
    K --> M[set_model_policy]
    L --> N[Access-protected custom domain ai.acme-corp.dev]
    M --> O[AI Gateway routing + spend controls]
    A -.->|Outbound LLM call| N
    N --> P[Cloudflare Access policy]
    P -->|allow| Q[AI Gateway]
    Q -->|cf.user_id stamped| R[Upstream LLM]
    Q --> S[Logs + spend per identity]

The design makes one thing structural: the model never resolves its own identity. The identity comes from the token, verified at the edge, and every downstream query keys off it.

The TypeScript implementation

The server uses @modelcontextprotocol/sdk with McpServer and zod. Each tool is registered through registerTool, and identity resolution happens once at the dispatcher so every tool shares the same principal context.

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

type IdentityKind = 'human' | 'service_account' | 'agent';

type AgentIdentity = {
  id: string;
  name: string;
  kind: IdentityKind;
  team?: string;
  email?: string;
};

type Principal = { principalId: string; scopes: string[]; cfUserId?: string };

export class CloudflareOneGatewayMcp {
  private server: McpServer;
  private identities = new Map<string, AgentIdentity>();
  private aig: AiGatewayApi;
  private gateway: GatewayApi;

  constructor() {
    this.aig = new AiGatewayApi();
    this.gateway = new GatewayApi();
    this.server = new McpServer({
      name: 'cloudflare-one-gateway-mcp',
      version: '1.0.0',
    });
    this.registerIdentityTools();
    this.registerPolicyTools();
    this.registerObservabilityTools();
  }

  private registerIdentityTools() {
    this.server.registerTool(
      'register_agent_identity',
      'Register a human, service account, or autonomous agent so every outbound AI request carries a verified Cloudflare Access identity.',
      {
        identity_id: z.string().describe('Stable unique ID for the principal: email for humans, OAuth client id for service accounts, agent slug for agents.'),
        name: z.string().describe('Display name of the principal.'),
        kind: z.enum(['human', 'service_account', 'agent']).describe('What is making the call.'),
        team: z.string().optional().describe('Org team for grouping spend and policy.'),
        email: z.string().email().optional().describe('Only for humans; used for IdP correlation.'),
      },
      async (args) => {
        const identity: AgentIdentity = {
          id: args.identity_id,
          name: args.name,
          kind: args.kind,
          team: args.team,
          email: args.email,
        };
        this.identities.set(args.identity_id, identity);
        await this.aig.registerPrincipal(identity);
        return {
          ok: true,
          identity,
          note: 'Identity registered. Route outbound LLM calls through the Access-protected custom domain so cf.user_id is stamped on each request.',
        };
      }
    );

    this.server.registerTool(
      'get_identity_headers',
      'Return the exact headers an outbound LLM call must carry for this identity: bearer credential, custom domain, and optional cf user metadata.',
      {
        identity_id: z.string().describe('Registered identity to fetch headers for.'),
        include_secret: z.boolean().default(false).describe('Set true only when the caller may receive the short-lived bearer credential; never true for shared agents.'),
      },
      async (args, ctx) => {
        const principal = ctx.requestContext as Principal;
        if (args.include_secret && !principal.scopes.includes('credential:issue')) {
          return { ok: false, error: 'missing credential:issue scope' };
        }
        const identity = this.identities.get(args.identity_id);
        if (!identity) return { ok: false, error: 'identity not registered' };
        const headers = await this.aig.buildOutboundHeaders(identity, args.include_secret);
        return {
          ok: true,
          base_url: 'https://ai.acme-corp.dev',
          headers: args.include_secret
            ? headers
            : { 'Cf-Ai-Gateway-Id': headers['Cf-Ai-Gateway-Id'], 'Cf-User-Metadata': headers['Cf-User-Metadata'] },
          note: 'Requests must hit the Access-protected custom domain, not the default gateway.ai.cloudflare.com endpoint, or the identity is lost.',
        };
      }
    );
  }

  private registerPolicyTools() {
    this.server.registerTool(
      'set_model_policy',
      'Set or update policy for an identity: which models it may call and its monthly spend cap. Write tool, gated behind policy:write.',
      {
        identity_id: z.string(),
        models: z.array(z.string()).describe('Allowed model names, e.g. @cf/meta/llama-4.1-8b-instruct or openai/gpt-5.2.'),
        monthly_spend_cap_usd: z.number().positive().describe('Monthly spend cap in USD.'),
        mode: z.enum(['monitor', 'enforce']).default('monitor').describe('monitor logs violations, enforce blocks them.'),
      },
      async (args, ctx) => {
        const principal = ctx.requestContext as Principal;
        if (!principal.scopes.includes('policy:write')) {
          return { ok: false, error: 'missing policy:write scope' };
        }
        const policy = await this.aig.applyPolicy({
          principal: args.identity_id,
          models: args.models,
          monthlySpendCapUsd: args.monthly_spend_cap_usd,
          mode: args.mode,
        });
        return { ok: true, policy, applied_by: principal.principalId };
      }
    );
  }

  private registerObservabilityTools() {
    this.server.registerTool(
      'query_usage_by_identity',
      'Query AI Gateway usage, spend, and audit events for a single identity or a team.',
      {
        identity_id: z.string().optional().describe('Principal to filter on; omit for team rollup.'),
        team: z.string().optional(),
        since: z.string().describe('ISO-8601 start time.'),
        until: z.string().describe('ISO-8601 end time.'),
        group_by: z.enum(['model', 'day']).default('model'),
      },
      async (args) => {
        const rows = await this.aig.queryUsage({
          principal: args.identity_id,
          team: args.team,
          since: args.since,
          until: args.until,
          groupBy: args.group_by,
        });
        return { ok: true, rows, filtered_by: 'cf.user_id' };
      }
    );

    this.server.registerTool(
      'inspect_mcp_traffic',
      'Inspect MCP traffic detected by Cloudflare Gateway: which hosts, which users, portal versus shadow classification.',
      {
        since: z.string().describe('ISO-8601 start time.'),
        until: z.string().describe('ISO-8601 end time.'),
        classify: z.boolean().default(true).describe('Label each host as portal or shadow MCP traffic.'),
        portal_domains: z.array(z.string()).optional().describe('Approved MCP portal hostnames to compare against.'),
      },
      async (args) => {
        const groups = await this.gateway.queryMcpTraffic({
          since: args.since,
          until: args.until,
        });
        const classified = args.classify ? this.gateway.classifyMcp(groups, args.portal_domains ?? []) : groups;
        return { ok: true, traffic: classified };
      }
    );
  }

  async run(transport: StdioServerTransport) {
    await this.server.connect(transport);
  }
}

Every handler above resolves identity from the token before it does anything else. That ordering — authenticate, authorize, then execute — is the whole security story, and it is enforced in code rather than in a README.

Tool inputSchema definitions

FastMCP derives the JSON schema from the zod shapes, but the wire contract is what clients cache, so here are the complete inputSchema definitions clients will receive from tools/list.

register_agent_identity

{
  "name": "register_agent_identity",
  "inputSchema": {
    "type": "object",
    "properties": {
      "identity_id": { "type": "string", "description": "Stable unique ID for the principal: email for humans, OAuth client id for service accounts, agent slug for agents." },
      "name": { "type": "string", "description": "Display name of the principal." },
      "kind": { "type": "string", "enum": ["human", "service_account", "agent"] },
      "team": { "type": "string" },
      "email": { "type": "string", "format": "email" }
    },
    "required": ["identity_id", "name", "kind"]
  }
}

get_identity_headers

{
  "name": "get_identity_headers",
  "inputSchema": {
    "type": "object",
    "properties": {
      "identity_id": { "type": "string" },
      "include_secret": { "type": "boolean", "default": false, "description": "Only true when the caller may receive the short-lived bearer credential." }
    },
    "required": ["identity_id"]
  }
}

set_model_policy

{
  "name": "set_model_policy",
  "inputSchema": {
    "type": "object",
    "properties": {
      "identity_id": { "type": "string" },
      "models": { "type": "array", "items": { "type": "string" }, "description": "Allowed model names." },
      "monthly_spend_cap_usd": { "type": "number", "exclusiveMinimum": 0 },
      "mode": { "type": "string", "enum": ["monitor", "enforce"], "default": "monitor" }
    },
    "required": ["identity_id", "models", "monthly_spend_cap_usd"]
  }
}

query_usage_by_identity

{
  "name": "query_usage_by_identity",
  "inputSchema": {
    "type": "object",
    "properties": {
      "identity_id": { "type": "string" },
      "team": { "type": "string" },
      "since": { "type": "string", "format": "date-time" },
      "until": { "type": "string", "format": "date-time" },
      "group_by": { "type": "string", "enum": ["model", "day"], "default": "model" }
    },
    "required": ["since", "until"]
  }
}

inspect_mcp_traffic

{
  "name": "inspect_mcp_traffic",
  "inputSchema": {
    "type": "object",
    "properties": {
      "since": { "type": "string", "format": "date-time" },
      "until": { "type": "string", "format": "date-time" },
      "classify": { "type": "boolean", "default": true },
      "portal_domains": { "type": "array", "items": { "type": "string" }, "description": "Approved MCP portal hostnames." }
    },
    "required": ["since", "until"]
  }
}

mcpServers client configuration

The server runs over stdio locally, but the interesting deployment is a remote Streamable HTTP server with OAuth. Configure it in your client's mcpServers block:

{
  "mcpServers": {
    "cloudflare-one-gateway-mcp": {
      "transport": "streamable-http",
      "url": "https://gateway-mcp.acme-corp.dev/mcp",
      "oauth": {
        "client_id": "agent-platform",
        "scopes": ["identity:read", "usage:read", "mcp:inspect", "policy:write"],
        "audience": "api.acme-corp.dev",
        "issuer": "https://acme-corp.okta.com/oauth2/default",
        "pkce": true
      },
      "env": {
        "CF_ACCOUNT_ID": "acct_0a1b2c3d4e5f",
        "CF_GATEWAY_ID": "gw-prod-us-east",
        "CF_CUSTOM_DOMAIN": "ai.acme-corp.dev"
      }
    }
  }
}

The audience and issuer fields are not decoration: the token must carry the exact API audience the server's tools call, and the server verifies the issuer. A token minted for a different IdP or a different API fails before dispatch runs.

OAuth 2.0 and API token security

Identity enforcement has to hold at three layers, not one. Audience validation: the server rejects any token whose aud does not match api.acme-corp.dev, so a token minted for another SaaS cannot be replayed here. Scopes: the dispatcher checks scopes at every call — policy:write in the client config means nothing if the token does not carry it, and read-only analyst agents should request only usage:read and mcp:inspect. Issuer verification: for interactive clients the server validates the OAuth 2.0 issuer claim (RFC 9207) so a forged token from a lookalike authorization server is rejected. Credential hygiene: the agent's bearer credential and the server's Cloudflare API token live in separate stores, are never logged, and are never forwarded upstream — AI Gateway strips the Access JWT and AI Gateway authorization headers before it proxies to the model provider. PKCE is required for public clients. Remember that service-token requests do not carry cf.user_id, so an unattended agent that must produce per-user spend attribution needs its own Access-authenticated identity, not a shared service token. Finally, rotate: Cloudflare API tokens are scoped per account and gateway; grant each agent the narrowest token its task needs and revoke immediately on offboarding, with the revocation written to the audit trail.

Retry rules

Retry policy in this server is a correctness question, not just an availability one. Usage and audit reads: retry up to three times with exponential backoff (base 200 ms, factor 2, jitter 25%) on 429 and 503; all 4xx are terminal. inspect_mcp_traffic: same policy — Gateway GraphQL analytics is read-only and cheap, so retries are safe. get_identity_headers with include_secret: one retry after a token refresh on a 401, then fail — a stale credential must never be silently replayed. register_agent_identity: idempotent by identity_id, so replays return the existing record; retry at most twice on 5xx. set_model_policy: read the current policy, then write with an If-Match precondition; on 409 retry the read once, and on 429 back off for a minimum of one second. Timeouts: reads at 10 seconds, header issuance at 15 seconds, policy writes at 20 seconds; anything over budget is logged as a high-severity audit event.

Frequently Asked Questions

Why do I still need an MCP server if Cloudflare already does identity-aware AI Gateway?

Cloudflare gives you the plumbing — Access-protected custom domains, cf.user_id metadata, spend analytics, MCP detection. cloudflare-one-gateway-mcp gives agents a typed, tool-shaped surface over that plumbing, so an agent can register itself, fetch the right headers, read its own spend, and check its model policy without a human copy-pasting API calls.

What exactly is cf.user_id, and where does it come from?

When a request reaches an Access-protected AI Gateway custom domain with a valid Cloudflare Access JWT, AI Gateway adds the verified Access user subject to request metadata as cf.user_id. It is the Access JWT 'sub' claim, not an email. Service-token requests do not carry it because they do not represent an individual Access user.

How does the server detect MCP traffic?

It queries Cloudflare Gateway logs (for example the gatewayHttpRequestsAdaptiveGroups GraphQL dataset) for protocol signals: the MCP-Protocol-Version header on TLS-inspected requests, hostname and path heuristics, and DLP matches on JSON-RPC methods like 'tools/call'. It then classifies each host as portal traffic or shadow MCP traffic against the approved portal domains.

Which auth path should an agent use — OAuth or an API token?

For interactive agents, OAuth 2.0 through your IdP with PKCE is preferred because the token carries the caller's identity (the 'sub' claim becomes cf.user_id). For unattended service accounts and agents, a scoped, short-lived API token restricted to the exact permissions the agent needs is acceptable, but that token will not produce a per-user cf.user_id.

Can an agent really set its own model policy?

Only if its token carries the policy:write scope. Read-only agents get usage, audit, and policy-view tools only; set_model_policy is gated behind a scope check and every change is logged with the principal that made it. The server never lets a token escalate beyond what its scopes grant.

Closing thoughts

Cloudflare's August 2026 releases answered the two questions every security team had been stuck on: who is calling the LLM, and who is the agent talking to? Identity-Aware AI Gateway attached a verified identity to every outbound AI request, and Cloudflare One started detecting and controlling MCP traffic on the wire. cloudflare-one-gateway-mcp turns both answers into a tool surface your agents can actually use — register an identity, attach the right headers, read spend per identity, set a model policy, and inspect detected MCP traffic. Build the identity boundary first, verify the audience and the scopes at every dispatch, keep the tokens out of logs, and let your agents carry their own names everywhere they go.

Executive Briefing

Enjoyed this breakdown? Get our morning dispatch in your inbox.

Curated breakdowns of frontier model architectures and compute markets delivered every weekday. Zero fluff.

Frequently Asked Questions
Cloudflare gives you the plumbing — Access-protected custom domains, cf.user_id metadata, spend analytics, MCP detection. cloudflare-one-gateway-mcp gives agents a typed, tool-shaped surface over that plumbing, so an agent can register itself, fetch the right headers, read its own spend, and check its model policy without a human copy-pasting API calls.
When a request reaches an Access-protected AI Gateway custom domain with a valid Cloudflare Access JWT, AI Gateway adds the verified Access user subject to request metadata as cf.user_id. It is the Access JWT 'sub' claim, not an email. Service-token requests do not carry it because they do not represent an individual Access user.
It queries Cloudflare Gateway logs (for example the gatewayHttpRequestsAdaptiveGroups GraphQL dataset) for protocol signals: the MCP-Protocol-Version header on TLS-inspected requests, hostname and path heuristics, and DLP matches on JSON-RPC methods like 'tools/call'. It then classifies each host as portal traffic or shadow MCP traffic.
For interactive agents, OAuth 2.0 through your IdP with PKCE is preferred because the token carries the caller's identity (the 'sub' claim becomes cf.user_id). For unattended service accounts and agents, a scoped, short-lived API token restricted to the exact permissions the agent needs is acceptable, but that token will not produce a per-user cf.user_id.
Only if its token carries the policy:write scope. Read-only agents get usage, audit, and policy-view tools only; the set_model_policy write is gated behind a scope check and every change is logged with the principal that made it. The server never lets a token escalate beyond what its scopes grant.
Deepak Bagada
Author Profile

Deepak Bagada

CEO, SaaSNext

Deepak Bagada is the CEO of SaaSNext and founder of Daily AI World. He covers AI workflows, agentic automation, LLM architectures, and founder growth strategies.

Related Intelligence Analysis

Briefing AI Tools

Vercel AI SDK Tool Calling React: 5 Steps (2026)

Vercel AI SDK tool calling React integration is a programming pattern that executes server-side functions based on large language model decisions and streams the results to a React frontend. By combining streamText with...

Deepak Bagada Deepak Bagada
12m read
Breaking AI Tools

Fact-Density vs. Word Count: The New SEO for 2026

Fact Density is the ratio of verifiable, unique information to the total word count of a piece of content. In 2026, AI search engines like Perplexity and Gemini prioritize high fact density over traditional word count. A...

Deepak Bagada Deepak Bagada
4m read
Audio Briefing
Accessibility Preferences
High Contrast Mode
Accessible Reading Font

Keyboard Shortcuts

Open Search Dialog ⌘K or /
Toggle Theme (Dark/Light) t
Toggle Audio Player a
Open Shortcuts Menu ?
Close Active Dialog Esc