What MCP servers actually expose
If you run an MCP server behind an agent that auto-approves tool calls, one question
decides your blast radius: which tools does the server put in
tools/list? Not which ones it will refuse — which ones the
model can see, and therefore plan around.
You cannot answer that from the README. I know because I tried: I built a survey of sixteen servers from their documentation, then started reading source, and the survey was wrong in both directions. Servers documented as having no control had it. A server whose code comments promise tools are "removed from the tool list" never reads the field that would do the removing.
So this page is the narrow version: servers I have actually read, with a file and line for every claim. Six of them, which is fewer than I would like, but they are checked.
The spec does not settle it
There is no per-tool permission mechanism in the Model Context Protocol. Not a field,
not an interface, not a recommended format. In revision 2025-11-25 the word
"permission" appears in schema.ts exactly once, and it is about filesystem
roots; there is no scope, role, allowlist or audit concept attached to a tool anywhere in
the schema.
What it has instead is a requirement without a mechanism:
Servers MUST: Validate all tool inputs; Implement proper access controls; Rate limit tool invocations; Sanitize tool outputs
— spec 2025-11-25,docs/specification/2025-11-25/server/tools.mdx:512-516@aa7306ef
How to implement proper access controls is left open. Every server invents its own answer, which is why the syntax for the same idea differs in every project.
The one primitive that distinguishes reading from deleting is ToolAnnotations
— readOnlyHint, destructiveHint, idempotentHint,
openWorldHint. The schema disclaims it in place:
NOTE: all properties in ToolAnnotations are hints. They are not guaranteed to provide a faithful description of tool behavior... Clients should never make tool use decisions based on ToolAnnotations received from untrusted servers.
—schema/2025-11-25/schema.ts:1171-1176@aa7306ef
That disclaimer is correct, and it has a useful consequence: annotations are worthless to a client building policy over untrusted servers, but they are fine for a server filtering its own tools, where the author of the tool is also the author of the hint. Which is where the mechanisms below live.
Call logging is mentioned once in the whole page, as a SHOULD for clients: "Log tool usage
for audit purposes" (tools.mdx:524). Nothing is asked of servers.
Three distinctions that decide everything
1. Filtered out of the inventory, or refused at call time
These are not variations of the same protection. If a destructive tool appears in
tools/list, the model plans with it in scope even when every call is
rejected. The best statement of this I have seen came from a user, not a vendor:
If destructive tools are visible in a read-only profile, agents may still plan around them even if the server rejects the call later.
— supabase/mcp#281, open since 2026-05-12
Rejection at call time protects your data. Filtering protects the plan. They cost about the same to implement and only one of them is usually done.
2. Allowlist or denylist
A denylist is fail-open across upgrades. You name the dangerous tools you know about; the next release adds one; it arrives enabled. An allowlist is fail-closed: a new tool is absent until you name it. For a surface that grows with every release, this is the whole argument.
3. What happens when you misspell a tool name
The quietest failure in this whole area. You set an allowlist, typo one entry, and the server starts happily with a surface narrower than you think — or, on some designs, wider. Nothing tells you. A server that refuses to start on an unknown name converts a silent misconfiguration into a loud one, at the cost of four lines.
Six servers, read line by line
supabase/mcp eeb12563,
Flux159/mcp-server-kubernetes f35b6c4d,
containers/kubernetes-mcp-server 71949f32,
getsentry/sentry-mcp 58aa4f67,
redis/mcp-redis 3d7ca68a.
This is what the code does, not what the docs say — where they disagree, the
disagreement is noted. Nothing here is a security audit; it is one question asked of six
codebases. Supabase and Sentry are monorepos; their paths below are given from
packages/ down.
| Server | Per-tool allowlist | Filters tools/list |
Unknown name | Evidence |
|---|---|---|---|---|
| chigwell/telegram-mcp | yes, over a read-only baseline | yes — unnamed write tools are never registered | fails to start | added in #168, merged 2026-07-27 |
| Flux159/mcp-server-kubernetes | yes — ALLOWED_TOOLS |
yes, and refuses the call too | fails at startup, since #356 | src/index.ts:93-104, :242, :185-199 |
| containers/kubernetes-mcp-server | yes — enabled_tools |
yes | — | pkg/config/config.go:63-65 |
| supabase/mcp | no | no — rejected at call time | n/a | mcp-server-supabase/src/server.ts:136-196 |
| getsentry/sentry-mcp | no — five groups only | yes, at registration | inconsistent by transport | catalog-runtime/availability.ts:86, :147 |
| redis/mcp-redis | none at all | no filtering point exists | n/a | src/common/server.py |
supabase/mcp — documented behavior that no code performs
Write tools are registered unconditionally and throw when called:
if (readOnly) throw new Error('Cannot ... in read-only mode.') in
mcp-server-supabase/src/tools/branching-tools.ts at lines 166, 190, 201, 212
and 225, and the same shape in storage-tools.ts:106,
database-operation-tools.ts:354, edge-function-tools.ts:135 and
account-tools.ts:266,288,299. The tool callback in
mcp-server-supabase/src/server.ts:136-196 does no readOnly
filtering whatsoever — the flag is passed down into each getter.
The part worth reading twice is in mcp-server-supabase/src/tools/util.ts:10-11:
/** 'adapt' = stays available in read-only mode, adapts behavior.
'exclude' (default) = removed from tool list. */
readOnlyBehavior?: 'exclude' | 'adapt';
The server never reads readOnlyBehavior. It is declared above, set once
— on execute_sql, in
mcp-server-supabase/src/tools/database-operation-tools.ts:171 — and read
only in mcp-server-supabase/src/tools/tool-schemas.ts:147 and that file's
test. tool-schemas.ts builds schemas for consumers rather than serving
tools/list: following imports from the published entry point,
src/transports/stdio.ts, reaches 34 files, and that is not one of them.
"Removed from tool list" is documented in the source and does not happen.
A filtering primitive does exist — two, in fact. hidden: true is
declared one line below, at util.ts:12-13, and it is honored:
mcp-utils/src/server.ts:459 filters on it with
.filter(([, tool]) => !tool.hidden). So the server can already remove a
tool from the inventory; read-only just doesn't use the mechanism.
This is being fixed by someone else. PR
#283 adds a listedTools concept, hides mutating tools from read-only
tools/list, and carries a regression test. It has maintainer review on it and
is waiting on a re-review, so the right thing to do is stay out of its way.
Flux159/mcp-server-kubernetes — correct, and undiscoverable
A per-tool allowlist that takes precedence over every other flag, in src/index.ts:93-104:
const isToolAllowed = (name: string): boolean => {
if (explicitlyAllowedToolNames) {
return explicitlyAllowedToolNames.has(name);
}
if (allowOnlyReadonlyTools) {
return readonlyTools.some((t) => t.name === name);
}
if (nonDestructiveTools) {
return !destructiveTools.some((dt) => dt.name === name);
}
return true;
};
It is an allowlist rather than a denylist, it is covered by tests
(tests/tool_restriction_enforcement.test.ts), and it is applied in both
places: the inventory is filtered at src/index.ts:242
(baseTools.filter((tool) => isToolAllowed(tool.name))) and a call to a
disallowed tool is refused at :255. Belt and braces, which is the right
answer — filtering is what keeps the tool out of the model's plan, refusing is what
catches a client that ignored the inventory. By the three distinctions above, this is the
design you want.
Until recently a misspelled tool name was a plain Set lookup and was dropped
in silence — you believed you had granted a tool and you had not. That one is closed:
unknown names now fail at startup with the list of valid ones
(src/index.ts:185-199, from
#356, merged
2026-07-28).
One gap left: ALLOWED_TOOLS replaces the baseline instead of
extending it, so there is no way to say "read-only, plus these two writes" without
enumerating every read tool by hand.
The discoverability problem is separate and, I think, larger: this feature is documented
in ADVANCED_README.md and not in the main README. A survey by documentation
— mine included — misses it, and so does a user.
containers/kubernetes-mcp-server — four levels, already built
pkg/config/config.go:63-65 carries EnabledTools,
DisabledTools and ToolOverrides, on top of the already-documented
--read-only, --disable-destructive and --toolsets.
Tests in pkg/mcp/mcp_tools_test.go and pkg/config/config_test.go.
Documentation describes the three coarse levels; the fourth, per-tool one is in the code.
getsentry/sentry-mcp — a deliberate no
Granularity here is five skill groups, gated by isAllowedBySkills at
mcp-core/src/tools/catalog-runtime/availability.ts:86. You cannot grant a
single write tool: triage ships add_issue_note together with
update_issue, and project-management is eight tools, seven of
which write. stdio with no flags falls through to
finalSkills = new Set<Skill>(ACTIVE_SKILLS)
(mcp-server/src/cli/resolve.ts:94) — every active skill — so
update_issue is in the default inventory.
The filtering itself is done properly — at registration, not at call time. But the
architecture has two surfaces: besides tools/list there is a meta-tool,
execute_sentry_tool, declared in
mcp-core/src/tools/surfaces.ts:12 as infrastructure sitting outside the
catalog, through which catalog tools are reachable without appearing in the inventory
themselves.
The two paths are worth tracing, because they converge and then diverge. Registration goes
getToolsForMcpRegistration (availability.ts:190) →
getAvailableTools (:147) → .filter(isTopLevel).
The meta-tool goes execute-tool.ts:113 →
getSearchableTools (availability.ts:217) → the same
getAvailableTools. They share the gate and part company after it,
which means a per-tool allowlist bolted onto the registration site leaves the execute path
wide open. It has to go into getAvailableTools itself.
Typo handling differs by transport: stdio throws on an invalid skill
(resolve.ts:83-89), while the OAuth callback path drops invalid values
without complaint (mcp-cloudflare/src/server/oauth/routes/callback.ts).
Worth saying plainly, because it is a design position rather than an oversight: the
maintainer has declined this model twice, in
#196 and by closing a
--denied-tools PR. The reasoning — that per-tool selection produces
incoherent tool sets and that tool selection may belong upstream — is a real
argument, and the open design issue
#941 is where it is being
had.
redis/mcp-redis — no control surface at all
Twenty-three CLI options and twenty-eight environment variables, all of them about
connecting to Redis; none about tools. Registration is nineteen lines in
src/common/server.py: pkgutil.iter_modules imports every module
under src.tools and the @mcp.tool() decorators register
everything unconditionally. Between tool definition and tools/list there is
no filtering point. There is no call-time rejection either.
Worth being precise about which FastMCP this is, because the answer changes what's
available. The import is from mcp.server.fastmcp import FastMCP — the
one bundled with the official Python SDK (mcp[cli], 1.28.1 in the lockfile),
not the standalone fastmcp 2.x package with its tag system and per-tool
enabled flag. In the bundled version the path runs straight through:
ToolManager.list_tools() returns list(self._tools.values())
(tool_manager.py:41-43), FastMCP.list_tools() maps that one to
one (server.py:315-330), and there is no predicate anywhere along it. So
"just use FastMCP's tags" is not an answer here; the framework this server actually
imports has nothing to filter with.
53 tools — I counted the @mcp.tool() decorators — a good half of
them writes. The irreversible ones are delete,
json_del, hdel, xdel, srem,
zrem, lrem, lpop/rpop and
xgroup_destroy. Two deserve special mention: expire, which is
deletion on a timer and reads as harmless, and rename, which overwrites the
destination key without a word.
For accuracy in the other direction: there is no FLUSHDB,
FLUSHALL, CONFIG SET, EVAL or KEYS tool.
The blast radius is per-key, not "drop the database in one call". It is
scan_all_keys plus delete that makes it interesting.
The README recommends Redis ACLs. That is the pattern this page is about: the tool stays
in the inventory, the agent plans a write, and NOPERM arrives as a string
afterwards. ACLs are also scoped to Redis commands rather than MCP tools, and are
unavailable on some managed instances.
What I got wrong
I started this with a documentation survey of sixteen servers and a headline number: twelve of fifteen with a write surface had no per-tool allowlist. It made a good sentence. Then I read code for five of them, and three of the five did not match:
- Flux159 — recorded as a single binary toggle. Has had a full per-tool allowlist all along, documented in the advanced README only.
- containers — recorded as group-level. Has
enabled_toolsper name in the config struct. - supabase — the hole was real, but I had it filed as unclaimed when there was already a live PR with maintainer review on it.
So: twelve of fifteen is wrong and I am not going to repeat it. The real number is smaller and I do not know what it is, because knowing it means reading ten more codebases.
The mistake is more interesting than the number, though, and it points the same direction
from the other side. Documentation lags code in both directions here —
features exist and are unlisted, and features are listed and do not exist. The best-known
case is not mine:
github-mcp-server#2156,
where --read-only was documented, shipped, and did not restrict write tools
over the new http transport — create_branch,
create_pull_request and merge_pull_request were all reachable
with the flag on.
If you are relying on one of these flags for anything that matters, the honest advice is
the inconvenient one: call tools/list against your own configuration and read
what comes back. That is the only source of truth in this stack today.
What I could not find
Any public report of an agent destroying something through an over-broad MCP tool surface. Not one, in issue trackers or anywhere else.
So the demand here is precautionary, not post-mortem: engineers asking for the lever before the accident rather than after it. That weakens the case for urgency, and it is worth saying so rather than reaching for a hypothetical disaster. Everything above is an argument about defaults and blast radius, not a report of harm.
Second gap, wider than the first and outside this page's scope: server-side call logging effectively does not exist. Of the servers I have looked at, one records tool invocations, and it does so in a vendor cloud rather than in the server. Platform audit logs catch what reached the API; a call shaped or dropped at the MCP layer is not there. I have not investigated this properly and am not going to claim more.
The shape of the patch
One sentence, portable across projects: deny writes by default, grant write tools
by name, and filter the unnamed ones out of tools/list rather than rejecting
them at call time — and refuse to start on an unknown name.
Where I've written it out, in case a concrete diff is more useful than a description:
- chigwell/telegram-mcp#168
—
read-only+send_message,reply_to_message: a read-only baseline plus named writes, so the coherent read set stays as the maintainer composed it. Unnamed write tools are never registered. Merged. - Flux159/mcp-server-kubernetes#356 — the narrow startup-validation case described above, on a mechanism that was already there. Merged.
- redis/mcp-redis#161
— proposed, not merged.
MCP_REDIS_ALLOWED_TOOLSwhere there was no control at all: unnamed tools removed from the registry through the publicremove_toolAPI, unknown names fatal at startup, unset means behavior is unchanged. Whether this is the right shape for that codebase is the maintainer's call, and it hasn't been made yet.
None of this is clever, which is the point. It runs from four lines to a few dozen
depending on the codebase, plus a test asserting that an unnamed write tool is absent
from tools/list.
Method, and corrections
Six servers read at the commits above on 2026-07-29. Claims are per file and line so they can be checked or falsified — every line reference on this page was opened at the listed commit rather than carried over from notes, which is how two stale ones and a set of wrong paths were caught before publishing. Servers I have only read documentation for are deliberately not in the table; that is how the first version of this page went wrong.
If a row is wrong, or your server does this properly and is not listed, please open an issue and I will fix it and say what changed. The table is dated because it will go stale; three of its rows were already stale in someone else's survey.