discord-mcp — read+write over the Discord REST API, write off by default
Python MCP SDK 162 passing tests write off by default route-layer gate public repoThe problem
A Discord bot that can read channel/role structure is low-risk. The same bot wired to also create channels, edit roles, or delete a channel an agent misjudged is a different risk class entirely — and the common failure mode isn't a missing permission check, it's a gate that only lives in the MCP tool wrapper while the underlying function is still directly callable and untested in isolation. If the gate isn't provably enforced at the source, an agent with the right imports (or a future refactor) can route around it.
What I built
discord-mcp is an MCP server over the Discord REST API: 5 always-on
read tools and 7 write tools that are OFF by default and
refuse locally unless DISCORD_MCP_ENABLE_WRITE is set truthy in the server's
own environment.
- Read, unconditional —
list_channels,list_roles,list_categories,get_channel_permission_overwrites,get_member_roles. Never gated, by design — unchanged from the server's original 5-tool, read-only scope. - Write, gated at the route, not just the tool wrapper —
create_channel,edit_channel,delete_channel,create_role,edit_role,edit_guild,create_messageeach go through agated_writedecorator applied directly to the route function inroutes.py. A disabled call returns a structuredpolicy_refusalpayload with no Discord call ever attempted — not even a coroutine created for one. - No lower bar for the destructive one —
delete_channelgets no separate, looser gate despite being the most dangerous write. It goes through the exact sameGROUP_WRITEcheck as the other six. - Env read fresh every call — the gate reads
os.environon every invocation, never cached at import time, so an operator can arm/disarm without restarting the process and tests can monkeypatch it directly.
Evidence you can check yourself
Public on purpose — every claim maps to a file you can open before you reply:
- Repo: github.com/jaimenbell/discord-mcp (MIT), squash-clean history on
master. - Tests:
.venv/Scripts/python.exe -m pytest -q→162 passed, 1 skipped(junitxml: 163 total, 0 failed, 0 errors; the 1 skip is a real-network live smoke gated behindDISCORD_MCP_LIVE=1). discord_mcp/config.py'sgated_writedecorator and_ENV_GATESmap are the whole enforcement surface — no separate allowlist to drift out of sync with the route file.
Stated plainly: this is a REST wrapper, not a Discord Gateway bot — no real-time event
listening (message-create webhooks, presence updates), just request/response tool calls
against Discord's HTTP API. The bot token is read fresh from the environment on every call
and never logged or included in an error payload, but its custody is still the operator's
responsibility — this server has no secret-storage layer of its own. And the write gate is
a local policy refusal, not a Discord-side permission restriction: an agent with
DISCORD_MCP_ENABLE_WRITE=1 set can still only do what the underlying bot's
actual Discord role permissions allow — the gate controls whether this server will
attempt the call, not what Discord itself will accept.
What it shows about how I work
Same pattern as the rest of this portfolio's write-gated servers (bus-mcp, github-mcp, desktop-mcp): copy the gate shape that's already been hardened elsewhere, enforce it at the function that does the dangerous thing rather than at a layer an agent might not go through, and give the destructive action zero special treatment that would make it easier to reach.