index.ts•29.8 kB
#!/usr/bin/env node
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from "@modelcontextprotocol/sdk/types.js";
import {
Client,
GatewayIntentBits,
TextChannel,
ChannelType,
Collection,
PermissionsBitField,
Message,
Partials,
Events,
MessageReaction,
User,
GuildMember,
ChannelManager,
} from "discord.js";
// Constants and configuration
const DISCORD_TOKEN = process.env.DISCORD_TOKEN;
const MAX_RETRY_ATTEMPTS = 3;
const CHANNEL_CACHE_TTL = 60 * 60 * 1000; // 1 hour in milliseconds
if (!DISCORD_TOKEN) {
throw new Error("DISCORD_TOKEN environment variable is required");
}
// Request type interfaces
interface SendMessageArgs {
channel: string;
message: string;
}
interface ReadMessagesArgs {
channel: string;
limit?: number;
}
interface ListChannelsArgs {
server?: string;
}
interface GetUserInfoArgs {
user: string;
}
interface ReactToMessageArgs {
channel: string;
messageId: string;
emoji: string;
}
interface RequestParams {
name: string;
arguments?: Record<string, unknown>;
_meta?: unknown;
}
interface McpRequest {
params: RequestParams;
method?: string;
}
// Channel cache interface
interface ChannelCacheEntry {
channel: TextChannel;
timestamp: number;
}
// Validation functions
const isValidSendMessageArgs = (args: unknown): args is SendMessageArgs =>
typeof args === "object" &&
args !== null &&
typeof (args as SendMessageArgs).channel === "string" &&
(args as SendMessageArgs).channel.trim().length > 0 &&
typeof (args as SendMessageArgs).message === "string" &&
(args as SendMessageArgs).message.trim().length > 0;
const isValidReadMessagesArgs = (args: unknown): args is ReadMessagesArgs => {
if (typeof args !== "object" || args === null) {
return false;
}
const typedArgs = args as ReadMessagesArgs;
if (typeof typedArgs.channel !== "string" || typedArgs.channel.trim().length === 0) {
return false;
}
if (typedArgs.limit !== undefined && (typeof typedArgs.limit !== "number" || typedArgs.limit <= 0)) {
return false;
}
return true;
};
const isValidListChannelsArgs = (args: unknown): args is ListChannelsArgs =>
typeof args === "object" &&
args !== null &&
((args as ListChannelsArgs).server === undefined ||
typeof (args as ListChannelsArgs).server === "string");
const isValidGetUserInfoArgs = (args: unknown): args is GetUserInfoArgs =>
typeof args === "object" &&
args !== null &&
typeof (args as GetUserInfoArgs).user === "string" &&
(args as GetUserInfoArgs).user.trim().length > 0;
const isValidReactToMessageArgs = (args: unknown): args is ReactToMessageArgs =>
typeof args === "object" &&
args !== null &&
typeof (args as ReactToMessageArgs).channel === "string" &&
(args as ReactToMessageArgs).channel.trim().length > 0 &&
typeof (args as ReactToMessageArgs).messageId === "string" &&
(args as ReactToMessageArgs).messageId.trim().length > 0 &&
typeof (args as ReactToMessageArgs).emoji === "string" &&
(args as ReactToMessageArgs).emoji.trim().length > 0;
class DiscordServer {
private server: Server;
private discordClient: Client;
private ready: boolean = false;
private channelCache: Map<string, ChannelCacheEntry> = new Map();
private rateLimitTimestamp: number = 0;
private requestCount: number = 0;
constructor() {
this.server = new Server(
{
name: "discord-mcp-server",
version: "1.0.0",
},
{
capabilities: {
tools: {},
},
}
);
// Initialize Discord client with all needed intents and partials
this.discordClient = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessageReactions,
],
partials: [
Partials.Message,
Partials.Channel,
Partials.Reaction,
Partials.User,
],
});
this.setupDiscordClient();
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error("[MCP Error]", error);
process.on("SIGINT", async () => {
await this.discordClient.destroy();
await this.server.close();
process.exit(0);
});
}
private setupDiscordClient() {
this.discordClient.once(Events.ClientReady, () => {
this.ready = true;
console.error(
`Discord MCP server connected as ${this.discordClient.user?.tag}`
);
console.error(
`Connected to ${this.discordClient.guilds.cache.size} servers`
);
});
this.discordClient.on(Events.Error, (error) => {
console.error("[Discord Error]", error);
});
// Debug events for troubleshooting
if (process.env.DEBUG) {
this.discordClient.on(Events.Debug, (info) => {
console.error(`[Discord Debug] ${info}`);
});
}
// Setup handlers for partials
this.discordClient.on(Events.MessageReactionAdd, async (reaction, user) => {
// Handle partial reactions
if (reaction.partial) {
try {
await reaction.fetch();
} catch (error) {
console.error('Error fetching reaction:', error);
return;
}
}
// This is where you'd handle reaction events if needed
});
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: "send-message",
description: "Send a message to a Discord channel",
inputSchema: {
type: "object",
properties: {
channel: {
type: "string",
description:
"Channel name or ID (use # for channel names, e.g. '#general')",
},
message: {
type: "string",
description: "The message content to send",
},
},
required: ["channel", "message"],
},
},
{
name: "read-messages",
description: "Read recent messages from a Discord channel",
inputSchema: {
type: "object",
properties: {
channel: {
type: "string",
description:
"Channel name or ID (use # for channel names, e.g. '#general')",
},
limit: {
type: "number",
description:
"Number of recent messages to retrieve (default: 10, max: 100)",
},
},
required: ["channel"],
},
},
{
name: "list-channels",
description: "List available channels in a Discord server",
inputSchema: {
type: "object",
properties: {
server: {
type: "string",
description:
"Server name or ID (optional, will list channels from all servers if not provided)",
},
},
},
},
{
name: "list-servers",
description: "List servers the bot has access to",
inputSchema: {
type: "object",
properties: {},
},
},
{
name: "get-user-info",
description: "Get information about a Discord user",
inputSchema: {
type: "object",
properties: {
user: {
type: "string",
description:
"Username or user ID (use @ for usernames, e.g. '@username')",
},
},
required: ["user"],
},
},
{
name: "react-to-message",
description: "Add a reaction emoji to a message",
inputSchema: {
type: "object",
properties: {
channel: {
type: "string",
description:
"Channel name or ID where the message is located",
},
messageId: {
type: "string",
description: "ID of the message to react to",
},
emoji: {
type: "string",
description: "Emoji to react with (Unicode emoji or custom emoji name)",
},
},
required: ["channel", "messageId", "emoji"],
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
// Ensure Discord client is ready
if (!this.ready) {
await new Promise<void>((resolve) => {
const checkReady = () => {
if (this.ready) {
resolve();
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
});
}
// Rate limit protection
this.enforceRateLimit();
try {
switch (request.params.name) {
case "send-message":
return await this.handleSendMessage(request);
case "read-messages":
return await this.handleReadMessages(request);
case "list-channels":
return await this.handleListChannels(request);
case "list-servers":
return await this.handleListServers();
case "get-user-info":
return await this.handleGetUserInfo(request);
case "react-to-message":
return await this.handleReactToMessage(request);
default:
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
} catch (error) {
console.error(`Error handling ${request.params.name}:`, error);
return {
content: [
{
type: "text",
text: `Error processing request: ${(error as Error).message || "Unknown error"}`,
},
],
isError: true,
};
}
});
}
private enforceRateLimit() {
const now = Date.now();
const resetInterval = 1000; // 1 second
const maxRequestsPerInterval = 5;
if (now - this.rateLimitTimestamp > resetInterval) {
// Reset counter if interval passed
this.rateLimitTimestamp = now;
this.requestCount = 1;
} else {
// Increment counter within current interval
this.requestCount++;
// Enforce rate limit
if (this.requestCount > maxRequestsPerInterval) {
throw new McpError(
ErrorCode.InternalError,
`Rate limit exceeded. Maximum ${maxRequestsPerInterval} requests per second.`
);
}
}
}
private async handleSendMessage(request: McpRequest) {
if (!isValidSendMessageArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid arguments for send-message. Channel and message must be non-empty strings."
);
}
const { channel: channelInput, message } = request.params
.arguments as SendMessageArgs;
try {
const channel = await this.findChannel(channelInput);
if (!channel) {
return {
content: [
{
type: "text",
text: `Channel not found: ${channelInput}`,
},
],
isError: true,
};
}
// Check if bot has permission to send messages
if (
!channel.permissionsFor(this.discordClient.user?.id || "")?.has(
PermissionsBitField.Flags.SendMessages
)
) {
return {
content: [
{
type: "text",
text: `Bot does not have permission to send messages in ${channel.name}`,
},
],
isError: true,
};
}
// Use retry logic for API calls that might fail
let attempt = 0;
let sentMessage: Message | undefined = undefined;
let lastError: Error | undefined = undefined;
while (attempt < MAX_RETRY_ATTEMPTS && !sentMessage) {
try {
sentMessage = await channel.send(message);
break;
} catch (error) {
lastError = error as Error;
attempt++;
if (attempt < MAX_RETRY_ATTEMPTS) {
// Wait before retrying with exponential backoff
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt - 1)));
}
}
}
if (!sentMessage) {
throw lastError || new Error("Failed to send message after multiple attempts");
}
return {
content: [
{
type: "text",
text: `Message sent to #${channel.name} in server ${channel.guild.name}`,
},
],
};
} catch (error) {
console.error("Error sending message:", error);
return {
content: [
{
type: "text",
text: `Error sending message: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleReadMessages(request: McpRequest) {
if (!isValidReadMessagesArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid arguments for read-messages. Channel must be a non-empty string and limit must be a positive number."
);
}
const { channel: channelInput, limit = 10 } = request.params
.arguments as ReadMessagesArgs;
try {
const channel = await this.findChannel(channelInput);
if (!channel) {
return {
content: [
{
type: "text",
text: `Channel not found: ${channelInput}`,
},
],
isError: true,
};
}
// Check if bot has permission to read message history
if (
!channel.permissionsFor(this.discordClient.user?.id || "")?.has(
PermissionsBitField.Flags.ReadMessageHistory
)
) {
return {
content: [
{
type: "text",
text: `Bot does not have permission to read message history in ${channel.name}`,
},
],
isError: true,
};
}
// Limit to max 100 messages
const actualLimit = Math.min(limit, 100);
const messages = await channel.messages.fetch({ limit: actualLimit });
if (messages.size === 0) {
return {
content: [
{
type: "text",
text: `No messages found in #${channel.name}`,
},
],
};
}
// Format messages from oldest to newest
const formattedMessages = [...messages.values()]
.reverse()
.map((msg) => this.formatMessage(msg))
.join("\n\n");
return {
content: [
{
type: "text",
text: `Last ${messages.size} messages from #${channel.name} in server ${channel.guild.name}:\n\n${formattedMessages}`,
},
],
};
} catch (error) {
console.error("Error reading messages:", error);
return {
content: [
{
type: "text",
text: `Error reading messages: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleListChannels(request: McpRequest) {
if (!isValidListChannelsArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid arguments for list-channels. Server must be a string or undefined."
);
}
try {
const { server: serverInput } = (request.params.arguments ||
{}) as ListChannelsArgs;
// If server is specified, find it
if (serverInput) {
const guild = this.findServer(serverInput);
if (!guild) {
return {
content: [
{
type: "text",
text: `Server not found: ${serverInput}`,
},
],
isError: true,
};
}
// Get readable channels
const textChannels = guild.channels.cache.filter(
(c) =>
c.type === ChannelType.GuildText &&
c
.permissionsFor(this.discordClient.user?.id || "")
?.has(PermissionsBitField.Flags.ViewChannel)
);
if (textChannels.size === 0) {
return {
content: [
{
type: "text",
text: `No readable text channels found in server ${guild.name}`,
},
],
};
}
const channelList = textChannels
.map((c) => `- #${c.name} (ID: ${c.id})`)
.join("\n");
return {
content: [
{
type: "text",
text: `Available channels in server ${guild.name}:\n\n${channelList}`,
},
],
};
}
// List channels from all servers
const allChannels: string[] = [];
this.discordClient.guilds.cache.forEach((guild) => {
const serverChannels = guild.channels.cache
.filter(
(c) =>
c.type === ChannelType.GuildText &&
c
.permissionsFor(this.discordClient.user?.id || "")
?.has(PermissionsBitField.Flags.ViewChannel)
)
.map((c) => `- ${guild.name} / #${c.name} (ID: ${c.id})`);
allChannels.push(...serverChannels);
});
if (allChannels.length === 0) {
return {
content: [
{
type: "text",
text: "No readable text channels found in any server",
},
],
};
}
return {
content: [
{
type: "text",
text: `Available channels in all servers:\n\n${allChannels.join(
"\n"
)}`,
},
],
};
} catch (error) {
console.error("Error listing channels:", error);
return {
content: [
{
type: "text",
text: `Error listing channels: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleListServers() {
try {
if (this.discordClient.guilds.cache.size === 0) {
return {
content: [
{
type: "text",
text: "The bot is not a member of any servers",
},
],
};
}
const serverList = this.discordClient.guilds.cache
.map((guild) => `- ${guild.name} (ID: ${guild.id})`)
.join("\n");
return {
content: [
{
type: "text",
text: `Available servers:\n\n${serverList}`,
},
],
};
} catch (error) {
console.error("Error listing servers:", error);
return {
content: [
{
type: "text",
text: `Error listing servers: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleGetUserInfo(request: McpRequest) {
if (!isValidGetUserInfoArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid arguments for get-user-info. User must be a non-empty string."
);
}
const { user: userInput } = request.params.arguments as GetUserInfoArgs;
try {
// Remove @ prefix if present
const cleanUserInput = userInput.startsWith("@")
? userInput.substring(1)
: userInput;
// Try to find user by ID first
let fetchedUser: User | undefined = undefined;
try {
if (/^\d+$/.test(cleanUserInput)) {
fetchedUser = await this.discordClient.users.fetch(cleanUserInput);
}
} catch (error) {
// Not a valid ID or user not found
}
// If not found by ID, search by username
if (!fetchedUser) {
for (const guild of this.discordClient.guilds.cache.values()) {
try {
const members = await guild.members.fetch();
const member = members.find(
(m) =>
m.user.username.toLowerCase() ===
cleanUserInput.toLowerCase() ||
m.displayName.toLowerCase() === cleanUserInput.toLowerCase()
);
if (member) {
fetchedUser = member.user;
break;
}
} catch (error) {
console.error(`Error fetching members for guild ${guild.name}:`, error);
}
}
}
if (!fetchedUser) {
return {
content: [
{
type: "text",
text: `User not found: ${userInput}`,
},
],
isError: true,
};
}
// Get user presence across all shared servers
const userPresence: Record<string, string> = {};
for (const guild of this.discordClient.guilds.cache.values()) {
try {
const member = await guild.members.fetch(fetchedUser.id);
if (member) {
userPresence[guild.name] = member.displayName;
}
} catch (error) {
// Member not in this guild
}
}
const serverList =
Object.keys(userPresence).length > 0
? "Shared servers:\n" +
Object.entries(userPresence)
.map(
([server, displayName]) =>
`- ${server}${
displayName !== fetchedUser?.username
? ` (as ${displayName})`
: ""
}`
)
.join("\n")
: "No shared servers with this user";
return {
content: [
{
type: "text",
text: `User Information:
Username: ${fetchedUser.username}
ID: ${fetchedUser.id}
Account Created: ${fetchedUser.createdAt.toISOString().split("T")[0]}
Bot: ${fetchedUser.bot ? "Yes" : "No"}
${serverList}`,
},
],
};
} catch (error) {
console.error("Error getting user info:", error);
return {
content: [
{
type: "text",
text: `Error getting user info: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleReactToMessage(request: McpRequest) {
if (!isValidReactToMessageArgs(request.params.arguments)) {
throw new McpError(
ErrorCode.InvalidParams,
"Invalid arguments for react-to-message. Channel, messageId, and emoji must be non-empty strings."
);
}
const { channel: channelInput, messageId, emoji } = request.params.arguments as ReactToMessageArgs;
try {
const channel = await this.findChannel(channelInput);
if (!channel) {
return {
content: [
{
type: "text",
text: `Channel not found: ${channelInput}`,
},
],
isError: true,
};
}
// Check permissions
if (
!channel.permissionsFor(this.discordClient.user?.id || "")?.has(
PermissionsBitField.Flags.AddReactions
)
) {
return {
content: [
{
type: "text",
text: `Bot does not have permission to add reactions in ${channel.name}`,
},
],
isError: true,
};
}
// Fetch the message
let message;
try {
message = await channel.messages.fetch(messageId);
} catch (error) {
return {
content: [
{
type: "text",
text: `Message not found with ID: ${messageId}`,
},
],
isError: true,
};
}
// Add the reaction
await message.react(emoji);
return {
content: [
{
type: "text",
text: `Added reaction ${emoji} to message in ${channel.name}`,
},
],
};
} catch (error) {
console.error("Error adding reaction:", error);
return {
content: [
{
type: "text",
text: `Error adding reaction: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
// Helper methods
private async findChannel(
channelInput: string
): Promise<TextChannel | null> {
// Check cache first
const cacheKey = channelInput.toLowerCase();
const cachedEntry = this.channelCache.get(cacheKey);
if (cachedEntry && Date.now() - cachedEntry.timestamp < CHANNEL_CACHE_TTL) {
return cachedEntry.channel;
}
// Remove # prefix if present
const cleanChannelInput = channelInput.startsWith("#")
? channelInput.substring(1)
: channelInput;
// Try to find channel by ID first
if (/^\d+$/.test(cleanChannelInput)) {
try {
const channel = await this.discordClient.channels.fetch(
cleanChannelInput
);
if (channel && channel.type === ChannelType.GuildText) {
// Cache the result
this.channelCache.set(cacheKey, {
channel: channel as TextChannel,
timestamp: Date.now()
});
return channel as TextChannel;
}
} catch (error) {
// Not a valid ID or channel not found
}
}
// Search by name across all servers
for (const guild of this.discordClient.guilds.cache.values()) {
const channel = guild.channels.cache.find(
(c) =>
c.type === ChannelType.GuildText &&
c.name.toLowerCase() === cleanChannelInput.toLowerCase()
);
if (channel) {
// Cache the result
this.channelCache.set(cacheKey, {
channel: channel as TextChannel,
timestamp: Date.now()
});
return channel as TextChannel;
}
}
return null;
}
private findServer(serverInput: string) {
// Try to find server by ID first
if (/^\d+$/.test(serverInput)) {
const guild = this.discordClient.guilds.cache.get(serverInput);
if (guild) return guild;
}
// Search by name
return this.discordClient.guilds.cache.find(
(g) => g.name.toLowerCase() === serverInput.toLowerCase()
);
}
private formatMessage(message: Message) {
const timestamp = message.createdAt.toISOString().split("T").join(" ").substring(0, 19);
let content = message.content;
// Add embeds content if present
if (message.embeds.length > 0) {
const embedsContent = message.embeds
.map(embed => {
let embedText = "";
if (embed.title) embedText += `[Title: ${embed.title}]\n`;
if (embed.description) embedText += embed.description;
return embedText;
})
.filter(text => text.length > 0)
.join("\n");
if (embedsContent) {
content += content ? `\n\n${embedsContent}` : embedsContent;
}
}
// Add attachment info if present
if (message.attachments.size > 0) {
const attachmentInfo = message.attachments.map(a => `[Attachment: ${a.name || "file"}]`).join(", ");
content += content ? `\n${attachmentInfo}` : attachmentInfo;
}
// Add reaction info if present
if (message.reactions.cache.size > 0) {
const reactionInfo = message.reactions.cache
.map(r => `${r.emoji.name || r.emoji.toString()}: ${r.count}`)
.join(", ");
content += content ? `\n[Reactions: ${reactionInfo}]` : `[Reactions: ${reactionInfo}]`;
}
return `**${message.author.username}** (${timestamp}):\n${content || "[No text content]"}`;
}
async run() {
try {
// Connect to Discord first
await this.discordClient.login(DISCORD_TOKEN);
// Then connect the MCP server
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error(`Discord MCP server running on stdio`);
} catch (error) {
console.error("Error starting server:", error);
process.exit(1);
}
}
}
const server = new DiscordServer();
server.run().catch(console.error);