import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { DiscordClientManager } from "../discord/client.js";
import { Logger } from "../utils/logger.js";
import { z } from "zod";
import {
PermissionDeniedError,
ChannelNotFoundError,
MessageNotFoundError,
} from "../errors/discord.js";
import { embedSchema, messageSchema } from "../types/schemas.js";
import { PermissionFlagsBits } from "discord.js";
export function registerMessagingTools(
server: McpServer,
discordManager: DiscordClientManager,
logger: Logger,
) {
// Send Message Tool
server.registerTool(
"send_message",
{
title: "Send Discord Message",
description: "Send a message to a Discord channel",
inputSchema: {
channelId: z.string().describe("Channel ID (snowflake) or name"),
content: z.string().max(2000).describe("Message content"),
embeds: z
.array(embedSchema)
.max(10)
.optional()
.describe("Optional embeds (max 10)"),
},
outputSchema: {
success: z.boolean(),
messageId: z.string().optional(),
channelId: z.string().optional(),
timestamp: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, content, embeds }) => {
try {
const client = discordManager.getClient();
// Resolve channel
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.SendMessages)) {
throw new PermissionDeniedError("SendMessages", channelId);
}
if (
channel.isThread() &&
!permissions?.has(PermissionFlagsBits.SendMessagesInThreads)
) {
throw new PermissionDeniedError("SendMessagesInThreads", channelId);
}
}
// Send message (type narrowing for send method)
if (!("send" in channel)) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.send({
content,
embeds: embeds || [],
});
const output = {
success: true,
messageId: message.id,
channelId: channel.id,
timestamp: message.createdAt.toISOString(),
};
logger.info("Message sent", { channelId, messageId: message.id });
return {
content: [
{
type: "text" as const,
text: `Message sent successfully to <#${channel.id}>`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to send message", {
error: error.message,
channelId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to send message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Read Messages Tool
server.registerTool(
"read_messages",
{
title: "Read Discord Messages",
description: "Retrieve recent message history from a channel",
inputSchema: {
channelId: z.string().describe("Channel ID or name"),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe("Number of messages to retrieve"),
before: z
.string()
.optional()
.describe("Get messages before this message ID"),
after: z
.string()
.optional()
.describe("Get messages after this message ID"),
},
outputSchema: {
success: z.boolean(),
messages: z.array(messageSchema).optional(),
hasMore: z.boolean().optional(),
error: z.string().optional(),
},
},
async ({ channelId, limit, before, after }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.ViewChannel)) {
throw new PermissionDeniedError("ViewChannel", channelId);
}
if (!permissions?.has(PermissionFlagsBits.ReadMessageHistory)) {
throw new PermissionDeniedError("ReadMessageHistory", channelId);
}
}
// Fetch messages
const messages = await channel.messages.fetch({
limit,
before,
after,
});
const formattedMessages = messages.map((msg) => ({
id: msg.id,
content: msg.content,
author: {
id: msg.author.id,
username: msg.author.username,
discriminator: msg.author.discriminator,
bot: msg.author.bot,
avatar: msg.author.avatar,
},
timestamp: msg.createdAt.toISOString(),
editedTimestamp: msg.editedAt?.toISOString() || null,
attachments: msg.attachments.map((att) => ({
id: att.id,
filename: att.name,
size: att.size,
url: att.url,
contentType: att.contentType || undefined,
})),
embeds: msg.embeds.map((embed) => ({
title: embed.title || undefined,
description: embed.description || undefined,
url: embed.url || undefined,
color: embed.color || undefined,
timestamp: embed.timestamp
? new Date(embed.timestamp).toISOString()
: undefined,
})),
reactions: msg.reactions.cache.map((reaction) => ({
emoji: reaction.emoji.name || reaction.emoji.id || "",
count: reaction.count,
me: reaction.me,
})),
}));
const output = {
success: true,
messages: formattedMessages,
hasMore: messages.size === limit,
};
logger.info("Messages retrieved", { channelId, count: messages.size });
return {
content: [
{
type: "text" as const,
text: `Retrieved ${messages.size} message(s) from <#${channel.id}>`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to read messages", {
error: error.message,
channelId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to read messages: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Delete Message Tool
server.registerTool(
"delete_message",
{
title: "Delete Discord Message",
description: "Delete a specific message from a channel",
inputSchema: {
channelId: z.string().describe("Channel ID"),
messageId: z.string().describe("Message ID to delete"),
reason: z
.string()
.optional()
.describe("Reason for deletion (audit log)"),
},
outputSchema: {
success: z.boolean(),
deletedMessageId: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, messageId, reason }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.messages
.fetch(messageId)
.catch(() => null);
if (!message) {
throw new MessageNotFoundError(messageId);
}
// Check permissions (bot can always delete own messages)
if (message.author.id !== client.user!.id) {
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new PermissionDeniedError("ManageMessages", channelId);
}
}
}
await message.delete();
const output = {
success: true,
deletedMessageId: messageId,
};
logger.info("Message deleted", { channelId, messageId, reason });
return {
content: [
{
type: "text" as const,
text: `Message ${messageId} deleted successfully`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to delete message", {
error: error.message,
channelId,
messageId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to delete message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Add Reaction Tool
server.registerTool(
"add_reaction",
{
title: "Add Reaction to Message",
description: "Add an emoji reaction to a message",
inputSchema: {
channelId: z.string().describe("Channel ID"),
messageId: z.string().describe("Message ID"),
emoji: z
.string()
.describe("Unicode emoji or custom emoji format (name:id)"),
},
outputSchema: {
success: z.boolean(),
error: z.string().optional(),
},
},
async ({ channelId, messageId, emoji }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.messages
.fetch(messageId)
.catch(() => null);
if (!message) {
throw new MessageNotFoundError(messageId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.AddReactions)) {
throw new PermissionDeniedError("AddReactions", channelId);
}
}
await message.react(emoji);
const output = { success: true };
logger.info("Reaction added", { channelId, messageId, emoji });
return {
content: [
{
type: "text" as const,
text: `Reaction ${emoji} added to message`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to add reaction", {
error: error.message,
channelId,
messageId,
emoji,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to add reaction: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Edit Message Tool
server.registerTool(
"edit_message",
{
title: "Edit Discord Message",
description: "Edit an existing message sent by the bot",
inputSchema: {
channelId: z.string().describe("Channel ID"),
messageId: z.string().describe("Message ID to edit"),
content: z
.string()
.max(2000)
.optional()
.describe("New message content"),
embeds: z
.array(embedSchema)
.max(10)
.optional()
.describe("New embeds (max 10)"),
},
outputSchema: {
success: z.boolean(),
messageId: z.string().optional(),
editedTimestamp: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, messageId, content, embeds }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.messages
.fetch(messageId)
.catch(() => null);
if (!message) {
throw new MessageNotFoundError(messageId);
}
// Check if message was sent by the bot
if (message.author.id !== client.user!.id) {
throw new Error(
"Can only edit messages sent by the bot. This message belongs to another user.",
);
}
// Edit message
const editedMessage = await message.edit({
content: content || message.content,
embeds: embeds || message.embeds,
});
const output = {
success: true,
messageId: editedMessage.id,
editedTimestamp: editedMessage.editedAt?.toISOString(),
};
logger.info("Message edited", { channelId, messageId });
return {
content: [
{
type: "text" as const,
text: `Message ${messageId} edited successfully`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to edit message", {
error: error.message,
channelId,
messageId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to edit message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Send Message with File Tool
server.registerTool(
"send_message_with_file",
{
title: "Send Discord Message with File",
description: "Send a message with file attachment to a Discord channel",
inputSchema: {
channelId: z.string().describe("Channel ID (snowflake) or name"),
content: z
.string()
.max(2000)
.optional()
.describe("Message content (optional if file provided)"),
filePath: z.string().describe("Absolute path to file to attach"),
fileName: z
.string()
.optional()
.describe(
"Custom filename (optional, uses original if not provided)",
),
embeds: z
.array(embedSchema)
.max(10)
.optional()
.describe("Optional embeds (max 10)"),
},
outputSchema: {
success: z.boolean(),
messageId: z.string().optional(),
channelId: z.string().optional(),
timestamp: z.string().optional(),
attachmentUrl: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, content, filePath, fileName, embeds }) => {
try {
const client = discordManager.getClient();
// Resolve channel
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.SendMessages)) {
throw new PermissionDeniedError("SendMessages", channelId);
}
if (!permissions?.has(PermissionFlagsBits.AttachFiles)) {
throw new PermissionDeniedError("AttachFiles", channelId);
}
if (
channel.isThread() &&
!permissions?.has(PermissionFlagsBits.SendMessagesInThreads)
) {
throw new PermissionDeniedError("SendMessagesInThreads", channelId);
}
}
// Import fs module dynamically
const fs = await import("fs");
const path = await import("path");
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Prepare attachment
const attachment = {
attachment: filePath,
name: fileName || path.basename(filePath),
};
// Send message (type narrowing for send method)
if (!("send" in channel)) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.send({
content: content || undefined,
embeds: embeds || [],
files: [attachment],
});
const attachmentUrl =
message.attachments.first()?.url || "No attachment URL";
const output = {
success: true,
messageId: message.id,
channelId: channel.id,
timestamp: message.createdAt.toISOString(),
attachmentUrl,
};
logger.info("Message with file sent", {
channelId,
messageId: message.id,
filePath,
});
return {
content: [
{
type: "text" as const,
text: `Message with file sent successfully to <#${channel.id}>`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to send message with file", {
error: error.message,
channelId,
filePath,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to send message with file: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Pin Message Tool
server.registerTool(
"pin_message",
{
title: "Pin Discord Message",
description: "Pin a message to the channel",
inputSchema: {
channelId: z.string().describe("Channel ID"),
messageId: z.string().describe("Message ID to pin"),
reason: z
.string()
.optional()
.describe("Reason for pinning (audit log)"),
},
outputSchema: {
success: z.boolean(),
pinnedMessageId: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, messageId, reason }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.messages
.fetch(messageId)
.catch(() => null);
if (!message) {
throw new MessageNotFoundError(messageId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new PermissionDeniedError("ManageMessages", channelId);
}
}
await message.pin(reason);
const output = {
success: true,
pinnedMessageId: messageId,
};
logger.info("Message pinned", { channelId, messageId, reason });
return {
content: [
{
type: "text" as const,
text: `Message ${messageId} pinned successfully`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to pin message", {
error: error.message,
channelId,
messageId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to pin message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Send Rich Message Tool
server.registerTool(
"send_rich_message",
{
title: "Send Rich Discord Message",
description:
"Send a richly formatted message with embeds, images, and advanced formatting. Supports full Discord markdown and embed features.",
inputSchema: {
channelId: z.string().describe("Channel ID (snowflake) or name"),
content: z
.string()
.max(2000)
.optional()
.describe(
"Message content with Discord markdown formatting (bold: **text**, italic: *text*, underline: __text__, strikethrough: ~~text~~, code: `code`, code block: ```language\\ncode\\n```)",
),
embed: z
.object({
title: z.string().max(256).optional(),
description: z.string().max(4096).optional(),
url: z.string().url().optional(),
color: z
.number()
.int()
.min(0)
.max(0xffffff)
.optional()
.describe("Hex color as integer (e.g., 0x00ff00 for green)"),
image: z
.string()
.url()
.optional()
.describe("Large image URL at bottom of embed"),
thumbnail: z
.string()
.url()
.optional()
.describe("Small image URL at top-right of embed"),
author: z
.object({
name: z.string().max(256),
url: z.string().url().optional(),
iconURL: z.string().url().optional(),
})
.optional(),
footer: z
.object({
text: z.string().max(2048),
iconURL: z.string().url().optional(),
})
.optional(),
fields: z
.array(
z.object({
name: z.string().max(256),
value: z.string().max(1024),
inline: z.boolean().optional(),
}),
)
.max(25)
.optional(),
timestamp: z.boolean().optional().describe("Add current timestamp"),
})
.optional()
.describe("Rich embed object with images and formatting"),
},
outputSchema: {
success: z.boolean(),
messageId: z.string().optional(),
channelId: z.string().optional(),
timestamp: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, content, embed }) => {
try {
const client = discordManager.getClient();
// Resolve channel
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.SendMessages)) {
throw new PermissionDeniedError("SendMessages", channelId);
}
if (embed && !permissions?.has(PermissionFlagsBits.EmbedLinks)) {
throw new PermissionDeniedError("EmbedLinks", channelId);
}
if (
channel.isThread() &&
!permissions?.has(PermissionFlagsBits.SendMessagesInThreads)
) {
throw new PermissionDeniedError("SendMessagesInThreads", channelId);
}
}
// Build embed if provided
let embedPayload: any[] = [];
if (embed) {
const embedObj: any = {};
if (embed.title) embedObj.title = embed.title;
if (embed.description) embedObj.description = embed.description;
if (embed.url) embedObj.url = embed.url;
if (embed.color !== undefined) embedObj.color = embed.color;
if (embed.author) {
embedObj.author = {
name: embed.author.name,
url: embed.author.url,
icon_url: embed.author.iconURL,
};
}
if (embed.footer) {
embedObj.footer = {
text: embed.footer.text,
icon_url: embed.footer.iconURL,
};
}
if (embed.image) {
embedObj.image = { url: embed.image };
}
if (embed.thumbnail) {
embedObj.thumbnail = { url: embed.thumbnail };
}
if (embed.fields) {
embedObj.fields = embed.fields;
}
if (embed.timestamp) {
embedObj.timestamp = new Date().toISOString();
}
embedPayload = [embedObj];
}
// Send message (type narrowing for send method)
if (!("send" in channel)) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.send({
content: content || undefined,
embeds: embedPayload,
});
const output = {
success: true,
messageId: message.id,
channelId: channel.id,
timestamp: message.createdAt.toISOString(),
};
logger.info("Rich message sent", { channelId, messageId: message.id });
return {
content: [
{
type: "text" as const,
text: `Rich message sent successfully to <#${channel.id}>`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to send rich message", {
error: error.message,
channelId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to send rich message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
// Unpin Message Tool
server.registerTool(
"unpin_message",
{
title: "Unpin Discord Message",
description: "Unpin a message from the channel",
inputSchema: {
channelId: z.string().describe("Channel ID"),
messageId: z.string().describe("Message ID to unpin"),
reason: z
.string()
.optional()
.describe("Reason for unpinning (audit log)"),
},
outputSchema: {
success: z.boolean(),
unpinnedMessageId: z.string().optional(),
error: z.string().optional(),
},
},
async ({ channelId, messageId, reason }) => {
try {
const client = discordManager.getClient();
const channel = await client.channels
.fetch(channelId)
.catch(() => null);
if (!channel || !channel.isTextBased()) {
throw new ChannelNotFoundError(channelId);
}
const message = await channel.messages
.fetch(messageId)
.catch(() => null);
if (!message) {
throw new MessageNotFoundError(messageId);
}
// Check permissions
if ("guild" in channel && channel.guild) {
const permissions = channel.permissionsFor(client.user!);
if (!permissions?.has(PermissionFlagsBits.ManageMessages)) {
throw new PermissionDeniedError("ManageMessages", channelId);
}
}
await message.unpin(reason);
const output = {
success: true,
unpinnedMessageId: messageId,
};
logger.info("Message unpinned", { channelId, messageId, reason });
return {
content: [
{
type: "text" as const,
text: `Message ${messageId} unpinned successfully`,
},
],
structuredContent: output,
};
} catch (error: any) {
logger.error("Failed to unpin message", {
error: error.message,
channelId,
messageId,
});
const output = {
success: false,
error: error.message,
};
return {
content: [
{
type: "text" as const,
text: `Failed to unpin message: ${error.message}`,
},
],
structuredContent: output,
isError: true,
};
}
},
);
}