ani-mcp 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  # ani-mcp
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/ani-mcp)](https://www.npmjs.com/package/ani-mcp)
8
+ [![npm downloads](https://img.shields.io/npm/dm/ani-mcp)](https://www.npmjs.com/package/ani-mcp)
8
9
  [![CI](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/gavxm/ani-mcp/actions/workflows/ci.yml)
9
10
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
10
11
  [![Node](https://img.shields.io/node/v/ani-mcp)](https://nodejs.org)
@@ -9,8 +9,8 @@ import pRetry, { AbortError } from "p-retry";
9
9
  import pThrottle from "p-throttle";
10
10
  import { USER_LIST_QUERY } from "./queries.js";
11
11
  const ANILIST_API_URL = process.env.ANILIST_API_URL || "https://graphql.anilist.co";
12
- // Budget under the 90 req/min limit to leave headroom
13
- const RATE_LIMIT_PER_MINUTE = 85;
12
+ // No rate limit needed when API is mocked in tests
13
+ const RATE_LIMIT_PER_MINUTE = process.env.VITEST ? 10_000 : 85;
14
14
  const MAX_RETRIES = 3;
15
15
  // Hard timeout per fetch attempt (retries get their own timeout)
16
16
  const FETCH_TIMEOUT_MS = 15_000;
@@ -5,21 +5,21 @@
5
5
  * if the AniList schema changes.
6
6
  */
7
7
  /** Paginated search with optional genre, year, and format filters */
8
- export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
8
+ export declare const SEARCH_MEDIA_QUERY = "\n query SearchMedia(\n $search: String!\n $type: MediaType\n $genre: [String]\n $year: Int\n $format: MediaFormat\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo {\n total\n currentPage\n lastPage\n hasNextPage\n }\n media(\n search: $search\n type: $type\n genre_in: $genre\n startDate_year: $year\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
9
9
  /** Full media lookup with relations and recommendations */
10
- export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
10
+ export declare const MEDIA_DETAILS_QUERY = "\n query MediaDetails($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n ...MediaFields\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n }\n }\n }\n recommendations(sort: RATING_DESC, perPage: 5) {\n nodes {\n rating\n mediaRecommendation {\n id\n title { romaji english }\n format\n meanScore\n genres\n siteUrl\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
11
11
  /** Discover top-rated titles by genre without a search term */
12
- export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
12
+ export declare const DISCOVER_MEDIA_QUERY = "\n query DiscoverMedia(\n $type: MediaType\n $genre_in: [String]\n $page: Int\n $perPage: Int\n $sort: [MediaSort]\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, genre_in: $genre_in, sort: $sort) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
13
13
  /** Browse anime by season and year */
14
- export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
14
+ export declare const SEASONAL_MEDIA_QUERY = "\n query SeasonalMedia(\n $season: MediaSeason\n $seasonYear: Int\n $type: MediaType\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage lastPage hasNextPage }\n media(\n season: $season\n seasonYear: $seasonYear\n type: $type\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
15
15
  /** User profile statistics - watching/reading stats, genre/tag/score breakdowns */
16
16
  export declare const USER_STATS_QUERY = "\n query UserStats($name: String!) {\n User(name: $name) {\n id\n name\n mediaListOptions {\n scoreFormat\n }\n statistics {\n anime {\n count\n meanScore\n minutesWatched\n episodesWatched\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n minutesWatched\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n genres(sort: COUNT_DESC, limit: 10) {\n genre\n count\n meanScore\n chaptersRead\n }\n scores(sort: MEAN_SCORE_DESC) {\n score\n count\n }\n formats(sort: COUNT_DESC) {\n format\n count\n }\n }\n }\n }\n }\n";
17
17
  /** Media recommendations for a given title */
18
- export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
18
+ export declare const RECOMMENDATIONS_QUERY = "\n query MediaRecommendations($id: Int, $search: String, $perPage: Int) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n recommendations(sort: RATING_DESC, perPage: $perPage) {\n nodes {\n rating\n mediaRecommendation {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
19
19
  /** Trending anime or manga right now */
20
- export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
20
+ export declare const TRENDING_MEDIA_QUERY = "\n query TrendingMedia(\n $type: MediaType\n $isAdult: Boolean\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(type: $type, isAdult: $isAdult, sort: TRENDING_DESC) {\n ...MediaFields\n trending\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
21
21
  /** Browse by genre without a search term, with optional filters */
22
- export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
22
+ export declare const GENRE_BROWSE_QUERY = "\n query GenreBrowse(\n $type: MediaType\n $genre_in: [String]\n $year: Int\n $status: MediaStatus\n $format: MediaFormat\n $isAdult: Boolean\n $sort: [MediaSort]\n $page: Int\n $perPage: Int\n ) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n media(\n type: $type\n genre_in: $genre_in\n startDate_year: $year\n status: $status\n format: $format\n isAdult: $isAdult\n sort: $sort\n ) {\n ...MediaFields\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
23
23
  /** Staff and voice actors for a media title */
24
24
  export declare const STAFF_QUERY = "\n query MediaStaff($id: Int, $search: String) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n format\n siteUrl\n staff(sort: RELEVANCE, perPage: 15) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n }\n }\n characters(sort: ROLE, perPage: 10) {\n edges {\n role\n node {\n id\n name { full native }\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full native }\n siteUrl\n }\n }\n }\n }\n }\n";
25
25
  /** Airing schedule for currently airing anime */
@@ -31,7 +31,7 @@ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaLis
31
31
  /** Remove a list entry */
32
32
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
33
33
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
34
- export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
34
+ export declare const USER_LIST_QUERY = "\n query UserMediaList(\n $userName: String!\n $type: MediaType\n $status: MediaListStatus\n $sort: [MediaListSort]\n ) {\n MediaListCollection(\n userName: $userName\n type: $type\n status: $status\n sort: $sort\n ) {\n lists {\n name\n status\n isCustomList\n entries {\n id\n score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting\n progress\n status\n updatedAt\n startedAt { year month day }\n completedAt { year month day }\n notes\n media {\n ...MediaFields\n }\n }\n }\n }\n }\n \n fragment MediaFields on Media {\n id\n type\n title {\n romaji\n english\n native\n }\n format\n status\n episodes\n duration\n chapters\n volumes\n meanScore\n averageScore\n popularity\n genres\n tags {\n name\n rank\n isMediaSpoiler\n }\n season\n seasonYear\n startDate { year month day }\n endDate { year month day }\n studios(isMain: true) {\n nodes { name }\n }\n source\n isAdult\n coverImage { large }\n siteUrl\n description(asHtml: false)\n }\n\n";
35
35
  /** Search for staff by name with their top works */
36
36
  export declare const STAFF_SEARCH_QUERY = "\n query StaffSearch($search: String!, $page: Int, $perPage: Int, $mediaPerPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n staff(search: $search, sort: SEARCH_MATCH) {\n id\n name { full native }\n primaryOccupations\n siteUrl\n staffMedia(sort: POPULARITY_DESC, perPage: $mediaPerPage) {\n edges {\n staffRole\n node {\n id\n title { romaji english }\n format\n type\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n }\n";
37
37
  /** Authenticated user info */
@@ -50,3 +50,5 @@ export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String)
50
50
  export declare const MEDIA_REVIEWS_QUERY = "\n query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n reviews(page: $page, perPage: $perPage, sort: $sort) {\n pageInfo { total hasNextPage }\n nodes {\n id\n score\n summary\n body\n rating\n ratingAmount\n createdAt\n user { name siteUrl }\n }\n }\n }\n }\n";
51
51
  /** Search for a studio by name with their productions */
52
52
  export declare const STUDIO_SEARCH_QUERY = "\n query StudioSearch($search: String!, $perPage: Int) {\n Studio(search: $search, sort: SEARCH_MATCH) {\n id\n name\n isAnimationStudio\n siteUrl\n media(sort: POPULARITY_DESC, perPage: $perPage) {\n edges {\n isMainStudio\n node {\n id\n title { romaji english }\n format\n type\n status\n meanScore\n siteUrl\n }\n }\n }\n }\n }\n";
53
+ /** Batch-fetch relations for a list of media IDs */
54
+ export declare const BATCH_RELATIONS_QUERY = "\n query BatchRelations($ids: [Int]) {\n Page(perPage: 50) {\n media(id_in: $ids) {\n id\n title { romaji english }\n format\n status\n relations {\n edges {\n relationType\n node {\n id\n title { romaji english }\n format\n status\n type\n season\n seasonYear\n }\n }\n }\n }\n }\n }\n";
@@ -17,6 +17,7 @@ const MEDIA_FRAGMENT = `
17
17
  format
18
18
  status
19
19
  episodes
20
+ duration
20
21
  chapters
21
22
  volumes
22
23
  meanScore
@@ -650,3 +651,30 @@ export const STUDIO_SEARCH_QUERY = `
650
651
  }
651
652
  }
652
653
  `;
654
+ /** Batch-fetch relations for a list of media IDs */
655
+ export const BATCH_RELATIONS_QUERY = `
656
+ query BatchRelations($ids: [Int]) {
657
+ Page(perPage: 50) {
658
+ media(id_in: $ids) {
659
+ id
660
+ title { romaji english }
661
+ format
662
+ status
663
+ relations {
664
+ edges {
665
+ relationType
666
+ node {
667
+ id
668
+ title { romaji english }
669
+ format
670
+ status
671
+ type
672
+ season
673
+ seasonYear
674
+ }
675
+ }
676
+ }
677
+ }
678
+ }
679
+ }
680
+ `;
@@ -0,0 +1,36 @@
1
+ /** Franchise graph traversal for watch order guidance. */
2
+ /** Single entry in a franchise watch order */
3
+ export interface FranchiseEntry {
4
+ id: number;
5
+ title: string;
6
+ format: string | null;
7
+ status: string | null;
8
+ type: "main" | "special";
9
+ }
10
+ /** Media entry with its relations */
11
+ export interface RelationNode {
12
+ id: number;
13
+ title: {
14
+ romaji: string | null;
15
+ english: string | null;
16
+ };
17
+ format: string | null;
18
+ status: string | null;
19
+ relations: {
20
+ edges: Array<{
21
+ relationType: string;
22
+ node: {
23
+ id: number;
24
+ title: {
25
+ romaji: string | null;
26
+ english: string | null;
27
+ };
28
+ format: string | null;
29
+ status: string | null;
30
+ type: string;
31
+ };
32
+ }>;
33
+ };
34
+ }
35
+ /** Build a watch order by following SEQUEL edges from the franchise root */
36
+ export declare function buildWatchOrder(startId: number, relationsMap: Map<number, RelationNode>, includeSpecials: boolean): FranchiseEntry[];
@@ -0,0 +1,87 @@
1
+ /** Franchise graph traversal for watch order guidance. */
2
+ // Main formats in a franchise timeline
3
+ const MAIN_FORMATS = new Set(["TV", "MOVIE", "ONA", "TV_SHORT"]);
4
+ // Max BFS depth to prevent runaway traversal
5
+ const MAX_DEPTH = 30;
6
+ /** Find the earliest entry by following PREQUEL edges backward */
7
+ function findRoot(startId, relationsMap) {
8
+ let current = startId;
9
+ const visited = new Set();
10
+ while (true) {
11
+ visited.add(current);
12
+ const node = relationsMap.get(current);
13
+ if (!node)
14
+ break;
15
+ const prequel = node.relations.edges.find((e) => e.relationType === "PREQUEL" && !visited.has(e.node.id));
16
+ if (!prequel)
17
+ break;
18
+ current = prequel.node.id;
19
+ }
20
+ return current;
21
+ }
22
+ /** Resolve format/status for a node from the map or from relation edges */
23
+ function resolveNodeInfo(id, relationsMap) {
24
+ // Direct lookup
25
+ const node = relationsMap.get(id);
26
+ if (node)
27
+ return { format: node.format, status: node.status };
28
+ // Fallback: scan relation edges from other nodes
29
+ for (const n of relationsMap.values()) {
30
+ for (const edge of n.relations.edges) {
31
+ if (edge.node.id === id) {
32
+ return { format: edge.node.format, status: edge.node.status };
33
+ }
34
+ }
35
+ }
36
+ return { format: null, status: null };
37
+ }
38
+ /** Build a watch order by following SEQUEL edges from the franchise root */
39
+ export function buildWatchOrder(startId, relationsMap, includeSpecials) {
40
+ const rootId = findRoot(startId, relationsMap);
41
+ const entries = [];
42
+ const visited = new Set();
43
+ // BFS through sequel and side-story edges
44
+ const queue = [rootId];
45
+ let depth = 0;
46
+ while (queue.length > 0 && depth < MAX_DEPTH) {
47
+ const id = queue.shift();
48
+ if (id === undefined || visited.has(id))
49
+ continue;
50
+ visited.add(id);
51
+ depth++;
52
+ const node = relationsMap.get(id);
53
+ const title = node
54
+ ? (node.title.english ?? node.title.romaji ?? "Unknown")
55
+ : "Unknown";
56
+ const { format, status } = resolveNodeInfo(id, relationsMap);
57
+ const isMain = MAIN_FORMATS.has(format ?? "");
58
+ if (isMain || includeSpecials) {
59
+ entries.push({
60
+ id,
61
+ title,
62
+ format,
63
+ status,
64
+ type: isMain ? "main" : "special",
65
+ });
66
+ }
67
+ if (!node)
68
+ continue;
69
+ // Collect sequels and side stories
70
+ const sequels = [];
71
+ const sides = [];
72
+ for (const edge of node.relations.edges) {
73
+ if (visited.has(edge.node.id))
74
+ continue;
75
+ if (edge.relationType === "SEQUEL") {
76
+ sequels.push(edge.node.id);
77
+ }
78
+ else if (includeSpecials &&
79
+ (edge.relationType === "SIDE_STORY" || edge.relationType === "SPIN_OFF")) {
80
+ sides.push(edge.node.id);
81
+ }
82
+ }
83
+ // Side stories appear after their parent, before the next sequel
84
+ queue.push(...sides, ...sequels);
85
+ }
86
+ return entries;
87
+ }
@@ -9,12 +9,18 @@ export interface MoodModifiers {
9
9
  penalizeGenres: Set<string>;
10
10
  penalizeTags: Set<string>;
11
11
  }
12
+ export declare function loadCustomMoods(): void;
12
13
  /** Parse a freeform mood string into genre/tag boost and penalize sets */
13
14
  export declare function parseMood(mood: string): MoodModifiers;
14
15
  /** Check whether a mood string matches any known keywords */
15
16
  export declare function hasMoodMatch(mood: string): boolean;
16
17
  /** List all recognized mood keywords */
17
18
  export declare function getMoodKeywords(): string[];
19
+ /** Extract mood keywords as separate genre and tag arrays */
20
+ export declare function parseMoodFilters(mood: string): {
21
+ genres: string[];
22
+ tags: string[];
23
+ };
18
24
  /** Suggest mood keywords that fit the current anime season */
19
25
  export declare function seasonalMoodSuggestions(): {
20
26
  season: string;
@@ -146,16 +146,44 @@ const MOOD_SYNONYMS = {
146
146
  // competitive
147
147
  rivalry: "competitive",
148
148
  tournament: "competitive",
149
+ // additional natural language terms
150
+ psychological: "brainy",
151
+ thoughtful: "brainy",
152
+ battle: "action",
153
+ fighting: "action",
154
+ heartfelt: "sad",
155
+ touching: "sad",
156
+ lighthearted: "wholesome",
157
+ feel: "wholesome",
158
+ feels: "wholesome",
159
+ suspense: "intense",
160
+ suspenseful: "intense",
149
161
  };
150
162
  // Merge base rules and synonyms into a single lookup
151
163
  const MOOD_RULES = { ...BASE_MOOD_RULES };
152
164
  for (const [synonym, base] of Object.entries(MOOD_SYNONYMS)) {
153
165
  MOOD_RULES[synonym] = BASE_MOOD_RULES[base];
154
166
  }
167
+ // Load user-defined mood overrides from env
168
+ export function loadCustomMoods() {
169
+ const raw = process.env.ANILIST_MOOD_CONFIG;
170
+ if (!raw)
171
+ return;
172
+ try {
173
+ const custom = JSON.parse(raw);
174
+ for (const [key, rule] of Object.entries(custom)) {
175
+ MOOD_RULES[key.toLowerCase()] = rule;
176
+ }
177
+ }
178
+ catch {
179
+ console.warn("[ani-mcp] Invalid ANILIST_MOOD_CONFIG JSON, using defaults.");
180
+ }
181
+ }
182
+ loadCustomMoods();
155
183
  // === Mood Parser ===
156
184
  /** Parse a freeform mood string into genre/tag boost and penalize sets */
157
185
  export function parseMood(mood) {
158
- // Strip punctuation and split into lowercase tokens for keyword matching
186
+ // Lowercase tokens, stripped of punctuation
159
187
  const words = mood
160
188
  .toLowerCase()
161
189
  .replace(/[^a-z\s]/g, "")
@@ -168,7 +196,7 @@ export function parseMood(mood) {
168
196
  const rule = MOOD_RULES[word];
169
197
  if (!rule)
170
198
  continue;
171
- // Add to both genre and tag sets since we can't distinguish at parse time
199
+ // Add to both sets (matcher checks genres and tags separately)
172
200
  for (const name of rule.boost) {
173
201
  boostGenres.add(name);
174
202
  boostTags.add(name);
@@ -192,6 +220,40 @@ export function hasMoodMatch(mood) {
192
220
  export function getMoodKeywords() {
193
221
  return Object.keys(MOOD_RULES);
194
222
  }
223
+ // AniList's fixed genre set (stable, rarely changes)
224
+ const ANILIST_GENRES = new Set([
225
+ "Action",
226
+ "Adventure",
227
+ "Comedy",
228
+ "Drama",
229
+ "Ecchi",
230
+ "Fantasy",
231
+ "Horror",
232
+ "Mahou Shoujo",
233
+ "Mecha",
234
+ "Music",
235
+ "Mystery",
236
+ "Psychological",
237
+ "Romance",
238
+ "Sci-Fi",
239
+ "Slice of Life",
240
+ "Sports",
241
+ "Supernatural",
242
+ "Thriller",
243
+ ]);
244
+ /** Extract mood keywords as separate genre and tag arrays */
245
+ export function parseMoodFilters(mood) {
246
+ const mods = parseMood(mood);
247
+ const genres = [];
248
+ const tags = [];
249
+ for (const name of mods.boostGenres) {
250
+ if (ANILIST_GENRES.has(name))
251
+ genres.push(name);
252
+ else
253
+ tags.push(name);
254
+ }
255
+ return { genres, tags };
256
+ }
195
257
  // === Seasonal Suggestions ===
196
258
  const SEASONAL_MOODS = {
197
259
  WINTER: ["cozy", "dark", "nostalgic", "brainy"],
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
18
18
  }
19
19
  const server = new FastMCP({
20
20
  name: "ani-mcp",
21
- version: "0.4.0",
21
+ version: "0.5.0",
22
22
  });
23
23
  registerSearchTools(server);
24
24
  registerListTools(server);
package/dist/schemas.d.ts CHANGED
@@ -74,11 +74,57 @@ export declare const PickInputSchema: z.ZodObject<{
74
74
  ANIME: "ANIME";
75
75
  MANGA: "MANGA";
76
76
  }>>;
77
+ profileType: z.ZodOptional<z.ZodEnum<{
78
+ ANIME: "ANIME";
79
+ MANGA: "MANGA";
80
+ }>>;
81
+ source: z.ZodDefault<z.ZodEnum<{
82
+ PLANNING: "PLANNING";
83
+ SEASONAL: "SEASONAL";
84
+ DISCOVER: "DISCOVER";
85
+ }>>;
86
+ season: z.ZodOptional<z.ZodEnum<{
87
+ WINTER: "WINTER";
88
+ SPRING: "SPRING";
89
+ SUMMER: "SUMMER";
90
+ FALL: "FALL";
91
+ }>>;
92
+ year: z.ZodOptional<z.ZodNumber>;
77
93
  mood: z.ZodOptional<z.ZodString>;
78
94
  maxEpisodes: z.ZodOptional<z.ZodNumber>;
79
95
  limit: z.ZodDefault<z.ZodNumber>;
80
96
  }, z.core.$strip>;
81
97
  export type PickInput = z.infer<typeof PickInputSchema>;
98
+ /** Input for planning a watch/read session within a time budget */
99
+ export declare const SessionInputSchema: z.ZodObject<{
100
+ username: z.ZodOptional<z.ZodString>;
101
+ type: z.ZodDefault<z.ZodEnum<{
102
+ ANIME: "ANIME";
103
+ MANGA: "MANGA";
104
+ }>>;
105
+ minutes: z.ZodNumber;
106
+ mood: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>;
108
+ export type SessionInput = z.infer<typeof SessionInputSchema>;
109
+ /** Input for finding sequels to completed titles airing this season */
110
+ export declare const SequelAlertInputSchema: z.ZodObject<{
111
+ username: z.ZodOptional<z.ZodString>;
112
+ season: z.ZodOptional<z.ZodEnum<{
113
+ WINTER: "WINTER";
114
+ SPRING: "SPRING";
115
+ SUMMER: "SUMMER";
116
+ FALL: "FALL";
117
+ }>>;
118
+ year: z.ZodOptional<z.ZodNumber>;
119
+ }, z.core.$strip>;
120
+ export type SequelAlertInput = z.infer<typeof SequelAlertInputSchema>;
121
+ /** Input for franchise watch order guidance */
122
+ export declare const WatchOrderInputSchema: z.ZodObject<{
123
+ id: z.ZodOptional<z.ZodNumber>;
124
+ title: z.ZodOptional<z.ZodString>;
125
+ includeSpecials: z.ZodDefault<z.ZodBoolean>;
126
+ }, z.core.$strip>;
127
+ export type WatchOrderInput = z.infer<typeof WatchOrderInputSchema>;
82
128
  /** Input for comparing taste profiles between two users */
83
129
  export declare const CompareInputSchema: z.ZodObject<{
84
130
  user1: z.ZodString;
package/dist/schemas.js CHANGED
@@ -88,7 +88,15 @@ export const ListInputSchema = z.object({
88
88
  .default("ANIME")
89
89
  .describe("Get anime or manga list"),
90
90
  status: z
91
- .enum(["CURRENT", "COMPLETED", "PLANNING", "DROPPED", "PAUSED", "ALL", "CUSTOM"])
91
+ .enum([
92
+ "CURRENT",
93
+ "COMPLETED",
94
+ "PLANNING",
95
+ "DROPPED",
96
+ "PAUSED",
97
+ "ALL",
98
+ "CUSTOM",
99
+ ])
92
100
  .default("ALL")
93
101
  .describe("Filter by list status. CURRENT = watching/reading now. CUSTOM = user-created lists."),
94
102
  customListName: z
@@ -127,6 +135,27 @@ export const PickInputSchema = z.object({
127
135
  .enum(["ANIME", "MANGA"])
128
136
  .default("ANIME")
129
137
  .describe("Recommend from anime or manga planning list"),
138
+ profileType: z
139
+ .enum(["ANIME", "MANGA"])
140
+ .optional()
141
+ .describe("Build taste profile from this media type. Defaults to same as type. " +
142
+ "Set to get cross-media recs, e.g. anime picks based on manga taste."),
143
+ source: z
144
+ .enum(["PLANNING", "SEASONAL", "DISCOVER"])
145
+ .default("PLANNING")
146
+ .describe("Where to find candidates. PLANNING = user's plan-to-watch list (default). " +
147
+ "SEASONAL = currently airing anime. DISCOVER = top-rated titles matching taste."),
148
+ season: z
149
+ .enum(["WINTER", "SPRING", "SUMMER", "FALL"])
150
+ .optional()
151
+ .describe("Season for SEASONAL source. Defaults to the current season."),
152
+ year: z
153
+ .number()
154
+ .int()
155
+ .min(1940)
156
+ .max(new Date().getFullYear() + 2)
157
+ .optional()
158
+ .describe("Year for SEASONAL source. Defaults to the current year."),
130
159
  mood: z
131
160
  .string()
132
161
  .optional()
@@ -145,6 +174,61 @@ export const PickInputSchema = z.object({
145
174
  .default(5)
146
175
  .describe("Number of recommendations to return (default 5, max 15)"),
147
176
  });
177
+ /** Input for planning a watch/read session within a time budget */
178
+ export const SessionInputSchema = z.object({
179
+ username: usernameSchema
180
+ .optional()
181
+ .describe("AniList username. Falls back to configured default if not provided."),
182
+ type: z
183
+ .enum(["ANIME", "MANGA"])
184
+ .default("ANIME")
185
+ .describe("Plan session from anime or manga currently-watching list"),
186
+ minutes: z
187
+ .number()
188
+ .int()
189
+ .min(10)
190
+ .max(720)
191
+ .describe("Time budget in minutes (10-720)"),
192
+ mood: z
193
+ .string()
194
+ .optional()
195
+ .describe('Optional mood to prioritize titles, e.g. "dark", "chill"'),
196
+ });
197
+ /** Input for finding sequels to completed titles airing this season */
198
+ export const SequelAlertInputSchema = z.object({
199
+ username: usernameSchema
200
+ .optional()
201
+ .describe("AniList username. Falls back to configured default if not provided."),
202
+ season: z
203
+ .enum(["WINTER", "SPRING", "SUMMER", "FALL"])
204
+ .optional()
205
+ .describe("Season to check for sequels. Defaults to the current season."),
206
+ year: z
207
+ .number()
208
+ .int()
209
+ .min(1940)
210
+ .max(new Date().getFullYear() + 2)
211
+ .optional()
212
+ .describe("Year to check. Defaults to the current year."),
213
+ });
214
+ /** Input for franchise watch order guidance */
215
+ export const WatchOrderInputSchema = z
216
+ .object({
217
+ id: z
218
+ .number()
219
+ .int()
220
+ .positive()
221
+ .optional()
222
+ .describe("AniList media ID of any title in the franchise"),
223
+ title: z.string().optional().describe("Search by title if no ID is known"),
224
+ includeSpecials: z
225
+ .boolean()
226
+ .default(false)
227
+ .describe("Include OVAs, specials, and spin-offs in the watch order"),
228
+ })
229
+ .refine((data) => data.id !== undefined || data.title !== undefined, {
230
+ message: "Provide either an id or a title.",
231
+ });
148
232
  /** Input for comparing taste profiles between two users */
149
233
  export const CompareInputSchema = z.object({
150
234
  user1: usernameSchema.describe("First AniList username"),
@@ -514,16 +598,8 @@ export const ProfileInputSchema = z.object({
514
598
  /** Input for fetching community reviews for a title */
515
599
  export const ReviewsInputSchema = z
516
600
  .object({
517
- id: z
518
- .number()
519
- .int()
520
- .positive()
521
- .optional()
522
- .describe("AniList media ID"),
523
- title: z
524
- .string()
525
- .optional()
526
- .describe("Search by title if no ID is known"),
601
+ id: z.number().int().positive().optional().describe("AniList media ID"),
602
+ title: z.string().optional().describe("Search by title if no ID is known"),
527
603
  sort: z
528
604
  .enum(["HELPFUL", "NEWEST"])
529
605
  .default("HELPFUL")
@@ -10,7 +10,7 @@ export function registerDiscoverTools(server) {
10
10
  name: "anilist_trending",
11
11
  description: "Show what's trending on AniList right now. " +
12
12
  "Use when the user asks what's hot, trending, or generating buzz. " +
13
- "No search term needed - returns titles ranked by current trending score.",
13
+ "No search term needed. Returns ranked list with title, format, score, genres, and episode count.",
14
14
  parameters: TrendingInputSchema,
15
15
  annotations: {
16
16
  title: "Trending Now",
@@ -51,7 +51,7 @@ export function registerDiscoverTools(server) {
51
51
  description: "Browse top anime or manga in a specific genre. " +
52
52
  "Use when the user asks for the best titles in a genre, " +
53
53
  'e.g. "best romance anime" or "top thriller manga from 2023". ' +
54
- "No search term needed - discovers by genre with optional year/status/format filters.",
54
+ "Supports year, status, and format filters. Returns ranked list with title, score, and genres.",
55
55
  parameters: GenreBrowseInputSchema,
56
56
  annotations: {
57
57
  title: "Browse by Genre",
@@ -113,8 +113,8 @@ export function registerDiscoverTools(server) {
113
113
  server.addTool({
114
114
  name: "anilist_genre_list",
115
115
  description: "List all valid AniList genres and content tags. " +
116
- "Use when the user asks what genres exist, wants to see available tags, " +
117
- "or needs valid genre names for filtering other tools.",
116
+ "Use before genre-filtering tools to ensure valid genre names. " +
117
+ "Returns genres and content tags grouped by category with descriptions.",
118
118
  parameters: GenreListInputSchema,
119
119
  annotations: {
120
120
  title: "List Genres & Tags",
@@ -79,7 +79,7 @@ export function registerInfoTools(server) {
79
79
  name: "anilist_staff",
80
80
  description: "Get staff and voice actor credits for an anime or manga. " +
81
81
  "Use when the user asks who directed, wrote, or voiced characters in a title. " +
82
- "Shows directors, writers, character designers, and Japanese voice actors.",
82
+ "Returns production staff with roles and characters with Japanese voice actors.",
83
83
  parameters: StaffInputSchema,
84
84
  annotations: {
85
85
  title: "Get Staff Credits",
@@ -138,7 +138,8 @@ export function registerInfoTools(server) {
138
138
  name: "anilist_schedule",
139
139
  description: "Get the airing schedule for an anime. " +
140
140
  "Use when the user asks when the next episode airs, " +
141
- "or wants to see upcoming episode dates for a currently airing show.",
141
+ "or wants to see upcoming episode dates for a currently airing show. " +
142
+ "Returns next episode date/countdown and upcoming episode schedule.",
142
143
  parameters: ScheduleInputSchema,
143
144
  annotations: {
144
145
  title: "Airing Schedule",
@@ -200,7 +201,8 @@ export function registerInfoTools(server) {
200
201
  name: "anilist_characters",
201
202
  description: "Search for anime/manga characters by name. " +
202
203
  "Use when the user asks about a specific character, wants to know " +
203
- "which series a character appears in, or who voices them.",
204
+ "which series a character appears in, or who voices them. " +
205
+ "Returns character appearances with roles and voice actors.",
204
206
  parameters: CharacterSearchInputSchema,
205
207
  annotations: {
206
208
  title: "Search Characters",
@@ -250,8 +252,8 @@ export function registerInfoTools(server) {
250
252
  server.addTool({
251
253
  name: "anilist_staff_search",
252
254
  description: "Search for anime/manga staff by name and see their works. " +
253
- "Use when the user asks about a director, voice actor, animator, or writer " +
254
- "and wants to see everything they have worked on.",
255
+ "Use when the user asks about a director, voice actor, animator, or writer. " +
256
+ "Returns staff occupations, works with roles, and scores.",
255
257
  parameters: StaffSearchInputSchema,
256
258
  annotations: {
257
259
  title: "Search Staff",
@@ -325,8 +327,8 @@ export function registerInfoTools(server) {
325
327
  server.addTool({
326
328
  name: "anilist_studio_search",
327
329
  description: "Search for an animation studio by name and see their productions. " +
328
- "Use when the user asks about a studio like MAPPA, Kyoto Animation, or Bones " +
329
- "and wants to see what they have produced.",
330
+ "Use when the user asks about a studio like MAPPA, Kyoto Animation, or Bones. " +
331
+ "Returns main and supporting productions with format, score, and status.",
330
332
  parameters: StudioSearchInputSchema,
331
333
  annotations: {
332
334
  title: "Search Studios",