Metadata-Version: 2.4
Name: jaimenbell-discord-mcp
Version: 0.1.0
Summary: 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.
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastmcp==3.4.2
Requires-Dist: httpx==0.28.1
Provides-Extra: test
Requires-Dist: pytest==9.0.3; extra == "test"
Requires-Dist: respx==0.23.1; extra == "test"
Requires-Dist: anyio==4.14.1; extra == "test"
Dynamic: license-file

# discord-mcp

[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Tests](https://img.shields.io/badge/tests-161%20%28160%20passing%2C%201%20skipped%29-brightgreen)](#testing)

An MCP server over the real [Discord REST API](https://discord.com/developers/docs/reference)
-- so a Claude agent calls `list_channels(guild_id="...")` instead of
hand-rolling an authenticated `httpx` request. Built to the
[github-mcp](https://github.com/jaimenbell/github-mcp)/[bus-mcp](https://github.com/jaimenbell/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)

```bash
python -m venv .venv
.venv/Scripts/python.exe -m pip install -e ".[test]"
```

Add to your Claude Desktop/Code MCP config:

```json
{
  "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` (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:
  ```json
  {"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

```bash
.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.

```bash
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

```bash
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

```bash
.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](https://jaimenbell.dev). For production MCP
integrations, custom servers, or agent-reliability work, see
[jaimenbell.dev](https://jaimenbell.dev) or sponsor ongoing maintenance via
[GitHub Sponsors](https://github.com/sponsors/jaimenbell).

<!-- MCP registry ownership marker -->
mcp-name: io.github.jaimenbell/discord-mcp
