- Book: System Design Pocket Guide: Fundamentals
- The series: Pocket Guides for Developers — 8 short books on agents, RAG, prompts, observability, events, databases and system design — the whole series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You wrote "mcp__a" in your allow list because you trust server a. Before Claude Code 2.1.299, that rule also let Claude Code call a different server, named a__b, without asking you.
Claude Code 2.1.299 shipped on October 14, 2026, with 131 bug fixes. One of its changes reads: "Changed MCP allow rules to reach only the server they name: mcp__a no longer allows a server named a__b or a_; write mcp__<server>__* with the server's whole normalized name." Another line gives agent tools: lists the same treatment, and in the bug fixes, deny and ask rules for a server whose name contains a double underscore now apply.
Those three lines describe one bug, and it comes down to where a separator is allowed to appear.
How an MCP tool gets its name
Claude Code gives every MCP tool one flat name: mcp__, then the server name, then __, then the tool name. The permissions docs show the three rule shapes that follow from it. mcp__puppeteer matches any tool from the puppeteer server, mcp__puppeteer__* matches the same set with a wildcard, and mcp__puppeteer__puppeteer_navigate matches one tool.
The server name is whatever you typed into claude mcp add, or the key in a .mcp.json file. The MCP docs say those names "can contain only letters, numbers, hyphens, and underscores." Two underscores in a row are still underscores, so a__b and a_ are both legal server names.
Tool names have the same freedom. The MCP spec lists letters, digits, underscore, hyphen and dot as the allowed characters, so a server is free to call a tool b__whoami.
Now build the names for one tool, whoami, on three servers:
server a -> mcp__a__whoami
server a__b -> mcp__a__b__whoami
server a_ -> mcp__a___whoami
The separator is __, and the server name is allowed to contain __. Read the second line from the left and stop at the first __ after the prefix: you get server a, tool b__whoami. Do the same with the third line and you get server a, tool _whoami. Both now look like tools of server a, and a bare mcp__a rule means "any tool of server a."

Two different servers, one prefix: the tool name alone can't say where the server name ends.
The changelog doesn't publish the old matcher, so treat the paragraph above as a reconstruction. It is the simplest reading I could find that produces both of the changelog's examples (a__b and a_). It also explains the deny-side fix in the same release. A deny rule mcp__a__b, written to block server a__b, reads as "server a, tool b" under the same split, and no tool of a__b is literally called b. The release notes say deny and ask rules "for an MCP server (mcp__<server>)" didn't apply when "the server's normalized name contains a double underscore." Same split, opposite direction.
Server a with a tool named b__whoami, and server a__b with a tool named whoami, produce the identical string mcp__a__b__whoami. No parser can tell those two apart from the name alone. The matcher has to know which servers are configured, which is what "the server's whole normalized name" in the changelog points at. The MCP spec warns about this from the client side: tools aggregated from several servers "MAY encounter naming collisions," and clients "SHOULD implement a disambiguation strategy such as prefixing tool names with a server identifier." Prefixing helps only when the identifier can't contain the separator.
Why this is a security change
An allow rule skips the permission prompt. A rule that reaches a second server skips the prompt for a server you never reviewed.
Who picks server names? You do, with claude mcp add. So does any repository you open that ships a .mcp.json. In an interactive session, Claude Code asks you before connecting those project servers, per the permissions docs. Under claude -p or the SDK in a folder you never trusted, the same docs say they're "Connected without asking, approved or not," which matters if your CI runs claude -p against checked-out repos. And a server connecting is a different decision from letting each of its tool calls run unprompted. Picture a user-level mcp__github rule and a project server named github__mirror: on the old matching, once that server connected, the rule plausibly covered its tools as well. That's hypothetical. I haven't seen a reported incident.
Anthropic's managed MCP docs make the general point in a warning: a serverName entry "is not a security control," because "a user can call any server github." For admin allowlists they point you to serverCommand or serverUrl. Permission rules have no such option. They key on the name, so it pays to know how far a name reaches.

An allow rule written for one door that also opened the one next to it.
Reproduce it with two dummy servers
You need Node and Claude Code. The server below uses the v2 MCP TypeScript SDK, whose package is @modelcontextprotocol/server, and exposes one tool that says which server answered:
// dummy.mjs: one tool that names the server it runs as
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport }
from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
const label = process.argv[2] ?? 'dummy';
const server = new McpServer({ name: label, version: '1.0.0' });
server.registerTool(
'whoami',
{
description: "'Say which server answered',"
inputSchema: z.object({}),
},
async () => ({
content: [{ type: 'text', text: `served by ${label}` }],
}),
);
await server.connect(new StdioServerTransport());
Register the same file twice, under two names, in a scratch folder:
mkdir mcp-collide && cd mcp-collide
npm init -y
npm i @modelcontextprotocol/server zod
# save dummy.mjs here, then:
claude mcp add a -- node "$PWD/dummy.mjs" a
claude mcp add a__b -- node "$PWD/dummy.mjs" a__b
claude mcp list
claude mcp add defaults to local scope, which only applies in this folder. Now allow server a and nothing else, in the folder's local settings:
mkdir -p .claude
cat > .claude/settings.local.json <<'EOF'
{ "permissions": { "allow": ["mcp__a"] } }
EOF
Run both calls in dontAsk mode. The permission modes docs define it as: pre-approved tools run, and "anything that would prompt is denied." That turns the allow rule into a clean yes or no, with no prompt to click through:
claude -p "Call the whoami tool of the a MCP server" \
--permission-mode dontAsk
claude -p "Call the whoami tool of the a__b MCP server" \
--permission-mode dontAsk
The first call should run on any version. On 2.1.299 or later, the changelog says mcp__a no longer covers a__b, so dontAsk denies the second. Before 2.1.299, the rule reached a__b too, and the reply would say "served by a_b". I haven't run this myself, so check claude --version and compare your result with the changelog. A third server named `a covers the other example, and claude mcp remove` cleans up.
One timing note. The setup docs say the stable release channel is "typically about one week old," and Homebrew's claude-code cask tracks it. 2.1.299 shipped on October 14, so if you're on stable in the week after that, there's a good chance you still have the old matching.
Find rules that leaned on the old matching
The rules live in your user settings (~/.claude/settings.json), the project's .claude/settings.json and your .claude/settings.local.json, all listed in the permissions docs. Server names live in ~/.claude.json (user and local scope) and in the project's .mcp.json, per the MCP docs. This script, run from a project root, pulls bare mcp__ rules from the settings files and compares each one with every configured server name. It needs jq:
#!/bin/sh
# Bare mcp__ rules that reached a second server before 2.1.299
servers=$(jq -r '.. | objects | .mcpServers? // empty
| keys[]' ~/.claude.json .mcp.json 2>/dev/null | sort -u)
rules=$(grep -hoE '"mcp__[A-Za-z0-9_-]+"' \
~/.claude/settings.json .claude/settings.json \
.claude/settings.local.json 2>/dev/null \
| tr -d '"' | sort -u)
for r in $rules; do
s=${r#mcp__}
for n in $servers; do
case $n in
"$s"__*|"$s"_) echo "$r also covered server $n" ;;
esac
if [ "$s" = "$n" ]; then
case $n in
*__*) echo "$r names $n: deny/ask missed it" ;;
esac
fi
done
done
The first check flags an allow rule like mcp__a when a server named a__… or a_ exists: that rule now covers less than it used to. The second flags a rule that names a server containing __ exactly. On old versions, deny and ask rules of that shape didn't fire. The grep sees every mcp__ string in those files, allow, ask and deny alike, so read each hit against the list it sits in.
Two more places hold MCP rules. Subagent files carry tools: lists, which 2.1.299 also narrowed, so run grep -rn 'mcp__' .claude/agents ~/.claude/agents. And CI scripts often pass rules on the command line with --allowedTools. Servers from plugins and claude.ai connectors aren't in those two files, so check claude mcp list for those names too.
The fix: name the whole server
The changelog spells out the form: mcp__<server>__*, with the server's whole normalized name. For the two dummy servers:
{
"permissions": {
"allow": ["mcp__a__*", "mcp__a__b__*"]
}
}
For a server bundled in a plugin, the whole normalized name includes the plugin. The MCP docs give the pattern mcp__plugin_<plugin-name>_<server-name>__<tool-name>, where any character outside letters, digits, _ and - becomes _. The MCP docs warn that a hook matcher written against the bare server key never fires for a plugin server. A permission rule matches the same tool names, so write it against the full plugin_… name.
My own preference goes one step further: keep __ out of server names entirely. Hyphens are allowed, so a-b gives you mcp__a-b__whoami, which can't be split two ways. Remove the server, add it again under the new name, and update the rules that pointed at it. And per the permissions docs, rules are checked "deny, then ask, then allow," with the first match winning, so a broad deny still beats any allow you wrote.
Where Hermes IDE fits
I build Hermes IDE, so weigh this section with that in mind. Hermes runs each agent's own interface in a real terminal, so the permission rules, the prompts and this fix all belong to Claude Code. Hermes never answers a permission prompt for you.
Two things in it help here. Press ⌘N (Ctrl+Shift+N on Windows and Linux) and the agent picker lists the installed agents with their versions, so you can see whether you're on 2.1.299.

The ⌘N New task sheet with its agent picker open: Claude Code (installed, 2.1.299) selected, Codex installed (0.145.0), the other supported agents marked Not installed, plus Custom agent.
And Hermes saves every agent turn, so the Review Desk (⌘G) shows changes by turn or by file. If an agent edits .claude/settings.json or .mcp.json mid-task, that file shows up in the turn that changed it, and you can restore that one turn. Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. It isn't affiliated with Anthropic, OpenAI, Nous Research or any agent vendor. Download it, and if it's useful, star it on GitHub.
If this was useful
This bug lived at a boundary: one flat string carrying two identifiers, with a separator that both of them were allowed to contain. If you like reasoning about where systems break at their seams, my System Design Pocket Guide: Fundamentals covers the core building blocks of scalable systems in a short read. It's one of 8 short books in the Pocket Guides for Developers series, on agents, RAG, prompts, observability, events, databases and system design.

Top comments (0)