Claude Code 2.1.305: Managed stdio Servers with Env-Only Variables Bypass Allowlist Checks
This article analyzes the Claude Code 2.1.305 change where managed-mcp.json stdio servers using variables only in env no longer undergo allowedMcpServers checks, detailing the version history, matching logic, verification commands, and how to block them using deniedMcpServers.
Overview
Starting with Claude Code 2.1.305 (released 2026-10-20), a managed-mcp.json stdio server that references variables only in its env block will no longer be filtered by the allowedMcpServers allowlist. Such a server loads on the user's first launch of the new version without any prompt or error. The only way to block it is to add an entry to deniedMcpServers.
Three Distinct Configuration Objects
managed-mcp.json
A standalone file deployed by administrators to system paths:
macOS: /Library/Application Support/ClaudeCode/managed-mcp.json Linux/WSL: /etc/claude-code/managed-mcp.json Windows: C:\Program Files\ClaudeCode\managed-mcp.json Its format matches .mcp.json but only processes with admin write permissions can deploy it. It cannot be delivered via server-managed settings. Deploying this file enforces exclusive MCP control: only servers defined in this file, those provided via managedMcpServers, in-process servers registered by the host, and explicitly allowed Chrome connections will load. Users cannot add or modify other servers; --mcp-config causes immediate exit on workstations. Claude.ai connectors are suppressed unless allowAllClaudeAiMcps is set.
allowedMcpServers
An allowlist, not a registry. Servers must first be added by the user, a plugin, or the organization before the list can allow or block them. Entries have three keys: serverUrl (for remote servers), serverCommand (for stdio servers, requiring exact command and args match), and serverName (a fallback per category). serverName is a user-defined label and is not a security control — users can name any server "github". Three states exist: unset = allow all; empty array = block all except exempt categories; populated = allow only matches. Lists merge across all settings scopes; users can widen in their own settings. Only when allowManagedMcpServersOnly is true does the list restrict to managed sources (requires 2.1.273+).
deniedMcpServers
A denylist with stricter merging: every scope merges, effective regardless of server origin (except in-process SDK entries). It applies to managed-mcp.json and managedMcpServers servers as well. User-defined denies merge in, letting users block organization-provided servers. A match blocks immediately; the allowlist cannot override.
Evaluation order: merge lists → check denylist → check allowlist. Re-evaluated on reconnect in /mcp or when re-enabling a disabled server.
Official History: Three Phases Without Stated Rationale
The documentation records three phases in the same section:
Pre-2.1.259: Every managed server was checked against the allowlist if it was set.
2.1.259 to 2.1.304: Literal managed-mcp.json entries (no variable expansion outside env) were exempt; entries using variables in env were still checked.
2.1.305+: Entries using variables only in env are also exempt; entries using variables outside env (command, args, url, headers) remain checked.
The 2.1.259 release note (2026-09-02) stated: "Changed allowedMcpServers to govern only servers users add: a literal managed-mcp.json server your allowlist used to filter out now loads on upgrade; use deniedMcpServers to keep it off." The 2.1.305 note is nearly identical but replaces "literal" with "stdio servers that use ${VAR} only in env." Neither release note nor documentation explains why the initial check existed or why it was removed in 2.1.305. The author cautions against inferring a reason (e.g., "env doesn't participate in matching so this fixes over-matching") — that substitutes structural plausibility for missing evidence.
Env and Headers Do Not Participate in Matching; Only Env Removed from Trigger Set
Variable expansion occurs in five positions: command, args, env, url, headers. Syntax: ${VAR} or with default. Unset variables without defaults load as literals; claude mcp list shows a missing-variable warning. Exception: for remote servers, certain credential variables in url and headers expand to empty strings without warning, ignoring defaults.
Expansion happens before loading and matching. Server values expand from the launching process's environment; allowlist entries expand from a fixed environment (requires 2.1.219+). Thus changing a variable in user/project settings does not alter policy entry meaning. Documentation advises using literal URLs and commands for enforcement.
Matching keys: remote servers compare url; stdio servers compare command + args; name compares the label. Env and headers are never matching keys. Yet the trigger for "needs allowlist check" was: managed entry uses variable expansion outside env. Headers with variables still trigger checks despite never being matched. 2.1.305 simply removes env from the trigger set; headers remains.
This makes the change hard to spot. A stdio server with variables only in env appears as a pure literal in matching fields ( command, args), so reviewers see no allowlist clue. Pre-2.1.305, the loader still classified it as "needs check" because env was in the trigger set.
Real-world factor: managed-mcp.json is world-readable. Documentation discourages plaintext secrets in env and recommends three patterns: (1) variable references reading per-user environment (produces the affected shape), (2) OAuth or per-user headers (if written as variable references, still trigger checks), (3) headersHelper generating credentials at connect time (no config variable expansion). The first pattern is a recommended practice, not a misconfiguration. The alternative managedMcpServers path only accepts HTTP/SSE entries and explicitly forbids command, args, env, headersHelper, and any variable references — so local stdio servers with per-user credentials must use managed-mcp.json.
Upside: since matching ignores env, a serverCommand deny entry matches regardless of per-machine env values. Command and args must match exactly (order and count); env is irrelevant. Generate deny entries from the file to avoid manual errors (missing args, absolute path vs command name).
What Happens on Upgrade Day
Documentation: unless these servers use variables outside env, they start loading on each user's first launch of a version that no longer checks them — no prompt, no notification. "On upgrade" means "first launch of the non-checking version." Enterprise rollout and user terminal launch are not simultaneous.
Version inconsistency window: users on 2.1.304 won't see the server; users on 2.1.305 will. Screenshots of /mcp will contradict; correlate version with load result.
Only servers previously excluded by the allowlist change behavior. If the allowlist already had a matching serverCommand, or no serverCommand entries but a matching serverName fallback, the server loaded before and after — no change. Empty array allowlist (block all except exempt) also blocked env-only servers pre-2.1.305.
Most common affected combo: allowlist written for remote servers only (only serverUrl entries, no serverName). Stdio servers first seek serverCommand; none exist → fallback to serverName; none exist → blocked. This plus env-only managed stdio = the cell that changes in 2.1.305. Conversely, a single matching serverName lets the stdio server through via fallback (fallback only checks same-category entries).
Three-step evaluation walk-through: merge → denylist (no hit) → allowlist (has content, so check). Stdio: look for serverCommand (none) → serverName fallback (none) → block. Post-2.1.305, same entry skips step 3 entirely. The env variable only affects whether the entry enters step 3; 2.1.305 changes that gate.
Verification
1. Confirm Version
claude --version2. Classify Servers in managed-mcp.json (read-only, no launch)
jq -r '.mcpServers | to_entries[] | .key as $n
| (.value | del(.env) | tostring) as $id
| if ($id | contains("${"))
then "\($n)\tstill checked"
else "\($n)\tenv-only or literal"
end' managed-mcp.jsonThe two $ variables are jq-internal; wrap whole script in single quotes. Output "still checked" = variables outside env (still checked in 2.1.305+). Output "env-only or literal" = potentially affected. Simpler check: any variable reference in command, args, url, headers → still checked; none there but present in env → affected.
3. Cross-Reference with Allowlist
Assuming allowedMcpServers in a JSON file:
jq -n --slurpfile m managed-mcp.json \
--slurpfile s allowlist.json '
(($s[0].allowedMcpServers // null) | type) as $t
| ($t == "array") as $has
| ($s[0].allowedMcpServers // []) as $allow
| ($allow | map(select(.serverCommand)) | length) as $ncmd
| $m[0].mcpServers | to_entries[]
| select(.value.command != null)
| select(((.value | del(.env) | tostring)
| contains("${")) | not)
| select(((.value.env // {}) | tostring)
| contains("${"))
| .key as $name
| ([.value.command] + (.value.args // [])) as $cmd
| (if ($has | not) then "no allowlist: loads"
elif $ncmd > 0 then
(if ($allow
| map(select(.serverCommand == $cmd))
| length) > 0
then "matched by command" else "excluded" end)
else
(if ($allow
| map(select(.serverName == $name))
| length) > 0
then "matched by name" else "excluded" end)
end) as $before
| "\($name)\t\($before)"
'Output "excluded" = servers that will flip from "not loading" to "loading" post-upgrade (provided denylist doesn't already block them). "matched by name" = already allowed via name fallback (not affected). "no allowlist" = this file has no allowlist (equals allow all per docs), but other scopes may have one.
Caveat: if allowlist comes from server-managed settings, MDM profiles, or registry, local file may be a subset. Using it as the full set over-reports impact (false positives), not under-reports. Fetch from distribution source or verify in console.
4. Verify Actual Load Result
Run claude mcp list — should show only managed-mcp.json servers plus managedMcpServers servers. If user servers appear, the managed file isn't active.
Test exclusive control:
claude mcp add --transport http test https://example.com/mcpShould fail with enterprise policy error "enterprise MCP configuration is active". URL need not exist; policy check rejects before connection. Run both checks pre- and post-upgrade to distinguish "extra server appeared" from "managed file not effective."
Blocking
Add deniedMcpServers entries in managed settings using serverCommand (command + args):
jq '[.mcpServers | to_entries[]
| select(.value.command != null)
| select(((.value | del(.env) | tostring)
| contains("${")) | not)
| select(((.value.env // {}) | tostring)
| contains("${"))
| {serverCommand: ([.value.command]
+ (.value.args // []))}]' managed-mcp.jsonGenerated array goes directly into deniedMcpServers. Covers only env-only stdio entries; entries with variables outside env are already checked and don't need extra denies.
Three details:
Use serverCommand or serverUrl, not serverName — names are mutable labels, not security controls.
Denylist applies to managed servers; user denies merge in, so no extra lock needed.
Denylist evaluated before allowlist; match blocks irreversibly. serverCommand matching: command and args must match exactly (order, count). ["npx","-y","server"] ≠ ["npx","server"]; extra --flag breaks match. Env block ignored. Generate from file to avoid manual errors (missing arg, absolute path vs command name) — denylist misses silently.
Separate concern: if the server should load but its tools must be restricted, control via permission rules mcp__<server>__<tool>, not MCP allowlist. allowManagedMcpServersOnly and allowManagedPermissionRulesOnly are independent.
Deploy denies before users upgrade, or ship different managed-mcp.json per group. Adding denies after upgrade leaves a window where the server loads.
Things Not to Overstate
Not "all MCP servers load." Four constraints: only in managed-mcp.json, only stdio, only env-only variables, only previously excluded by allowlist. managedMcpServers entries never needed allowlist; user-added, plugin, claude.ai, --mcp-config servers still require allowlist.
No sandbox/network boundary change. No new network paths, ports, or isolation levels. --mcp-config still causes immediate exit on workstations; exclusive control unchanged.
Loading ≠ tool approval. Tools still governed by permission rules mcp__<server>__<tool>. The two knobs are separate.
allowManagedMcpServersOnly does not make allowlist authoritative over exempt servers. That flag selects which allowlist to keep; exempt categories (org servers, built-ins, Slack tools, in-process SDK) still skip checks regardless.
Better framed as scope tightening , not allowlist failure. Allowlist still governs user-added, plugin, connector servers. Only a residual check on org-owned entries was removed.
Change is silent — no error, no prompt. Only detectable via /mcp diff pre/post upgrade and inspecting managed-mcp.json env lines. Version-only checks won't surface it.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Architecture Development Notes
Focused on architecture design, technology trend analysis, and practical development experience sharing.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
