Allowlist, denylist or both? Designing MCP server policy after GitHub's enterprise allowlists
Use both. An MCP allowlist answers "did we approve this server?" A denylist answers "has a server we approved since been reported as malicious?" Neither answers the other's question. Since August 2026, GitHub Copilot and Claude Code both let administrators set allowedMcpServers and deniedMcpServers in managed settings. In both, the deny check wins over the allow check, and both vendors say plainly that matching on a server's name is not a security control. Match on URL for remote servers and on the exact command, including a pinned version, for local ones.
The question you are actually asking
You are rolling out coding agents, and you have to decide which MCP servers they may load. The tempting answer is an allowlist: list what is approved, block the rest. That is a good start. The catch is that an allowlist records a decision you made on one day about one thing. The thing can change after that day, and the allowlist will not notice.
A denylist has the opposite blind spot. It blocks what is already known to be bad and lets everything else through, including servers nobody has looked at.
What shipped, and where it applies
On 6 August 2026 GitHub made MCP allowlists generally available in enterprise managed settings, as described in the GitHub changelog. The keys are allowedMcpServers and deniedMcpServers, placed in copilot/managed-settings.json. They are enforced in three clients: the GitHub Copilot app, Copilot CLI and VS Code.
Claude Code has keys with the same names, documented in Anthropic's managed MCP guide. Cursor offers an MCP allowlist under Team Settings, matched by command and URL patterns, according to Cursor's MCP docs. This article works through the GitHub and Claude Code behaviour in detail because both are documented in full. Check every other client your developers run, since one client's policy does not cover another.
How matching works
Each list holds entries, and each entry identifies servers in one of three ways.
serverUrl matches remote servers, the ones reached over HTTP. Both vendors support * wildcards. GitHub says URLs are canonicalized "to block evasion."
serverCommand matches local servers, the ones started as a program on the developer's machine. Claude Code compares "every argument, in order." So ["npx", "-y", "server"] does not match ["npx", "server"].
serverName matches the label a user typed when adding the server. GitHub calls it "only supplied as a convenience, not a security control, since users can rename servers." Anthropic's warning is blunter: "a user can call any server github."
In plain English: the name is a sticky note on the box. The URL and the command describe what is actually in the box.
How the two lists combine
Anthropic documents the order of checks for Claude Code. First the lists from every settings scope merge. Then the denylist runs: "Nothing overrides a denylist match." Then the allowlist runs.
Two details change the outcome:
- Unset is not empty. If
allowedMcpServersis unset, every server that passes the denylist loads. If it is an empty array, none load, apart from a few exempt groups such as the organisation's own managed servers. - Users can widen a soft allowlist. Without
allowManagedMcpServersOnly: true, allowlists from a user's own settings merge in. Denylists always merge, so a user can block more but never unblock.
GitHub states that "a server must pass every layer" when policies come from several layers, and that policies "fail closed, meaning a malformed or unverifiable configuration is blocked rather than allowed." Its changelog does not say how an allow entry and a deny entry interact inside a single policy, so test that case in your own tenant.
GitHub also has an older, registry-based restriction in public preview. Digital Applied quotes GitHub's documentation as saying that mechanism "can be bypassed by editing configuration files." Use the managed-settings keys for anything you need to rely on.
Where each list fails on its own
The allowlist's blind spot: approved, then changed
Think about what a serverCommand entry like ["npx", "-y", "SOME-MCP-SERVER"] really approves. It approves a command. That command fetches whatever version is newest on the registry each time it runs. Your approval stays valid while the code underneath it changes.
This is not hypothetical. In September 2025 an npm package called postmark-mcp added one line in version 1.0.16 that blind-copied every email sent through it to an outside address, The Hacker News reported. A team that had approved the package before that version would have kept loading it, because the allowlist entry still matched.
Remote servers have the same gap. A serverUrl entry matches an address. Whoever runs that address can change the tools behind it at any time.
The denylist's blind spot: everything nobody has looked at
A denylist only blocks what someone has reported. A copycat server published yesterday, or a server a developer wrote over the weekend, is not on any list. A deny-only policy lets it load. Claude Code's docs list "Denylist only: Block known-bad servers, allow everything else" as a supported pattern, and that description is accurate about what it does.
The decision matrix
THREAT ALLOW ONLY DENY ONLY BOTH
--------------------------------------- ------------ ------------ ------------
Developer adds an unreviewed server Blocked Loads Blocked
Unapproved server renamed to an Blocked* Loads Blocked*
approved name
Approved local package publishes a Loads Blocked once Blocked once
malicious new version reported reported
Approved remote URL changes its tools Loads Blocked once Blocked once
reported reported
Server named in a public malicious- Blocked if Blocked Blocked
artifact report never listed
Fake copy of an approved server Blocked* Loads until Blocked*
reported
--------------------------------------- ------------ ------------ ------------
* only if entries match on URL or command, not serverName
The "both" column still has two "once reported" cells. That gap is the time between a malicious version appearing and someone publishing it. Two habits narrow it: pin versions inside serverCommand entries so a new version does not match until you approve it, and keep the denylist fed from sources that update quickly.
A worked example
Illustrative example: a 200-developer team approves two remote servers and one local server. The names below are placeholders; uppercase package names cannot exist on npm, which keeps the example from pointing at a real package.
{
"allowManagedMcpServersOnly": true,
"allowedMcpServers": [
{ "serverUrl": "https://tracker.example.com/mcp/*" },
{ "serverUrl": "https://docs.example.com/mcp/*" },
{ "serverCommand": ["npx", "-y", "[email protected]"] }
],
"deniedMcpServers": [
{ "serverUrl": "https://*.lookalike.example.net/*" }
]
}
Walk through it the way the client does:
- A developer adds
[email protected]. The denylist has no match. The allowlist needs an exact command match, and@2.5.0is not@2.4.1. Blocked until someone reviews 2.5.0. - Months later, version 2.4.1, the one you approved, is reported as malicious. You add
{ "serverCommand": ["npx", "-y", "[email protected]"] }to the denylist. The deny check runs first, so the server stops loading everywhere even though the allowlist still names it. You then remove the allow entry at your own pace. - A developer edits their own settings to allow a lookalike URL.
allowManagedMcpServersOnlymeans their allowlist is ignored, and the managed deny entry blocks it anyway.
The catch in step 1 is cost: every upgrade now needs an approval. That is the trade. A pinned allowlist turns "new version" into a review event instead of a silent change.
Where this simple model stops being true
Remote servers cannot be pinned the same way. A URL has no version. You can approve the address but not the code behind it. For remote servers, the denylist and a regular snapshot of each server's tool list do the work that version pinning does for local ones.
The env block is not compared. Claude Code's docs note that ["node", "server.js"] matches with any environment values, and "some environment variables change what node loads at startup." To control the environment too, define the server yourself in managed-mcp.json.
Coverage is per client. GitHub's policy covers three Copilot clients. Claude Code's covers Claude Code. A developer who also runs another agent is governed by that agent's settings, or by none.
Exempt servers exist. In Claude Code, your organisation's own managed servers and some built-in servers skip the allowlist. The denylist still applies to them.
Decision checklist
- Turn on an allowlist in managed settings, and lock it with
allowManagedMcpServersOnly(Claude Code) or managed-only delivery (Copilot). - Write every allow entry as
serverUrlorserverCommand. Treat anyserverNameallow entry as a bug. - Pin a version inside each
serverCommand. - Maintain a denylist from public malicious-artifact reports and review it weekly.
- For remote servers, record the tool list at approval and compare it on a schedule.
- List every agent client in use and confirm each one has its own policy.
The threat.cafe board collects documented malicious MCP servers with sources, which is one input for the denylist half of this policy.
Sources
- MCP allowlists in enterprise managed settings — GitHub changelog, 6 August 2026
- Control MCP server access for your organization — Anthropic Claude Code docs, accessed 15 October 2026
- Model Context Protocol (MCP) — Cursor docs, accessed 15 October 2026
- GitHub MCP allowlists, Copilot ROI and agent governance — Digital Applied, 10 August 2026
- First malicious MCP server found stealing emails in rogue Postmark-MCP package — The Hacker News, 29 September 2025