MCP server over the real Discord REST API -- 5 read-only tools always on, plus 6 write tools (create/edit channel, create/edit role, edit guild, delete channel) gated behind DISCORD_MCP_ENABLE_WRITE=1 (default OFF), instead of hand-rolled Discord API calls.
Project description
discord-mcp
An MCP server over the real Discord REST API
-- so a Claude agent calls list_channels(guild_id="...") instead of
hand-rolling an authenticated httpx request. Built to the
github-mcp/bus-mcp
standard in this portfolio (own pyproject, fastmcp server, typed errors, real
test suite, honest README) -- fifth flagship, first over Discord.
5 read-only tools, always on + 7 write tools, gated OFF by default
behind DISCORD_MCP_ENABLE_WRITE=1 -- see "Write tools" below.
Quickstart (60 seconds)
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[test]"
Add to your Claude Desktop/Code MCP config:
{
"mcpServers": {
"discord-mcp": {
"command": "python",
"args": ["C:/path/to/discord-mcp/run_server.py"],
"env": { "DISCORD_BOT_TOKEN": "your-bot-token-here" }
}
}
}
Without DISCORD_BOT_TOKEN set, every tool call still returns a clean
structured error (Discord's own 401) instead of crashing -- see "Typed
errors" below. Write tools also need DISCORD_MCP_ENABLE_WRITE=1 in the same
env block, or they refuse locally with policy_refusal -- see "Write
tools" below.
What this is / is not
This is a reference portfolio implementation demonstrating an MCP server
over a real external SaaS API (Discord) -- it is NOT an official Discord
MCP server, and it is not affiliated with Discord Inc. It started from an
earlier, separate sibling project's HttpDiscordClient, a stdlib-urllib
Discord transport built and verified against a real live guild for that
project's own server-provisioning tooling. This repo hand-adapts that
client's request-building, header construction (including its deliberately
descriptive User-Agent -- see below), and error handling onto httpx
(matching this fleet's other MCP servers) as its own standalone client with
no dependency on that sibling project. discord-mcp does not import from or
depend on that other package at all.
Started as 5 read-only tools with no write capability at all; now ships a
7-tool write group, off by default, mirroring the *_MCP_ENABLE_WRITE-style
gate already shipped in github-mcp/bus-mcp/desktop-mcp in this same
portfolio -- see "Write tools" below.
Tools
Read (always on, no gate)
| Tool | Discord endpoint | Purpose |
|---|---|---|
list_channels |
GET /guilds/{guild_id}/channels |
All channels (every type) in a guild |
list_roles |
GET /guilds/{guild_id}/roles |
All roles in a guild |
list_categories |
GET /guilds/{guild_id}/channels (filtered) |
Category channels only (Discord type 4) -- Discord has no dedicated categories-only endpoint, so this filters the same channels payload client-side |
get_channel_permission_overwrites |
GET /channels/{channel_id} (permission_overwrites field) |
Role/member allow+deny bitfields set on one channel |
get_member_roles |
GET /guilds/{guild_id}/members/{member_id} (roles field) |
Role ids currently assigned to one guild member |
Write (gated behind DISCORD_MCP_ENABLE_WRITE=1, default OFF)
| Tool | Discord endpoint | Purpose |
|---|---|---|
create_channel |
POST /guilds/{guild_id}/channels |
Create a text/voice/category channel |
edit_channel |
PATCH /channels/{channel_id} |
Rename/re-topic/re-parent/reorder an existing channel |
create_role |
POST /guilds/{guild_id}/roles |
Create a role |
edit_role |
PATCH /guilds/{guild_id}/roles/{role_id} |
Edit an existing role |
edit_guild |
PATCH /guilds/{guild_id} |
Update guild-level identity (name/icon/banner/description) |
delete_channel |
DELETE /channels/{channel_id} |
Destructive. Delete a channel |
create_message |
POST /channels/{channel_id}/messages |
Post a message to a text channel (content: non-empty, <= 2000 chars) |
Write tools
Seven write tools were added on top of the original 5 read-only tools,
mirroring the exact write-gate pattern already shipped in this portfolio
(github-mcp's GITHUB_MCP_ENABLE_WRITE, desktop-mcp's
DESKTOP_MCP_ENABLE_*, and most closely bus-mcp's BUS_MCP_ENABLE_WRITE +
gated_write decorator, copied as the reference template). create_message
was added a night later than the other 6, once it became clear that none of
create_channel/edit_channel/create_role/edit_role/edit_guild/delete_channel
can actually post content to a channel -- it reuses the exact same gate and
the post() helper create_channel/create_role already added to
client.py, no new HTTP plumbing.
- Off by default. Set
DISCORD_MCP_ENABLE_WRITE=1(ortrue/yes/on) in the server's environment to enable the write group. Unset (or any other value) means every write tool call refuses locally, with zero Discord API calls attempted, returning a structuredpolicy_refusalerror:{"ok": false, "error": {"type": "policy_refusal", "message": "Tool group 'write' is disabled. Set DISCORD_MCP_ENABLE_WRITE=1 in the server's environment to enable it.", "group": "write", "tool": "create_channel", "required_env": "DISCORD_MCP_ENABLE_WRITE"}}
- Enforced at the route layer, not just the MCP-tool layer. The
@config.gated_writedecorator wraps each write function directly indiscord_mcp/routes.py(not merely the@mcp.toolwrapper inserver.py), so the gate is unit-testable without spinning up fastmcp or a real transport, and can't be bypassed by any alternate calling path intoroutes.py. - Read fresh from the environment on every call, never cached at import time -- an operator can arm/disarm the write group without restarting the server process, and tests can monkeypatch it per-test.
delete_channelgets no separate or lower bar. Despite being genuinely destructive/irreversible against a real guild, it is gated behind the exact sameDISCORD_MCP_ENABLE_WRITEenv var as the other write tools -- confirmed by dedicated tests intest_routes.py(setting plausible-but-wrong var names likeDISCORD_MCP_ENABLE_DELETEdoes not arm it).- Same auth path as the read tools. Write calls reuse the exact same
_headers()/pooledhttpx.AsyncClientconstruction inclient.pyas every read tool -- there is only one auth code path in this repo. create_messagevalidates content locally, not just via Discord's own 400.contentmust be a non-empty string of at most 2000 characters (Discord's real message-length limit) -- checked byclient.validate_message_contentbefore any request is built, same "never even attempt the call" discipline as the snowflake-id checks. A violation returnsvalidation_error, not a raw Discord 400.
icon_base64/banner_base64 on edit_guild -- a flagged assumption
Discord's docs describe PATCH /guilds/{id}'s icon/banner fields as an
"image data" string -- a full data URI (data:image/png;base64,<base64>),
not a bare base64 payload. edit_guild accepts either:
- a full
data:...;base64,...URI, passed through unchanged, or - raw base64 bytes, which are assumed to be PNG and wrapped as
data:image/png;base64,<value>.
This PNG assumption is not verified against a real Discord response --
this repo has not confirmed whether Discord accepts, rejects, or silently
mis-renders a non-PNG image (JPEG, animated GIF for boosted-server icons,
etc.) sent under an image/png label. If you have a non-PNG image, pass a
full data:image/...;base64,... URI yourself rather than relying on the
default. Flagged here rather than guessed silently.
Typed errors, never a raw crash
Every tool returns {"ok": true, ...} on success or {"ok": false, "error":
{...}} on failure -- never an unhandled exception or stack trace.
network_error-- connection refused, timeout, DNS failure, or a malformed request URL. Discord's API wasn't reachable at all.auth_error(401) -- missing or invalid bot token.permission_error(403, with Discord's own JSON error body, e.g.{"message": "Missing Access", "code": 50001}) -- the bot lacks the permission/scope for this call, or isn't in the guild.cloudflare_blocked(403, with no JSON error body) -- Discord's API docs ask for a descriptiveUser-Agent; without one, a client's default UA is a well-known bot fingerprint that Discord's Cloudflare edge can reject with a bare 403 before the request ever reaches route-level permission checks. This is otherwise indistinguishable from a realpermission_error403 -- Discord's real permission-denied responses always carry a JSON body, so absence of a JSON body on a 403 is the signal this type is built on. This is a best-effort heuristic, not a certainty: a proxy, load balancer, or future Discord change that strips the body on a different kind of 403 would also land here. discord-mcp sends the same descriptiveUser-Agentthis heuristic exists to explain (seeclient.py's_headers()), so in practice this type should rarely fire from this server's own calls -- see "Honest limitations" below.not_found(404) -- bad/unknown guild, channel, role, or member id.rate_limited(429) -- Discord's rate limit. Carriesretry_after_s, parsed straight from Discord's own JSON body (retry_after, in seconds). This server does not auto-retry -- it surfaces the limit as a structured error immediately and leaves any backoff/retry decision to the caller.decode_error-- a 2xx response whose body isn't valid JSON (should not happen against the real API; guards against a malformed proxy/mock).discord_api_error-- any other 4xx/5xx not covered above.policy_refusal(write tools only) -- the write group is disabled; see "Write tools" above. No Discord API call is attempted.invalid_id-- a guild/channel/role/member id failed snowflake-shape validation before any request was built (defense against path injection).validation_error(create_messageonly) --contentwas empty or exceeded Discord's real 2000-character message limit, caught before any request was built.
Internally, discord_mcp/client.py raises typed DiscordUnreachable /
DiscordApiError (with a DiscordDecodeError subclass for the 2xx-non-JSON
case) exceptions; discord_mcp/routes.py catches both and normalizes to the
dict shape above before a tool ever returns. Tests exercise both layers for
every error type, across every HTTP verb (GET/POST/PATCH/DELETE).
Honest limitations
- The
cloudflare_blockedvs.permission_errorsplit is a heuristic (JSON-body-present-or-not), not something Discord documents or guarantees. It is accurate for the specific failure mode it was written to explain (an edge reject due to a missing/generic User-Agent) but a 403 with a stripped body from an unrelated cause (e.g. a misbehaving proxy in between) would also be classified ascloudflare_blocked. get_channel_permission_overwritesandget_member_roleswere not exercised against every possible real-world edge case (e.g. a member with zero roles, a channel with zero overwrites) via the live smoke test -- only via respx-mocked unit tests. The live smoke test only callslist_channels/list_roles; it does not exercise any write tool.- No pagination is implemented anywhere.
list_channels/list_rolesare single-request, unpaginated Discord endpoints (Discord doesn't paginate either of these), so this is a non-issue for the tools in scope -- but a guild large enough to need member-list pagination is out of scope entirely (there is nolist_memberstool here). - The
icon_base64/banner_base64"assumed PNG" default onedit_guildhas not been verified against a real Discord response -- see "Write tools" above. - No write tool has been exercised against the real Discord API at all (by
design -- this task's constraints require zero real network calls; the
live smoke test remains read-only-only). This includes
create_message-- it has never posted a real message to a real live guild as part of this repo's own build/test process; that remains a deliberate, separate operator action.
Env vars
| Var | Default | Purpose |
|---|---|---|
DISCORD_BOT_TOKEN |
unset | Bot token, sent as Authorization: Bot <token>. Read-only tools still work without one in the unit test suite (fully mocked); against the real API, a missing token surfaces as a normal auth_error (401) from Discord itself. |
DISCORD_MCP_ENABLE_WRITE |
unset (OFF) | Set to 1/true/yes/on to enable the 7 write tools. See "Write tools" above. |
DISCORD_MCP_TIMEOUT_S |
10.0 |
Per-request timeout (seconds) |
DISCORD_MCP_LIVE |
unset | Set to 1 to run the real-network smoke test (see Testing) |
DISCORD_MCP_SMOKE_GUILD_ID |
unset | Guild id the live smoke test targets. Test skips cleanly if unset. |
DISCORD_MCP_SMOKE_ENV_PATH |
unset | Path to an external .env file to source DISCORD_BOT_TOKEN from for the live smoke test, if not already in the environment. |
DISCORD_API_BASE (https://discord.com/api/v10) is a fixed constant, not
env-overridable -- unlike bus-mcp's self-hosted BUS_MCP_BASE_URL, Discord's
REST API has exactly one real base URL.
Usage examples
Once connected in a Claude session, an agent can:
list_channels(guild_id="123456789012345678")
list_roles(guild_id="123456789012345678")
list_categories(guild_id="123456789012345678")
get_channel_permission_overwrites(channel_id="...")
get_member_roles(guild_id="123456789012345678", member_id="...")
With DISCORD_MCP_ENABLE_WRITE=1 set:
create_channel(guild_id="...", name="general", type=0)
edit_channel(channel_id="...", name="renamed", position=3)
create_role(guild_id="...", name="Mod", color=1752220, hoist=True)
edit_role(guild_id="...", role_id="...", name="Senior Mod", permissions="8")
edit_guild(guild_id="...", name="New Server Name")
delete_channel(channel_id="...")
create_message(channel_id="...", content="hello from discord-mcp")
Testing
.venv/Scripts/python.exe -m pytest -q
161 tests: 160 unit tests (respx-mocked, zero real network) + 1 live smoke
(gated, see below). All 12 tools' happy paths are covered, every error type
above is exercised at both the client layer (test_client.py) and the
routes-normalization layer (test_routes.py), and test_server.py actually
asyncio.run()s each @mcp.tool async wrapper function against a
monkeypatched routes module -- not just introspects list_tools() -- so an
arg-name mismatch or dropped kwarg between server.py and routes.py would
be caught.
Write-tool coverage specifically (test_config.py + test_routes.py):
gate-off refusal for each of the 7 write tools with len(respx.calls) == 0
asserted (proving no Discord call is even attempted), gate-on success paths
with mocked Discord responses (including request-body assertions), gate-on
Discord-API-error passthrough (permission/not-found/rate-limit/network/5xx),
and delete_channel specifically double-checked -- both that it refuses
identically to the other write tools, and that no plausible-but-wrong env
var name (DISCORD_MCP_ENABLE_DELETE, etc.) accidentally arms it.
create_message additionally gets dedicated content-length boundary tests
(test_client.py/test_routes.py): exactly 2000 characters is accepted (1
respx call), 2001 characters and an empty string are both rejected locally
as validation_error with zero respx calls.
Live smoke test
tests/test_live_smoke.py::test_live_list_channels_and_roles_against_a_real_guild
is gated behind DISCORD_MCP_LIVE=1 and DISCORD_MCP_SMOKE_GUILD_ID (unset
by default -- both must be set to run) and calls the real Discord API's
list_channels/list_roles against that guild. It sources
DISCORD_BOT_TOKEN from this process's own environment if already set,
otherwise, if DISCORD_MCP_SMOKE_ENV_PATH points at an external .env
file, loads it from there (once, without ever printing or logging the
value) -- this repo has no .env of its own and never will; the real token
lives in a separate operator-controlled location. It remains
read-only-only -- no write tool has a live smoke test.
DISCORD_MCP_LIVE=1 DISCORD_MCP_SMOKE_GUILD_ID=<your-guild-id> \
DISCORD_MCP_SMOKE_ENV_PATH=<path-to-.env-with-token> \
.venv/Scripts/python.exe -m pytest tests/test_live_smoke.py -v
Verified passing against a real guild at the time of writing: real channels, roles, and categories all returned as non-empty lists.
Install / connect
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[test]"
Register in ~/.claude.json under mcpServers.discord-mcp as a stdio
server invoking run_server.py by absolute path (no cwd needed -- the
entrypoint adds its own directory to sys.path). env: {} in the real
registration -- the real bot token is never baked into ~/.claude.json;
set DISCORD_BOT_TOKEN in whatever process actually launches the server if
you want authenticated calls, and DISCORD_MCP_ENABLE_WRITE=1 if you want
the write group armed. Arming the write gate in the real registration is a
deliberate, separate operator action -- not part of this repo's default
config.
Handshake check
.venv/Scripts/python.exe scripts/list_tools.py
Prints the twelve registered tool names with no transport started -- pure introspection, useful for verifying the server wires up cleanly after any change.
Out of scope
- Pagination /
list_members(see "Honest limitations" above). - Retrying rate-limited (429) requests -- surfaced as a structured error, left to the caller.
- Distinguishing every possible cause of a body-less 403 with certainty
(see the
cloudflare_blockedheuristic's honest limitation above). - Any write operation beyond the 7 tools above (e.g. member role
assignment/kick/ban, message edit/delete, embeds/attachments/reactions,
webhook management) --
create_messagecovers plain-textcontentonly, no embeds/files/reactions. A broader, differently shaped write-capable project exists elsewhere in this author's portfolio with anapply_server_structure-style tool, out of scope here.
Commercial support
Maintained by Jaimen Bell. For production MCP integrations, custom servers, or agent-reliability work, see jaimenbell.dev or sponsor ongoing maintenance via GitHub Sponsors.
mcp-name: io.github.jaimenbell/discord-mcp
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file jaimenbell_discord_mcp-0.1.0.tar.gz.
File metadata
- Download URL: jaimenbell_discord_mcp-0.1.0.tar.gz
- Upload date:
- Size: 42.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dc7f378848d34c8b7c6964efb8f3d2492920a3c9e5363963262fd66dce49a647
|
|
| MD5 |
a2e69197ef32584b966696db7dd7bcbc
|
|
| BLAKE2b-256 |
82f66b9b52d66e5c0eef43742a6713b7549066f5411dbe6cbc39187325bf0fe9
|
File details
Details for the file jaimenbell_discord_mcp-0.1.0-py3-none-any.whl.
File metadata
- Download URL: jaimenbell_discord_mcp-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68de1bbde3f8a4a35b1e012accd47515cfa29a288a31a9f4a5ae9f80634610d7
|
|
| MD5 |
50e0b309a3ffdbb012aacc41b047ce33
|
|
| BLAKE2b-256 |
217e212b7dc9a8fc457a1418aa8627b842216ca9e058d19d8a2724b4adfa1089
|