{"id":"3399c4ce-6140-4185-8b30-205e90e32c02","slug":"jaimenbell-discord-mcp","kind":"mcp","name":"discord-mcp","summary":"MCP server over the Discord REST API: 5 read tools always on, 7 write tools gated off by default behind an env flag. Typed errors for every failure mode.","owner":"jaimenbell","repoUrl":"https://github.com/jaimenbell/discord-mcp","license":"MIT","licenseTier":"permissive","trust":"community","stars":0,"match":null,"fewStars":true,"format":"mcp","createdAt":"2026-07-12T18:38:30.000Z","addedAt":"2026-10-14T05:39:15.456Z","logoUrl":"https://avatars.githubusercontent.com/u/271556866?u=e4819f39a5fc18946bff77dc64615c1fa674c49c&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["communication","cloud-infra","memory-knowledge"],"tags":["bot","claude","discord"],"runtime":"python","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-14T16:13:19.741Z","sample":false,"url":"https://conduits.dev/tools/jaimenbell-discord-mcp","language":"Python","transports":["stdio"],"install":{"pypi":"jaimenbell-discord-mcp"},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# discord-mcp\n\n[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)\n[![Tests](https://img.shields.io/badge/tests-163%20%28162%20passing%2C%201%20skipped%29-brightgreen)](#testing)\n\nAn MCP server over the real [Discord REST API](https://discord.com/developers/docs/reference)\n-- so a Claude agent calls `list_channels(guild_id=\"...\")` instead of\nhand-rolling an authenticated `httpx` request. Built to the\n[github-mcp](https://github.com/jaimenbell/github-mcp)/[bus-mcp](https://github.com/jaimenbell/bus-mcp)\nstandard in this portfolio (own pyproject, fastmcp server, typed errors, real\ntest suite, honest README) -- fifth flagship, first over Discord.\n\n**5 read-only tools, always on** + **7 write tools, gated OFF by default**\nbehind `DISCORD_MCP_ENABLE_WRITE=1` -- see \"Write tools\" below.\n\n## Quickstart (60 seconds)\n\n```bash\npython -m venv .venv\n.venv/Scripts/python.exe -m pip install -e \".[test]\"\n```\n\nAdd to your Claude Desktop/Code MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"discord-mcp\": {\n      \"command\": \"python\",\n      \"args\": [\"C:/path/to/discord-mcp/run_server.py\"],\n      \"env\": { \"DISCORD_BOT_TOKEN\": \"your-bot-token-here\" }\n    }\n  }\n}\n```\n\nWithout `DISCORD_BOT_TOKEN` set, every tool call still returns a clean\nstructured error (Discord's own 401) instead of crashing -- see \"Typed\nerrors\" below. Write tools also need `DISCORD_MCP_ENABLE_WRITE=1` in the same\n`env` block, or they refuse locally with `policy_refusal` -- see \"Write\ntools\" below.\n\n## What this is / is not\n\nThis is a **reference portfolio implementation** demonstrating an MCP server\nover a real external SaaS API (Discord) -- it is **NOT** an official Discord\nMCP server, and it is not affiliated with Discord Inc. It started from an\nearlier, separate sibling project's `HttpDiscordClient`, a stdlib-`urllib`\nDiscord transport built and verified against a real live guild for that\nproject's own server-provisioning tooling. This repo hand-adapts that\nclient's request-building, header construction (including its deliberately\ndescriptive `User-Agent` -- see below), and error handling onto `httpx`\n(matching this fleet's other MCP servers) as its own standalone client with\nno dependency on that sibling project. discord-mcp does not import from or\ndepend on that other package at all.\n\nStarted as 5 read-only tools with no write capability at all; now ships a\n7-tool write group, off by default, mirroring the `*_MCP_ENABLE_WRITE`-style\ngate already shipped in github-mcp/bus-mcp/desktop-mcp in this same\nportfolio -- see \"Write tools\" below.\n\n## Tools\n\n### Read (always on, no gate)\n\n| Tool | Discord endpoint | Purpose |\n|---|---|---|\n| `list_channels` | `GET /guilds/{guild_id}/channels` | All channels (every type) in a guild |\n| `list_roles` | `GET /guilds/{guild_id}/roles` | All roles in a guild |\n| `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 |\n| `get_channel_permission_overwrites` | `GET /channels/{channel_id}` (`permission_overwrites` field) | Role/member allow+deny bitfields set on one channel |\n| `get_member_roles` | `GET /guilds/{guild_id}/members/{member_id}` (`roles` field) | Role ids currently assigned to one guild member |\n\n### Write (gated behind `DISCORD_MCP_ENABLE_WRITE=1`, default OFF)\n\n| Tool | Discord endpoint | Purpose |\n|---|---|---|\n| `create_channel` | `POST /guilds/{guild_id}/channels` | Create a text/voice/category channel |\n| `edit_channel` | `PATCH /channels/{channel_id}` | Rename/re-topic/re-parent/reorder an existing channel |\n| `create_role` | `POST /guilds/{guild_id}/roles` | Create a role |\n| `edit_role` | `PATCH /guilds/{guild_id}/roles/{role_id}` | Edit an existing role |\n| `edit_guild` | `PATCH /guilds/{guild_id}` | Update guild-level identity (name/icon/banner/description) |\n| `delete_channel` | `DELETE /channels/{channel_id}` | **Destructive.** Delete a channel |\n| `create_message` | `POST /channels/{channel_id}/messages` | Post a message to a text channel (`content`: non-empty, <= 2000 chars) |\n\n## Write tools\n\nSeven write tools were added on top of the original 5 read-only tools,\nmirroring the exact write-gate pattern already shipped in this portfolio\n(github-mcp's `GITHUB_MCP_ENABLE_WRITE`, desktop-mcp's\n`DESKTOP_MCP_ENABLE_*`, and most closely bus-mcp's `BUS_MCP_ENABLE_WRITE` +\n`gated_write` decorator, copied as the reference template). `create_message`\nwas added a night later than the other 6, once it became clear that none of\ncreate_channel/edit_channel/create_role/edit_role/edit_guild/delete_channel\ncan actually post content to a channel -- it reuses the exact same gate and\nthe `post()` helper `create_channel`/`create_role` already added to\n`client.py`, no new HTTP plumbing.\n\n- **Off by default.** Set `DISCORD_MCP_ENABLE_WRITE=1` (or `true`/`yes`/`on`)\n  in the server's environment to enable the write group. Unset (or any other\n  value) means every write tool call refuses **locally**, with zero Discord\n  API calls attempted, returning a structured `policy_refusal` error:\n  ```json\n  {\"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\"}}\n  ```\n- **Enforced at the route layer, not just the MCP-tool layer.** The\n  `@config.gated_write` decorator wraps each write function directly in\n  `discord_mcp/routes.py` (not merely the `@mcp.tool` wrapper in\n  `server.py`), so the gate is unit-testable without spinning up fastmcp or\n  a real transport, and can't be bypassed by any alternate calling path into\n  `routes.py`.\n- **Read fresh from the environment on every call**, never cached at\n  import time -- an operator can arm/disarm the write group without\n  restarting the server process, and tests can monkeypatch it per-test.\n- **`delete_channel` gets no separate or lower bar.** Despite being\n  genuinely destructive/irreversible against a real guild, it is gated\n  behind the exact same `DISCORD_MCP_ENABLE_WRITE` env var as the other\n  write tools -- confirmed by dedicated tests in `test_routes.py` (setting\n  plausible-but-wrong var names like `DISCORD_MCP_ENABLE_DELETE` does\n  **not** arm it).\n- **Same auth path as the read tools.** Write calls reuse the exact same\n  `_headers()`/pooled `httpx.AsyncClient` construction in `client.py` as\n  every read tool -- there is only one auth code path in this repo.\n- **`create_message` validates content locally, not just via Discord's own\n  400.** `content` must be a non-empty string of at most 2000 characters\n  (Discord's real message-length limit) -- checked by\n  `client.validate_message_content` before any request is built, same\n  \"never even attempt the call\" discipline as the snowflake-id checks. A\n  violation returns `validation_error`, not a raw Discord 400.\n\n### `icon_base64`/`banner_base64` on `edit_guild` -- a flagged assumption\n\nDiscord's docs describe `PATCH /guilds/{id}`'s `icon`/`banner` fields as an\n\"image data\" string -- a full data URI (`data:image/png;base64,<base64>`),\nnot a bare base64 payload. `edit_guild` accepts either:\n\n- a full `data:...;base64,...` URI, passed through unchanged, or\n- raw base64 bytes, which are **assumed to be PNG** and wrapped as\n  `data:image/png;base64,<value>`.\n\n**This PNG assumption is not verified against a real Discord response** --\nthis repo has not confirmed whether Discord accepts, rejects, or silently\nmis-renders a non-PNG image (JPEG, animated GIF for boosted-server icons,\netc.) sent under an `image/png` label. If you have a non-PNG image, pass a\nfull `data:image/...;base64,...` URI yourself rather than relying on the\ndefault. Flagged here rather than guessed silently.\n\n## Typed errors, never a raw crash\n\nEvery tool returns `{\"ok\": true, ...}` on success or `{\"ok\": false, \"error\":\n{...}}` on failure -- never an unhandled exception or stack trace.\n\n- **`network_error`** -- connection refused, timeout, DNS failure, or a\n  malformed request URL. Discord's API wasn't reachable at all.\n- **`auth_error`** (401) -- missing or invalid bot token.\n- **`permission_error`** (403, with Discord's own JSON error body, e.g.\n  `{\"message\": \"Missing Access\", \"code\": 50001}`) -- the bot lacks the\n  permission/scope for this call, or isn't in the guild.\n- **`cloudflare_blocked`** (403, with **no** JSON error body) -- Discord's\n  API docs ask for a descriptive `User-Agent`; without one, a client's\n  default UA is a well-known bot fingerprint that Discord's Cloudflare edge\n  can reject with a bare 403 *before* the request ever reaches route-level\n  permission checks. This is otherwise indistinguishable from a real\n  `permission_error` 403 -- Discord's real permission-denied responses\n  always carry a JSON body, so **absence of a JSON body on a 403 is the\n  signal this type is built on.** This is a best-effort heuristic, not a\n  certainty: a proxy, load balancer, or future Discord change that strips\n  the body on a *different* kind of 403 would also land here. discord-mcp\n  sends the same descriptive `User-Agent` this heuristic exists to explain\n  (see `client.py`'s `_headers()`), so in practice this type should rarely\n  fire from this server's own calls -- see \"Honest limitations\" below.\n- **`not_found`** (404) -- bad/unknown guild, channel, role, or member id.\n- **`rate_limited`** (429) -- Discord's rate limit. Carries `retry_after_s`,\n  parsed straight from Discord's own JSON body (`retry_after`, in seconds).\n  This server does **not** auto-retry -- it surfaces the limit as a\n  structured error immediately and leaves any backoff/retry decision to the\n  caller.\n- **`decode_error`** -- a 2xx response whose body isn't valid JSON (should\n  not happen against the real API; guards against a malformed proxy/mock).\n- **`discord_api_error`** -- any other 4xx/5xx not covered above.\n- **`policy_refusal`** (write tools only) -- the write group is disabled;\n  see \"Write tools\" above. No Discord API call is attempted.\n- **`invalid_id`** -- a guild/channel/role/member id failed snowflake-shape\n  validation before any request was built (defense against path injection).\n- **`validation_error`** (`create_message` only) -- `content` was empty or\n  exceeded Discord's real 2000-character message limit, caught before any\n  request was built.\n\nInternally, `discord_mcp/client.py` raises typed `DiscordUnreachable` /\n`DiscordApiError` (with a `DiscordDecodeError` subclass for the 2xx-non-JSON\ncase) exceptions; `discord_mcp/routes.py` catches both and normalizes to the\ndict shape above before a tool ever returns. Tests exercise both layers for\nevery error type, across every HTTP verb (GET/POST/PATCH/DELETE).\n\n### Honest limitations\n\n- The `cloudflare_blocked` vs. `permission_error` split is a **heuristic**\n  (JSON-body-present-or-not), not something Discord documents or guarantees.\n  It is accurate for the specific failure mode it was written to explain\n  (an edge reject due to a missing/generic User-Agent) but a 403 with a\n  stripped body from an unrelated cause (e.g. a misbehaving proxy in\n  between) would also be classified as `cloudflare_blocked`.\n- `get_channel_permission_overwrites` and `get_member_roles` were not\n  exercised against every possible real-world edge case (e.g. a member with\n  zero roles, a channel with zero overwrites) via the live smoke test --\n  only via respx-mocked unit tests. The live smoke test only calls\n  `list_channels`/`list_roles`; it does not exercise any write tool.\n- No pagination is implemented anywhere. `list_channels`/`list_roles` are\n  single-request, unpaginated Discord endpoints (Discord doesn't paginate\n  either of these), so this is a non-issue for the tools in scope -- but a\n  guild large enough to need member-list pagination is out of scope\n  entirely (there is no `list_members` tool here).\n- The `icon_base64`/`banner_base64` \"assumed PNG\" default on `edit_guild` has\n  not been verified against a real Discord response -- see \"Write tools\"\n  above.\n- No write tool has been exercised against the real Discord API at all (by\n  design -- this task's constraints require zero real network calls; the\n  live smoke test remains read-only-only). This includes `create_message`\n  -- it has never posted a real message to a real live guild as part of\n  this repo's own build/test process; that remains a deliberate, separate\n  operator action.\n\n## Env vars\n\n| Var | Default | Purpose |\n|---|---|---|\n| `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. |\n| `DISCORD_MCP_ENABLE_WRITE` | unset (OFF) | Set to `1`/`true`/`yes`/`on` to enable the 7 write tools. See \"Write tools\" above. |\n| `DISCORD_MCP_TIMEOUT_S` | `10.0` | Per-request timeout (seconds) |\n| `DISCORD_MCP_LIVE` | unset | Set to `1` to run the real-network smoke test (see Testing) |\n| `DISCORD_MCP_SMOKE_GUILD_ID` | unset | Guild id the live smoke test targets. Test skips cleanly if unset. |\n| `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. |\n\n`DISCORD_API_BASE` (`https://discord.com/api/v10`) is a fixed constant, not\nenv-overridable -- unlike bus-mcp's self-hosted `BUS_MCP_BASE_URL`, Discord's\nREST API has exactly one real base URL.\n\n## Usage examples\n\nOnce connected in a Claude session, an agent can:\n\n```\nlist_channels(guild_id=\"123456789012345678\")\nlist_roles(guild_id=\"123456789012345678\")\nlist_categories(guild_id=\"123456789012345678\")\nget_channel_permission_overwrites(channel_id=\"...\")\nget_member_roles(guild_id=\"123456789012345678\", member_id=\"...\")\n```\n\nWith `DISCORD_MCP_ENABLE_WRITE=1` set:\n\n```\ncreate_channel(guild_id=\"...\", name=\"general\", type=0)\nedit_channel(channel_id=\"...\", name=\"renamed\", position=3)\ncreate_role(guild_id=\"...\", name=\"Mod\", color=1752220, hoist=True)\nedit_role(guild_id=\"...\", role_id=\"...\", name=\"Senior Mod\", permissions=\"8\")\nedit_guild(guild_id=\"...\", name=\"New Server Name\")\ndelete_channel(channel_id=\"...\")\ncreate_message(channel_id=\"...\", content=\"hello from discord-mcp\")\n```\n\n## Testing\n\n```bash\n.venv/Scripts/python.exe -m pytest -q\n```\n\n163 tests: 162 unit tests (respx-mocked, zero real network) + 1 live smoke\n(gated, see below). All 12 tools' happy paths are covered, every error type\nabove is exercised at both the client layer (`test_client.py`) and the\nroutes-normalization layer (`test_routes.py`), and `test_server.py` actually\n`asyncio.run()`s each `@mcp.tool` async wrapper function against a\nmonkeypatched `routes` module -- not just introspects `list_tools()` -- so an\narg-name mismatch or dropped kwarg between `server.py` and `routes.py` would\nbe caught.\n\nWrite-tool coverage specifically (`test_config.py` + `test_routes.py`):\ngate-off refusal for each of the 7 write tools with `len(respx.calls) == 0`\nasserted (proving no Discord call is even attempted), gate-on success paths\nwith mocked Discord responses (including request-body assertions), gate-on\nDiscord-API-error passthrough (permission/not-found/rate-limit/network/5xx),\nand `delete_channel` specifically double-checked -- both that it refuses\nidentically to the other write tools, and that no plausible-but-wrong env\nvar name (`DISCORD_MCP_ENABLE_DELETE`, etc.) accidentally arms it.\n`create_message` additionally gets dedicated content-length boundary tests\n(`test_client.py`/`test_routes.py`): exactly 2000 characters is accepted (1\nrespx call), 2001 characters and an empty string are both rejected locally\nas `validation_error` with zero respx calls.\n\n### Live smoke test\n\n`tests/test_live_smoke.py::test_live_list_channels_and_roles_against_a_real_guild`\nis gated behind `DISCORD_MCP_LIVE=1` and `DISCORD_MCP_SMOKE_GUILD_ID` (unset\nby default -- both must be set to run) and calls the real Discord API's\n`list_channels`/`list_roles` against that guild. It sources\n`DISCORD_BOT_TOKEN` from this process's own environment if already set,\notherwise, if `DISCORD_MCP_SMOKE_ENV_PATH` points at an external `.env`\nfile, loads it from there (once, without ever printing or logging the\nvalue) -- this repo has no `.env` of its own and never will; the real token\nlives in a separate operator-controlled location. It remains\nread-only-only -- no write tool has a live smoke test.\n\n```bash\nDISCORD_MCP_LIVE=1 DISCORD_MCP_SMOKE_GUILD_ID=<your-guild-id> \\\nDISCORD_MCP_SMOKE_ENV_PATH=<path-to-.env-with-token> \\\n.venv/Scripts/python.exe -m pytest tests/test_live_smoke.py -v\n```\n\nVerified passing against a real guild at the time of writing: real\nchannels, roles, and categories all returned as non-empty lists.\n\n## Install / connect\n\n```bash\npython -m venv .venv\n.venv/Scripts/python.exe -m pip install -e \".[test]\"\n```\n\nRegister in `~/.claude.json` under `mcpServers.discord-mcp` as a stdio\nserver invoking `run_server.py` by absolute path (no `cwd` needed -- the\nentrypoint adds its own directory to `sys.path`). `env: {}` in the real\nregistration -- the real bot token is never baked into `~/.claude.json`;\nset `DISCORD_BOT_TOKEN` in whatever process actually launches the server if\nyou want authenticated calls, and `DISCORD_MCP_ENABLE_WRITE=1` if you want\nthe write group armed. Arming the write gate in the real registration is a\ndeliberate, separate operator action -- not part of this repo's default\nconfig.\n\n## Handshake check\n\n```bash\n.venv/Scripts/python.exe scripts/list_tools.py\n```\n\nPrints the twelve registered tool names with no transport started -- pure\nintrospection, useful for verifying the server wires up cleanly after any\nchange.\n\n## Out of scope\n\n- Pagination / `list_members` (see \"Honest limitations\" above).\n- Retrying rate-limited (429) requests -- surfaced as a structured error,\n  left to the caller.\n- Distinguishing every possible cause of a body-less 403 with certainty\n  (see the `cloudflare_blocked` heuristic's honest limitation above).\n- Any write operation beyond the 7 tools above (e.g. member role\n  assignment/kick/ban, message edit/delete, embeds/attachments/reactions,\n  webhook management) -- `create_message` covers plain-text `content` only,\n  no embeds/files/reactions. A broader, differently shaped write-capable\n  project exists elsewhere in this author's portfolio with an\n  `apply_server_structure`-style tool, out of scope here.\n\n## Commercial support\n\nMaintained by [Jaimen Bell](https://jaimenbell.dev). For production MCP\nintegrations, custom servers, or agent-reliability work, see\n[jaimenbell.dev](https://jaimenbell.dev).\n\nBuilding your own MCP server? The [MCP Starter Kit](https://jaimenbell.gumroad.com/l/adnojp)\nhas templates, a build playbook, and packaging war-stories from shipping this one.\n\n<!-- MCP registry ownership marker -->\nmcp-name: io.github.jaimenbell/discord-mcp"}