Shadow MCP Is the New Shadow IT
Shadow MCP is the new shadow IT. One-week runbook: sweep harness configs, build an approved MCP inventory, quarantine unregistered servers, catch drift nightly.
Go deeper. Build your own.
The move
Use this five-day plan to build an approved MCP catalog in git, document discovery coverage, and hold unregistered servers for review. Start with one managed team, then expand once its exceptions and enforcement tests work.
The news, once: Cloudflare puts shadow MCP on the Gateway dashboard
On August 14, 2026, Cloudflare announced MCP traffic detection, an MCP dashboard, and controls for direct connections on managed network paths (Cloudflare announcement). Its selector, experimental.is_mcp, identifies the protocol header on TLS-inspected requests. A Portal-based rule can block detected MCP traffic arriving outside the approved Portal path.
That is useful coverage, not a complete inventory. Local stdio connections, off-network use, Do Not Inspect traffic, and nonconforming requests can remain invisible. Treat the dashboard as one discovery input, then account for the gaps at the endpoint.
The supply-chain reason is concrete. In its August 12 Deadbugz research, Pillar documented 23 malicious configuration or listing pull requests: 17 remote configurations, four local Python-path references, and two listings. None had merged through GitHub’s PR mechanism at review. Researchers confirmed malicious metadata after a three-call trigger; that establishes the attack behavior, not successful compromise of those projects.
Why acting agents changed the requirement
An unapproved SaaS integration can expose data. An unapproved MCP server can also put instructions and executable tools directly into an agent’s workflow. Depending on the tools and credentials granted, that can reach files, repositories, or business systems without a person choosing each subsequent action.
A repository config change, installed plugin, or user setting can introduce that connection. Client trust prompts help, but they do not establish an organization-wide approved catalog. The approve-once problem adds another requirement: approval must cover observed runtime behavior as well as the configuration originally reviewed.
The runbook: an MCP inventory and a quarantine path in five working days
Each day produces a reviewable artifact: inventory, catalog, approval evidence, enforcement, then drift checks.
Step 1 (Monday): discovery, from three directions
Config sweep. Servers are declared in a short list of files per harness. Sweep them with whatever already runs on every machine: osquery, an Intune or Jamf script, a cron job on build boxes.
| Harness | Where servers get declared | Scope |
|---|---|---|
| Claude Code | .mcp.json at the repo root; ~/.claude.json (user scope, plus per-project local scope under projects); managed-mcp.json for admin-deployed servers |
project / user / managed |
| Codex CLI | ~/.codex/config.toml, and .codex/config.toml in trusted projects, as [mcp_servers.<name>] tables |
user / project |
| Cursor | ~/.cursor/mcp.json; .cursor/mcp.json in the project |
user / project |
| VS Code | .vscode/mcp.json; the user-level mcp.json |
project / user |
| Claude Desktop | claude_desktop_config.json in the app’s config folder (%APPDATA%\Claude on Windows) |
user |
These are starting locations. Also inventory VS Code user profiles and remote hosts, dev-container configuration, plugin registrations, and cloud connector settings. Read the effective configuration for each installed client version; a file list alone cannot discover every server. The sweep below emits one record per explicitly supplied config file without copying its contents.
# inventory-configs.py — runnable file inventory, Python 3
# Usage: python3 inventory-configs.py /path/to/.mcp.json /path/to/config.toml
import hashlib
import json
import socket
import sys
from pathlib import Path
for supplied in sys.argv[1:]:
path = Path(supplied).expanduser()
try:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
record = {"host": socket.gethostname(), "file": str(path),
"sha256": digest, "status": "read"}
except OSError as exc:
record = {"host": socket.gethostname(), "file": str(path),
"status": "unreadable", "error_type": type(exc).__name__}
print(json.dumps(record))
Your endpoint job supplies paths from managed user and repository inventories, including each WSL home and container mount. Preserve unreadable-file records as coverage gaps. Parse locally with the right JSON, JSONC, or TOML parser: Claude/Cursor use mcpServers, Codex uses mcp_servers, and VS Code uses servers. Send only normalized endpoint or executable identity, scope, host, and file hash. Strip URL credentials, query secrets, environment values, and command-line tokens before collection.
Network signals. Export MCP detections with user, host, endpoint, and observation time. Cloudflare’s historical log tutorial uses hostname/path heuristics; these can miss ordinary URLs and match unrelated traffic. Header visibility also requires decryption and the managed route. Stateless MCP routing explains how newer protocol headers improve classification without proving authorization.
stdio spawns. The harness-to-server transport is local, but the server process can still make outbound network requests. Correlate declared commands with endpoint process-start telemetry and egress. This osquery process snapshot is only a candidate finder:
-- Illustrative heuristic: processes alive now, not a historical event log.
SELECT p.pid, p.name, p.path, pp.name AS parent, p.start_time
FROM processes p JOIN processes pp ON p.parent = pp.pid
WHERE pp.name IN ('claude', 'codex', 'Cursor', 'Code')
AND p.name IN ('node', 'python', 'python3', 'npx', 'uvx');
Wrappers, grandchildren, short-lived processes, and arbitrary executable names escape this query. Use retained endpoint events for actual execution coverage, and keep raw command lines restricted because they can contain secrets.
Monday’s artifact is discovered.jsonl plus a coverage report: hosts checked, homes checked, unreadable files, network bypasses, and event retention. Deduplicate on normalized server identity and tenant/credential scope; the same URL with different privileges is not one approval.
Step 2 (Tuesday): the catalog schema
The catalog is a file in a repo, reviewed by pull request, read by machines. Each entry answers who owns this, what it may do, and when it gets reviewed again. The gateway decision runbook separates purchased discovery from catalog ownership.
# mcp-catalog/platform.yaml — illustrative; one file per tenant, in git
- id: github-readonly
transport: http # http | stdio
endpoint: https://mcp.example.com/github
# command: ["npx", "-y", "@org/server@1.4.2"] # stdio: pinned version, never "latest"
owner: platform-tools@corp # a team alias, never a person
manifest_hash: sha256:9f1c… # placeholder: canonical capability snapshot hash
scopes: [repo:read, issues:read] # what the credential it receives can do
allowed_hosts: [api.github.com] # egress it may make; anything else is a finding
tenant: platform
approved_by: [platform-tools@corp, security-review@corp]
approved_on: 2026-09-02
review_date: 2026-12-01 # 90 days; 30 for any write scope
status: approved # approved | quarantined | exception | revoked
Define manifest_hash over canonicalized tool names, descriptions, input schemas, and relevant prompt metadata/content, using a fixed principal and scope. Remove timestamps and ordering noise before hashing. It detects a changed declaration; it cannot prove unchanged implementation or harmless tool results. Enforce allowed_hosts through the actual network sandbox; putting hosts in YAML does not restrict egress. The posture underneath each entry, pinning, sandboxing, secrets handling, is already in the MCP hardening checklist; the catalog is where those decisions get recorded per server.
Step 3 (Wednesday): the approval workflow
An entry moves from quarantined to approved through one PR, with a template that demands evidence rather than reassurance.
| Item | Who | What counts as evidence |
|---|---|---|
| Ownership | the requesting team | A team alias in owner, never a person |
| Provenance | requester | Source repo and commit, or vendor URL; for stdio, the pinned package and version |
| Manifest snapshot | requester, from a sandbox run | tools/list and prompts/list output attached; hash recorded |
| Call-time sample | requester | Repeated harmless calls, refreshed tool/prompt metadata, and observed egress in a disposable sandbox |
| Scope and egress review | security reviewer | scopes and allowed_hosts match the sample; any write scope gets a 30-day review |
| Expiry | CI | review_date set from the scope class; no entry merges without one |
Two signatures: the owning team and a security reviewer. Attach redacted evidence. Cross known activation thresholds when reproducing a published finding, but do not declare a server safe after any fixed call count. Time, identity, arguments, or remote configuration can gate behavior too. Keep call-time checks and least privilege after approval.
Step 4 (Thursday): the quarantine path
Use managed endpoint and network controls together. In Claude Code, administrator-managed allowedMcpServers and deniedMcpServers restrict server use; enabledMcpjsonServers is project-server approval configuration, not an equivalent security boundary. Verify the effective policy on the installed version. Other harnesses need their own enforced settings or an OS sandbox that limits execution and egress.
Test a permitted server, an unknown remote server, and a local stdio server on a canary device. Confirm the actual refusal and audit event. If a client lacks an enforceable allowlist, a sweep reports drift but does not stop it; record that gap until endpoint restrictions or a managed runtime closes it. A successful CI check on the catalog is not deployment evidence.
The hold queue is a directory, not a meeting. Every unregistered MCP server the sweep finds gets a stub written by the nightly job:
# mcp-catalog/quarantine/unknown-helper.yaml — illustrative queue record
id: unknown-helper
endpoint: https://unapproved.example.com/mcp
first_seen: 2026-09-03T02:10Z
seen_on: [lt-0412, lt-0377] # hosts, from the sweep
found_in: [.mcp.json, ~/.cursor/mcp.json]
status: quarantined
ticket: SEC-2291 # opened automatically
Time-boxed exceptions exist so people do not route around you. status: exception requires a requester, a reason, and an expires no more than seven days out; the enforcement service denies expired exceptions at request time. CI also updates the catalog; a missed nightly job must not extend access. The kill switch is a single status change. Wire revoked to three actions, each with an acknowledged result: a Gateway deny for the endpoint (or a managed deny for the command), revocation of whatever credential the server was issued, and a sweep run that reports every host still carrying the entry.
Step 5 (Friday): drift checks, nightly
The catalog is only true on the day it is written. A nightly job re-runs the sweep and diffs the result against the catalog.
illustrative nightly-diff 2026-09-04 02:00
NEW jira-helper not in catalog -> quarantine stub, SEC-2294
CHANGED github-readonly endpoint differs on 3 hosts -> quarantine, owner paged
MANIFEST docs-search tools/list hash mismatch -> quarantine, no review wait
EXPIRED ci-runner-mcp review_date 2026-09-01 -> quarantine until re-approved
EXCEPTION sandbox-scraper expires 2026-09-05 -> reminder to requester
A nightly diff finds inventory drift; it is too slow to be the runtime gate. At session discovery and metadata refresh, compare the observed capabilities before exposing changed tools to the model. Deny unknown versions until review. Monitor call outcomes and egress as well, because unchanged metadata can conceal changed behavior.
Step 6: per-team isolation
The tenant field records an intended boundary; policy must enforce it. One catalog file per team plus a small core set; a session in the payments tenant cannot see the data-science tenant’s servers, and each tenant has its own Gateway on-ramp and its own credentials, so a compromised server in one tenant is holding one team’s tokens. Exceptions are per tenant too: a seven-day pass for a scraper in research is not a pass for the same scraper in payments.
Three inputs, one diff, three exits. Approval records and enforcement acknowledgements close the loop.
What breaks, and how you’ll know
Stale catalog. Track expired reviews, missing owners, and discovered servers newer than the last catalog update. Expired approval must stop access at the enforced boundary, with an owner and recovery path for the blocked workflow.
Bypassed controls. Watch growing Do Not Inspect lists, disabled device clients, and local proxies forwarding remote traffic. Test these paths explicitly. A useful exception process helps legitimate work proceed through the approved route, but its expiry still needs enforcement.
Dev-only false positives. Loopback servers need ownership and review too: localhost does not prevent outbound traffic or credential access. A dev-local policy can permit a sandboxed test server with no sensitive mounts or credentials. Do not exempt every localhost URL from inventory or policy.
The operating-layer frame: the org keeps the list, the desk knows who called it
The catalog and enforcement jobs belong to the organization’s agent control plane. Their evidence should identify covered hosts, observed calls, and acknowledged policy decisions. Gaps remain explicit until endpoint or network controls close them.
The desk answers a different question: which of my sessions actually touched that server before it was quarantined. Search the supported local session transcripts for the server or tool name, then correlate any matches with the authoritative gateway or endpoint events. Missing transcript text is not proof that no call occurred. Automater’s local Library and fleet replay workflow can help an operator inspect retained sessions; they do not automatically build an MCP access inventory or enforce this catalog. The organization still owns identity, policy, and quarantine, a distinction that matters when putting agents on company PCs. Explore Automater Lite for the operator’s local session workflow.
FAQ: shadow MCP
What is shadow MCP?
Shadow MCP is a connection from an AI harness to an MCP server the organization has not approved. It may originate in a user config, repository, plugin, or connector. Discovery combines effective client configuration, endpoint execution, and inspected network traffic; each source has gaps, so record coverage alongside the approved-server list.
How do I build an MCP inventory across developer laptops?
Sweep the harness config paths (Claude Code, Codex, Cursor, VS Code) with the endpoint tooling you already run, add Gateway or proxy logs for remote servers, and query process trees for stdio servers spawned by a harness. Deduplicate by endpoint or command, then diff the result nightly against a catalog kept in git.
Does network detection catch local stdio MCP servers?
No. Cloudflare’s own post lists local stdio servers, off-network connections, and uninspected traffic as outside its view. A stdio server is a child process of the harness, so inventory it from the endpoint: config sweeps for the command entries and an osquery-style process query for what actually spawned.
Sources
- Cloudflare — MCP detection and managed network coverage (August 14, 2026)
- Cloudflare One — historical MCP detection in Gateway logs
- Pillar Security — Deadbugz campaign evidence (August 12, 2026)
- Claude Code — MCP configuration and managed controls
- OpenAI — Codex MCP configuration
- Cursor — MCP configuration locations
- VS Code — MCP files, profiles, and remote configuration
- MCP — connecting Claude Desktop to local servers
- osquery — processes table schema
