What are shadow MCP servers, and how do you find them?

MCP servers live in laptop configs, remote URLs, team-hosted services, and SaaS connectors, and each kind shows up in a different place.

19 min read
In short: A shadow MCP server is one that runs or connects without approval, which OWASP lists as MCP09:2025. MCP servers live in four places: stdio servers in client config files, remote servers that people connect with OAuth, servers that teams host, and connectors inside Claude and ChatGPT. Config file inventory, process and network data, IdP grants, hook events, and gateway logs each see some of them. Inventory config files across the fleet without collecting secrets, record each approval with a version pin and owner, and control what runs with managed settings, gateways, hook allowlists, and endpoint blocking.

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:

ClientUser configProject configServer key
Claude Desktop

~/Library/Application Support/Claude/claude_desktop_config.json

on macOS, %APPDATA%\Claude\claude_desktop_config.json on Windows

NonemcpServers
VS Code

mcp.json in the user profile folder, next to settings.json, plus ~/.copilot/mcp-config.json

.vscode/mcp.jsonservers
Windsurf (Devin Desktop)

~/.codeium/windsurf/mcp_config.json, or ~/.config/devin/mcp_config.json in Devin Desktop

.devin/mcp_config.json in Devin Desktop

mcpServers
Cline

cline_mcp_settings.json, in ~/.cline/data/settings/ for the CLI and in the extension's global storage in VS Code

NonemcpServers
Gemini CLI~/.gemini/settings.json.gemini/settings.jsonmcpServers
Zed~/.config/zed/settings.jsonNot documented for serverscontext_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:

KindConfig file inventoryProcess inventoryNetwork and DNSIdP OAuth grantsAgent hook eventMCP gateway logs
Local stdio serverYes: name, command, arguments, and env namesYes, while it runs, as a child of the clientNo link to the client; only what the server itself contactsNo; stdio servers take credentials from the environmentYes, each tool call on a hooked agentOnly if the server runs behind the gateway
Remote server a user connectsYes: the URL, or the URL inside a bridge's argumentsNo, except a bridge processYes: the hostname, from the deviceOnly when its sign-in goes through your IdPYes, each tool call on a hooked agentOnly if the client points at the gateway
Server a team hostsYes, as a URL on the clients that use itOn the host, not on laptopsYes: internal DNS, flow logs, and a probe of the routeOnly if it authenticates with your IdPYes, each tool call on a hooked agentYes, if it's registered there
SaaS app connectorNo; it's stored in the vendor accountNoNo; it connects from the vendor's cloudOnly when its sign-in uses your IdPNo in the chat app; yes when Claude Code loads itOnly 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:

Terminal window
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 -u
H="${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 files
find "$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"; done
find "$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"
done

Pass 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:

ServerVersion pinOwnerData reachDecision

Sentry remote server, https://mcp.sentry.dev/mcp

Vendor-hosted; tool names recorded at reviewPlatform teamError events and stack traces, through each user's Sentry accessApprove, OAuth per user
@modelcontextprotocol/server-postgresNone; archived upstreamNobodyStaging database, with a shared password in the argumentsDeny; 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 /mcp routes, 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.