Skip to content
guide

Shadow MCP: the enterprise problem nobody budgeted for

| 8 min read
Dark network lines representing shadow traffic and MCP detection
Photo by Markus Spiske on Unsplash
Office worker at a laptop with multiple terminal windows open
Your staff engineer added three community MCP servers to Cursor this week. You don't know which three. Photo by Luke Peters on Unsplash

Shadow IT used to mean a marketing manager expensing Dropbox. Shadow SaaS meant a product team signing up for Figma on a personal credit card. Both were contained: the tools ran in a browser tab, touched data the user could already see, and couldn't reach past the tab boundary. Shadow MCP breaks that containment. An employee toggles a community MCP server into Cursor or Claude Desktop, and now an agent can read SSH keys, run shell commands, and talk to production databases through the user's ambient credentials.

Cloudflare Gateway shipped MCP-aware rules in April 2026. Zscaler and Netskope followed within a week. The tooling caught up because enterprise security teams started asking. This post covers the four things to do this quarter: understand the threat model, build detection, ship an inventory script, land an allowlist, and publish a three-page policy.

Why Shadow MCP is worse than Shadow SaaS

A rogue SaaS tab in a browser runs in a sandbox. It can't read the user's SSH private key. It can't spawn a shell. It can't open a TCP connection to your production Postgres. The browser's same-origin policy and OS sandbox carry the containment.

An MCP server runs outside the browser. Claude Desktop and Cursor spawn stdio servers as child processes on the user's machine with the user's permissions. An HTTP MCP server connects with whatever credentials the user pastes in. The agent then calls tools from that server as part of its normal loop. From the operating system's point of view, the tool call is the user opening a file or running a command.

Concrete difference: a community GitHub MCP server pulled from a random npm package and added to ~/.cursor/mcp.json can exfiltrate anything in ~/.ssh/, every .env file under your home directory, and the entire contents of any repo the user has checked out. The user approved it once. The agent never asks again. A browser-based rogue tool cannot do any of that.

Add one more wrinkle: the user's VPN. If the laptop is on the corporate VPN when the agent runs, the MCP server inherits that network position. Internal services, staging databases, and metadata endpoints all become reachable. The server didn't need to phish anyone. It just waited for the VPN to connect.

Finding Shadow MCP on your network

Detection works at three layers: the endpoint, the egress gateway, and the DNS. You want signal from all three because each one catches a different failure mode.

On the endpoint, watch for child processes that Claude Desktop or Cursor spawns. Both clients invoke stdio servers as subprocesses with known parent process names. Your EDR (CrowdStrike, SentinelOne, Defender for Endpoint) can alert on any process tree rooted at Claude.app, Cursor.app, or claude-code. Filter to the ones you approved; flag everything else.

On egress, filter HTTP traffic. MCP over Streamable HTTP ships a distinctive header (mcp-protocol-version) and a distinctive content type (application/json-rpc-2). Cloudflare Gateway, Zscaler, and Netskope all match on both. Here's the Cloudflare Zero Trust rule to block unsanctioned MCP while allowing your approved hosts:

# Cloudflare Zero Trust / WARP HTTP policy
# Blocks unsanctioned MCP traffic while allowing approved servers
name: block-shadow-mcp
action: block
filters:
  - type: http
    expression: |
      (http.request.headers["mcp-protocol-version"] ne "") and
      not (http.request.host in {
        "api.botoi.com",
        "mcp.github.com",
        "mcp.acme-corp.internal"
      })
  - type: http
    expression: |
      (http.response.headers["content-type"] contains "application/json-rpc-2") and
      not (http.request.host in $approved_mcp_hosts)
notification:
  include_context: true
  message: |
    Shadow MCP blocked. Request the server through
    https://acme-corp.internal/mcp-review to add it to the allowlist.

On DNS, your resolver sees lookups for community MCP server hosts before any HTTPS connection opens. Feed the approved allowlist into your DNS filter (Cisco Umbrella, NextDNS, Cloudflare Gateway DNS) as the only permitted MCP destinations. Every other lookup that matches a known MCP registry feed gets a block and a ticket.

For npm-installed stdio servers, your endpoint DLP agent can watch for packages matching *-mcp-*, @modelcontextprotocol/*, or any package declaring an mcp entry in its package.json bin field. That catches the install event before the user wires the server into Cursor.

Build an MCP inventory in one afternoon

Detection tells you when a new server shows up. Inventory tells you what's already installed. Ship a script, run it through your MDM (Jamf, Intune, Kandji), and post results to a reporting endpoint. An afternoon of work, then you know which machines have which servers.

The script reads the two config files every major client uses. Claude Desktop keeps its config at ~/Library/Application Support/Claude/claude_desktop_config.json on macOS and %APPDATA%\Claude/claude_desktop_config.json on Windows. Cursor keeps its config at ~/.cursor/mcp.json. Both files are plain JSON with an mcpServers object mapping server names to commands, args, URLs, and env vars.

#!/usr/bin/env python3
# mcp-inventory.py: scan a dev machine for installed MCP servers
# Run via MDM (Jamf, Intune, Kandji) and post results to your inventory API.

import hashlib
import json
import os
import platform
import socket
import sys
from datetime import datetime, timezone
from pathlib import Path
from urllib import request

REPORT_URL = os.environ.get("MCP_REPORT_URL", "https://inventory.acme-corp.internal/mcp")
REPORT_TOKEN = os.environ.get("MCP_REPORT_TOKEN", "")

def config_paths():
    home = Path.home()
    system = platform.system()
    if system == "Darwin":
        claude = home / "Library/Application Support/Claude/claude_desktop_config.json"
    elif system == "Windows":
        claude = Path(os.environ.get("APPDATA", "")) / "Claude/claude_desktop_config.json"
    else:
        claude = home / ".config/Claude/claude_desktop_config.json"
    cursor = home / ".cursor/mcp.json"
    return [("claude_desktop", claude), ("cursor", cursor)]

def enumerate_servers(path: Path):
    if not path.is_file():
        return []
    try:
        data = json.loads(path.read_text())
    except (json.JSONDecodeError, OSError):
        return []
    servers = data.get("mcpServers") or data.get("servers") or {}
    entries = []
    for name, spec in servers.items():
        entries.append({
            "name": name,
            "transport": "stdio" if spec.get("command") else "http",
            "command": spec.get("command"),
            "args": spec.get("args", []),
            "url": spec.get("url"),
            "env_keys": sorted((spec.get("env") or {}).keys()),
        })
    return entries

def sha256(blob: bytes) -> str:
    return hashlib.sha256(blob).hexdigest()

def main():
    report = {
        "hostname": socket.gethostname(),
        "user": os.environ.get("USER") or os.environ.get("USERNAME"),
        "os": platform.platform(),
        "collected_at": datetime.now(timezone.utc).isoformat(),
        "clients": [],
    }
    for client, path in config_paths():
        if not path.is_file():
            continue
        raw = path.read_bytes()
        report["clients"].append({
            "client": client,
            "config_path": str(path),
            "config_sha256": sha256(raw),
            "servers": enumerate_servers(path),
        })
    body = json.dumps(report).encode("utf-8")
    req = request.Request(
        REPORT_URL,
        data=body,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {REPORT_TOKEN}",
        },
    )
    with request.urlopen(req, timeout=10) as resp:
        print(f"reported: {resp.status}")

if __name__ == "__main__":
    sys.exit(main())

The hash matters. Config files change when a user adds a server, rotates a key, or pulls down a team template. Store the SHA-256 of the whole file and alert on any change you didn't push. The env var names (not values) go in the report too; seeing GITHUB_PERSONAL_ACCESS_TOKEN listed under a community server is its own signal.

Run the script daily. Feed the output into your existing asset inventory (Snipe-IT, ServiceNow, or a Postgres table). Build a dashboard that shows: new servers this week, servers on more than N machines, and servers whose config hash changed. Those three views cover most of what you'll need to ask later.

Allowlist the servers you trust

Inventory shows you what's installed. An allowlist says what's allowed. Default deny, every server reviewed, every approval expiring. Version the file in Git so the approval history is auditable.

# mcp-allowlist.yaml: approved MCP servers for Acme Corp
# Default deny. Every server below has a review record.
version: 2026.04
default_action: deny
review_url: https://acme-corp.internal/mcp-review

approved:
  - id: botoi-api
    host: api.botoi.com
    transport: http
    url: https://api.botoi.com/mcp
    scopes: ["dns.read", "ssl.read", "ip.read", "headers.read"]
    review_ticket: SEC-4821
    reviewed_by: security-platform
    reviewed_on: 2026-03-12
    expires_on: 2026-09-12

  - id: github-official
    host: mcp.github.com
    transport: http
    url: https://mcp.github.com/mcp
    scopes: ["repo.read", "issues.read", "pulls.read"]
    review_ticket: SEC-4902
    reviewed_by: security-platform
    reviewed_on: 2026-03-18
    expires_on: 2026-09-18

  - id: postgres-readonly
    host: localhost
    transport: stdio
    command: /opt/acme/bin/postgres-mcp
    allowed_args: ["--readonly", "--database=analytics"]
    scopes: ["db.read"]
    review_ticket: SEC-4933
    reviewed_by: data-platform
    reviewed_on: 2026-03-22
    expires_on: 2026-09-22

  - id: filesystem-scoped
    host: localhost
    transport: stdio
    command: /opt/acme/bin/fs-mcp
    allowed_args: ["--root=/Users/$USER/Work"]
    scopes: ["fs.read", "fs.write"]
    review_ticket: SEC-4941
    reviewed_by: security-platform
    reviewed_on: 2026-03-25
    expires_on: 2026-09-25

deny_reasons:
  unapproved: "Server not in allowlist. Submit a review request."
  expired:    "Approval expired. Renew via the MCP review process."
  rotation:   "Scope change detected. Re-review required."

Three things make this allowlist useful. First, every entry has a review ticket and an expiry; approvals don't live forever. Second, stdio servers include the allowed args, so a postgres-mcp binary approved with --readonly can't be re-run with write access without a new review. Third, scopes are named and explicit; a later control layer can enforce them.

Onboarding a new server to the allowlist takes two automated checks before the human review. First, verify the TLS posture of the server's host. A server serving MCP over plaintext HTTP, or with a cert expiring in 10 days, or missing HSTS, isn't a candidate:

# Verify TLS posture of an MCP server as part of onboarding
curl -s -X POST https://api.botoi.com/v1/ssl/check \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $BOTOI_API_KEY" \
  -d '{
    "hostname": "api.botoi.com",
    "port": 443
  }'

# Response (truncated): verify TLS 1.2+, valid chain, HSTS, and enough days_remaining
# {
#   "success": true,
#   "data": {
#     "hostname": "api.botoi.com",
#     "protocol": "TLS 1.3",
#     "cipher": "TLS_AES_256_GCM_SHA384",
#     "valid_from": "2026-01-04T00:00:00Z",
#     "valid_to":   "2026-07-04T00:00:00Z",
#     "days_remaining": 76,
#     "issuer": "Let's Encrypt",
#     "chain_trusted": true,
#     "hsts": true
#   }
# }

Second, inspect the response headers. You want HSTS with a long max-age, a sensible content-type, and an mcp-protocol-version header that matches what the server says it supports in its manifest. Drift between header and manifest is a red flag:

# Inspect response headers for HSTS, CSP, and MCP protocol hints
curl -s -X POST https://api.botoi.com/v1/headers/inspect \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $BOTOI_API_KEY" \
  -d '{
    "url": "https://api.botoi.com/mcp",
    "method": "GET"
  }'

# Look for (relevant bits):
#   strict-transport-security: max-age=63072000; includeSubDomains; preload
#   content-type: application/json
#   mcp-protocol-version: 2025-11-05
#   x-content-type-options: nosniff
# If HSTS is missing or mcp-protocol-version disagrees with what the server
# advertises in its tool manifest, flag the server for re-review.

Run both checks as part of the intake ticket. If either fails, the ticket auto-closes with the failure reason. Reviewer only sees servers that passed the basics.

The three-page MCP policy every company needs

The policy doesn't need to be long. It needs to answer five questions: what's approved, who approves new servers, what can each role do, how do you monitor it, and what happens when something goes wrong. A short table does more work than a 30-page document nobody reads.

Area Policy Control
Approved servers Only servers in the allowlist YAML can run on corporate devices or touch corporate data. DNS + egress block, EDR process rule, MDM-pushed allowlist file, six-month approval expiry.
Review process Security Platform reviews all new server requests. Data Platform co-signs any server with database access. Legal signs off on servers that process regulated data. Review ticket template, automated TLS and header checks, published SLA of five business days.
Scope limits Engineers get read-only data servers by default. Write access requires a second approver. Production credentials are forbidden; use staging or a scoped token. Per-role allowlist bundles pushed via MDM, scoped API keys with short TTL, auth gateway enforcing scopes.
Monitoring Every tool call through an approved server lands in the audit log. Unapproved server attempts trigger an alert within 15 minutes. Gateway audit sink to Kafka and SIEM, daily inventory diff, config-file hash drift alerts.
Incident response A compromised MCP server is treated as a compromised API integration. Revoke the server's keys, quarantine affected endpoints, review audit logs for the exposure window. Documented runbook, on-call rotation, quarterly tabletop that includes an MCP scenario.
Exceptions Temporary exceptions require the CISO's approval, a 30-day expiry, and a written follow-up plan. Exception ticket template, auto-expiring allowlist entries, weekly exception review.
Offboarding When an employee leaves, their MCP configs are wiped alongside other corporate state. Any keys they held are rotated within 24 hours. MDM wipe action, key rotation runbook, audit log of last-access per key.

Key takeaways

  • MCP capabilities exceed SaaS capabilities. A community MCP server reads SSH keys, runs shell commands, and inherits your VPN position. Treat it like a privileged integration, not like a browser tab.
  • Detection needs three layers. EDR process rules, gateway HTTP filters on mcp-protocol-version, and DNS allowlisting cover different failure modes.
  • Inventory in one afternoon. A 60-line Python script reads the two config files every major client uses, hashes them, and posts to a reporting endpoint. Run it daily via MDM.
  • Default-deny allowlist, short expiries. Every server has a review ticket and a six-month expiry. Use /v1/ssl/check and /v1/headers/inspect as automatic intake gates.
  • A table beats a 30-page policy. Approved servers, review process, scope limits, monitoring, incident response, exceptions, offboarding. Seven rows, one page, posted in the engineering wiki.

Botoi's own MCP server at api.botoi.com/mcp is an example of how a server looks once it's ready for your allowlist: TLS 1.3, HSTS, scoped API keys, per-key rate limits, and 49 curated tools with annotations. You can verify the posture yourself with the two curls above before adding it, or check the MCP setup page and the API docs for the full tool list.

Frequently asked questions

What counts as an MCP server for inventory purposes?
Both stdio and HTTP servers count. A stdio server is a child process Claude Desktop or Cursor spawns from a config file entry; there's no network footprint until the agent starts it, but it has the same capability surface. An HTTP server lives at a URL the client connects to over Streamable HTTP or SSE. Your inventory needs to cover both, which means reading config files on the endpoint and watching egress for MCP traffic signatures.
Can I block MCP entirely on corporate networks without breaking approved agents?
Yes. Block everything by default at the egress gateway, then allowlist the specific hostnames your approved MCP servers live on. Approved stdio servers don't touch the network at all (they talk to their backing APIs over normal HTTPS, which you already control). You lose community MCP experimentation on corporate devices, which is the point. Developers can still use personal machines outside the VPN for exploration.
How does Shadow MCP differ from prompt injection risk?
Prompt injection is an attacker tricking a model into doing something its operator didn't want. Shadow MCP is the operator themselves wiring in an unreviewed capability. Different threat actor, different control. Prompt injection defenses (tool confirmation, sandboxing, output filtering) don't help if the tool was never supposed to be there. You need inventory and allowlisting on top of the model-level defenses.
Does blocking require a next-gen firewall upgrade?
No. DNS filtering plus HTTP-layer egress inspection covers most of it. Cloudflare Gateway, Zscaler, and Netskope all ship MCP-aware rules as of April 2026. For on-prem, you can filter on the MCP-Protocol-Version header or the application/json-rpc-2 content type with any L7 proxy. Squid, Envoy, and HAProxy all handle this. The hard part isn't the filter; it's maintaining the allowlist.
What audit trail does MCP give me out of the box?
Almost none. The protocol defines tool calls and responses, but doesn't require the server or client to log them. Claude Desktop keeps some local logs; Cursor less so. Community servers often log nothing. If you need an audit trail that survives incident review, you instrument it yourself at the server side, or put a gateway in front that writes structured logs. Don't rely on the client to hold the evidence.

More guide posts

Start building with botoi

150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.