Skip to main content
Glama

discord-mcp

License: MIT Tests

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.

Related MCP server: mcp-discord

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 (or true/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 structured policy_refusal error:

    {"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_write decorator wraps each write function directly in discord_mcp/routes.py (not merely the @mcp.tool wrapper in server.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 into routes.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_channel gets no separate or lower bar. Despite being genuinely destructive/irreversible against a real guild, it is gated behind the exact same DISCORD_MCP_ENABLE_WRITE env var as the other write tools -- confirmed by dedicated tests in test_routes.py (setting plausible-but-wrong var names like DISCORD_MCP_ENABLE_DELETE does not arm it).

  • Same auth path as the read tools. Write calls reuse the exact same _headers()/pooled httpx.AsyncClient construction in client.py as every read tool -- there is only one auth code path in this repo.

  • create_message validates content locally, not just via Discord's own 400. content must be a non-empty string of at most 2000 characters (Discord's real message-length limit) -- checked by client.validate_message_content before any request is built, same "never even attempt the call" discipline as the snowflake-id checks. A violation returns validation_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 descriptive User-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 real permission_error 403 -- 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 descriptive User-Agent this heuristic exists to explain (see client.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. Carries retry_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_message only) -- content was 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_blocked vs. permission_error split 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 as cloudflare_blocked.

  • get_channel_permission_overwrites and get_member_roles were 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 calls list_channels/list_roles; it does not exercise any write tool.

  • No pagination is implemented anywhere. list_channels/list_roles are 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 no list_members tool here).

  • The icon_base64/banner_base64 "assumed PNG" default on edit_guild has 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_blocked heuristic'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_message covers plain-text content only, no embeds/files/reactions. A broader, differently shaped write-capable project exists elsewhere in this author's portfolio with an apply_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

A
license - permissive license
-
quality - not tested
C
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/jaimenbell/discord-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server