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

Know when your Discord Bot Token gets invalidated before your community bot goes silent

AliveMCP polls your /health/discord endpoint every 60 seconds and alerts you the moment GET /users/@me fails — catching token revocations and API outages before they affect your community.

Start monitoring free