Blog • MCP

After upgrading to Claude Code 2.1.305, a managed-mcp.json stdio server that uses a variable only in env loads again. How do you write deniedMcpServers?

The Claude Code 2.1.305 note is one sentence, and after the upgrade a server can show up on its own: allowedMcpServers no longer filters stdio servers in managed-mcp.json that use ${VAR} only in env. If the allowlist left that server out, it loads. What keeps it off is deniedMcpServers, not another copy of the allowlist.

Who owns MCP permissions is in the MCP security article. Which log step stops credentials going to the wrong authorization server is in the previous OAuth article. This one only covers the 2.1.305 change: the allowlist no longer stops a stdio server that uses a variable only in env, and the denylist JSON has to match the command exactly.

The decision: a stdio server that uses ${VAR} only in env is no longer stopped by the allowlist after 2.1.305. Write a serverCommand in the denylist that is identical to command and args.

Which allowlist case did 2.1.305 change?

The changelog says allowedMcpServers stops filtering managed-mcp.json stdio servers that use ${VAR} only in env. One the allowlist left out now loads on upgrade. To keep it off, use deniedMcpServers. The changelog is the Claude Code changelog.

The organization MCP guide still states the earlier rule: servers in managed-mcp.json skip the allowlist, except a definition that uses ${VAR}, which is still checked. Before 2.1.259, every managed server had to pass an allowlist whenever one was set. 2.1.305 removes one narrow slice: stdio, and the variable only in env. The changelog does not say a ${VAR} in the command, arguments, URL, or headers is also exempt. The guide is the organization MCP guide.

How do you see that the variable is only in env?

The guide's stdio example has type, command, args, and env. Do not put a secret in an env value. Per-user credentials use ${VAR} and are read from that user's environment. The file below keeps the variable only in env. The command and arguments are literals. That is the case 2.1.305 names. The command path is a placeholder, not a package to install.

The variable is only in env. After 2.1.305 the allowlist no longer filters out this stdio server.
{
  "mcpServers": {
    "notes": {
      "type": "stdio",
      "command": "/usr/local/bin/notes-server",
      "args": ["--stdio"],
      "env": {
        "NOTES_TOKEN": "${NOTES_TOKEN}"
      }
    }
  }
}

If ${VAR} is in command, args, the URL, or headers, the changelog does not include those in this exemption. Treat that server as still subject to the allowlist check in the guide, until the changelog says otherwise. The denylist example here covers only the env case.

Which command must deniedMcpServers match?

In the guide, serverCommand matches the command and arguments that start a stdio server, and the match is exact. The command above plus args is that array. Drop an argument, add one, or swap the order, and it does not match. serverName is only a label. The guide says a serverName entry in either list is not a security control, because a user can give any server the same name.

The denylist that keeps this server off. The array is the same as command and args above.
{
  "deniedMcpServers": [
    {
      "serverCommand": ["/usr/local/bin/notes-server", "--stdio"]
    }
  ]
}

The denylist applies to managed servers too, and a user's own deniedMcpServers merges in, so a user can block one managed server for themselves. When the allowlist is an empty array, the guide says no servers are allowed apart from those that skip the allowlist check. After 2.1.305, this stdio server with a variable only in env is one that skips, so an empty allowlist does not keep it out.

Which schema pins the shape of this denylist?

Use JSON Schema to pin the types. deniedMcpServers must be an array. Each serverCommand must be an array of strings. An empty array is still a valid shape. It does not prove this server will be stopped. What stops it is the app rule: the array must be identical to command plus args.

The shape of the denylist. An empty array still passes.
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["deniedMcpServers"],
  "properties": {
    "deniedMcpServers": {
      "type": "array",
      "items": {
        "type": "object",
        "required": ["serverCommand"],
        "properties": {
          "serverCommand": {
            "type": "array",
            "items": { "type": "string" }
          }
        }
      }
    }
  }
}
What the record showsSchemaIf this server must stay off
serverCommand is one stringFails. This field needs an array of strings.Split it into the command and the argument first.
An argument is a numberFails. Every item in the array is a string.Change it to the same string that is in args.
deniedMcpServers is an empty array, and the shape is rightPasses. An empty array is still an array.Add the serverCommand that matches command and args. An empty list blocks nothing.

Of the three checks before load, which one actually stops it?

The guide writes the checks before load as three steps, and runs them again when a user reconnects a server or turns a disabled one back on in /mcp. In-process servers of type sdk skip all three. After a denylist match, nothing overrides it.

StepThis env-only server
Merge the listsAllowlist and denylist entries from every settings scope combine. When allowManagedMcpServersOnly is true, only the managed allowlist is kept. The denylist always merges from every scope.
Check the denylist firstAn exact serverCommand match stops it. After this step, the allowlist cannot put it back.
Then check the allowlistAfter 2.1.305, this stdio server with a variable only in env skips this step. Leaving it off the allowlist no longer stops it.

How do you check this denylist in JSONNote?

  1. 1
    Pick the right operation

    Open JSON Schema. Set Operation to Check with Schema. Paste the denylist in the first field and the schema above in the second. serverCommand must be an array of strings.

  2. 2
    Reject four bad documents

    Set serverCommand to one string, delete deniedMcpServers, set an argument to a number, or make deniedMcpServers an object. Each of those fails. The denylist above passes. A pass only means the shape is right.

  3. 3
    Diff the empty list against that command

    Open JSON Compare Diff. Paste the denylist above in the first field. In the second, change deniedMcpServers to an empty array. Both pass the schema. What stops this server is that serverCommand, not the empty array.

Common questions

Can adding the same command to the allowlist replace the denylist?

No. After 2.1.305 this server skips the allowlist. Whether the command is on the allowlist no longer decides if it loads. To stop it, a denylist entry has to match. After a denylist match, the allowlist cannot put it back.

Is a serverName of notes enough?

No. The guide says serverName is the label a user assigns, and wildcards are not expanded. A user can call any server by the same name. To stop this stdio server, use serverCommand, identical to command and args.

If the command also has ${VAR}, does it still load?

The changelog does not include that case in the exemption. It names only stdio, and a variable only in env. When ${VAR} is also in the command, arguments, URL, or headers, keep treating it as the allowlist check in the organization guide. If that server does not match the allowlist, do not assume 2.1.305 will load it.

Can a user's own settings block the one the organization deployed?

Yes. The guide says deniedMcpServers applies to servers in managed-mcp.json, and a user's own denylist merges in. A user can block one server for themselves. The allowlist does not replace that step.

Should the env block store the token value?

No. The guide says anyone on the machine can read managed-mcp.json, so do not put an API key or other credential in env. Per-user credentials use ${VAR} and are read from that user's environment. The denylist should not contain a token either, only the command and arguments.

Summary

2.1.305 exempts one case: a stdio server in managed-mcp.json whose ${VAR} is only in env. The allowlist no longer filters it out. The changelog does not exempt a variable in the command, arguments, URL, or headers along with it.

The decision: to keep it off, write a serverCommand in deniedMcpServers identical to command and args; an empty allowlist does not keep it out; serverName is not enough; do not store the token value in env.

Next: pin the denylist array with the schema, then use Diff to see the empty array against that serverCommand.

Back to the blog