What is a shadow MCP server?
A shadow MCP server is a Model Context Protocol server that runs or connects in your organization without anyone approving it. The OWASP MCP Top 10, a beta project, lists the risk as MCP09:2025 Shadow MCP Servers: "unapproved or unsupervised deployments of Model Context Protocol instances" that "operate outside the organization's formal security governance." OWASP's test is that if the security team can't list every active MCP server, shadow deployments already exist.
OWASP's examples are servers that a team stands up for testing, with default credentials and no monitoring. Most shadow MCP servers are smaller: a line in a laptop config file, or a connector in someone's Claude or ChatGPT account. This guide applies OWASP's definition to all of them, because each gives an AI client tools and credentials that nobody signed off on.
Shadow AI is any AI tool or account that you haven't approved. Shadow MCP is the part of it that gives an AI client reach into other systems, and an approved client can still load shadow servers. A rogue MCP server behaves maliciously, approved or not. A shadow server can be harmless, and an approved server can turn rogue after an update, so finding shadow servers is an inventory problem, while containing rogue ones takes per-call policy and version pins.
For the stdio servers that Claude Code, Cursor, Codex, and Copilot load on one developer laptop, see shadow AI coding agents. This guide covers the rest of the organization.
Where do MCP servers live in an organization?
MCP servers sit in four places, and each leaves its evidence in a different system.
Local servers in client config files
A local server uses the stdio transport: the client starts it as a child process from a command in a config file and talks to it over standard input and output. The following table covers clients that the coding agents guide doesn't, from vendor documentation reviewed on November 3, 2026:
| Client | User config | Project config | Server key |
|---|---|---|---|
| Claude Desktop | on macOS, | None | mcpServers |
| VS Code |
| .vscode/mcp.json | servers |
| Windsurf (Devin Desktop) |
|
| mcpServers |
| Cline |
| None | mcpServers |
| Gemini CLI | ~/.gemini/settings.json | .gemini/settings.json | mcpServers |
| Zed | ~/.config/zed/settings.json | Not documented for servers | context_servers |
Sources: Claude Desktop, VS Code's MCP reference and settings locations, Devin Desktop, where Windsurf's docs redirect, Cline, Gemini CLI, and Zed.
VS Code can also read claude_desktop_config.json, ~/.cursor/mcp.json, and Windsurf's file, so one entry can load in two clients. Gemini CLI's "trust": true skips every tool call confirmation for a server. Claude Desktop writes each server's stderr to mcp-server-SERVERNAME.log in ~/Library/Logs/Claude on macOS, which shows that a configured server started.
Remote servers that people connect with OAuth
A remote server speaks Streamable HTTP or the older SSE transport, and the config holds its URL. Under the MCP authorization spec, the server names its own authorization server, which "may be hosted with the resource server or a separate entity," and dynamic client registration lets the client get an OAuth client ID "without user interaction." No administrator pre-registers anything, so the grant lands with that authorization server: your identity provider (IdP) if the server federates to it, the vendor's console if it doesn't.
Some remote servers hide inside a stdio entry. A bridge such as mcp-remote, configured as npx -y mcp-remote https://mcp.example.com/sse, connects a stdio-only client to a remote server, and an inventory that reads only url fields misses the host.
Servers that teams host
This is OWASP's case. A data team runs an MCP server on a VM so its agents can query the warehouse, and it answers at http://10.20.4.17:8080/mcp with no authentication. Developers point their clients at it, so it also shows up as a URL in laptop configs.
A hosted server can serve a cloud AI app without an inbound port. OpenAI's Secure MCP Tunnel runs a tunnel-client inside your network that polls api.openai.com over outbound HTTPS and forwards requests to a private server for ChatGPT, Codex, or the Responses API. OpenAI logs tunnel.created, tunnel.updated, and tunnel.deleted in the API Platform audit logs, and says tunnel traffic isn't emitted as ChatGPT Compliance Platform app events.
Connectors inside SaaS AI apps
Claude and ChatGPT let people add remote MCP servers as connectors that never touch the device. Anthropic's custom connector documentation says that Pro and Max users add connectors to their own accounts, that only Owners and Primary Owners add them on Team plans, and that Claude connects to the server from Anthropic's cloud, not from the user's device. It also warns that server developers can change tool behavior without notice. In ChatGPT, a user adds a custom MCP server by URL or through a tunnel, subject to account and workspace policies.
A connector on a personal account is the hardest to see. A product manager with a personal Claude Pro plan adds a connector for the company's issue tracker and signs in with a work login. Nothing appears in your Claude organization or runs on the laptop.
Which signals find each kind of MCP server?
The following table shows what each signal sees for each kind of server:
| Kind | Config file inventory | Process inventory | Network and DNS | IdP OAuth grants | Agent hook event | MCP gateway logs |
|---|---|---|---|---|---|---|
| Local stdio server | Yes: name, command, arguments, and env names | Yes, while it runs, as a child of the client | No link to the client; only what the server itself contacts | No; stdio servers take credentials from the environment | Yes, each tool call on a hooked agent | Only if the server runs behind the gateway |
| Remote server a user connects | Yes: the URL, or the URL inside a bridge's arguments | No, except a bridge process | Yes: the hostname, from the device | Only when its sign-in goes through your IdP | Yes, each tool call on a hooked agent | Only if the client points at the gateway |
| Server a team hosts | Yes, as a URL on the clients that use it | On the host, not on laptops | Yes: internal DNS, flow logs, and a probe of the route | Only if it authenticates with your IdP | Yes, each tool call on a hooked agent | Yes, if it's registered there |
| SaaS app connector | No; it's stored in the vendor account | No | No; it connects from the vendor's cloud | Only when its sign-in uses your IdP | No in the chat app; yes when Claude Code loads it | Only if the connector URL is the gateway |
A stdio server runs as node, python, uv, or docker under the client, so process inventory confirms that a configured server ran but rarely finds one alone. Hosted servers are the one kind you can scan for. The following command posts a JSON-RPC initialize request to a host you're responsible for:
curl -i -sS -m 5 -X POST "http://10.20.4.17:8080/mcp" \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-06-18","capabilities":{}, "clientInfo":{"name":"inventory-probe","version":"1.0"}}}'A serverInfo object in the response means an MCP server answered without authentication. A 401 whose WWW-Authenticate header names a resource_metadata URL means a protected server. The request calls no tool. For SaaS connectors, the vendor's admin console is the inventory, such as Organization settings > Connectors in Claude. Claude Code also reports MCP server and tool names in its OpenTelemetry events when you set OTEL_LOG_TOOL_DETAILS=1.
What makes a shadow MCP server risky?
Judge each inventory row by what the server can do and how its code reaches the machine.
The tools it exposes and the credentials it holds
The MCP spec tells stdio servers to retrieve credentials from the environment, so a config's env block is often a plaintext credential store, and arguments carry credentials too. The archived reference Postgres server, whose repository went read-only on May 29, 2025, takes a connection string argument, so a typical entry is npx -y @modelcontextprotocol/server-postgres postgresql://app:PASSWORD@db.staging.internal/billing: an unmaintained package holding a database password, with a tool that runs SQL. A read-only search server is a lighter review than one that writes tickets or sends email.
Unpinned packages launched with npx or uvx
npx -y installs a package without a confirmation prompt, and uvx runs a tool in a temporary environment. When the arguments name no version, the config doesn't record which code you reviewed. Postmark says that a package impersonating its name "built trust over 15 versions" on npm, then "added a backdoor in version 1.0.16 that secretly BCC'd emails to an external server." A review of 1.0.15 said nothing about what an unpinned npx -y postmark-mcp entry launched later.
A pin also keeps old flaws. The mcp-remote bridge had a critical command injection flaw, CVE-2025-6514, reachable when it connected to an untrusted server and fixed in 0.1.16. Pin versions, and update them as reviewed changes.
Tool definitions that change after review
The MCP spec defines a notifications/tools/list_changed message for a server that changes its tools while running, and a remote server can change them on any deploy. Invariant Labs described the rug pull on April 1, 2025: "a malicious server can change the tool description after the client has already approved it." Record the tool names you reviewed, and compare them at each re-review.
How do you inventory MCP servers across the organization?
Collect config files from every managed device, add the network and SaaS sources, review each server by what it is, and record a decision.
Collect config files with a fleet script
The following script reads one user's MCP configs on macOS or Linux and prints a tab-separated row per server: device, user, file, scope, name, transport, command or URL, and environment variable names. It never prints env or header values. It strips userinfo and query strings from URLs and masks credential-like arguments, including the value after a flag such as --api-key. It needs jq:
#!/usr/bin/env bash# mcp-inventory.sh HOME_DIR [REPO_DIR]: one TSV row per configured MCP server.# Never prints env values, header values, or credential-like arguments.set -uH="${1:-$HOME}"REPOS="${2:-$H/src}"case "$(uname)" in Darwin) APP="$H/Library/Application Support" ;; *) APP="${XDG_CONFIG_HOME:-$H/.config}" ;;esac
JQ='def secretish: test("(?i)(token|secret|passw|api[-_]?key|auth|bearer|cred)");def clean: sub("://[^/@]+@"; "://REDACTED@") | sub("[?#].*$"; "");def safeargs: (.args // []) as $a | [range(0; $a | length) as $i | if ($a[$i] | secretish) or ($i > 0 and ($a[$i - 1] | startswith("-") and secretish)) then "REDACTED" else ($a[$i] | clean) end];def rows($scope): to_entries[] | (.value.url // .value.serverUrl // .value.httpUrl // "") as $url | [$scope, .key, (.value.type // (if $url != "" then "http" else "stdio" end)), (if $url != "" then ($url | clean) else ([.value.command // ""] + (.value | safeargs) | join(" ")) end), ((.value.env // {}) | keys | join(","))] | @tsv;((.mcpServers // .servers // .context_servers // {}) | rows("user")),((.projects // {}) | to_entries[] | .key as $p | (.value.mcpServers // {}) | rows($p))'
HOST=$(hostname)OWNER=$(basename "$H")
# emit FILE: prefix each line on stdin with the device, user, and file.# Paths go through printf arguments only, never into a program like sed.emit() { local line while IFS= read -r line; do printf '%s\t%s\t%s\t%s\n' "$HOST" "$OWNER" "$1" "$line" done}
report() { [ -f "$1" ] || return 0 local out if out=$(jq -r "$JQ" "$1" 2>/dev/null); then [ -n "$out" ] && printf '%s\n' "$out" | emit "$1" else echo PARSE_ERROR | emit "$1" fi}
for f in \ "$H/.claude.json" \ "$H/.cursor/mcp.json" \ "$H/.copilot/mcp-config.json" \ "$H/.gemini/settings.json" \ "$H/.codeium/windsurf/mcp_config.json" \ "$H/.config/devin/mcp_config.json" \ "$H/.config/zed/settings.json" \ "$H/.cline/data/settings/cline_mcp_settings.json" \ "$APP/Claude/claude_desktop_config.json" \ "$APP/Code/User/mcp.json"; do report "$f"done
# VS Code profiles, the Cline extension, and project-level filesfind "$APP/Code/User" -maxdepth 4 \( -path '*/profiles/*/mcp.json' \ -o -name cline_mcp_settings.json \) 2>/dev/null | while IFS= read -r f; do report "$f"; donefind "$REPOS" -maxdepth 5 \( -name .mcp.json -o -path '*/.cursor/mcp.json' \ -o -path '*/.vscode/mcp.json' -o -path '*/.gemini/settings.json' \ -o -path '*/.devin/mcp_config.json' \) 2>/dev/null | while IFS= read -r f; do report "$f"; done
# Codex keeps servers in TOML; report the table names only{ echo "$H/.codex/config.toml" find "$REPOS" -maxdepth 5 -path '*/.codex/config.toml' 2>/dev/null} | while IFS= read -r f; do [ -f "$f" ] || continue scope=project [ "$f" = "$H/.codex/config.toml" ] && scope=user grep -oE '^\[mcp_servers\.[^].]+' "$f" | cut -d. -f2 | while IFS= read -r name; do printf '%s\t%s\n' "$scope" "$name"; done | emit "$f"donePass your repository directory as the second argument, and run the script through your MDM once per local user account. In our test, a project-scoped Claude Code entry came out as staging-db stdio npx -y @modelcontextprotocol/server-postgres postgresql://REDACTED@db.staging.internal/billing PGSSLMODE.
jq rejects the comments that VS Code and Zed allow, so those files report PARSE_ERROR for you to collect by hand. Redaction is a pattern match, so check a sample, and treat the output as sensitive, because it holds project paths and internal hostnames. On Windows, read the same files under %APPDATA% and %USERPROFILE% with PowerShell's ConvertFrom-Json. Don't call claude mcp list from an inventory script, because it starts every stdio server to check its health.
Review by what a server is
Group stdio rows by command and package, and remote rows by URL host. Ignore the server name: it's a label the user typed, so github can point at anything. Sort the groups by device count, then read them in risk order. Start with credential signs, such as a postgresql://REDACTED@ URL or a GITHUB_PERSONAL_ACCESS_TOKEN variable. Then take unpinned packages, bridges pointing at unfamiliar hosts, and private-address URLs, which lead you to hosted servers to probe. Add the connector lists from your Claude and ChatGPT admin consoles, and the MCP hosts that your DNS or endpoint logs show.
Record a decision for each server
Record exactly what you approved, so the next inventory can tell an approved server from a lookalike. The following table shows the fields with two example rows:
| Server | Version pin | Owner | Data reach | Decision |
|---|---|---|---|---|
Sentry remote server, | Vendor-hosted; tool names recorded at review | Platform team | Error events and stack traces, through each user's Sentry access | Approve, OAuth per user |
@modelcontextprotocol/server-postgres | None; archived upstream | Nobody | Staging database, with a shared password in the arguments | Deny; rotate the password; offer a maintained server |
For a stdio server, the pin is the full command and arguments, such as npx -y some-package@1.4.2, because that's what Claude Code's allowlist matches. For a remote server, record the URL and tool names. Name a team as the owner, not a person who might leave.
How do you control which MCP servers run?
Each of the following controls reaches a different set of clients, so you need more than one.
Vendor managed settings come first. Claude Code reads allowedMcpServers and, with allowManagedMcpServersOnly: true, ignores any allowlist that a user writes. A managed-mcp.json file at /Library/Application Support/ClaudeCode/ on macOS or C:\Program Files\ClaudeCode\ on Windows deploys a fixed server set that users can't add to. Anthropic's managed MCP documentation says that a serverCommand entry matches the command and every argument exactly, in order, so an entry for ["npx", "-y", "some-package@1.4.2"] doubles as a version pin, and that a serverName entry "is not a security control." Gemini CLI has mcp.allowed, and GitHub's Copilot MCP registry policy is in public preview, with a managed settings file as GitHub's recommended method. For per-vendor settings and hook allowlists, see restrict MCP servers in coding agents.
An MCP gateway puts approved servers behind one logged endpoint. Docker MCP Gateway runs servers in containers with restricted privileges, and Runlayer serves a catalog of approved servers through its gateway. A gateway sees only traffic sent to it, so a stdio server in a laptop config or a connector in a personal Claude account bypasses it.
A hook allowlist decides each MCP tool call before it runs, so it catches a server that got configured anyway. It compares the name the agent reports, so pin what each approved name points to with managed settings.
Endpoint or DNS blocking stops remote servers on known hosts. It does nothing for stdio servers or for SaaS connectors, which connect from the vendor's cloud; control those in the vendor's admin console and with tenant controls that refuse personal accounts.
Where does Arcjet fit, and where doesn't it?
Arcjet coding agent hooks receive every MCP tool call in Claude Code, GitHub Copilot, Cursor, OpenAI Codex, and Muse Code before it runs, with the server name, tool name, and arguments. Arcjet normalizes them into mcp_server and mcp_tool inputs, so the coding-agent.mcp-allowlist starter policy denies an unlisted server on all five. In dry run, the Activity view in the Arcjet Console shows which servers and tools each session called, approved or not.
The Arcjet endpoint agent on macOS and Windows records each device's connections to AI services, including remote MCP hosts that its detection rules cover, with the signed app and the person behind the local account. Endpoint provider policies set Allow or Block per provider for a group of devices, and the agent blocks the connection on the device without decrypting TLS. Each decision lands in Arcjet Activity.
Arcjet doesn't inventory MCP config files across a fleet, so it doesn't list servers that are configured but never called. The following products do:
- Runlayer's AI Watch deploys through MDM and reads config files by default, with optional process and container discovery. See Runlayer vs Arcjet.
- Microsoft Defender for Endpoint's local AI agent discovery lists agents and their configured MCP servers on Windows and macOS, and needs antivirus active mode and real-time protection for MCP configuration discovery. See Microsoft Agent 365 vs Arcjet.
- Jamf AI Governance builds an MCP Server Inventory on Macs from Jamf Protect telemetry, which Jamf says "shows which MCP servers are running, what they expose, and which AI clients connect to them." See Jamf AI Governance vs Arcjet.
Vendor details were reviewed on November 3, 2026. Arcjet is the wrong tool, or only part of the answer, in the following cases:
- Clients without supported hooks. Claude Desktop, VS Code agent mode, Windsurf, Cline, Gemini CLI, and Zed produce no Arcjet hook event, and their stdio calls make no connection.
- Connectors in Claude and ChatGPT accounts. They run from the vendor's cloud, out of reach of hooks and the endpoint agent.
- Servers that teams host. Arcjet doesn't scan for
/mcproutes, and sees calls to a hosted server only from a hooked agent. - Unmanaged devices, which have neither the endpoint agent nor managed hooks.
To start, run a config file inventory, then publish coding-agent.mcp-allowlist in dry run with the servers you approve, and review the servers that appear in one list but not the other. To see the endpoint agent on your own devices, book a demo. For the policy inputs, see the coding agent policies documentation.
Frequently asked questions
What is a shadow MCP server?
It's a Model Context Protocol server that runs or connects in your organization without approval. OWASP lists the risk as MCP09:2025 Shadow MCP Servers. In practice, it's usually an entry in a client's config file, a remote server someone signed in to, a server a team hosts, or a connector added to a Claude or ChatGPT account.
What's the difference between a shadow MCP server and a rogue MCP server?
A shadow server is unapproved; a rogue server behaves maliciously. A shadow server can be harmless, and an approved server can turn rogue after an update. Finding shadow servers takes an inventory, and containing rogue ones takes per-call policy and version pins.
Where is the Claude Desktop MCP config file?
On macOS, it's ~/Library/Application Support/Claude/claude_desktop_config.json, and on Windows, %APPDATA%\Claude\claude_desktop_config.json. Servers are listed under mcpServers with a command, arguments, and an optional env block.
How do I find MCP servers across my organization?
Run a read-only script through your MDM that reads each client's MCP config files and reports server names, commands, packages, and URL hosts, never env values. Add the connector lists from your Claude and ChatGPT admin consoles, DNS or endpoint logs for remote MCP hosts, and a probe of internal hosts for MCP routes.
Can network monitoring find MCP servers?
Only remote ones. A stdio server talks to its client over standard input and output, so it makes no network connection for that link. Connectors in Claude and ChatGPT connect from the vendor's cloud, not the device.
Does Arcjet inventory MCP config files?
No. Arcjet hooks see each MCP tool call in Claude Code, Copilot, Cursor, Codex, and Muse Code, and an MCP allowlist policy can deny it, and the endpoint agent records connections to remote MCP hosts. For a fleet inventory of config files, use Runlayer AI Watch, Microsoft Defender local agent discovery, Jamf AI Governance, or your own script.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Publish the MCP allowlist starter policy in dry run and compare the servers your coding agents call with the ones your inventory found.