Skip to main content
Glama
gavxm
by gavxm

anilist_group_pick

Read-only

Finds anime or manga that multiple users have on their plan-to-watch lists or have all rated highly. Pick a title everyone will enjoy by analyzing group preferences.

Instructions

Find anime or manga for a group to watch together. Finds titles on multiple users' planning lists (or highly rated by all). Use when friends want to pick something everyone will enjoy.

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
usersYesAniList usernames (2-10) to find group recommendations for
typeNoRecommend anime or mangaANIME
sourceNoPLANNING = overlap in plan-to-watch lists. COMPLETED = titles everyone loved.PLANNING
limitNoNumber of recommendations to return (default 10, max 15)

Implementation Reference

  • The execute handler for the anilist_group_pick tool. Fetches lists for multiple users, finds overlap, optionally ranks by taste profile, and returns formatted recommendations.
    execute: async (args) => {
      try {
        const status = args.source === "PLANNING" ? "PLANNING" : "COMPLETED";
    
        // Fetch all users' lists in parallel
        const listsPromise = args.users.map((u) =>
          anilistClient.fetchList(u, args.type, status),
        );
        const allLists = await Promise.all(listsPromise);
    
        // Count how many users have each media ID
        const mediaCount = new Map<number, number>();
        const entryMap = new Map<number, AniListMediaListEntry>();
        for (const entries of allLists) {
          const seen = new Set<number>();
          for (const e of entries) {
            if (seen.has(e.media.id)) continue;
            seen.add(e.media.id);
            mediaCount.set(e.media.id, (mediaCount.get(e.media.id) ?? 0) + 1);
            if (!entryMap.has(e.media.id)) entryMap.set(e.media.id, e);
          }
        }
    
        // Titles present in every user's list
        const userCount = args.users.length;
        const shared = [...mediaCount.entries()]
          .filter(([, count]) => count === userCount)
          .map(([id]) => entryMap.get(id))
          .filter((e): e is AniListMediaListEntry => e !== undefined);
    
        if (shared.length === 0) {
          // Fall back to titles on most lists
          const maxOverlap = Math.max(...mediaCount.values());
          if (maxOverlap < 2) {
            return `No overlap found across ${userCount} users' ${status.toLowerCase()} lists.`;
          }
    
          const partial = [...mediaCount.entries()]
            .filter(([, count]) => count === maxOverlap)
            .map(([id]) => entryMap.get(id))
            .filter((e): e is AniListMediaListEntry => e !== undefined)
            .slice(0, args.limit);
    
          const lines = [
            `# Group Picks for ${args.users.join(", ")}`,
            `No titles on all ${userCount} lists, but ${partial.length} on ${maxOverlap}/${userCount}:`,
            "",
          ];
          for (let i = 0; i < partial.length; i++) {
            const e = partial[i];
            const title = getTitle(e.media.title);
            const score = e.media.meanScore
              ? ` (${(e.media.meanScore / 10).toFixed(1)}/10 community)`
              : "";
            lines.push(`${i + 1}. ${title}${score}`);
          }
          return lines.join("\n");
        }
    
        // Build a merged taste profile to rank shared titles
        const allEntries = allLists.flat();
        const scored = allEntries.filter((e) => e.score > 0);
        let rankedMedia: Array<{
          title: string;
          format: string | null;
          meanScore: number | null;
        }>;
    
        if (scored.length >= 5) {
          const profile = buildTasteProfile(scored);
          const matched = matchCandidates(
            shared.map((e) => e.media),
            profile,
          );
          rankedMedia = matched.slice(0, args.limit).map((m) => ({
            title: getTitle(m.media.title),
            format: m.media.format,
            meanScore: m.media.meanScore,
          }));
        } else {
          rankedMedia = shared
            .sort((a, b) => (b.media.meanScore ?? 0) - (a.media.meanScore ?? 0))
            .slice(0, args.limit)
            .map((e) => ({
              title: getTitle(e.media.title),
              format: e.media.format,
              meanScore: e.media.meanScore,
            }));
        }
    
        const lines = [
          `# Group Picks for ${args.users.join(", ")}`,
          `${shared.length} ${args.type.toLowerCase()} on all ${userCount} ${status.toLowerCase()} lists:`,
          "",
        ];
    
        for (let i = 0; i < rankedMedia.length; i++) {
          const e = rankedMedia[i];
          const parts: string[] = [];
          if (e.format) parts.push(e.format);
          if (e.meanScore) parts.push(`${(e.meanScore / 10).toFixed(1)}/10`);
          const meta = parts.length ? ` (${parts.join(" - ")})` : "";
          lines.push(`${i + 1}. ${e.title}${meta}`);
        }
    
        return lines.join("\n");
      } catch (error) {
        return throwToolError(error, "finding group recommendations");
      }
    },
  • GroupPickInputSchema – defines input parameters: users (2-10), type (ANIME/MANGA), source (PLANNING/COMPLETED), limit (1-15).
    export const GroupPickInputSchema = z.object({
      users: z
        .array(usernameSchema)
        .min(2)
        .max(10)
        .describe("AniList usernames (2-10) to find group recommendations for"),
      type: z
        .enum(["ANIME", "MANGA"])
        .default("ANIME")
        .describe("Recommend anime or manga"),
      source: z
        .enum(["PLANNING", "COMPLETED"])
        .default("PLANNING")
        .describe(
          "PLANNING = overlap in plan-to-watch lists. COMPLETED = titles everyone loved.",
        ),
      limit: z
        .number()
        .int()
        .min(1)
        .max(15)
        .default(10)
        .describe("Number of recommendations to return (default 10, max 15)"),
    });
  • Tool registration via server.addTool() with name 'anilist_group_pick', description, parameters schema, and annotations.
    server.addTool({
      name: "anilist_group_pick",
      description:
        "Find anime or manga for a group to watch together. " +
        "Finds titles on multiple users' planning lists (or highly rated by all). " +
        "Use when friends want to pick something everyone will enjoy.",
      parameters: GroupPickInputSchema,
      annotations: {
        title: "Group Recommendations",
        readOnlyHint: true,
        destructiveHint: false,
        openWorldHint: true,
      },
      execute: async (args) => {
        try {
          const status = args.source === "PLANNING" ? "PLANNING" : "COMPLETED";
    
          // Fetch all users' lists in parallel
          const listsPromise = args.users.map((u) =>
            anilistClient.fetchList(u, args.type, status),
          );
          const allLists = await Promise.all(listsPromise);
    
          // Count how many users have each media ID
          const mediaCount = new Map<number, number>();
          const entryMap = new Map<number, AniListMediaListEntry>();
          for (const entries of allLists) {
            const seen = new Set<number>();
            for (const e of entries) {
              if (seen.has(e.media.id)) continue;
              seen.add(e.media.id);
              mediaCount.set(e.media.id, (mediaCount.get(e.media.id) ?? 0) + 1);
              if (!entryMap.has(e.media.id)) entryMap.set(e.media.id, e);
            }
          }
    
          // Titles present in every user's list
          const userCount = args.users.length;
          const shared = [...mediaCount.entries()]
            .filter(([, count]) => count === userCount)
            .map(([id]) => entryMap.get(id))
            .filter((e): e is AniListMediaListEntry => e !== undefined);
    
          if (shared.length === 0) {
            // Fall back to titles on most lists
            const maxOverlap = Math.max(...mediaCount.values());
            if (maxOverlap < 2) {
              return `No overlap found across ${userCount} users' ${status.toLowerCase()} lists.`;
            }
    
            const partial = [...mediaCount.entries()]
              .filter(([, count]) => count === maxOverlap)
              .map(([id]) => entryMap.get(id))
              .filter((e): e is AniListMediaListEntry => e !== undefined)
              .slice(0, args.limit);
    
            const lines = [
              `# Group Picks for ${args.users.join(", ")}`,
              `No titles on all ${userCount} lists, but ${partial.length} on ${maxOverlap}/${userCount}:`,
              "",
            ];
            for (let i = 0; i < partial.length; i++) {
              const e = partial[i];
              const title = getTitle(e.media.title);
              const score = e.media.meanScore
                ? ` (${(e.media.meanScore / 10).toFixed(1)}/10 community)`
                : "";
              lines.push(`${i + 1}. ${title}${score}`);
            }
            return lines.join("\n");
          }
    
          // Build a merged taste profile to rank shared titles
          const allEntries = allLists.flat();
          const scored = allEntries.filter((e) => e.score > 0);
          let rankedMedia: Array<{
            title: string;
            format: string | null;
            meanScore: number | null;
          }>;
    
          if (scored.length >= 5) {
            const profile = buildTasteProfile(scored);
            const matched = matchCandidates(
              shared.map((e) => e.media),
              profile,
            );
            rankedMedia = matched.slice(0, args.limit).map((m) => ({
              title: getTitle(m.media.title),
              format: m.media.format,
              meanScore: m.media.meanScore,
            }));
          } else {
            rankedMedia = shared
              .sort((a, b) => (b.media.meanScore ?? 0) - (a.media.meanScore ?? 0))
              .slice(0, args.limit)
              .map((e) => ({
                title: getTitle(e.media.title),
                format: e.media.format,
                meanScore: e.media.meanScore,
              }));
          }
    
          const lines = [
            `# Group Picks for ${args.users.join(", ")}`,
            `${shared.length} ${args.type.toLowerCase()} on all ${userCount} ${status.toLowerCase()} lists:`,
            "",
          ];
    
          for (let i = 0; i < rankedMedia.length; i++) {
            const e = rankedMedia[i];
            const parts: string[] = [];
            if (e.format) parts.push(e.format);
            if (e.meanScore) parts.push(`${(e.meanScore / 10).toFixed(1)}/10`);
            const meta = parts.length ? ` (${parts.join(" - ")})` : "";
            lines.push(`${i + 1}. ${e.title}${meta}`);
          }
    
          return lines.join("\n");
        } catch (error) {
          return throwToolError(error, "finding group recommendations");
        }
      },
    });
  • getTitle utility – extracts the best title string from an AniList media title object.
    export function getTitle(title: AniListMedia["title"]): string {
      const pref = process.env.ANILIST_TITLE_LANGUAGE?.toLowerCase();
      if (pref === "romaji")
        return title.romaji || title.english || title.native || "Unknown Title";
      if (pref === "native")
        return title.native || title.romaji || title.english || "Unknown Title";
      // Default: english first
      return title.english || title.romaji || title.native || "Unknown Title";
    }
    
    /** Whether NSFW/adult content is enabled via env var (default: false) */
    export function isNsfwEnabled(): boolean {
      const val = process.env.ANILIST_NSFW?.toLowerCase();
      return val === "true" || val === "1";
    }
    
    // Common abbreviations to full AniList titles
    const ALIAS_MAP: Record<string, string> = {
      aot: "Attack on Titan",
      snk: "Shingeki no Kyojin",
      jjk: "Jujutsu Kaisen",
      csm: "Chainsaw Man",
      mha: "My Hero Academia",
      bnha: "Boku no Hero Academia",
      hxh: "Hunter x Hunter",
      fmab: "Fullmetal Alchemist Brotherhood",
      fma: "Fullmetal Alchemist",
      opm: "One Punch Man",
      sao: "Sword Art Online",
      re0: "Re:Zero",
      rezero: "Re:Zero",
      konosuba: "Kono Subarashii Sekai ni Shukufuku wo!",
      danmachi: "Is It Wrong to Try to Pick Up Girls in a Dungeon?",
      oregairu: "My Teen Romantic Comedy SNAFU",
      toradora: "Toradora!",
      nge: "Neon Genesis Evangelion",
      eva: "Neon Genesis Evangelion",
      ttgl: "Tengen Toppa Gurren Lagann",
      klk: "Kill la Kill",
      jojo: "JoJo's Bizarre Adventure",
      dbz: "Dragon Ball Z",
      dbs: "Dragon Ball Super",
      op: "One Piece",
      bc: "Black Clover",
      ds: "Demon Slayer",
      kny: "Demon Slayer",
      aob: "Blue Exorcist",
      mob: "Mob Psycho 100",
      yyh: "Yu Yu Hakusho",
    };
    
    /** Resolve common abbreviations to full titles */
    export function resolveAlias(query: string): string {
      return ALIAS_MAP[query.toLowerCase()] ?? query;
    }
    
    /** Truncate to max length, breaking at word boundary. Strips residual HTML. */
    export function truncateDescription(
      text: string | null,
      maxLength = 500,
    ): string {
      if (!text) return "No description available.";
      // AniList descriptions can contain HTML even with asHtml: false
      let clean = text.replace(/<br\s*\/?>/gi, "\n");
      // Loop to handle nested fragments like <scr<script>ipt>
      let prev = "";
      while (prev !== clean) {
        prev = clean;
        clean = clean.replace(/<[^>]+>/g, "");
      }
      if (clean.length <= maxLength) return clean;
      const truncated = clean.slice(0, maxLength);
      const lastSpace = truncated.lastIndexOf(" ");
      // Break at the last space if it's within the final 20%, otherwise hard-cut to avoid losing too much
      return (
        (lastSpace > maxLength * 0.8 ? truncated.slice(0, lastSpace) : truncated) +
        "..."
      );
    }
    
    /** Resolve username from the provided arg or the configured default */
    export function getDefaultUsername(provided?: string): string {
      const username = provided || process.env.ANILIST_USERNAME;
      if (!username) {
        throw new Error(
          "No username provided and ANILIST_USERNAME is not set. " +
            "Pass a username parameter, or set the ANILIST_USERNAME environment variable.",
        );
      }
      return username;
    }
    
    /** Re-throw as a UserError so MCP clients see isError: true */
    export function throwToolError(error: unknown, action: string): never {
      if (error instanceof Error) {
        throw new UserError(`Error ${action}: ${error.message}`);
      }
      throw new UserError(`Unexpected error while ${action}. Please try again.`);
    }
    
    /** Pagination footer for multi-page results */
    export function paginationFooter(
      page: number,
      limit: number,
      total: number,
      hasNextPage: boolean,
    ): string {
      const lastPage = Math.ceil(total / limit);
      if (lastPage <= 1) return "";
      const line = `Page ${page} of ${lastPage} (${total} total)`;
      return hasNextPage ? `${line}. Use page: ${page + 1} for more.` : line;
    }
    
    /** Format a media entry as a compact multi-line summary */
    export function formatMediaSummary(media: AniListMedia): string {
      const title = getTitle(media.title);
  • buildTasteProfile – builds a taste profile from scored entries, used to rank shared titles.
    export function buildTasteProfile(
      entries: AniListMediaListEntry[],
    ): TasteProfile {
      // Filter out unscored entries (score 0 means the user didn't rate it)
      const scored = entries.filter((e) => e.score !== UNSCORED);
    
      if (scored.length < MIN_ENTRIES) {
        return emptyProfile(entries.length);
      }
    
      const genres = computeGenreWeights(scored);
      const tags = computeTagWeights(scored);
      const themes = computeTagWeights(scored, "Theme");
      const scoring = computeScoringPattern(scored);
      // Format breakdown uses all entries, not just scored ones
      const formats = computeFormatBreakdown(entries);
    
      return {
        genres,
        tags,
        themes,
        scoring,
        formats,
        totalCompleted: entries.length,
      };
    }
    
    /** Summarize a taste profile as natural language */
    export function describeTasteProfile(
      profile: TasteProfile,
      username: string,
    ): string {
      if (profile.genres.length === 0) {
        return (
          `${username} has completed ${profile.totalCompleted} titles, ` +
          `but not enough have scores to build a taste profile. ` +
          `Score more titles on AniList for a detailed breakdown.`
        );
      }
    
      const lines: string[] = [];
    
      // Top genres
      const topGenres = profile.genres
        .slice(0, 5)
        .map((g) => g.name)
        .join(", ");
      lines.push(`Top genres: ${topGenres}.`);
    
      // Top tags (themes)
      if (profile.tags.length > 0) {
        const topTags = profile.tags
          .slice(0, 5)
          .map((t) => t.name)
          .join(", ");
        lines.push(`Strongest themes: ${topTags}.`);
      }
    
      // Scoring tendency
      const { scoring } = profile;
      const tendencyDesc =
        scoring.tendency === "high"
          ? `Scores high (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
          : scoring.tendency === "low"
            ? `Scores low (avg ${scoring.meanScore.toFixed(1)} vs site avg ${SITE_MEAN})`
            : `Scores near average (avg ${scoring.meanScore.toFixed(1)})`;
      lines.push(`${tendencyDesc} across ${scoring.totalScored} rated titles.`);
    
      // Format preferences
      if (profile.formats.length > 0) {
        const fmtParts = profile.formats
          .slice(0, 3)
          .map((f) => `${f.format} ${f.percent}%`);
        lines.push(`Format split: ${fmtParts.join(", ")}.`);
      }
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

Annotations show readOnlyHint=true and destructiveHint=false. Description adds that it finds titles from planning or completed lists, explaining the algorithm. No contradictions with annotations.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness5/5

Is the description appropriately sized, front-loaded, and free of redundancy?

Two short sentences with no filler. Front-loaded with purpose and usage. Every word adds value.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

Given simplicity and comprehensive annotations, description covers purpose, input, and behavior. No output schema but not critical for a recommendation tool.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema coverage is 100% with descriptions for all 4 parameters. The description adds general context about group picking but doesn't improve parameter understanding beyond what the schema already provides. Baseline 3 is appropriate.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

Description clearly states it finds anime/manga for a group by analyzing multiple users' planning lists or highly rated titles. It uses a specific verb and resource, and likely distinguishes from similar siblings like anilist_pick or anilist_shared_planning.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

Includes explicit usage guidance: 'Use when friends want to pick something everyone will enjoy.' While it doesn't list when not to use, the context is clear and sufficient for an agent to decide.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/gavxm/ani-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server