DEV Community

Cover image for I didn't want a stranger's code holding my Discord bot token, so I wrote my own MCP server
Gautam Suthar
Gautam Suthar

Posted on Originally published at gautamsuthar.in

I didn't want a stranger's code holding my Discord bot token, so I wrote my own MCP server

I wanted Claude Code to manage my Discord server. Create channels, sort out roles, clean up messages, check the audit log, the boring admin stuff.

Community Discord MCP servers already exist. Every one of them needs your bot token. My bot has admin-level permissions on a server with real people in it. Handing that token to code I hadn't read felt like a bad trade for saving a weekend.

So I wrote my own. It's small enough to read end to end, it runs locally in Docker, and it only does what I put in it.

It's called discord-mcp, it's MIT licensed, and it's on 1.3.

What it does

67 tools across the parts of Discord you'd actually manage:

  • channels and categories, including permission overwrites and cloning
  • roles: create, edit, reorder, assign
  • moderation: kick, ban, timeout, nicknames, audit log
  • messages: read, send with embeds, edit, delete, bulk delete, pins, reactions
  • threads and forum posts
  • invites, webhooks, emojis, stickers, scheduled events, server settings
  • a permission audit, and server templates you can export and apply

The direction matters: this isn't a way to talk to Claude from Discord. Claude manages the server.

GIF

Safety first, then write tools

The first release with write tools came after a release that was only safety rails. If something is going to delete channels on my server, the guard rails come first.

Three settings narrow down what the bot can do beyond its Discord permissions:

Variable What it does
READ_ONLY true exposes only read-only tools. Nothing can change.
ALLOWED_GUILD_IDS Servers the bot may touch. Every other server is hidden.
DISABLED_TOOLS Switch off single tools by name.

A typo in any of them stops the server at startup. An unknown tool name or a server ID the bot isn't in is an error, not something silently ignored.

Read-only mode can't drift

The usual way to build a read-only mode is a list of "write tools" to skip. That list goes stale the first time someone adds a tool and forgets it.

discord-mcp uses the MCP annotations instead. Every tool has to declare readOnlyHint, every write tool has to declare destructiveHint, and every destructive tool has to accept a reason that ends up in the Discord audit log. Startup refuses any tool that breaks those rules.

Read-only mode then just drops anything not marked read-only. The client and the filter read the same annotations, so they can't disagree.

The tools I actually use

The CRUD tools are the boring part. Two newer ones are why I keep it running.

audit_permissions reads the whole server and flags what's risky: dangerous permissions on @everyone, roles and bots with Administrator, staff channels everyone can see, verification level, content filter, moderator 2FA. It only reads, so it works in read-only mode too. The first time I ran it on my test server it flagged the bot's own Administrator role. Fair.

export_template and apply_template turn a server's roles, categories and channels into JSON and back. Apply only adds, never edits or deletes, and skips anything that already exists, so running it twice is safe. The whole template gets checked before the first change, with dry_run if you just want to see what would happen. There's also a setup_community_server prompt: describe the community you want, Claude drafts the layout, dry runs it, and applies it after you say yes.

Errors that tell you what to fix

Discord's answer to a missing permission is a bare 403. Write tools check permissions and role order before calling Discord, so you get this instead: which permission the bot is missing, or which role sits at or above the bot's highest role, and how to fix it.

Tested against a real server

Unit tests cover the logic. They can't tell you how Discord actually behaves.

So there are 35 integration tests that spawn the server over stdio and call tools through the MCP SDK client, the same path Claude uses, against a throwaway test server. The harness locks itself to that one server and refuses any server with more than 10 members, so a wrong ID can't hit a real community.

They found two bugs the unit tests never would have:

  • list_role_members fetched all members on every call. Discord rate limits that to about once per 30 seconds per server, so the second call failed. Now it loads members once and the cache stays current after that.
  • get_message kept showing reactions that had already been removed. Discord leaves out reactions when there are none, and discord.js only patches its cached copy with fields that are present. Dropping the cached message before fetching fixed it.

Try it

You need a Discord bot with the permissions you want it to have. The README walks through that.

docker pull ghcr.io/callmegautam/discord-mcp:1
Enter fullscreen mode Exit fullscreen mode

Put your token in a .env and add it to Claude Code:

claude mcp add discord -s user -- docker run --rm -i --env-file /absolute/path/to/.env ghcr.io/callmegautam/discord-mcp:1
Enter fullscreen mode Exit fullscreen mode

It works with any MCP client, same command. Start with READ_ONLY=true if you just want it to look around first.

There's also an HTTP transport now (TRANSPORT=http with a bearer token) if you'd rather run one long-lived container than spawn one per session.

If something breaks on your server, open an issue. The things I couldn't test live yet are kick, ban and timeout on a real member, announcement and stage channels, and private threads.


I hang out with other devs in The Basement, my Discord server. Questions, side projects, and a contributor cup every month or so. https://discord.gg/BqAK3RNWeP

Top comments (0)