ani-mcp 0.3.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)
@@ -4,7 +4,7 @@
4
4
  * Handles rate limiting (token bucket), retry with exponential backoff,
5
5
  * and in-memory caching.
6
6
  */
7
- import type { AniListMediaListEntry } from "../types.js";
7
+ import type { AniListMediaListEntry, UserListResponse } from "../types.js";
8
8
  /** Per-category TTLs for the query cache */
9
9
  export declare const CACHE_TTLS: {
10
10
  readonly media: number;
@@ -31,6 +31,8 @@ declare class AniListClient {
31
31
  constructor();
32
32
  /** Execute a GraphQL query with caching and automatic retry */
33
33
  query<T = unknown>(query: string, variables?: Record<string, unknown>, options?: QueryOptions): Promise<T>;
34
+ /** Fetch a user's media list groups with metadata (name, status, isCustomList) */
35
+ fetchListGroups(username: string, type: string, status?: string, sort?: string[]): Promise<UserListResponse["MediaListCollection"]["lists"]>;
34
36
  /** Fetch a user's media list, flattened into a single array */
35
37
  fetchList(username: string, type: string, status?: string, sort?: string[]): Promise<AniListMediaListEntry[]>;
36
38
  /** Invalidate the entire query cache */
@@ -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;
@@ -87,17 +87,22 @@ class AniListClient {
87
87
  // No cache category - skip caching entirely
88
88
  return this.executeWithRetry(query, variables);
89
89
  }
90
- /** Fetch a user's media list, flattened into a single array */
91
- async fetchList(username, type, status, sort) {
90
+ /** Fetch a user's media list groups with metadata (name, status, isCustomList) */
91
+ async fetchListGroups(username, type, status, sort) {
92
92
  const variables = { userName: username, type };
93
93
  if (status)
94
94
  variables.status = status;
95
95
  if (sort)
96
96
  variables.sort = sort;
97
97
  const data = await this.query(USER_LIST_QUERY, variables, { cache: "list" });
98
+ return data.MediaListCollection.lists;
99
+ }
100
+ /** Fetch a user's media list, flattened into a single array */
101
+ async fetchList(username, type, status, sort) {
102
+ const lists = await this.fetchListGroups(username, type, status, sort);
98
103
  // Flatten across status groups
99
104
  const entries = [];
100
- for (const list of data.MediaListCollection.lists) {
105
+ for (const list of lists) {
101
106
  entries.push(...list.entries);
102
107
  }
103
108
  return entries;
@@ -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,12 +31,24 @@ 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 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 */
38
38
  export declare const VIEWER_QUERY = "\n query Viewer {\n Viewer {\n id\n name\n avatar { medium }\n siteUrl\n mediaListOptions {\n scoreFormat\n }\n }\n }\n";
39
39
  /** All valid genres and media tags */
40
40
  export declare const GENRE_TAG_COLLECTION_QUERY = "\n query GenreTagCollection {\n GenreCollection\n MediaTagCollection {\n name\n description\n category\n isAdult\n }\n }\n";
41
+ /** Toggle favourite on any entity type */
42
+ export declare const TOGGLE_FAVOURITE_MUTATION = "\n mutation ToggleFavourite(\n $animeId: Int\n $mangaId: Int\n $characterId: Int\n $staffId: Int\n $studioId: Int\n ) {\n ToggleFavourite(\n animeId: $animeId\n mangaId: $mangaId\n characterId: $characterId\n staffId: $staffId\n studioId: $studioId\n ) {\n anime { nodes { id } }\n manga { nodes { id } }\n characters { nodes { id } }\n staff { nodes { id } }\n studios { nodes { id } }\n }\n }\n";
43
+ /** Post a text activity to the authenticated user's feed */
44
+ export declare const SAVE_TEXT_ACTIVITY_MUTATION = "\n mutation SaveTextActivity($text: String!) {\n SaveTextActivity(text: $text) {\n id\n createdAt\n text\n user { name }\n }\n }\n";
45
+ /** Recent activity for a user, supports text and list activity types */
46
+ export declare const ACTIVITY_FEED_QUERY = "\n query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total currentPage hasNextPage }\n activities(userId: $userId, type: $type, sort: ID_DESC) {\n ... on TextActivity {\n __typename\n id\n text\n createdAt\n user { name }\n }\n ... on ListActivity {\n __typename\n id\n status\n progress\n createdAt\n user { name }\n media {\n id\n title { romaji english native }\n type\n }\n }\n }\n }\n }\n";
47
+ /** User profile with bio, stats summary, and top favourites */
48
+ export declare const USER_PROFILE_QUERY = "\n query UserProfile($name: String) {\n User(name: $name) {\n id\n name\n about\n avatar { large }\n bannerImage\n siteUrl\n createdAt\n updatedAt\n donatorTier\n statistics {\n anime {\n count\n meanScore\n episodesWatched\n minutesWatched\n }\n manga {\n count\n meanScore\n chaptersRead\n volumesRead\n }\n }\n favourites {\n anime(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n manga(perPage: 5) {\n nodes { id title { romaji english native } siteUrl }\n }\n characters(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n staff(perPage: 5) {\n nodes { id name { full } siteUrl }\n }\n studios(perPage: 5) {\n nodes { id name siteUrl }\n }\n }\n }\n }\n";
49
+ /** Community reviews for a media title */
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";
41
51
  /** Search for a studio by name with their productions */
42
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
@@ -418,6 +419,7 @@ export const USER_LIST_QUERY = `
418
419
  lists {
419
420
  name
420
421
  status
422
+ isCustomList
421
423
  entries {
422
424
  id
423
425
  score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
@@ -489,6 +491,141 @@ export const GENRE_TAG_COLLECTION_QUERY = `
489
491
  }
490
492
  }
491
493
  `;
494
+ // === 0.4.0 Social & Favourites ===
495
+ /** Toggle favourite on any entity type */
496
+ export const TOGGLE_FAVOURITE_MUTATION = `
497
+ mutation ToggleFavourite(
498
+ $animeId: Int
499
+ $mangaId: Int
500
+ $characterId: Int
501
+ $staffId: Int
502
+ $studioId: Int
503
+ ) {
504
+ ToggleFavourite(
505
+ animeId: $animeId
506
+ mangaId: $mangaId
507
+ characterId: $characterId
508
+ staffId: $staffId
509
+ studioId: $studioId
510
+ ) {
511
+ anime { nodes { id } }
512
+ manga { nodes { id } }
513
+ characters { nodes { id } }
514
+ staff { nodes { id } }
515
+ studios { nodes { id } }
516
+ }
517
+ }
518
+ `;
519
+ /** Post a text activity to the authenticated user's feed */
520
+ export const SAVE_TEXT_ACTIVITY_MUTATION = `
521
+ mutation SaveTextActivity($text: String!) {
522
+ SaveTextActivity(text: $text) {
523
+ id
524
+ createdAt
525
+ text
526
+ user { name }
527
+ }
528
+ }
529
+ `;
530
+ /** Recent activity for a user, supports text and list activity types */
531
+ export const ACTIVITY_FEED_QUERY = `
532
+ query ActivityFeed($userId: Int, $type: ActivityType, $page: Int, $perPage: Int) {
533
+ Page(page: $page, perPage: $perPage) {
534
+ pageInfo { total currentPage hasNextPage }
535
+ activities(userId: $userId, type: $type, sort: ID_DESC) {
536
+ ... on TextActivity {
537
+ __typename
538
+ id
539
+ text
540
+ createdAt
541
+ user { name }
542
+ }
543
+ ... on ListActivity {
544
+ __typename
545
+ id
546
+ status
547
+ progress
548
+ createdAt
549
+ user { name }
550
+ media {
551
+ id
552
+ title { romaji english native }
553
+ type
554
+ }
555
+ }
556
+ }
557
+ }
558
+ }
559
+ `;
560
+ /** User profile with bio, stats summary, and top favourites */
561
+ export const USER_PROFILE_QUERY = `
562
+ query UserProfile($name: String) {
563
+ User(name: $name) {
564
+ id
565
+ name
566
+ about
567
+ avatar { large }
568
+ bannerImage
569
+ siteUrl
570
+ createdAt
571
+ updatedAt
572
+ donatorTier
573
+ statistics {
574
+ anime {
575
+ count
576
+ meanScore
577
+ episodesWatched
578
+ minutesWatched
579
+ }
580
+ manga {
581
+ count
582
+ meanScore
583
+ chaptersRead
584
+ volumesRead
585
+ }
586
+ }
587
+ favourites {
588
+ anime(perPage: 5) {
589
+ nodes { id title { romaji english native } siteUrl }
590
+ }
591
+ manga(perPage: 5) {
592
+ nodes { id title { romaji english native } siteUrl }
593
+ }
594
+ characters(perPage: 5) {
595
+ nodes { id name { full } siteUrl }
596
+ }
597
+ staff(perPage: 5) {
598
+ nodes { id name { full } siteUrl }
599
+ }
600
+ studios(perPage: 5) {
601
+ nodes { id name siteUrl }
602
+ }
603
+ }
604
+ }
605
+ }
606
+ `;
607
+ /** Community reviews for a media title */
608
+ export const MEDIA_REVIEWS_QUERY = `
609
+ query MediaReviews($id: Int, $search: String, $page: Int, $perPage: Int, $sort: [ReviewSort]) {
610
+ Media(id: $id, search: $search) {
611
+ id
612
+ title { romaji english native }
613
+ reviews(page: $page, perPage: $perPage, sort: $sort) {
614
+ pageInfo { total hasNextPage }
615
+ nodes {
616
+ id
617
+ score
618
+ summary
619
+ body
620
+ rating
621
+ ratingAmount
622
+ createdAt
623
+ user { name siteUrl }
624
+ }
625
+ }
626
+ }
627
+ }
628
+ `;
492
629
  /** Search for a studio by name with their productions */
493
630
  export const STUDIO_SEARCH_QUERY = `
494
631
  query StudioSearch($search: String!, $perPage: Int) {
@@ -514,3 +651,30 @@ export const STUDIO_SEARCH_QUERY = `
514
651
  }
515
652
  }
516
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
@@ -8,6 +8,7 @@ import { registerRecommendTools } from "./tools/recommend.js";
8
8
  import { registerDiscoverTools } from "./tools/discover.js";
9
9
  import { registerInfoTools } from "./tools/info.js";
10
10
  import { registerWriteTools } from "./tools/write.js";
11
+ import { registerSocialTools } from "./tools/social.js";
11
12
  // Both vars are optional - warn on missing so operators know what's available
12
13
  if (!process.env.ANILIST_USERNAME) {
13
14
  console.warn("ANILIST_USERNAME not set - tools will require a username argument.");
@@ -17,7 +18,7 @@ if (!process.env.ANILIST_TOKEN) {
17
18
  }
18
19
  const server = new FastMCP({
19
20
  name: "ani-mcp",
20
- version: "0.1.0",
21
+ version: "0.5.0",
21
22
  });
22
23
  registerSearchTools(server);
23
24
  registerListTools(server);
@@ -25,6 +26,7 @@ registerRecommendTools(server);
25
26
  registerDiscoverTools(server);
26
27
  registerInfoTools(server);
27
28
  registerWriteTools(server);
29
+ registerSocialTools(server);
28
30
  // === Transport ===
29
31
  const transport = process.env.MCP_TRANSPORT === "http" ? "httpStream" : "stdio";
30
32
  if (transport === "httpStream") {