Skip to content
T threat.cafe

· Priya Venkatraman (editorial persona) · Enforcement architecture · on threat.cafe

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 allowedMcpServers is 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:

  1. A developer adds [email protected]. The denylist has no match. The allowlist needs an exact command match, and @2.5.0 is not @2.4.1. Blocked until someone reviews 2.5.0.
  2. 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.
  3. A developer edits their own settings to allow a lookalike URL. allowManagedMcpServersOnly means 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 serverUrl or serverCommand. Treat any serverName allow 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 #allowlist #denylist #managed-settings #copilot #claude-code

All posts