server.ts•19.7 kB
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,
  GuildNotFoundError,
} from "../errors/discord.js";
import { PermissionFlagsBits, AuditLogEvent } from "discord.js";
export function registerServerTools(
  server: McpServer,
  discordManager: DiscordClientManager,
  logger: Logger,
) {
  // Modify Server Tool
  server.registerTool(
    "modify_server",
    {
      title: "Modify Server Settings",
      description: "Update server name, description, or other settings",
      inputSchema: {
        guildId: z.string().describe("Guild ID"),
        name: z.string().min(2).max(100).optional().describe("New server name"),
        description: z
          .string()
          .max(120)
          .optional()
          .describe("New server description"),
        reason: z
          .string()
          .optional()
          .describe("Reason for modification (audit log)"),
      },
      outputSchema: {
        success: z.boolean(),
        guild: z
          .object({
            id: z.string(),
            name: z.string(),
            description: z.string().nullable(),
          })
          .optional(),
        error: z.string().optional(),
      },
    },
    async ({ guildId, name, description, reason }) => {
      try {
        const client = discordManager.getClient();
        const guild = await client.guilds.fetch(guildId).catch(() => null);
        if (!guild) {
          throw new GuildNotFoundError(guildId);
        }
        const botMember = await guild.members.fetchMe();
        if (!botMember.permissions.has(PermissionFlagsBits.ManageGuild)) {
          throw new PermissionDeniedError("ManageGuild", guildId);
        }
        const updateOptions: any = {};
        if (name !== undefined) updateOptions.name = name;
        if (description !== undefined) updateOptions.description = description;
        if (reason !== undefined) updateOptions.reason = reason;
        const updatedGuild = await guild.edit(updateOptions);
        const output = {
          success: true,
          guild: {
            id: updatedGuild.id,
            name: updatedGuild.name,
            description: updatedGuild.description,
          },
        };
        logger.info("Server modified", { guildId, reason });
        return {
          content: [
            {
              type: "text" as const,
              text: `Server "${updatedGuild.name}" modified successfully`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to modify server", {
          error: error.message,
          guildId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to modify server: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
  // Get Audit Logs Tool
  server.registerTool(
    "get_audit_logs",
    {
      title: "Get Server Audit Logs",
      description: "Retrieve recent audit log entries for the server",
      inputSchema: {
        guildId: z.string().describe("Guild ID"),
        limit: z
          .number()
          .int()
          .min(1)
          .max(100)
          .optional()
          .default(50)
          .describe("Number of entries to retrieve (max 100)"),
        userId: z
          .string()
          .optional()
          .describe("Filter by user who performed actions"),
        actionType: z
          .enum([
            "ALL",
            "MEMBER_KICK",
            "MEMBER_BAN_ADD",
            "MEMBER_BAN_REMOVE",
            "MEMBER_UPDATE",
            "MEMBER_ROLE_UPDATE",
            "CHANNEL_CREATE",
            "CHANNEL_DELETE",
            "CHANNEL_UPDATE",
            "ROLE_CREATE",
            "ROLE_DELETE",
            "ROLE_UPDATE",
            "MESSAGE_DELETE",
            "MESSAGE_BULK_DELETE",
          ])
          .optional()
          .default("ALL")
          .describe("Filter by action type"),
      },
      outputSchema: {
        success: z.boolean(),
        entries: z
          .array(
            z.object({
              id: z.string(),
              action: z.string(),
              executorId: z.string().nullable(),
              executorUsername: z.string().nullable(),
              targetId: z.string().nullable(),
              reason: z.string().nullable(),
              timestamp: z.string(),
            }),
          )
          .optional(),
        totalCount: z.number().optional(),
        error: z.string().optional(),
      },
    },
    async ({ guildId, limit = 50, userId, actionType = "ALL" }) => {
      try {
        const client = discordManager.getClient();
        const guild = await client.guilds.fetch(guildId).catch(() => null);
        if (!guild) {
          throw new GuildNotFoundError(guildId);
        }
        const botMember = await guild.members.fetchMe();
        if (!botMember.permissions.has(PermissionFlagsBits.ViewAuditLog)) {
          throw new PermissionDeniedError("ViewAuditLog", guildId);
        }
        const fetchOptions: any = { limit };
        if (userId) fetchOptions.user = userId;
        if (actionType !== "ALL") {
          const actionMap: Record<string, AuditLogEvent> = {
            MEMBER_KICK: AuditLogEvent.MemberKick,
            MEMBER_BAN_ADD: AuditLogEvent.MemberBanAdd,
            MEMBER_BAN_REMOVE: AuditLogEvent.MemberBanRemove,
            MEMBER_UPDATE: AuditLogEvent.MemberUpdate,
            MEMBER_ROLE_UPDATE: AuditLogEvent.MemberRoleUpdate,
            CHANNEL_CREATE: AuditLogEvent.ChannelCreate,
            CHANNEL_DELETE: AuditLogEvent.ChannelDelete,
            CHANNEL_UPDATE: AuditLogEvent.ChannelUpdate,
            ROLE_CREATE: AuditLogEvent.RoleCreate,
            ROLE_DELETE: AuditLogEvent.RoleDelete,
            ROLE_UPDATE: AuditLogEvent.RoleUpdate,
            MESSAGE_DELETE: AuditLogEvent.MessageDelete,
            MESSAGE_BULK_DELETE: AuditLogEvent.MessageBulkDelete,
          };
          fetchOptions.type = actionMap[actionType];
        }
        const auditLogs = await guild.fetchAuditLogs(fetchOptions);
        const entries = auditLogs.entries.map((entry) => ({
          id: entry.id,
          action: AuditLogEvent[entry.action],
          executorId: entry.executorId,
          executorUsername: entry.executor?.username || null,
          targetId: entry.targetId,
          reason: entry.reason,
          timestamp: entry.createdAt.toISOString(),
        }));
        const output = {
          success: true,
          entries,
          totalCount: entries.length,
        };
        logger.info("Audit logs retrieved", {
          guildId,
          count: entries.length,
        });
        return {
          content: [
            {
              type: "text" as const,
              text: `Retrieved ${entries.length} audit log entr${entries.length === 1 ? "y" : "ies"}`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to get audit logs", {
          error: error.message,
          guildId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to get audit logs: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
  // List Webhooks Tool
  server.registerTool(
    "list_webhooks",
    {
      title: "List Server Webhooks",
      description: "Get all webhooks in the server or a specific channel",
      inputSchema: {
        guildId: z.string().describe("Guild ID"),
        channelId: z
          .string()
          .optional()
          .describe("Filter by channel ID (optional)"),
      },
      outputSchema: {
        success: z.boolean(),
        webhooks: z
          .array(
            z.object({
              id: z.string(),
              name: z.string(),
              channelId: z.string(),
              channelName: z.string().optional(),
              url: z.string(),
            }),
          )
          .optional(),
        totalCount: z.number().optional(),
        error: z.string().optional(),
      },
    },
    async ({ guildId, channelId }) => {
      try {
        const client = discordManager.getClient();
        const guild = await client.guilds.fetch(guildId).catch(() => null);
        if (!guild) {
          throw new GuildNotFoundError(guildId);
        }
        const botMember = await guild.members.fetchMe();
        if (!botMember.permissions.has(PermissionFlagsBits.ManageWebhooks)) {
          throw new PermissionDeniedError("ManageWebhooks", guildId);
        }
        let webhooks;
        if (channelId) {
          const channel = await guild.channels.fetch(channelId);
          if (!channel || !("fetchWebhooks" in channel)) {
            throw new Error(
              "Invalid channel or channel doesn't support webhooks",
            );
          }
          webhooks = await channel.fetchWebhooks();
        } else {
          webhooks = await guild.fetchWebhooks();
        }
        const webhookList = webhooks.map((webhook) => ({
          id: webhook.id,
          name: webhook.name,
          channelId: webhook.channelId,
          channelName: guild.channels.cache.get(webhook.channelId)?.name,
          url: webhook.url,
        }));
        const output = {
          success: true,
          webhooks: webhookList,
          totalCount: webhookList.length,
        };
        logger.info("Webhooks listed", { guildId, count: webhookList.length });
        return {
          content: [
            {
              type: "text" as const,
              text: `Found ${webhookList.length} webhook(s)`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to list webhooks", {
          error: error.message,
          guildId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to list webhooks: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
  // Create Webhook Tool
  server.registerTool(
    "create_webhook",
    {
      title: "Create Webhook",
      description: "Create a new webhook for a channel",
      inputSchema: {
        channelId: z.string().describe("Channel ID"),
        name: z.string().min(1).max(80).describe("Webhook name"),
        reason: z
          .string()
          .optional()
          .describe("Reason for creating webhook (audit log)"),
      },
      outputSchema: {
        success: z.boolean(),
        webhook: z
          .object({
            id: z.string(),
            name: z.string(),
            url: z.string(),
            token: z.string(),
          })
          .optional(),
        error: z.string().optional(),
      },
    },
    async ({ channelId, name, reason }) => {
      try {
        const client = discordManager.getClient();
        const channel = await client.channels
          .fetch(channelId)
          .catch(() => null);
        if (!channel || !("createWebhook" in channel)) {
          throw new Error(
            "Invalid channel or channel doesn't support webhooks",
          );
        }
        if ("guild" in channel && channel.guild) {
          const botMember = await channel.guild.members.fetchMe();
          if (!botMember.permissions.has(PermissionFlagsBits.ManageWebhooks)) {
            throw new PermissionDeniedError("ManageWebhooks", channel.guild.id);
          }
        }
        const webhook = await (channel as any).createWebhook({
          name,
          reason,
        });
        const output = {
          success: true,
          webhook: {
            id: webhook.id,
            name: webhook.name,
            url: webhook.url,
            token: webhook.token || "",
          },
        };
        logger.info("Webhook created", { channelId, webhookId: webhook.id });
        return {
          content: [
            {
              type: "text" as const,
              text: `Webhook "${name}" created successfully`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to create webhook", {
          error: error.message,
          channelId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to create webhook: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
  // Get Invites Tool
  server.registerTool(
    "get_invites",
    {
      title: "Get Server Invites",
      description: "List all active invite links for the server",
      inputSchema: {
        guildId: z.string().describe("Guild ID"),
      },
      outputSchema: {
        success: z.boolean(),
        invites: z
          .array(
            z.object({
              code: z.string(),
              url: z.string(),
              channelId: z.string(),
              channelName: z.string().optional(),
              inviterId: z.string().nullable(),
              inviterUsername: z.string().nullable(),
              uses: z.number(),
              maxUses: z.number(),
              expiresAt: z.string().nullable(),
              temporary: z.boolean(),
            }),
          )
          .optional(),
        totalCount: z.number().optional(),
        error: z.string().optional(),
      },
    },
    async ({ guildId }) => {
      try {
        const client = discordManager.getClient();
        const guild = await client.guilds.fetch(guildId).catch(() => null);
        if (!guild) {
          throw new GuildNotFoundError(guildId);
        }
        const botMember = await guild.members.fetchMe();
        if (!botMember.permissions.has(PermissionFlagsBits.ManageGuild)) {
          throw new PermissionDeniedError("ManageGuild", guildId);
        }
        const invites = await guild.invites.fetch();
        const inviteList = invites.map((invite) => ({
          code: invite.code,
          url: invite.url,
          channelId: invite.channelId || "",
          channelName: invite.channel?.name,
          inviterId: invite.inviterId,
          inviterUsername: invite.inviter?.username || null,
          uses: invite.uses || 0,
          maxUses: invite.maxUses || 0,
          expiresAt: invite.expiresAt?.toISOString() || null,
          temporary: invite.temporary || false,
        }));
        const output = {
          success: true,
          invites: inviteList,
          totalCount: inviteList.length,
        };
        logger.info("Invites retrieved", { guildId, count: inviteList.length });
        return {
          content: [
            {
              type: "text" as const,
              text: `Found ${inviteList.length} active invite(s)`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to get invites", {
          error: error.message,
          guildId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to get invites: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
  // Create Invite Tool
  server.registerTool(
    "create_invite",
    {
      title: "Create Server Invite",
      description: "Create a new invite link for a channel",
      inputSchema: {
        channelId: z.string().describe("Channel ID to create invite for"),
        maxAge: z
          .number()
          .int()
          .min(0)
          .max(604800)
          .optional()
          .describe("Invite expiration in seconds (0 = never, max 7 days)"),
        maxUses: z
          .number()
          .int()
          .min(0)
          .max(100)
          .optional()
          .describe("Max number of uses (0 = unlimited)"),
        temporary: z
          .boolean()
          .optional()
          .describe("Grant temporary membership"),
        unique: z
          .boolean()
          .optional()
          .default(true)
          .describe("Create a unique invite (don't reuse existing)"),
        reason: z
          .string()
          .optional()
          .describe("Reason for creating invite (audit log)"),
      },
      outputSchema: {
        success: z.boolean(),
        invite: z
          .object({
            code: z.string(),
            url: z.string(),
            expiresAt: z.string().nullable(),
            maxUses: z.number(),
          })
          .optional(),
        error: z.string().optional(),
      },
    },
    async ({
      channelId,
      maxAge,
      maxUses,
      temporary,
      unique = true,
      reason,
    }) => {
      try {
        const client = discordManager.getClient();
        const channel = await client.channels
          .fetch(channelId)
          .catch(() => null);
        if (!channel || !("createInvite" in channel)) {
          throw new Error("Invalid channel or channel doesn't support invites");
        }
        if ("guild" in channel && channel.guild) {
          const botMember = await channel.guild.members.fetchMe();
          if (
            !botMember.permissions.has(PermissionFlagsBits.CreateInstantInvite)
          ) {
            throw new PermissionDeniedError(
              "CreateInstantInvite",
              channel.guild.id,
            );
          }
        }
        const invite = await (channel as any).createInvite({
          maxAge,
          maxUses,
          temporary,
          unique,
          reason,
        });
        const output = {
          success: true,
          invite: {
            code: invite.code,
            url: invite.url,
            expiresAt: invite.expiresAt?.toISOString() || null,
            maxUses: invite.maxUses || 0,
          },
        };
        logger.info("Invite created", { channelId, inviteCode: invite.code });
        return {
          content: [
            {
              type: "text" as const,
              text: `Invite created: ${invite.url}`,
            },
          ],
          structuredContent: output,
        };
      } catch (error: any) {
        logger.error("Failed to create invite", {
          error: error.message,
          channelId,
        });
        const output = {
          success: false,
          error: error.message,
        };
        return {
          content: [
            {
              type: "text" as const,
              text: `Failed to create invite: ${error.message}`,
            },
          ],
          structuredContent: output,
          isError: true,
        };
      }
    },
  );
}