Guide · MCP Discord Integration
MCP Server Discord — Bot token auth, embed messages, role management, and /health via GET /users/@me
Discord is the dominant real-time community platform for developer tools and open-source projects. This guide covers building TypeScript MCP tools for the Discord REST API: Bot token authentication with the correct Authorization: Bot TOKEN header format, sending messages with rich embeds, listing guild members with after-cursor pagination, managing member roles, and wiring a /health/discord endpoint that calls GET /users/@me to validate the bot token before AliveMCP alerts you to failures.
TL;DR
Discord Bot tokens use Authorization: Bot TOKEN — not Bearer TOKEN. User tokens use Authorization: TOKEN (no prefix) — do not use user tokens in server-side MCP tools. Discord enforces per-route rate limits tracked via X-RateLimit-* response headers; the global limit is 50 requests/second per bot. Guild member listing uses after snowflake ID cursor pagination, not offset. Messages sent cannot be recalled — always add a confirm guard. Health: GET /users/@me authenticates the bot and returns id, username, and verified fields, catching invalid or revoked tokens immediately.
SDK setup and authentication
For MCP tools the Discord REST API is the right surface — not the Gateway (WebSocket). Install discord.js for full feature coverage, or use a minimal REST-only client via @discordjs/rest to keep the dependency footprint small. The @discordjs/rest package handles per-route rate limit headers automatically.
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v10';
// CRITICAL: Discord Bot tokens require the "Bot " prefix in the Authorization header.
// @discordjs/rest adds this automatically when you pass { version: '10' }
// and call rest.setToken().
const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN!);
// For raw axios/fetch, the header must be:
// Authorization: Bot YOUR_BOT_TOKEN_HERE
// NOT:
// Authorization: Bearer YOUR_BOT_TOKEN_HERE ← this fails with 401
// Rate limit state tracked internally by @discordjs/rest per route bucket.
// Each route gets its own bucket — headers per response:
// X-RateLimit-Limit: 5 max requests in the window
// X-RateLimit-Remaining: 4 remaining this window
// X-RateLimit-Reset: 1720534800 Unix timestamp when window resets
// X-RateLimit-Reset-After: 0.25 seconds until window resets
// X-RateLimit-Bucket: abc123 route bucket ID
// X-RateLimit-Global: true (only on global rate limit hits)
| Auth header format | Token type | Correct |
|---|---|---|
Authorization: Bot MTc... |
Bot token | Yes — use this for server-side MCP tools |
Authorization: Bearer MTc... |
OAuth2 user access token | Only for OAuth2 user-delegated flows |
Authorization: MTc... |
User self-bot token | Never — violates Discord ToS |
Sending messages with embeds and confirm guards
Discord messages sent via POST /channels/{channel.id}/messages cannot be recalled once delivered. The embed object allows structured, color-coded, field-based presentation — far richer than plain text for agent notifications. Embeds have hard limits: 25 fields, 6000 total characters across all embed text combined, and 10 embeds per message.
server.tool('send_discord_message', {
channel_id: z.string().describe('Discord channel snowflake ID.'),
content: z.string().optional().describe('Plain text content (max 2000 chars). Optional if embeds provided.'),
embed: z.object({
title: z.string().optional().describe('Max 256 chars.'),
description: z.string().optional().describe('Max 4096 chars.'),
color: z.number().int().optional().describe('Decimal RGB color code (e.g. 5763719 for green).'),
fields: z.array(z.object({
name: z.string().describe('Max 256 chars.'),
value: z.string().describe('Max 1024 chars.'),
inline: z.boolean().optional()
})).max(25).optional(),
footer: z.object({ text: z.string() }).optional(),
timestamp: z.string().optional().describe('ISO 8601 timestamp shown in footer.')
}).optional(),
confirm: z.literal(true).describe('Discord messages cannot be recalled. Pass true to confirm send.')
}, async ({ channel_id, content, embed }) => {
const body: Record = {};
if (content) body.content = content;
if (embed) body.embeds = [embed];
const res = await rest.post(
Routes.channelMessages(channel_id),
{ body }
) as { id: string; channel_id: string };
return {
content: [{
type: 'text',
text: `Message sent. id=${res.id} channel=${res.channel_id}`
}]
};
});
Common embed color values for agent notifications:
| Decimal value | Color | Use case |
|---|---|---|
5763719 |
Green (#57F287) | Success, healthy, deployed |
16711680 |
Red (#FF0000) | Error, failure, critical alert |
16776960 |
Yellow (#FFFF00) | Warning, degraded |
5793266 |
Blurple (#5865F2) | Discord brand, info |
Listing guild members with snowflake cursor pagination
Discord's GET /guilds/{guild.id}/members uses after-cursor pagination, not offset. The after parameter takes a snowflake ID (not a page number), and you advance by passing the last member's user.id from the previous response. Max limit is 1000 per request. This requires the GUILD_MEMBERS Privileged Intent to be enabled in the Discord Developer Portal.
server.tool('list_discord_guild_members', {
guild_id: z.string().describe('Discord guild (server) snowflake ID.'),
limit: z.number().int().min(1).max(1000).default(100),
after: z.string().optional().describe('Snowflake ID of last member from previous page.')
}, async ({ guild_id, limit, after }) => {
const params = new URLSearchParams({ limit: String(limit) });
if (after) params.set('after', after);
const members = await rest.get(
`${Routes.guildMembers(guild_id)}?${params}`
) as Array<{
user: { id: string; username: string; global_name: string | null };
nick: string | null;
roles: string[];
joined_at: string;
}>;
const nextCursor = members.length === limit
? members[members.length - 1].user.id
: null;
return {
content: [{
type: 'text',
text: JSON.stringify({
members: members.map(m => ({
user_id: m.user.id,
username: m.user.username,
display_name: m.nick ?? m.user.global_name ?? m.user.username,
roles: m.roles,
joined_at: m.joined_at
})),
next_cursor: nextCursor
}, null, 2)
}]
};
});
Managing member roles
Role assignment and removal are additive — you PUT or DELETE a single role on a member without replacing the full role list. This is different from some platforms where you must provide the complete desired role set. Both operations require the MANAGE_ROLES permission and your bot's highest role must be ranked above the role being modified in the guild's role hierarchy.
server.tool('manage_discord_role', {
guild_id: z.string(),
user_id: z.string(),
role_id: z.string(),
action: z.enum(['add', 'remove']),
confirm: z.literal(true).describe('Role changes affect member access. Pass true to confirm.')
}, async ({ guild_id, user_id, role_id, action }) => {
if (action === 'add') {
await rest.put(Routes.guildMemberRole(guild_id, user_id, role_id));
return { content: [{ type: 'text', text: `Role ${role_id} added to user ${user_id}.` }] };
} else {
await rest.delete(Routes.guildMemberRole(guild_id, user_id, role_id));
return { content: [{ type: 'text', text: `Role ${role_id} removed from user ${user_id}.` }] };
}
});
// Separate tool for listing available roles in a guild
server.tool('list_discord_roles', {
guild_id: z.string()
}, async ({ guild_id }) => {
const roles = await rest.get(Routes.guildRoles(guild_id)) as Array<{
id: string;
name: string;
color: number;
position: number;
permissions: string;
}>;
return {
content: [{
type: 'text',
text: JSON.stringify(
roles.sort((a, b) => b.position - a.position).map(r => ({
id: r.id,
name: r.name,
position: r.position
})),
null, 2
)
}]
};
});
Wiring /health/discord via GET /users/@me
GET /users/@me is the canonical Discord health probe. It requires no guild permissions — just a valid bot token. A successful response confirms the token is valid, the bot user exists, and the Discord API is reachable. It returns the bot's id, username, discriminator, and verified status.
app.get('/health/discord', async (req, res) => {
try {
const bot = await rest.get(Routes.user('@me')) as {
id: string;
username: string;
discriminator: string;
verified: boolean;
};
return res.json({
status: 'healthy',
bot_id: bot.id,
username: `${bot.username}#${bot.discriminator}`,
verified: bot.verified
});
} catch (err: any) {
// Discord REST errors have a .status property and a .rawError.message
const status = err.status ?? 503;
return res.status(503).json({
status: 'unhealthy',
http_status: status,
error: err.rawError?.message ?? err.message
});
}
});
| HTTP status | Discord error code | Meaning |
|---|---|---|
| 401 | 0 | Invalid token — missing "Bot " prefix, wrong token, or revoked |
| 403 | 50013 | Missing Permissions — bot lacks permission for the requested action |
| 404 | 10003 | Unknown Channel — channel deleted or bot not in it |
| 429 | — | Rate limited — check Retry-After header |
Frequently asked questions
What's the difference between the Discord REST API and the Gateway?
The REST API is for taking actions: sending messages, fetching members, assigning roles. The Gateway is a WebSocket connection for receiving real-time events: new messages, member joins, reaction adds. For MCP tools that send commands and query data, REST is the correct surface — you don't need a persistent WebSocket connection. Only add Gateway if your MCP server needs to listen for events rather than just take actions. Gateway requires separate intent declarations and significantly more complexity.
What are Privileged Gateway Intents and why do they matter for REST?
Privileged Intents (GUILD_MEMBERS and GUILD_PRESENCES) control whether your bot can receive member-related Gateway events. However, for REST API calls — including GET /guilds/{id}/members — you also need GUILD_MEMBERS enabled in the Discord Developer Portal under your bot's settings. Without it, the REST endpoint returns an empty array. Privileged Intents must be manually approved by Discord for bots in more than 100 servers.
How do I handle Discord snowflake IDs in JavaScript?
Discord snowflake IDs are 64-bit unsigned integers, which exceed JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). Discord's API returns them as strings to avoid precision loss. Always store and compare snowflake IDs as strings — never convert them to JavaScript numbers. In TypeScript, snowflake parameters should be typed as string, not number. The @discordjs/rest library handles this correctly by default.
Can I send a message to a DM channel with the same endpoint?
Yes, POST /channels/{channel.id}/messages works for both guild channels and DM channels — the channel ID format is identical. To initiate a DM with a user programmatically, first create the DM channel via POST /users/@me/channels with the user's ID, which returns a DM channel object with its id. Then use that channel ID for message sending. Note: Discord rate limits DMs to prevent spam — bots that send unsolicited DMs at scale risk being flagged.
Further reading
- MCP Server Slack — Block Kit messages, cursor pagination, and health probes via auth.test
- MCP Server PagerDuty — create incidents, manage on-call schedules, and health probes via /users/me
- MCP Server Webhooks — sending structured payloads with retry logic
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- MCP Server Authentication — token management and security patterns