diff --git a/README.md b/README.md index b2f8136..a8407bd 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ MCP-Discord provides the following Discord-related functionalities: - Login to Discord bot - List servers the bot is a member of - Get server information +- Search messages in a server - Read/delete channel messages - Send messages to specified channels (using either channel IDs or channel names) - Retrieve forum channel lists @@ -288,6 +289,7 @@ You can use Docker containers with both Claude and Cursor: ### Messages and Reactions +- `discord_search_messages`: Search messages in a server - `discord_read_messages`: Read channel messages - `discord_add_reaction`: Add a reaction to a message - `discord_add_multiple_reactions`: Add multiple reactions to a message diff --git a/src/schemas.ts b/src/schemas.ts index 68a58ef..474a3cd 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -131,4 +131,22 @@ export const DeleteWebhookSchema = z.object({ reason: z.string().optional() }); -export const ListServersSchema = z.object({}); \ No newline at end of file +export const ListServersSchema = z.object({}); + +export const SearchMessagesSchema = z.object({ + guildId: z.string().min(1, "guildId is required"), + // Optional filters + content: z.string().optional(), + authorId: z.string().optional(), + mentions: z.string().optional(), + has: z.enum(['link','embed','file','poll','image','video','sound','sticker','snapshot']).optional(), + maxId: z.string().optional(), + minId: z.string().optional(), + channelId: z.string().optional(), + pinned: z.boolean().optional(), + authorType: z.enum(['user','bot','webhook']).optional(), + sortBy: z.enum(['timestamp','relevance']).optional(), + sortOrder: z.enum(['desc','asc']).optional(), + limit: z.number().min(1).max(100).default(25).optional(), + offset: z.number().min(0).default(0).optional() +}); \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 4b328fe..d086f86 100644 --- a/src/server.ts +++ b/src/server.ts @@ -30,7 +30,8 @@ import { createCategoryHandler, editCategoryHandler, deleteCategoryHandler, - listServersHandler + listServersHandler, + searchMessagesHandler } from './tools/tools.js'; import { MCPTransport } from './transport.js'; import { info, error } from './logger.js'; @@ -184,6 +185,11 @@ export class DiscordMCPServer { toolResponse = await listServersHandler(args, this.toolContext); return toolResponse; + case "discord_search_messages": + this.logClientState("before discord_search_messages handler"); + toolResponse = await searchMessagesHandler(args, this.toolContext); + return toolResponse; + default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/toolList.ts b/src/toolList.ts index 5d70a28..d9dd333 100644 --- a/src/toolList.ts +++ b/src/toolList.ts @@ -82,7 +82,7 @@ export const toolList = [ forumChannelId: { type: "string" }, title: { type: "string" }, content: { type: "string" }, - tags: { + tags: { type: "array", items: { type: "string" } } @@ -301,5 +301,29 @@ export const toolList = [ properties: {}, required: [] } + }, + { + name: "discord_search_messages", + description: "Searches for messages in a Discord server", + inputSchema: { + type: "object", + properties: { + guildId: { type: "string", description: "The ID of the Discord server (guild) to search within" }, + content: { type: "string", description: "Search for messages containing specific text" }, + authorId: { type: "string", description: "Filter messages by a specific user ID" }, + mentions: { type: "string", description: "Filter messages that mention a specific user ID" }, + has: { type: "string", description: "Filter messages that contain specific content types (e.g., link, embed, file, poll, image, video, sound, sticker, snapshot)", enum: ["link", "embed", "file", "poll", "image", "video", "sound", "sticker", "snapshot"] }, + maxId: { type: "string", description: "Filter messages with IDs less than this value (messages before this ID)" }, + minId: { type: "string", description: "Filter messages with IDs greater than this value (messages after this ID)" }, + channelId: { type: "string", description: "Filter messages within a specific channel ID" }, + pinned: { type: "boolean", description: "Filter messages based on whether they are pinned" }, + authorType: { type: "string", description: "Filter messages by author type (user, bot, webhook)", enum: ["user", "bot", "webhook"] }, + sortBy: { type: "string", description: "Sort results by 'timestamp' or 'relevance'", enum: ["timestamp", "relevance"] }, + sortOrder: { type: "string", description: "Sort order: 'desc' for descending or 'asc' for ascending", enum: ["desc", "asc"] }, + limit: { type: "number", description: "Maximum number of messages to return (default 25, max 100)" }, + offset: { type: "number", description: "Number of messages to skip (for pagination)" } + }, + required: ["guildId"] + } } ]; \ No newline at end of file diff --git a/src/tools/channel.ts b/src/tools/channel.ts index f2f1e08..75d3e62 100644 --- a/src/tools/channel.ts +++ b/src/tools/channel.ts @@ -5,11 +5,9 @@ import { CreateTextChannelSchema, DeleteChannelSchema, ReadMessagesSchema, - GetServerInfoSchema, CreateCategorySchema, EditCategorySchema, - DeleteCategorySchema, - ListServersSchema + DeleteCategorySchema } from "../schemas.js"; import { handleDiscordError } from "../errorHandler.js"; @@ -261,135 +259,3 @@ export async function readMessagesHandler( return handleDiscordError(error); } } - -// Server information handler -export async function getServerInfoHandler( - args: unknown, - context: ToolContext -): Promise { - const { guildId } = GetServerInfoSchema.parse(args); - try { - if (!context.client.isReady()) { - return { - content: [{ type: "text", text: "Discord client not logged in." }], - isError: true - }; - } - - const guild = await context.client.guilds.fetch(guildId); - if (!guild) { - return { - content: [{ type: "text", text: `Cannot find guild with ID: ${guildId}` }], - isError: true - }; - } - - // Fetch additional server data - await guild.fetch(); - - // Fetch channel information - const channels = await guild.channels.fetch(); - - // Categorize channels by type - const channelsByType = { - text: channels.filter(c => c?.type === ChannelType.GuildText).size, - voice: channels.filter(c => c?.type === ChannelType.GuildVoice).size, - category: channels.filter(c => c?.type === ChannelType.GuildCategory).size, - forum: channels.filter(c => c?.type === ChannelType.GuildForum).size, - announcement: channels.filter(c => c?.type === ChannelType.GuildAnnouncement).size, - stage: channels.filter(c => c?.type === ChannelType.GuildStageVoice).size, - total: channels.size - }; - - // Get detailed information for all channels - const channelDetails = channels.map(channel => { - if (!channel) return null; - - return { - id: channel.id, - name: channel.name, - type: ChannelType[channel.type] || channel.type, - categoryId: channel.parentId, - position: channel.position, - // Only add topic for text channels - topic: 'topic' in channel ? channel.topic : null, - }; - }).filter(c => c !== null); // Filter out null values - - // Group channels by type - const groupedChannels = { - text: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildText] || c.type === ChannelType.GuildText), - voice: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildVoice] || c.type === ChannelType.GuildVoice), - category: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildCategory] || c.type === ChannelType.GuildCategory), - forum: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildForum] || c.type === ChannelType.GuildForum), - announcement: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildAnnouncement] || c.type === ChannelType.GuildAnnouncement), - stage: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildStageVoice] || c.type === ChannelType.GuildStageVoice), - all: channelDetails - }; - - // Get member count - const approximateMemberCount = guild.approximateMemberCount || "unknown"; - - // Format guild information - const guildInfo = { - id: guild.id, - name: guild.name, - description: guild.description, - icon: guild.iconURL(), - owner: guild.ownerId, - createdAt: guild.createdAt, - memberCount: approximateMemberCount, - channels: { - count: channelsByType, - details: groupedChannels - }, - features: guild.features, - premium: { - tier: guild.premiumTier, - subscriptions: guild.premiumSubscriptionCount - } - }; - - return { - content: [{ type: "text", text: JSON.stringify(guildInfo, null, 2) }] - }; - } catch (error) { - return handleDiscordError(error); - } -} - -// List servers handler -export async function listServersHandler( - args: unknown, - context: ToolContext -): Promise { - ListServersSchema.parse(args); - try { - if (!context.client.isReady()) { - return { - content: [{ type: "text", text: "Discord client not logged in." }], - isError: true - }; - } - - const guilds = await context.client.guilds.fetch(); - - if (guilds.size === 0) { - return { - content: [{ type: "text", text: "No servers found. The bot is not a member of any servers." }] - }; - } - - const guildsInfo = guilds.map(guild => ({ - id: guild.id, - name: guild.name, - icon: guild.iconURL() - })); - - return { - content: [{ type: "text", text: JSON.stringify(guildsInfo, null, 2) }] - }; - } catch (error) { - return handleDiscordError(error); - } -} \ No newline at end of file diff --git a/src/tools/server.ts b/src/tools/server.ts new file mode 100644 index 0000000..bcb52c7 --- /dev/null +++ b/src/tools/server.ts @@ -0,0 +1,189 @@ +import { ChannelType } from "discord.js"; +import { handleDiscordError } from "../errorHandler.js"; +import { GetServerInfoSchema, ListServersSchema, SearchMessagesSchema } from "../schemas.js"; +import { ToolContext, ToolResponse } from "./types.js"; + + +// Search server messages handler +export async function searchMessagesHandler( + args: unknown, + context: ToolContext +): Promise { + const { guildId, content, authorId, mentions, has, maxId, minId, channelId, pinned, authorType, sortBy, sortOrder, limit, offset } = SearchMessagesSchema.parse(args); + try { + if (!context.client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const guild = await context.client.guilds.fetch(guildId); + if (!guild) { + return { + content: [{ type: "text", text: `Cannot find guild with ID: ${guildId}` }], + isError: true + }; + } + + // Note: Discord.js does not support guild message search natively. + // This requires direct API calls or using a library that supports it. + // Here we will construct the API request using context.client.rest + const params = new URLSearchParams(); + if (content) params.append('content', content); + if (authorId) params.append('author_id', authorId); + if (mentions) params.append('mentions', mentions); + if (has) params.append('has', has); + if (maxId) params.append('max_id', maxId); + if (minId) params.append('min_id', minId); + if (channelId) params.append('channel_id', channelId); + if (typeof pinned === 'boolean') params.append('pinned', String(pinned)); + if (authorType) params.append('author_type', authorType); + if (sortBy) params.append('sort_by', sortBy); + if (sortOrder) params.append('sort_order', sortOrder); + params.append('limit', String(limit || 25)); + params.append('offset', String(offset || 0)); + + const response = await context.client.rest.get(`/guilds/${guildId}/messages/search?${params.toString()}`); + + return { + content: [{ type: "text", text: JSON.stringify(response, null, 2) }] + }; + } catch (error) { + return handleDiscordError(error); + } +} + +// List servers handler +export async function listServersHandler( + args: unknown, + context: ToolContext +): Promise { + ListServersSchema.parse(args); + try { + if (!context.client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const guilds = await context.client.guilds.fetch(); + + if (guilds.size === 0) { + return { + content: [{ type: "text", text: "No servers found. The bot is not a member of any servers." }] + }; + } + + const guildsInfo = guilds.map(guild => ({ + id: guild.id, + name: guild.name, + icon: guild.iconURL() + })); + + return { + content: [{ type: "text", text: JSON.stringify(guildsInfo, null, 2) }] + }; + } catch (error) { + return handleDiscordError(error); + } +} + +// Server information handler +export async function getServerInfoHandler( + args: unknown, + context: ToolContext +): Promise { + const { guildId } = GetServerInfoSchema.parse(args); + try { + if (!context.client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const guild = await context.client.guilds.fetch(guildId); + if (!guild) { + return { + content: [{ type: "text", text: `Cannot find guild with ID: ${guildId}` }], + isError: true + }; + } + + // Fetch additional server data + await guild.fetch(); + + // Fetch channel information + const channels = await guild.channels.fetch(); + + // Categorize channels by type + const channelsByType = { + text: channels.filter(c => c?.type === ChannelType.GuildText).size, + voice: channels.filter(c => c?.type === ChannelType.GuildVoice).size, + category: channels.filter(c => c?.type === ChannelType.GuildCategory).size, + forum: channels.filter(c => c?.type === ChannelType.GuildForum).size, + announcement: channels.filter(c => c?.type === ChannelType.GuildAnnouncement).size, + stage: channels.filter(c => c?.type === ChannelType.GuildStageVoice).size, + total: channels.size + }; + + // Get detailed information for all channels + const channelDetails = channels.map(channel => { + if (!channel) return null; + + return { + id: channel.id, + name: channel.name, + type: ChannelType[channel.type] || channel.type, + categoryId: channel.parentId, + position: channel.position, + // Only add topic for text channels + topic: 'topic' in channel ? channel.topic : null, + }; + }).filter(c => c !== null); // Filter out null values + + + // Group channels by type + const groupedChannels = { + text: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildText] || c.type === ChannelType.GuildText), + voice: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildVoice] || c.type === ChannelType.GuildVoice), + category: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildCategory] || c.type === ChannelType.GuildCategory), + forum: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildForum] || c.type === ChannelType.GuildForum), + announcement: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildAnnouncement] || c.type === ChannelType.GuildAnnouncement), + stage: channelDetails.filter(c => c.type === ChannelType[ChannelType.GuildStageVoice] || c.type === ChannelType.GuildStageVoice), + all: channelDetails + }; + + // Get member count + const approximateMemberCount = guild.approximateMemberCount || "unknown"; + + // Format guild information + const guildInfo = { + id: guild.id, + name: guild.name, + description: guild.description, + icon: guild.iconURL(), + owner: guild.ownerId, + createdAt: guild.createdAt, + memberCount: approximateMemberCount, + channels: { + count: channelsByType, + details: groupedChannels + }, + features: guild.features, + premium: { + tier: guild.premiumTier, + subscriptions: guild.premiumSubscriptionCount + } + }; + + return { + content: [{ type: "text", text: JSON.stringify(guildInfo, null, 2) }] + }; + } catch (error) { + return handleDiscordError(error); + } +} + diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 1134a2d..78e74c0 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -14,12 +14,15 @@ import { createTextChannelHandler, deleteChannelHandler, readMessagesHandler, - getServerInfoHandler, createCategoryHandler, editCategoryHandler, - deleteCategoryHandler, - listServersHandler + deleteCategoryHandler } from './channel.js'; +import { + getServerInfoHandler, + listServersHandler, + searchMessagesHandler +} from "./server.js"; import { addReactionHandler, addMultipleReactionsHandler, @@ -57,7 +60,8 @@ export { createCategoryHandler, editCategoryHandler, deleteCategoryHandler, - listServersHandler + listServersHandler, + searchMessagesHandler }; // Export common types diff --git a/src/transport.ts b/src/transport.ts index 0dde9f9..1be5716 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -27,7 +27,8 @@ import { editCategoryHandler, createCategoryHandler, deleteCategoryHandler, - listServersHandler + listServersHandler, + searchMessagesHandler } from './tools/tools.js'; import { Client, GatewayIntentBits } from "discord.js"; import { info, error } from './logger.js'; @@ -214,6 +215,8 @@ export class StreamableHttpTransport implements MCPTransport { case 'discord_create_category': case 'discord_edit_category': case 'discord_delete_category': + case 'discord_list_servers': + case 'discord_search_messages': // Check if client is logged in if (!this.toolContext!.client.isReady()) { error(`Client not ready for method ${method}, client state: ${JSON.stringify({ @@ -335,7 +338,11 @@ export class StreamableHttpTransport implements MCPTransport { case 'discord_delete_category': result = await deleteCategoryHandler(params, this.toolContext!); break; - + case 'discord_list_servers': + result = await listServersHandler(params, this.toolContext!); + case 'discord_search_messages': + result = await searchMessagesHandler(params, this.toolContext!); + break;break; } break; @@ -499,6 +506,13 @@ export class StreamableHttpTransport implements MCPTransport { case 'discord_delete_category': result = await deleteCategoryHandler(toolArgs, this.toolContext!); break; + + case 'discord_list_servers': + result = await listServersHandler(toolArgs, this.toolContext!); + break; + case 'discord_search_messages': + result = await searchMessagesHandler(toolArgs, this.toolContext!); + break; default: return res.status(400).json({