Skip to main content
BlogSecurity

Shadow MCP Detection with Cloudflare Gateway Policy

How Cloudflare Gateway flags unmanaged MCP traffic with one selector, where a Portal-only block rule fits, and what a client hook and the server still have to do.

Matthews Wong

Software Engineer

October 202612 min read
Shadow MCP Detection with Cloudflare Gateway Policy

Key Takeaway

Shadow MCP detection works by classifying TLS-inspected requests that carry the MCP-Protocol-Version header, which Cloudflare Gateway exposes as the experimental.is_mcp selector. Pair it with a Portal-only block rule, a client hook and server-side checks, because the header's absence proves nothing and stdio, off-network and uninspected traffic never reach Gateway.

An employee can connect Claude Code, Cursor or any other agent harness to an MCP server with one line of configuration, and nobody has to approve it. The traffic that results has no obvious shape. The protocol does not guarantee a hostname or a /mcp path, so a direct connection to a tool server looks like any other HTTPS API call.

This post is a close reading of Cloudflare's 14 August 2026 post on detecting MCP traffic, the Cloudflare tutorial it builds on, the MCP 2026-07-28 Streamable HTTP transport page, Claude Code's hooks documentation and the MCP Portals docs. I have not run the new selector against a live Gateway account, so every rule below comes from those sources and anything I infer from them is labelled as inference.

One tool call, three places to see it

Cloudflare's framing is that a single MCP tool call exists in three forms. Inside the client it is a decision to invoke a tool with some arguments. On the network it is an HTTP POST carrying a JSON-RPC message. At the server it becomes a call to a handler that may read data or change state. Each form gives a security team a different amount of information, and a different chance to say no. The post's own example request for a weather tool shows what is on the wire.

# What a 2026-07-28 tool call looks like on the wire. Only the three
# headers and the Host/path are visible without parsing the body.
POST /mcp HTTP/1.1
Host: tools.example.com
Authorization: Bearer <access-token>
Content-Type: application/json
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: get_weather

{
  "jsonrpc": "2.0",
  "id": 42,
  "method": "tools/call",
  "params": {
    "name": "get_weather",
    "arguments": { "city": "Austin" }
  }
}

# A 2025-11-25 call after initialize: the version header is there,
# but Mcp-Method and Mcp-Name are not, so the tool is only in the body.
POST /api HTTP/1.1
Host: tools.example.com
Content-Type: application/json
MCP-Protocol-Version: 2025-11-25

{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather"}}

The post singles out the arguments as the most sensitive part: they can hold a search query, source code, customer data or instructions to change infrastructure. The tool name says what the agent intends to do, while the arguments say what data leaves the building. The response matters too. Request inspection can stop an unsafe action before it runs, but response inspection and logging only show what came back to the agent.

Why grepping for mcp in URLs stops working

Cloudflare's first approach, documented in its MCP traffic detection tutorial, searches Gateway HTTP logs through the GraphQL Analytics API for hostnames containing mcp and paths containing /mcp or /sse. The post concedes the two weaknesses. It misses a server at an ordinary URL such as tools.example.com/api, and it can match an unrelated service that happens to use mcp in a hostname. It is still useful for older clients and for historical visibility.

# The tutorial's first-generation heuristic, as a GraphQL Analytics query.
# Window: at most 30 days per query. Fields are from the tutorial.
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
      }
    }
  }
}

# And the DLP body pattern for tools/call. Rust regex, first 1,024 bytes of a
# POST body only, so a method field later in the payload will not match.
"method"\s{0,5}:\s{0,5}"tools/call"

The tutorial also builds DLP regex patterns for JSON-RPC methods such as initialize, tools/call and resources/read, and a Gateway HTTP policy that blocks on the DLP profile. Its own limitations section matters more than the patterns: DLP scans only the first 1,024 bytes of a request body, only POST requests, uses Rust regex rather than JavaScript or PCRE syntax, and the GraphQL history covers at most 30 days per query. A tool call whose method field sits past the first kilobyte will not match.

SignalCatchesMissesNeeds
Hostname and path queryServers on mcp hosts or /mcp and /sse paths, older clients, historyA server at an ordinary path, and it can false-matchGateway HTTP logs and the GraphQL Analytics API
DLP regex on the bodyJSON-RPC methods in POST bodiesAnything past the first 1,024 bytes, and non-POST requestsA DLP profile, a Gateway HTTP policy and decrypted traffic
MCP-Protocol-Version header (experimental.is_mcp)Conforming remote clients on any hostname or pathThe first legacy initialize, and clients that omit the headerTLS inspection and the Cloudflare One Client on the device
Client hookRemote and local stdio servers, with tool name and argumentsEvery client you did not configure, and unmanaged devicesThe hook deployed on each client

Do not paste the tutorial's DLP profile snippet into production. As published it builds an object called dlpProfile, posts it to the gateway/rules endpoint, and sends a variable named dlpRule that is only defined later in the page. It also sets a luhn validation on every pattern without saying why. Read the current DLP API reference before scripting this, and treat the snippet as a sketch of the idea.

What the 2026-07-28 spec puts on the wire

The Streamable HTTP page of the 2026-07-28 specification is what makes header-based detection practical. These are the requirements that matter to a network control.

  • Every POST to the MCP endpoint MUST include an MCP-Protocol-Version header, and its value MUST match the protocol version in the request body's _meta field.
  • Mcp-Method is required on all requests, and Mcp-Name is required for tools/call, resources/read and prompts/get. Names that are not plain ASCII are carried as base64 with a =?base64? sentinel.
  • A header that disagrees with the body gets 400 Bad Request and a JSON-RPC error with code -32020, HeaderMismatch. The stated reason is that a load balancer routing on the header and a server executing on the body must not see different requests.
  • The initialize handshake and protocol-level sessions are gone. A server that wants to support clients older than 2025-06-18, which never sent the header, MAY treat a header-less request as 2025-03-26.

The last item is the catch for detection. Cloudflare notes that the 2025-11-25 specification requires the header on every HTTP request after initialization, so the very first request of a legacy flow carries none and cannot be classified by header alone. The spec also tells intermediaries that enforce policy on mirrored headers to reject requests whose version header is absent or too old, rather than trusting unvalidated values. A gateway that keys on the header should be read the same way: a positive is strong evidence, a negative is not evidence.

Three enforcement points and what each can see

Cloudflare names three places to act on a call: inside the client before the request is serialised, at the device's network boundary, and at the server before the tool handler runs. The table restates the post's own trade-offs. None of the three is complete, which is the argument for running more than one.

Enforcement pointCan seeCannot seeWeak spot
MCP client hookServer, tool name and arguments before serialisation, including local stdio serversClients and devices you do not manageControls must be reproduced in every client; telemetry from one client is not an inventory
Gateway at the network boundaryDirect connections to any remote server on managed paths, tied to a user and deviceLocal stdio calls, off-network traffic, Do Not Inspect trafficNeeds TLS decryption, a proxy on the device and an origin that checks the proxy was used
MCP server before the handlerAuthenticated caller, parsed message, validated argumentsServers that do not implement the checkOnly protects the servers you run. Switching clients cannot bypass it

Cloudflare's example of the server layer is WriteGuard, used across its internal MCP servers: each tool has a risk tier and an enabled or disabled state, a read passes through unchanged, an allowed write gets agent attribution and an audit event, and a critical action is blocked before its handler runs. The post stresses that logging after execution can explain what happened but cannot prevent it.

Gateway: the selector, the dashboard and the Portal-only rule

Gateway classifies every TLS-inspected request by looking for the MCP-Protocol-Version header and exposes the result as a boolean selector, experimental.is_mcp. Cloudflare says all Zero Trust customers see indications of MCP traffic in Gateway HTTP logs and can allow or block on the selector without keeping a list of MCP-looking domains. The selector name carries the word experimental, and the post says the documentation will be updated with protocol selector details as the signal reaches general availability, so expect it to move.

A new MCP traffic dashboard totals requests, unique users and unique servers, charts servers over time, breaks traffic down by on-ramp so Portal traffic is separate from direct device connections, and lists the top servers seen outside your Portals, which Cloudflare calls the shadow MCP traffic that matters most. Filters jump to Gateway HTTP logs for a host or user.

# Gateway HTTP policy: the baseline rule from Cloudflare's post.
# Selector: experimental.is_mcp is true when the MCP-Protocol-Version header
# is seen on a TLS-inspected request. Anything not stamped with the
# mcp_portal traffic source is, by definition, off the approved path.
Traffic:  experimental.is_mcp == true and not traffic.onramp in ("mcp_portal")
Action:   Block

# Portal side (docs): turn on "Route traffic through Cloudflare Gateway" for
# the whole portal, or per server through the secure_web_gateway field
# (defaults to false). Streamable HTTP upstreams only; SSE-only servers
# will not connect through Gateway.

Enforcement uses Traffic Source selectors in Gateway Network and HTTP policies. Traffic that a Portal routes through Gateway carries an mcp_portal traffic source, so one rule blocks everything that is MCP but did not come through a Portal. Cloudflare's docs add that Gateway routing is a toggle on the Portal or on a single server, that it supports Streamable HTTP upstreams only, and that SSE-only servers will not connect through it. My inference is that you should switch Portal routing on and read the logs before enforcing, because the mcp_portal stamp is described only for traffic that goes through Gateway.

Observe before you block. Cloudflare says Traffic Source and MCP detection now appear in HTTP logs for decrypted traffic with no policy at all, so the first week can be read-only: list the servers outside your Portals, decide which to approve, move those behind a Portal, and only then turn the Block rule on.

Note what the rule cannot tell apart. Shadow MCP is an unapproved server. Portal bypass is an approved server reached by its upstream URL, skipping the Portal's Access policy, curated tool catalog and audit trail. The same Block rule fires for both, but fixing a bypass also needs an origin that rejects direct requests, through an Access policy, a source IP restriction or authorization initiated by the MCP server itself.

The client layer: a Claude Code hook that fails closed

Claude Code names MCP tools mcp__server__tool, and a PreToolUse hook can inspect that name before the call leaves the machine. That covers local stdio servers, which Gateway never sees. Two details from the hooks documentation decide whether this works. A matcher containing only letters, digits, underscores, hyphens, spaces, commas and pipes is compared as an exact string, so a bare mcp__memory matches nothing and you need mcp__memory__.* for a whole server. And a hook that crashes or times out does not block the call by default; the onFailure field set to block, available from Claude Code v2.1.295, makes it fail closed.

// .claude/settings.json
// "mcp__.*" contains a dot, so it is evaluated as an unanchored regex.
// onFailure "block" (Claude Code v2.1.295+): a crashed or timed-out hook
// would otherwise let the call through.
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "mcp__.*",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/mcp-allowlist.sh",
            "args": [],
            "timeout": 10,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}
#!/bin/bash
# .claude/hooks/mcp-allowlist.sh
# Tool names arrive as mcp__<server>__<tool>; plugin servers as
# mcp__plugin_<plugin>_<server>__<tool>. Cut the server out, compare it
# to the approved list, and deny with exit 2 (stderr becomes the reason).
# Do not also print JSON: exit 2 blocks even if the JSON says allow.
APPROVED=" acme-portal memory "

input=$(cat)
tool=$(jq -r '.tool_name' <<<"$input")
server=${tool#mcp__}
server=${server%%__*}

case "$APPROVED" in
  *" $server "*) exit 0 ;;   # no decision: normal permission flow applies
esac

echo "Blocked: MCP server '$server' is not on the approved list" >&2
exit 2

The script below allowlists by server name. It denies with exit code 2, which sends stderr back to Claude as the reason. The documentation warns against mixing exit 2 with JSON output in one hook, since exit 2 blocks even if the JSON says allow. It is a client-side control for one client: the same policy has to be rebuilt for each other harness your team uses, which is the standardisation problem Cloudflare points at.

Where this breaks

The honest summary of the sources is that every layer has a stated blind spot, and the blind spots are different.

  • Gateway never sees local stdio servers, off-network connections, Do Not Inspect traffic or anything that does not traverse it.
  • The header is a strong positive signal and a weak negative one. Versions before 2025-06-18 did not define it, custom transports may not carry it, and a nonconforming client can leave it out.
  • Portals cannot yet reach servers on private networks. Cloudflare says private routing through Gateway is in active development, so private MCP servers have no governed path today.
  • The selector is experimental, and the tutorial's regex fallback only inspects the first 1,024 bytes of a POST body.
  • Granular tool-level control and tool-usage reporting across known and unknown servers are described as coming soon, not shipped.

A Portal-only block rule is a guardrail for managed devices on managed paths, not a perimeter. An agent running on a personal laptop, inside a container with its own egress, or over stdio is outside it. Treat the rule as raising the cost of the easy mistake, and keep the server-side checks for anything that writes data.

What to do on Monday

Sequence the work the way Cloudflare does: visibility first, then close the paths that should not exist.

  1. Confirm TLS inspection and the Cloudflare One Client cover the devices you care about, and list what is excluded by Do Not Inspect.
  2. Read the MCP traffic dashboard and HTTP logs for a week and compare every destination with the servers you have approved.
  3. Move approved servers behind an MCP Portal and turn on Gateway routing for it, checking that each upstream supports Streamable HTTP.
  4. Add the Block rule for MCP traffic outside the mcp_portal source and watch the logs for approved servers that still go around the Portal.
  5. Deploy the client hook with onFailure set to block, and put tool-level checks in front of every server you run that can write.
# Run from a managed device, against a server you own. Replace the host.
# 1. With the headers the 2026-07-28 spec requires: Gateway should classify
#    it as MCP. If your rule is enforcing, expect a block, not the server reply.
curl -i -X POST https://tools.example.com/api \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -H "MCP-Protocol-Version: 2026-07-28" \
  -H "Mcp-Method: tools/list" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

# 2. Same call without the headers: the selector has nothing to key on.
#    A server that rejects header-less requests answers 400 itself.
curl -i -X POST https://tools.example.com/api \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Before enforcing, test the policy from a managed device with a harmless request to a server you own. The first call carries the headers the 2026-07-28 spec requires, the second leaves them out. If both reach the server, the selector is not seeing your traffic, and that is the most valuable thing you can learn before rollout.

Detection is a property of the protocol now, not of a URL list, but only on paths you inspect and only for clients that follow the spec. Use the Gateway selector to find shadow servers, the Portal-only rule to make the approved path the easy one, and a client hook and server checks for everything the network cannot see.

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

What is shadow MCP?

Shadow MCP is a connection from an employee's agent to an MCP server that the organization has not approved. The employee finds the server in a repository, a product guide or a message and adds it to their client directly. The security team then has no idea which tools it exposes or what data is sent to it.

How does Cloudflare Gateway detect MCP traffic?

Gateway inspects every TLS-inspected request for the MCP-Protocol-Version header and sets the boolean selector experimental.is_mcp to true when it is present. You can use that selector in an Allow or Block policy without maintaining a list of MCP-looking domains. Traffic that is not decrypted, local stdio servers and off-network connections stay outside this view.

What does the Portal-only block rule look like?

Cloudflare's baseline rule is experimental.is_mcp == true and not traffic.onramp in ("mcp_portal") with the action Block. Requests routed through an MCP Portal and Gateway carry the mcp_portal traffic source, so only MCP traffic that goes around the Portal is blocked. Cloudflare suggests observing in HTTP logs first, since detection and traffic source appear there without any policy.

Can I rely on the MCP-Protocol-Version header alone?

No. Its presence is a strong positive indicator of MCP, but its absence proves nothing. Versions before 2025-06-18 did not define it, the first request of a legacy initialize flow carries none, and stdio or custom transports may never send it.

Does a Claude Code hook replace network detection?

No, they cover different gaps. A PreToolUse hook sees the server, tool and arguments before the call leaves the machine and covers local stdio servers, but it only protects clients where it is installed. Set onFailure to block, available from Claude Code v2.1.295, so a crashed hook does not let the call through.