From 6b9dde03e90038b0bc8504811813cf66ccff3771 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Feb 2026 15:17:54 -0500 Subject: [PATCH 1/2] feat: add forum tags, update forum post, and edit message tools Add three new Discord tools: - discord_get_forum_tags: retrieve available tags (id, name, emoji) for a forum channel - discord_update_forum_post: update a forum post's title, tags, archived/locked status - discord_edit_message: edit a bot-authored message with ownership validation Tags can be specified by name or ID in update_forum_post for convenience. --- src/schemas.ts | 18 ++++++ src/server.ts | 18 ++++++ src/toolList.ts | 39 +++++++++++++ src/tools/forum.ts | 113 ++++++++++++++++++++++++++++++++++++-- src/tools/send-message.ts | 55 ++++++++++++++++++- src/tools/tools.ts | 9 ++- 6 files changed, 242 insertions(+), 10 deletions(-) diff --git a/src/schemas.ts b/src/schemas.ts index 08f6a52..ee6b620 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -101,6 +101,24 @@ export const DeleteForumPostSchema = z.object({ reason: z.string().optional() }); +export const GetForumTagsSchema = z.object({ + forumChannelId: z.string() +}); + +export const UpdateForumPostSchema = z.object({ + threadId: z.string(), + name: z.string().optional(), + tags: z.array(z.string()).optional(), + archived: z.boolean().optional(), + locked: z.boolean().optional() +}); + +export const EditMessageSchema = z.object({ + channelId: z.string(), + messageId: z.string(), + content: z.string() +}); + export const DeleteMessageSchema = z.object({ channelId: z.string(), messageId: z.string(), diff --git a/src/server.ts b/src/server.ts index 535349b..f3dc6c1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,9 @@ import { listForumThreadsHandler, replyToForumHandler, deleteForumPostHandler, + getForumTagsHandler, + updateForumPostHandler, + editMessageHandler, createTextChannelHandler, deleteChannelHandler, readMessagesHandler, @@ -126,6 +129,21 @@ export class DiscordMCPServer { toolResponse = await deleteForumPostHandler(args, this.toolContext); return toolResponse; + case "discord_get_forum_tags": + this.logClientState("before discord_get_forum_tags handler"); + toolResponse = await getForumTagsHandler(args, this.toolContext); + return toolResponse; + + case "discord_update_forum_post": + this.logClientState("before discord_update_forum_post handler"); + toolResponse = await updateForumPostHandler(args, this.toolContext); + return toolResponse; + + case "discord_edit_message": + this.logClientState("before discord_edit_message handler"); + toolResponse = await editMessageHandler(args, this.toolContext); + return toolResponse; + case "discord_create_text_channel": this.logClientState("before discord_create_text_channel handler"); toolResponse = await createTextChannelHandler(args, this.toolContext); diff --git a/src/toolList.ts b/src/toolList.ts index 2fc7864..973cf00 100644 --- a/src/toolList.ts +++ b/src/toolList.ts @@ -235,6 +235,45 @@ export const toolList = [ required: ["threadId"] } }, + { + name: "discord_get_forum_tags", + description: "Gets all available tags for a Discord forum channel, including tag IDs, names, and emoji", + inputSchema: { + type: "object", + properties: { + forumChannelId: { type: "string", description: "The ID of the forum channel to get tags from" } + }, + required: ["forumChannelId"] + } + }, + { + name: "discord_update_forum_post", + description: "Updates a forum post's title, applied tags, archived status, or locked status. Tags can be specified by name or ID.", + inputSchema: { + type: "object", + properties: { + threadId: { type: "string", description: "The ID of the forum post/thread to update" }, + name: { type: "string", description: "New title for the forum post" }, + tags: { type: "array", items: { type: "string" }, description: "Tags to apply (by name or ID). Replaces all existing tags." }, + archived: { type: "boolean", description: "Whether to archive or unarchive the post" }, + locked: { type: "boolean", description: "Whether to lock or unlock the post" } + }, + required: ["threadId"] + } + }, + { + name: "discord_edit_message", + description: "Edits a message previously sent by the bot. Only messages authored by the bot can be edited.", + inputSchema: { + type: "object", + properties: { + channelId: { type: "string", description: "The ID of the channel containing the message" }, + messageId: { type: "string", description: "The ID of the message to edit" }, + content: { type: "string", description: "The new content for the message" } + }, + required: ["channelId", "messageId", "content"] + } + }, { name: "discord_delete_message", description: "Deletes a specific message from a Discord text channel", diff --git a/src/tools/forum.ts b/src/tools/forum.ts index 8fec4b0..c3cc5a7 100644 --- a/src/tools/forum.ts +++ b/src/tools/forum.ts @@ -1,5 +1,5 @@ import { ChannelType, ForumChannel } from 'discord.js'; -import { GetForumChannelsSchema, CreateForumPostSchema, GetForumPostSchema, ListForumThreadsSchema, ReplyToForumSchema, DeleteForumPostSchema } from '../schemas.js'; +import { GetForumChannelsSchema, CreateForumPostSchema, GetForumPostSchema, ListForumThreadsSchema, ReplyToForumSchema, DeleteForumPostSchema, GetForumTagsSchema, UpdateForumPostSchema } from '../schemas.js'; import { ToolHandler } from './types.js'; import { handleDiscordError } from "../errorHandler.js"; @@ -280,7 +280,7 @@ export const replyToForumHandler: ToolHandler = async (args, { client }) => { export const deleteForumPostHandler: ToolHandler = async (args, { client }) => { const { threadId, reason } = DeleteForumPostSchema.parse(args); - + try { if (!client.isReady()) { return { @@ -301,12 +301,113 @@ export const deleteForumPostHandler: ToolHandler = async (args, { client }) => { await thread.delete(reason || "Forum post deleted via API"); return { - content: [{ - type: "text", - text: `Successfully deleted forum post/thread with ID: ${threadId}` + content: [{ + type: "text", + text: `Successfully deleted forum post/thread with ID: ${threadId}` }] }; } catch (error) { return handleDiscordError(error); } -}; \ No newline at end of file +}; + +export const getForumTagsHandler: ToolHandler = async (args, { client }) => { + const { forumChannelId } = GetForumTagsSchema.parse(args); + + try { + if (!client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const channel = await client.channels.fetch(forumChannelId); + if (!channel || channel.type !== ChannelType.GuildForum) { + return { + content: [{ type: "text", text: `Channel ID ${forumChannelId} is not a forum channel.` }], + isError: true + }; + } + + const forumChannel = channel as ForumChannel; + const tags = forumChannel.availableTags.map(tag => ({ + id: tag.id, + name: tag.name, + moderated: tag.moderated, + emoji: tag.emoji ? (tag.emoji.name || tag.emoji.id) : null + })); + + return { + content: [{ type: "text", text: JSON.stringify(tags, null, 2) }] + }; + } catch (error) { + return handleDiscordError(error); + } +}; + +export const updateForumPostHandler: ToolHandler = async (args, { client }) => { + const { threadId, name, tags, archived, locked } = UpdateForumPostSchema.parse(args); + + try { + if (!client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const thread = await client.channels.fetch(threadId); + if (!thread || !thread.isThread()) { + return { + content: [{ type: "text", text: `Cannot find thread with ID: ${threadId}` }], + isError: true + }; + } + + const editOptions: any = {}; + if (name !== undefined) editOptions.name = name; + if (archived !== undefined) editOptions.archived = archived; + if (locked !== undefined) editOptions.locked = locked; + + // Resolve tag names to IDs if tags are provided + if (tags !== undefined) { + const parent = thread.parent; + if (parent && parent.type === ChannelType.GuildForum) { + const forumChannel = parent as ForumChannel; + const availableTags = forumChannel.availableTags; + const tagIds = tags.map(tagInput => { + // Accept either tag name or tag ID + const byName = availableTags.find(t => t.name === tagInput); + if (byName) return byName.id; + const byId = availableTags.find(t => t.id === tagInput); + if (byId) return byId.id; + return null; + }).filter((id): id is string => id !== null); + editOptions.appliedTags = tagIds; + } else { + return { + content: [{ type: "text", text: `Thread's parent channel is not a forum channel. Tags can only be applied to forum posts.` }], + isError: true + }; + } + } + + const updated = await thread.edit(editOptions); + + const changes: string[] = []; + if (name !== undefined) changes.push(`name → "${name}"`); + if (tags !== undefined) changes.push(`tags → [${tags.join(', ')}]`); + if (archived !== undefined) changes.push(`archived → ${archived}`); + if (locked !== undefined) changes.push(`locked → ${locked}`); + + return { + content: [{ + type: "text", + text: `Successfully updated forum post ${threadId}: ${changes.join(', ')}` + }] + }; + } catch (error) { + return handleDiscordError(error); + } +}; \ No newline at end of file diff --git a/src/tools/send-message.ts b/src/tools/send-message.ts index c73905a..0a6b0b9 100644 --- a/src/tools/send-message.ts +++ b/src/tools/send-message.ts @@ -1,4 +1,4 @@ -import { SendMessageSchema } from '../schemas.js'; +import { SendMessageSchema, EditMessageSchema } from '../schemas.js'; import { ToolHandler } from './types.js'; import { handleDiscordError } from "../errorHandler.js"; @@ -68,4 +68,55 @@ export const sendMessageHandler: ToolHandler = async (args, { client }) => { } catch (error) { return handleDiscordError(error); } -}; \ No newline at end of file +}; + +export const editMessageHandler: ToolHandler = async (args, { client }) => { + const { channelId, messageId, content } = EditMessageSchema.parse(args); + + try { + if (!client.isReady()) { + return { + content: [{ type: "text", text: "Discord client not logged in." }], + isError: true + }; + } + + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isTextBased()) { + return { + content: [{ type: "text", text: `Cannot find text channel ID: ${channelId}` }], + isError: true + }; + } + + if (!('messages' in channel)) { + return { + content: [{ type: "text", text: `This channel type does not support message editing` }], + isError: true + }; + } + + const message = await channel.messages.fetch(messageId); + if (!message) { + return { + content: [{ type: "text", text: `Cannot find message with ID: ${messageId}` }], + isError: true + }; + } + + if (message.author.id !== client.user?.id) { + return { + content: [{ type: "text", text: `Cannot edit message: only messages sent by the bot can be edited` }], + isError: true + }; + } + + await message.edit(content); + + return { + content: [{ type: "text", text: `Successfully edited message ${messageId} in channel ${channelId}` }] + }; + } catch (error) { + return handleDiscordError(error); + } +}; \ No newline at end of file diff --git a/src/tools/tools.ts b/src/tools/tools.ts index a35a785..fc81530 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -2,14 +2,16 @@ import { Client } from "discord.js"; import { z } from "zod"; import { ToolResponse, ToolContext, ToolHandler } from "./types.js"; import { loginHandler } from './login.js'; -import { sendMessageHandler } from './send-message.js'; +import { sendMessageHandler, editMessageHandler } from './send-message.js'; import { getForumChannelsHandler, createForumPostHandler, getForumPostHandler, listForumThreadsHandler, replyToForumHandler, - deleteForumPostHandler + deleteForumPostHandler, + getForumTagsHandler, + updateForumPostHandler } from './forum.js'; import { createTextChannelHandler, @@ -47,6 +49,9 @@ export { listForumThreadsHandler, replyToForumHandler, deleteForumPostHandler, + getForumTagsHandler, + updateForumPostHandler, + editMessageHandler, createTextChannelHandler, deleteChannelHandler, readMessagesHandler, From b7e009898713ddbf4e203a377f37c0bdb0e25377 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Feb 2026 16:02:04 -0500 Subject: [PATCH 2/2] fix: validate tag names in update_forum_post Return an error listing the invalid tag names and available tags instead of silently ignoring unrecognized tags. --- src/tools/forum.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/tools/forum.ts b/src/tools/forum.ts index c3cc5a7..2439605 100644 --- a/src/tools/forum.ts +++ b/src/tools/forum.ts @@ -376,15 +376,23 @@ export const updateForumPostHandler: ToolHandler = async (args, { client }) => { if (parent && parent.type === ChannelType.GuildForum) { const forumChannel = parent as ForumChannel; const availableTags = forumChannel.availableTags; - const tagIds = tags.map(tagInput => { - // Accept either tag name or tag ID + const resolved: string[] = []; + const invalid: string[] = []; + for (const tagInput of tags) { const byName = availableTags.find(t => t.name === tagInput); - if (byName) return byName.id; + if (byName) { resolved.push(byName.id); continue; } const byId = availableTags.find(t => t.id === tagInput); - if (byId) return byId.id; - return null; - }).filter((id): id is string => id !== null); - editOptions.appliedTags = tagIds; + if (byId) { resolved.push(byId.id); continue; } + invalid.push(tagInput); + } + if (invalid.length > 0) { + const validNames = availableTags.map(t => t.name).join(', '); + return { + content: [{ type: "text", text: `Unknown tag(s): ${invalid.join(', ')}. Available tags: ${validNames}` }], + isError: true + }; + } + editOptions.appliedTags = resolved; } else { return { content: [{ type: "text", text: `Thread's parent channel is not a forum channel. Tags can only be applied to forum posts.` }],