Merge pull request #16 from SootyOwl/feat/add-message-search

Add search messages tool
This commit is contained in:
Barry Yip 2025-10-20 15:45:02 +08:00 committed by GitHub
commit 306f6abe81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 267 additions and 144 deletions

View File

@ -14,6 +14,7 @@ MCP-Discord provides the following Discord-related functionalities:
- Login to Discord bot - Login to Discord bot
- List servers the bot is a member of - List servers the bot is a member of
- Get server information - Get server information
- Search messages in a server
- Read/delete channel messages - Read/delete channel messages
- Send messages to specified channels (using either channel IDs or channel names) - Send messages to specified channels (using either channel IDs or channel names)
- Retrieve forum channel lists - Retrieve forum channel lists
@ -288,6 +289,7 @@ You can use Docker containers with both Claude and Cursor:
### Messages and Reactions ### Messages and Reactions
- `discord_search_messages`: Search messages in a server
- `discord_read_messages`: Read channel messages - `discord_read_messages`: Read channel messages
- `discord_add_reaction`: Add a reaction to a message - `discord_add_reaction`: Add a reaction to a message
- `discord_add_multiple_reactions`: Add multiple reactions to a message - `discord_add_multiple_reactions`: Add multiple reactions to a message

View File

@ -131,4 +131,22 @@ export const DeleteWebhookSchema = z.object({
reason: z.string().optional() reason: z.string().optional()
}); });
export const ListServersSchema = z.object({}); 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()
});

View File

@ -30,7 +30,8 @@ import {
createCategoryHandler, createCategoryHandler,
editCategoryHandler, editCategoryHandler,
deleteCategoryHandler, deleteCategoryHandler,
listServersHandler listServersHandler,
searchMessagesHandler
} from './tools/tools.js'; } from './tools/tools.js';
import { MCPTransport } from './transport.js'; import { MCPTransport } from './transport.js';
import { info, error } from './logger.js'; import { info, error } from './logger.js';
@ -184,6 +185,11 @@ export class DiscordMCPServer {
toolResponse = await listServersHandler(args, this.toolContext); toolResponse = await listServersHandler(args, this.toolContext);
return toolResponse; return toolResponse;
case "discord_search_messages":
this.logClientState("before discord_search_messages handler");
toolResponse = await searchMessagesHandler(args, this.toolContext);
return toolResponse;
default: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }

View File

@ -82,7 +82,7 @@ export const toolList = [
forumChannelId: { type: "string" }, forumChannelId: { type: "string" },
title: { type: "string" }, title: { type: "string" },
content: { type: "string" }, content: { type: "string" },
tags: { tags: {
type: "array", type: "array",
items: { type: "string" } items: { type: "string" }
} }
@ -301,5 +301,29 @@ export const toolList = [
properties: {}, properties: {},
required: [] 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"]
}
} }
]; ];

View File

@ -5,11 +5,9 @@ import {
CreateTextChannelSchema, CreateTextChannelSchema,
DeleteChannelSchema, DeleteChannelSchema,
ReadMessagesSchema, ReadMessagesSchema,
GetServerInfoSchema,
CreateCategorySchema, CreateCategorySchema,
EditCategorySchema, EditCategorySchema,
DeleteCategorySchema, DeleteCategorySchema
ListServersSchema
} from "../schemas.js"; } from "../schemas.js";
import { handleDiscordError } from "../errorHandler.js"; import { handleDiscordError } from "../errorHandler.js";
@ -261,135 +259,3 @@ export async function readMessagesHandler(
return handleDiscordError(error); return handleDiscordError(error);
} }
} }
// Server information handler
export async function getServerInfoHandler(
args: unknown,
context: ToolContext
): Promise<ToolResponse> {
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<ToolResponse> {
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);
}
}

189
src/tools/server.ts Normal file
View File

@ -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<ToolResponse> {
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<ToolResponse> {
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<ToolResponse> {
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);
}
}

View File

@ -14,12 +14,15 @@ import {
createTextChannelHandler, createTextChannelHandler,
deleteChannelHandler, deleteChannelHandler,
readMessagesHandler, readMessagesHandler,
getServerInfoHandler,
createCategoryHandler, createCategoryHandler,
editCategoryHandler, editCategoryHandler,
deleteCategoryHandler, deleteCategoryHandler
listServersHandler
} from './channel.js'; } from './channel.js';
import {
getServerInfoHandler,
listServersHandler,
searchMessagesHandler
} from "./server.js";
import { import {
addReactionHandler, addReactionHandler,
addMultipleReactionsHandler, addMultipleReactionsHandler,
@ -57,7 +60,8 @@ export {
createCategoryHandler, createCategoryHandler,
editCategoryHandler, editCategoryHandler,
deleteCategoryHandler, deleteCategoryHandler,
listServersHandler listServersHandler,
searchMessagesHandler
}; };
// Export common types // Export common types

View File

@ -27,7 +27,8 @@ import {
editCategoryHandler, editCategoryHandler,
createCategoryHandler, createCategoryHandler,
deleteCategoryHandler, deleteCategoryHandler,
listServersHandler listServersHandler,
searchMessagesHandler
} from './tools/tools.js'; } from './tools/tools.js';
import { Client, GatewayIntentBits } from "discord.js"; import { Client, GatewayIntentBits } from "discord.js";
import { info, error } from './logger.js'; import { info, error } from './logger.js';
@ -214,6 +215,8 @@ export class StreamableHttpTransport implements MCPTransport {
case 'discord_create_category': case 'discord_create_category':
case 'discord_edit_category': case 'discord_edit_category':
case 'discord_delete_category': case 'discord_delete_category':
case 'discord_list_servers':
case 'discord_search_messages':
// Check if client is logged in // Check if client is logged in
if (!this.toolContext!.client.isReady()) { if (!this.toolContext!.client.isReady()) {
error(`Client not ready for method ${method}, client state: ${JSON.stringify({ error(`Client not ready for method ${method}, client state: ${JSON.stringify({
@ -335,7 +338,11 @@ export class StreamableHttpTransport implements MCPTransport {
case 'discord_delete_category': case 'discord_delete_category':
result = await deleteCategoryHandler(params, this.toolContext!); result = await deleteCategoryHandler(params, this.toolContext!);
break; break;
case 'discord_list_servers':
result = await listServersHandler(params, this.toolContext!);
case 'discord_search_messages':
result = await searchMessagesHandler(params, this.toolContext!);
break;break;
} }
break; break;
@ -499,6 +506,13 @@ export class StreamableHttpTransport implements MCPTransport {
case 'discord_delete_category': case 'discord_delete_category':
result = await deleteCategoryHandler(toolArgs, this.toolContext!); result = await deleteCategoryHandler(toolArgs, this.toolContext!);
break; break;
case 'discord_list_servers':
result = await listServersHandler(toolArgs, this.toolContext!);
break;
case 'discord_search_messages':
result = await searchMessagesHandler(toolArgs, this.toolContext!);
break;
default: default:
return res.status(400).json({ return res.status(400).json({