anilist_group_pick
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
| Name | Required | Description | Default |
|---|---|---|---|
| users | Yes | AniList usernames (2-10) to find group recommendations for | |
| type | No | Recommend anime or manga | ANIME |
| source | No | PLANNING = overlap in plan-to-watch lists. COMPLETED = titles everyone loved. | PLANNING |
| limit | No | Number of recommendations to return (default 10, max 15) |
Implementation Reference
- src/tools/social.ts:256-365 (handler)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"); } }, - src/schemas.ts:1127-1150 (schema)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)"), }); - src/tools/social.ts:243-366 (registration)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"); } }, }); - src/utils.ts:15-130 (helper)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); - src/engine/taste.ts:61-135 (helper)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(", ")}.`); }