ani-mcp 0.8.3 → 0.9.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
@@ -95,6 +95,7 @@ Works with any MCP-compatible client.
95
95
  | `anilist_sequels` | Sequels airing this season for titles you've completed |
96
96
  | `anilist_watch_order` | Viewing order for a franchise |
97
97
  | `anilist_session` | Plan a viewing session within a time budget |
98
+ | `anilist_mal_import` | Import a MyAnimeList user's list and generate recommendations |
98
99
 
99
100
  ### Info
100
101
 
@@ -104,6 +105,7 @@ Works with any MCP-compatible client.
104
105
  | `anilist_staff_search` | Search for a person by name and see all their works |
105
106
  | `anilist_studio_search` | Search for a studio and see their productions |
106
107
  | `anilist_schedule` | Airing schedule and next episode countdown |
108
+ | `anilist_airing` | Upcoming episodes for titles you're currently watching |
107
109
  | `anilist_characters` | Search characters by name with appearances and VAs |
108
110
  | `anilist_whoami` | Check authentication status and score format |
109
111
 
@@ -0,0 +1,23 @@
1
+ /** Jikan (MyAnimeList) API client for read-only list import */
2
+ export interface JikanAnimeEntry {
3
+ score: number;
4
+ episodes_watched: number;
5
+ anime: {
6
+ mal_id: number;
7
+ title: string;
8
+ type: string;
9
+ episodes: number | null;
10
+ score: number | null;
11
+ genres: Array<{
12
+ mal_id: number;
13
+ name: string;
14
+ }>;
15
+ year: number | null;
16
+ };
17
+ }
18
+ /** Map a MAL genre name to its AniList equivalent */
19
+ export declare function mapMalGenre(name: string): string;
20
+ /** Map a MAL format string to AniList format */
21
+ export declare function mapMalFormat(malType: string): string;
22
+ /** Fetch a MAL user's completed anime list via Jikan */
23
+ export declare function fetchMalList(username: string, maxPages?: number): Promise<JikanAnimeEntry[]>;
@@ -0,0 +1,61 @@
1
+ /** Jikan (MyAnimeList) API client for read-only list import */
2
+ import pThrottle from "p-throttle";
3
+ import pRetry, { AbortError } from "p-retry";
4
+ const JIKAN_BASE = "https://api.jikan.moe/v4";
5
+ const FETCH_TIMEOUT_MS = 15_000;
6
+ // Jikan rate limit: 3 req/sec (unauthenticated)
7
+ const throttle = pThrottle({
8
+ limit: process.env.VITEST ? 10_000 : 3,
9
+ interval: 1_000,
10
+ });
11
+ const throttled = throttle(() => { });
12
+ // === Genre Mapping ===
13
+ // MAL and AniList share most genre names; only map exceptions
14
+ const MAL_TO_ANILIST_GENRE = {
15
+ "Sci-Fi": "Sci-Fi",
16
+ "Slice of Life": "Slice of Life",
17
+ };
18
+ /** Map a MAL genre name to its AniList equivalent */
19
+ export function mapMalGenre(name) {
20
+ return MAL_TO_ANILIST_GENRE[name] ?? name;
21
+ }
22
+ /** Map a MAL format string to AniList format */
23
+ export function mapMalFormat(malType) {
24
+ const map = {
25
+ TV: "TV",
26
+ Movie: "MOVIE",
27
+ OVA: "OVA",
28
+ ONA: "ONA",
29
+ Special: "SPECIAL",
30
+ Music: "MUSIC",
31
+ };
32
+ return map[malType] ?? "TV";
33
+ }
34
+ // === Client ===
35
+ /** Fetch a MAL user's completed anime list via Jikan */
36
+ export async function fetchMalList(username, maxPages = 5) {
37
+ const entries = [];
38
+ for (let page = 1; page <= maxPages; page++) {
39
+ const data = await fetchPage(username, page);
40
+ entries.push(...data.data);
41
+ if (!data.pagination.has_next_page)
42
+ break;
43
+ }
44
+ return entries;
45
+ }
46
+ async function fetchPage(username, page) {
47
+ return pRetry(async () => {
48
+ await throttled();
49
+ const url = `${JIKAN_BASE}/users/${encodeURIComponent(username)}/animelist?status=completed&page=${page}`;
50
+ const response = await fetch(url, {
51
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
52
+ });
53
+ if (!response.ok) {
54
+ if (response.status === 404) {
55
+ throw new AbortError(`MAL user "${username}" not found.`);
56
+ }
57
+ throw new Error(`Jikan API error (HTTP ${response.status})`);
58
+ }
59
+ return (await response.json());
60
+ }, { retries: 3 });
61
+ }
@@ -5,35 +5,37 @@
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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 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";
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 extraLarge }\n trailer { id site thumbnail }\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 */
26
26
  export declare const AIRING_SCHEDULE_QUERY = "\n query AiringSchedule($id: Int, $search: String, $notYetAired: Boolean) {\n Media(id: $id, search: $search) {\n id\n title { romaji english native }\n status\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n airingSchedule(notYetAired: $notYetAired, perPage: 10) {\n nodes {\n episode\n airingAt\n timeUntilAiring\n }\n }\n siteUrl\n }\n }\n";
27
+ /** Batch-fetch next airing episodes for multiple media IDs */
28
+ export declare const BATCH_AIRING_QUERY = "\n query BatchAiring($ids: [Int], $perPage: Int) {\n Page(perPage: $perPage) {\n media(id_in: $ids, status: RELEASING) {\n id\n title { romaji english native }\n format\n episodes\n nextAiringEpisode {\n episode\n airingAt\n timeUntilAiring\n }\n siteUrl\n }\n }\n }\n";
27
29
  /** Search for characters by name */
28
30
  export declare const CHARACTER_SEARCH_QUERY = "\n query CharacterSearch($search: String!, $page: Int, $perPage: Int) {\n Page(page: $page, perPage: $perPage) {\n pageInfo { total hasNextPage }\n characters(search: $search, sort: FAVOURITES_DESC) {\n id\n name { full native alternative }\n image { medium }\n favourites\n siteUrl\n media(sort: POPULARITY_DESC, perPage: 5) {\n edges {\n characterRole\n node {\n id\n title { romaji english }\n format\n type\n siteUrl\n }\n voiceActors(language: JAPANESE) {\n id\n name { full }\n siteUrl\n }\n }\n }\n }\n }\n }\n";
29
31
  /** Create or update a list entry */
30
- export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n }\n }\n";
32
+ export declare const SAVE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation SaveMediaListEntry(\n $mediaId: Int\n $status: MediaListStatus\n $scoreRaw: Int\n $progress: Int\n $progressVolumes: Int\n $notes: String\n $private: Boolean\n ) {\n SaveMediaListEntry(\n mediaId: $mediaId\n status: $status\n scoreRaw: $scoreRaw\n progress: $progress\n progressVolumes: $progressVolumes\n notes: $notes\n private: $private\n ) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n progressVolumes\n }\n }\n";
31
33
  /** Fetch a single list entry for snapshotting before mutations */
32
- export declare const MEDIA_LIST_ENTRY_QUERY = "\n query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {\n MediaList(id: $id, mediaId: $mediaId, userName: $userName) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n notes\n private\n }\n }\n";
34
+ export declare const MEDIA_LIST_ENTRY_QUERY = "\n query MediaListEntry($id: Int, $mediaId: Int, $userName: String) {\n MediaList(id: $id, mediaId: $mediaId, userName: $userName) {\n id\n mediaId\n status\n score(format: POINT_10)\n progress\n progressVolumes\n notes\n private\n }\n }\n";
33
35
  /** Remove a list entry */
34
36
  export declare const DELETE_MEDIA_LIST_ENTRY_MUTATION = "\n mutation DeleteMediaListEntry($id: Int!) {\n DeleteMediaListEntry(id: $id) {\n deleted\n }\n }\n";
35
37
  /** User's anime/manga list, grouped by status. Omit $status to get all lists. */
36
- 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";
38
+ 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 progressVolumes\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 extraLarge }\n trailer { id site thumbnail }\n siteUrl\n description(asHtml: false)\n }\n\n";
37
39
  /** Search for staff by name with their top works */
38
40
  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";
39
41
  /** Authenticated user info */
@@ -38,7 +38,8 @@ const MEDIA_FRAGMENT = `
38
38
  }
39
39
  source
40
40
  isAdult
41
- coverImage { large }
41
+ coverImage { large extraLarge }
42
+ trailer { id site thumbnail }
42
43
  siteUrl
43
44
  description(asHtml: false)
44
45
  }
@@ -336,6 +337,25 @@ export const AIRING_SCHEDULE_QUERY = `
336
337
  }
337
338
  }
338
339
  `;
340
+ /** Batch-fetch next airing episodes for multiple media IDs */
341
+ export const BATCH_AIRING_QUERY = `
342
+ query BatchAiring($ids: [Int], $perPage: Int) {
343
+ Page(perPage: $perPage) {
344
+ media(id_in: $ids, status: RELEASING) {
345
+ id
346
+ title { romaji english native }
347
+ format
348
+ episodes
349
+ nextAiringEpisode {
350
+ episode
351
+ airingAt
352
+ timeUntilAiring
353
+ }
354
+ siteUrl
355
+ }
356
+ }
357
+ }
358
+ `;
339
359
  /** Search for characters by name */
340
360
  export const CHARACTER_SEARCH_QUERY = `
341
361
  query CharacterSearch($search: String!, $page: Int, $perPage: Int) {
@@ -375,6 +395,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
375
395
  $status: MediaListStatus
376
396
  $scoreRaw: Int
377
397
  $progress: Int
398
+ $progressVolumes: Int
378
399
  $notes: String
379
400
  $private: Boolean
380
401
  ) {
@@ -383,6 +404,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
383
404
  status: $status
384
405
  scoreRaw: $scoreRaw
385
406
  progress: $progress
407
+ progressVolumes: $progressVolumes
386
408
  notes: $notes
387
409
  private: $private
388
410
  ) {
@@ -391,6 +413,7 @@ export const SAVE_MEDIA_LIST_ENTRY_MUTATION = `
391
413
  status
392
414
  score(format: POINT_10)
393
415
  progress
416
+ progressVolumes
394
417
  }
395
418
  }
396
419
  `;
@@ -403,6 +426,7 @@ export const MEDIA_LIST_ENTRY_QUERY = `
403
426
  status
404
427
  score(format: POINT_10)
405
428
  progress
429
+ progressVolumes
406
430
  notes
407
431
  private
408
432
  }
@@ -438,6 +462,7 @@ export const USER_LIST_QUERY = `
438
462
  id
439
463
  score(format: POINT_10) # normalize to 1-10 scale regardless of user's profile setting
440
464
  progress
465
+ progressVolumes
441
466
  status
442
467
  updatedAt
443
468
  startedAt { year month day }
@@ -28,3 +28,5 @@ export interface TasteProfile {
28
28
  export declare function buildTasteProfile(entries: AniListMediaListEntry[]): TasteProfile;
29
29
  /** Summarize a taste profile as natural language */
30
30
  export declare function describeTasteProfile(profile: TasteProfile, username: string): string;
31
+ /** Detailed profile breakdown: genre weights, top themes, score distribution */
32
+ export declare function formatTasteProfileText(profile: TasteProfile): string[];
@@ -177,6 +177,37 @@ function computeDecay(entry) {
177
177
  const yearsSince = (now - epoch) / (365.25 * 24 * 3600);
178
178
  return Math.exp(-DECAY_LAMBDA * Math.max(0, yearsSince));
179
179
  }
180
+ /** Detailed profile breakdown: genre weights, top themes, score distribution */
181
+ export function formatTasteProfileText(profile) {
182
+ const lines = [];
183
+ // Detailed genre breakdown
184
+ if (profile.genres.length > 0) {
185
+ lines.push("", "Genre Weights (higher = stronger preference):");
186
+ for (const g of profile.genres.slice(0, 10)) {
187
+ lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
188
+ }
189
+ }
190
+ // Detailed tag breakdown
191
+ if (profile.tags.length > 0) {
192
+ lines.push("", "Top Themes:");
193
+ for (const t of profile.tags.slice(0, 10)) {
194
+ lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
195
+ }
196
+ }
197
+ // Score distribution bar chart
198
+ if (profile.scoring.totalScored > 0) {
199
+ lines.push("", "Score Distribution:");
200
+ for (let s = 10; s >= 1; s--) {
201
+ const count = profile.scoring.distribution[s] ?? 0;
202
+ if (count > 0) {
203
+ // Cap at 30 chars
204
+ const bar = "#".repeat(Math.min(count, 30));
205
+ lines.push(` ${s}/10: ${bar} (${count})`);
206
+ }
207
+ }
208
+ }
209
+ return lines;
210
+ }
180
211
  /** Empty profile for users with too few scored entries */
181
212
  function emptyProfile(totalCompleted) {
182
213
  return {
@@ -5,6 +5,7 @@ export interface EntrySnapshot {
5
5
  status: string;
6
6
  score: number;
7
7
  progress: number;
8
+ progressVolumes: number;
8
9
  notes: string | null;
9
10
  private: boolean;
10
11
  }
package/dist/index.js CHANGED
@@ -10,6 +10,7 @@ import { registerInfoTools } from "./tools/info.js";
10
10
  import { registerWriteTools } from "./tools/write.js";
11
11
  import { registerSocialTools } from "./tools/social.js";
12
12
  import { registerAnalyticsTools } from "./tools/analytics.js";
13
+ import { registerImportTools } from "./tools/import.js";
13
14
  import { registerResources } from "./resources.js";
14
15
  import { registerPrompts } from "./prompts.js";
15
16
  // Both vars are optional - warn on missing so operators know what's available
@@ -21,7 +22,7 @@ if (!process.env.ANILIST_TOKEN) {
21
22
  }
22
23
  const server = new FastMCP({
23
24
  name: "ani-mcp",
24
- version: "0.8.3",
25
+ version: "0.9.0",
25
26
  });
26
27
  registerSearchTools(server);
27
28
  registerListTools(server);
@@ -31,6 +32,7 @@ registerInfoTools(server);
31
32
  registerWriteTools(server);
32
33
  registerSocialTools(server);
33
34
  registerAnalyticsTools(server);
35
+ registerImportTools(server);
34
36
  registerResources(server);
35
37
  registerPrompts(server);
36
38
  // === Transport ===
package/dist/resources.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** MCP Resources: expose user context without tool calls */
2
2
  import { anilistClient } from "./api/client.js";
3
3
  import { USER_PROFILE_QUERY } from "./api/queries.js";
4
- import { buildTasteProfile, describeTasteProfile, } from "./engine/taste.js";
4
+ import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "./engine/taste.js";
5
5
  import { formatProfile } from "./tools/social.js";
6
6
  import { formatListEntry } from "./tools/lists.js";
7
7
  import { getDefaultUsername, getScoreFormat } from "./utils.js";
@@ -99,31 +99,7 @@ function formatTasteProfile(profile, username) {
99
99
  `# Taste Profile: ${username}`,
100
100
  "",
101
101
  describeTasteProfile(profile, username),
102
+ ...formatTasteProfileText(profile),
102
103
  ];
103
- // Detailed genre breakdown
104
- if (profile.genres.length > 0) {
105
- lines.push("", "Genre Weights (higher = stronger preference):");
106
- for (const g of profile.genres.slice(0, 10)) {
107
- lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
108
- }
109
- }
110
- // Detailed tag breakdown
111
- if (profile.tags.length > 0) {
112
- lines.push("", "Top Themes:");
113
- for (const t of profile.tags.slice(0, 10)) {
114
- lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
115
- }
116
- }
117
- // Score distribution
118
- if (profile.scoring.totalScored > 0) {
119
- lines.push("", "Score Distribution:");
120
- for (let s = 10; s >= 1; s--) {
121
- const count = profile.scoring.distribution[s] ?? 0;
122
- if (count > 0) {
123
- const bar = "#".repeat(Math.min(count, 30));
124
- lines.push(` ${s}/10: ${bar} (${count})`);
125
- }
126
- }
127
- }
128
104
  return lines.join("\n");
129
105
  }
package/dist/schemas.d.ts CHANGED
@@ -229,6 +229,18 @@ export declare const ScheduleInputSchema: z.ZodObject<{
229
229
  title: z.ZodOptional<z.ZodString>;
230
230
  }, z.core.$strip>;
231
231
  export type ScheduleInput = z.infer<typeof ScheduleInputSchema>;
232
+ /** Input for airing tracker across currently watching titles */
233
+ export declare const AiringTrackerInputSchema: z.ZodObject<{
234
+ username: z.ZodOptional<z.ZodString>;
235
+ limit: z.ZodDefault<z.ZodNumber>;
236
+ }, z.core.$strip>;
237
+ export type AiringTrackerInput = z.infer<typeof AiringTrackerInputSchema>;
238
+ /** Input for importing a MyAnimeList user's list for recommendations */
239
+ export declare const MalImportInputSchema: z.ZodObject<{
240
+ malUsername: z.ZodString;
241
+ limit: z.ZodDefault<z.ZodNumber>;
242
+ }, z.core.$strip>;
243
+ export type MalImportInput = z.infer<typeof MalImportInputSchema>;
232
244
  /** Input for character search */
233
245
  export declare const CharacterSearchInputSchema: z.ZodObject<{
234
246
  query: z.ZodString;
@@ -247,6 +259,7 @@ export type RecommendationsInput = z.infer<typeof RecommendationsInputSchema>;
247
259
  export declare const UpdateProgressInputSchema: z.ZodObject<{
248
260
  mediaId: z.ZodNumber;
249
261
  progress: z.ZodNumber;
262
+ volumeProgress: z.ZodOptional<z.ZodNumber>;
250
263
  status: z.ZodOptional<z.ZodEnum<{
251
264
  CURRENT: "CURRENT";
252
265
  COMPLETED: "COMPLETED";
package/dist/schemas.js CHANGED
@@ -389,6 +389,34 @@ export const ScheduleInputSchema = z
389
389
  .refine((data) => data.id !== undefined || data.title !== undefined, {
390
390
  message: "Provide either an id or a title.",
391
391
  });
392
+ /** Input for airing tracker across currently watching titles */
393
+ export const AiringTrackerInputSchema = z.object({
394
+ username: usernameSchema
395
+ .optional()
396
+ .describe("AniList username. Falls back to configured default if not provided."),
397
+ limit: z
398
+ .number()
399
+ .int()
400
+ .min(1)
401
+ .max(50)
402
+ .default(20)
403
+ .describe("Max titles to show (default 20, max 50)"),
404
+ });
405
+ /** Input for importing a MyAnimeList user's list for recommendations */
406
+ export const MalImportInputSchema = z.object({
407
+ malUsername: z
408
+ .string()
409
+ .min(2)
410
+ .max(20)
411
+ .describe("MyAnimeList username to import"),
412
+ limit: z
413
+ .number()
414
+ .int()
415
+ .min(1)
416
+ .max(15)
417
+ .default(5)
418
+ .describe("Number of recommendations to return (default 5, max 15)"),
419
+ });
392
420
  /** Input for character search */
393
421
  export const CharacterSearchInputSchema = z.object({
394
422
  query: z
@@ -432,6 +460,12 @@ export const UpdateProgressInputSchema = z.object({
432
460
  .int()
433
461
  .min(0)
434
462
  .describe("Episode or chapter number reached"),
463
+ volumeProgress: z
464
+ .number()
465
+ .int()
466
+ .min(0)
467
+ .optional()
468
+ .describe("Volume number reached (manga only)"),
435
469
  status: z
436
470
  .enum(["CURRENT", "COMPLETED", "PAUSED", "DROPPED", "REPEATING"])
437
471
  .optional()
@@ -468,7 +502,7 @@ export const RateInputSchema = z.object({
468
502
  .number()
469
503
  .min(0)
470
504
  .max(10)
471
- .describe("Score on a 0-10 scale. Use 0 to remove a score."),
505
+ .describe("Score on a 0-10 scale (decimals like 7.5 are supported). Use 0 to remove a score."),
472
506
  });
473
507
  /** Input for removing a title from the list */
474
508
  export const DeleteFromListInputSchema = z
@@ -498,10 +532,7 @@ export const ExplainInputSchema = z
498
532
  .positive()
499
533
  .optional()
500
534
  .describe("AniList media ID to evaluate against your taste profile"),
501
- title: z
502
- .string()
503
- .optional()
504
- .describe("Search by title if no ID is known"),
535
+ title: z.string().optional().describe("Search by title if no ID is known"),
505
536
  username: usernameSchema
506
537
  .optional()
507
538
  .describe("AniList username. Falls back to configured default if not provided."),
@@ -526,10 +557,7 @@ export const SimilarInputSchema = z
526
557
  .positive()
527
558
  .optional()
528
559
  .describe("AniList media ID to find similar titles for"),
529
- title: z
530
- .string()
531
- .optional()
532
- .describe("Search by title if no ID is known"),
560
+ title: z.string().optional().describe("Search by title if no ID is known"),
533
561
  limit: z
534
562
  .number()
535
563
  .int()
@@ -0,0 +1,4 @@
1
+ /** Import tools: cross-platform list import for recommendations */
2
+ import type { FastMCP } from "fastmcp";
3
+ /** Register import tools on the MCP server */
4
+ export declare function registerImportTools(server: FastMCP): void;
@@ -0,0 +1,134 @@
1
+ /** Import tools: cross-platform list import for recommendations */
2
+ import { fetchMalList, mapMalGenre, mapMalFormat, } from "../api/mal-client.js";
3
+ import { anilistClient } from "../api/client.js";
4
+ import { DISCOVER_MEDIA_QUERY } from "../api/queries.js";
5
+ import { MalImportInputSchema } from "../schemas.js";
6
+ import { throwToolError, formatMediaSummary } from "../utils.js";
7
+ import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
8
+ import { matchCandidates } from "../engine/matcher.js";
9
+ // === Tool Registration ===
10
+ /** Register import tools on the MCP server */
11
+ export function registerImportTools(server) {
12
+ server.addTool({
13
+ name: "anilist_mal_import",
14
+ description: "Import a MyAnimeList user's completed anime list and generate " +
15
+ "personalized recommendations based on their taste. No MAL auth needed. " +
16
+ "Use when the user mentions their MAL account or wants recs from MAL history. " +
17
+ "Returns a taste profile summary and recommended titles from AniList.",
18
+ parameters: MalImportInputSchema,
19
+ annotations: {
20
+ title: "MAL Import",
21
+ readOnlyHint: true,
22
+ destructiveHint: false,
23
+ openWorldHint: true,
24
+ },
25
+ execute: async (args) => {
26
+ try {
27
+ // Fetch MAL list via Jikan
28
+ const malEntries = await fetchMalList(args.malUsername);
29
+ if (malEntries.length === 0) {
30
+ return `No completed anime found for MAL user "${args.malUsername}".`;
31
+ }
32
+ // Convert to AniList-compatible format for taste engine
33
+ const converted = malEntriesToAniList(malEntries);
34
+ // Build taste profile
35
+ const profile = buildTasteProfile(converted);
36
+ const lines = [
37
+ `# MAL Import: ${args.malUsername}`,
38
+ `Imported ${malEntries.length} completed anime from MyAnimeList.`,
39
+ "",
40
+ "## Taste Profile",
41
+ describeTasteProfile(profile, args.malUsername),
42
+ ...formatTasteProfileText(profile),
43
+ ];
44
+ // Fetch AniList candidates using top genres
45
+ const topGenres = profile.genres.slice(0, 3).map((g) => g.name);
46
+ if (topGenres.length > 0) {
47
+ const candidates = await fetchDiscoverCandidates(topGenres);
48
+ // Filter out titles already on the MAL list
49
+ const malIds = new Set(malEntries.map((e) => e.anime.mal_id));
50
+ const filtered = candidates.filter((m) => !malIds.has(m.id));
51
+ // Score against taste profile
52
+ const ranked = matchCandidates(filtered, profile).slice(0, args.limit);
53
+ lines.push("", "## Recommendations", "");
54
+ if (ranked.length === 0) {
55
+ lines.push("No new recommendations found.");
56
+ }
57
+ else {
58
+ for (let i = 0; i < ranked.length; i++) {
59
+ const r = ranked[i];
60
+ lines.push(`${i + 1}. ${formatMediaSummary(r.media)}`);
61
+ if (r.reasons.length > 0) {
62
+ lines.push(` Why: ${r.reasons.slice(0, 3).join(", ")}`);
63
+ }
64
+ lines.push("");
65
+ }
66
+ }
67
+ }
68
+ return lines.join("\n");
69
+ }
70
+ catch (error) {
71
+ return throwToolError(error, "importing MAL list");
72
+ }
73
+ },
74
+ });
75
+ }
76
+ // === Helpers ===
77
+ // Convert MAL entries to AniList format for the taste engine
78
+ function malEntriesToAniList(entries) {
79
+ return entries
80
+ .filter((e) => e.score > 0)
81
+ .map((e) => ({
82
+ id: e.anime.mal_id,
83
+ score: e.score,
84
+ progress: e.episodes_watched,
85
+ progressVolumes: 0,
86
+ status: "COMPLETED",
87
+ updatedAt: 0,
88
+ startedAt: { year: null, month: null, day: null },
89
+ completedAt: { year: null, month: null, day: null },
90
+ notes: null,
91
+ media: {
92
+ id: e.anime.mal_id,
93
+ type: "ANIME",
94
+ title: {
95
+ romaji: e.anime.title,
96
+ english: e.anime.title,
97
+ native: null,
98
+ },
99
+ format: mapMalFormat(e.anime.type),
100
+ status: "FINISHED",
101
+ episodes: e.anime.episodes,
102
+ duration: null,
103
+ chapters: null,
104
+ volumes: null,
105
+ meanScore: e.anime.score ? e.anime.score * 10 : null,
106
+ averageScore: null,
107
+ popularity: null,
108
+ genres: e.anime.genres.map((g) => mapMalGenre(g.name)),
109
+ tags: [],
110
+ season: null,
111
+ seasonYear: e.anime.year,
112
+ startDate: { year: e.anime.year, month: null, day: null },
113
+ endDate: { year: null, month: null, day: null },
114
+ studios: { nodes: [] },
115
+ source: null,
116
+ isAdult: false,
117
+ coverImage: { large: null, extraLarge: null },
118
+ trailer: null,
119
+ siteUrl: `https://myanimelist.net/anime/${e.anime.mal_id}`,
120
+ description: null,
121
+ },
122
+ }));
123
+ }
124
+ // Fetch AniList discover candidates matching top taste genres
125
+ async function fetchDiscoverCandidates(genres) {
126
+ const data = await anilistClient.query(DISCOVER_MEDIA_QUERY, {
127
+ type: "ANIME",
128
+ genre_in: genres,
129
+ perPage: 50,
130
+ page: 1,
131
+ sort: ["POPULARITY_DESC"],
132
+ }, { cache: "search" });
133
+ return data.Page.media;
134
+ }
@@ -1,9 +1,9 @@
1
1
  /** Info tools: staff credits, airing schedule, character search, and auth check. */
2
2
  import { z } from "zod";
3
3
  import { anilistClient } from "../api/client.js";
4
- import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, VIEWER_QUERY, } from "../api/queries.js";
5
- import { StaffInputSchema, ScheduleInputSchema, CharacterSearchInputSchema, StaffSearchInputSchema, StudioSearchInputSchema, } from "../schemas.js";
6
- import { getTitle, throwToolError, paginationFooter } from "../utils.js";
4
+ import { STAFF_QUERY, AIRING_SCHEDULE_QUERY, BATCH_AIRING_QUERY, CHARACTER_SEARCH_QUERY, STAFF_SEARCH_QUERY, STUDIO_SEARCH_QUERY, VIEWER_QUERY, } from "../api/queries.js";
5
+ import { StaffInputSchema, ScheduleInputSchema, AiringTrackerInputSchema, CharacterSearchInputSchema, StaffSearchInputSchema, StudioSearchInputSchema, } from "../schemas.js";
6
+ import { getTitle, getDefaultUsername, throwToolError, paginationFooter, } from "../utils.js";
7
7
  // === Helpers ===
8
8
  /** Format seconds until airing as a readable duration */
9
9
  function formatTimeUntil(seconds) {
@@ -196,6 +196,76 @@ export function registerInfoTools(server) {
196
196
  }
197
197
  },
198
198
  });
199
+ // === Airing Tracker ===
200
+ server.addTool({
201
+ name: "anilist_airing",
202
+ description: "Show upcoming episodes for all anime you're currently watching. " +
203
+ "Use when the user asks what's airing soon, what episodes are coming up, " +
204
+ "or wants a watchlist calendar. " +
205
+ "Returns titles sorted by next airing time with episode number and countdown.",
206
+ parameters: AiringTrackerInputSchema,
207
+ annotations: {
208
+ title: "Airing Tracker",
209
+ readOnlyHint: true,
210
+ destructiveHint: false,
211
+ openWorldHint: true,
212
+ },
213
+ execute: async (args) => {
214
+ try {
215
+ const username = getDefaultUsername(args.username);
216
+ // Fetch currently watching anime
217
+ const entries = await anilistClient.fetchList(username, "ANIME", "CURRENT");
218
+ if (!entries.length) {
219
+ return `${username} is not currently watching any anime.`;
220
+ }
221
+ // Extract media IDs for batch airing lookup
222
+ const mediaIds = entries.map((e) => e.media.id);
223
+ // Batch-fetch airing info (50 per page max)
224
+ const airingMedia = [];
225
+ for (let i = 0; i < mediaIds.length; i += 50) {
226
+ const batch = mediaIds.slice(i, i + 50);
227
+ const data = await anilistClient.query(BATCH_AIRING_QUERY, { ids: batch, perPage: 50 }, { cache: "schedule" });
228
+ airingMedia.push(...data.Page.media);
229
+ }
230
+ // Map media ID to user progress
231
+ const progressMap = new Map(entries.map((e) => [e.media.id, e.progress]));
232
+ // Sort by nearest airing time
233
+ const airing = airingMedia
234
+ .filter((m) => m.nextAiringEpisode)
235
+ .sort((a, b) => (a.nextAiringEpisode?.timeUntilAiring ?? Infinity) -
236
+ (b.nextAiringEpisode?.timeUntilAiring ?? Infinity))
237
+ .slice(0, args.limit);
238
+ const notAiringCount = entries.length - airingMedia.length;
239
+ const lines = [
240
+ `# Airing tracker for ${username}`,
241
+ `${entries.length} currently watching, ${airing.length} with upcoming episodes`,
242
+ "",
243
+ ];
244
+ for (const m of airing) {
245
+ const next = m.nextAiringEpisode;
246
+ if (!next)
247
+ continue;
248
+ const title = getTitle(m.title);
249
+ const date = new Date(next.airingAt * 1000);
250
+ const dateStr = date.toLocaleDateString("en-US", {
251
+ weekday: "short",
252
+ month: "short",
253
+ day: "numeric",
254
+ });
255
+ const totalEp = m.episodes ? `/${m.episodes}` : "";
256
+ const userProgress = progressMap.get(m.id) ?? 0;
257
+ lines.push(`${title} (${m.format ?? "?"})`, ` Ep ${next.episode}${totalEp} - ${dateStr} (${formatTimeUntil(next.timeUntilAiring)})`, ` Your progress: ${userProgress}${totalEp} ep`, "");
258
+ }
259
+ if (notAiringCount > 0) {
260
+ lines.push(`${notAiringCount} title(s) not currently airing.`);
261
+ }
262
+ return lines.join("\n");
263
+ }
264
+ catch (error) {
265
+ return throwToolError(error, "tracking airing schedule");
266
+ }
267
+ },
268
+ });
199
269
  // === Character Search ===
200
270
  server.addTool({
201
271
  name: "anilist_characters",
@@ -204,10 +204,14 @@ export function formatListEntry(entry, index, scoreFmt) {
204
204
  const media = entry.media;
205
205
  const title = getTitle(media.title);
206
206
  const format = media.format ?? "?";
207
- // Progress string (e.g. "5/12 ep" or "30/? ch")
207
+ // Progress string (e.g. "5/12 ep" or "30/? ch, 5/20 vol")
208
208
  const total = media.episodes ?? media.chapters ?? "?";
209
209
  const unit = media.episodes !== null ? "ep" : "ch";
210
- const progress = `${entry.progress}/${total} ${unit}`;
210
+ let progress = `${entry.progress}/${total} ${unit}`;
211
+ if (entry.progressVolumes > 0) {
212
+ const totalVol = media.volumes ?? "?";
213
+ progress += `, ${entry.progressVolumes}/${totalVol} vol`;
214
+ }
211
215
  const score = formatScore(entry.score, scoreFmt);
212
216
  const updated = entry.updatedAt
213
217
  ? new Date(entry.updatedAt * 1000).toLocaleDateString("en-US", {
@@ -4,7 +4,7 @@ import { BATCH_RELATIONS_QUERY, DISCOVER_MEDIA_QUERY, MEDIA_DETAILS_QUERY, RECOM
4
4
  import { TasteInputSchema, PickInputSchema, SessionInputSchema, SequelAlertInputSchema, WatchOrderInputSchema, CompareInputSchema, WrappedInputSchema, ExplainInputSchema, SimilarInputSchema, } from "../schemas.js";
5
5
  import { getTitle, getDefaultUsername, throwToolError, isNsfwEnabled, resolveSeasonYear, resolveAlias, } from "../utils.js";
6
6
  import { SEARCH_MEDIA_QUERY } from "../api/queries.js";
7
- import { buildTasteProfile, describeTasteProfile, } from "../engine/taste.js";
7
+ import { buildTasteProfile, describeTasteProfile, formatTasteProfileText, } from "../engine/taste.js";
8
8
  import { matchCandidates, explainMatch } from "../engine/matcher.js";
9
9
  import { parseMood, hasMoodMatch, seasonalMoodSuggestions, } from "../engine/mood.js";
10
10
  import { computeCompatibility, computeGenreDivergences, findCrossRecs, } from "../engine/compare.js";
@@ -80,33 +80,8 @@ export function registerRecommendTools(server) {
80
80
  `# Taste Profile: ${username}`,
81
81
  "",
82
82
  describeTasteProfile(profile, username),
83
+ ...formatTasteProfileText(profile),
83
84
  ];
84
- // Detailed genre breakdown
85
- if (profile.genres.length > 0) {
86
- lines.push("", "Genre Weights (higher = stronger preference):");
87
- for (const g of profile.genres.slice(0, 10)) {
88
- lines.push(` ${g.name}: ${g.weight.toFixed(2)} (${g.count} titles)`);
89
- }
90
- }
91
- // Detailed tag breakdown
92
- if (profile.tags.length > 0) {
93
- lines.push("", "Top Themes:");
94
- for (const t of profile.tags.slice(0, 10)) {
95
- lines.push(` ${t.name}: ${t.weight.toFixed(2)} (${t.count} titles)`);
96
- }
97
- }
98
- // Score distribution bar chart
99
- if (profile.scoring.totalScored > 0) {
100
- lines.push("", "Score Distribution:");
101
- for (let s = 10; s >= 1; s--) {
102
- const count = profile.scoring.distribution[s] ?? 0;
103
- if (count > 0) {
104
- // Cap at 30 chars
105
- const bar = "#".repeat(Math.min(count, 30));
106
- lines.push(` ${s}/10: ${bar} (${count})`);
107
- }
108
- }
109
- }
110
85
  return lines.join("\n");
111
86
  }
112
87
  catch (error) {
@@ -2,7 +2,7 @@
2
2
  import { anilistClient } from "../api/client.js";
3
3
  import { SEARCH_MEDIA_QUERY, MEDIA_DETAILS_QUERY, SEASONAL_MEDIA_QUERY, RECOMMENDATIONS_QUERY, } from "../api/queries.js";
4
4
  import { SearchInputSchema, DetailsInputSchema, SeasonalInputSchema, RecommendationsInputSchema, } from "../schemas.js";
5
- import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, BROWSE_SORT_MAP, } from "../utils.js";
5
+ import { getTitle, truncateDescription, throwToolError, formatMediaSummary, paginationFooter, isNsfwEnabled, resolveAlias, resolveSeasonYear, trailerUrl, BROWSE_SORT_MAP, } from "../utils.js";
6
6
  // Default to popularity for broad queries
7
7
  const SEARCH_SORT = ["POPULARITY_DESC"];
8
8
  // === Tool Registration ===
@@ -146,6 +146,14 @@ export function registerSearchTools(server) {
146
146
  lines.push(` - ${recTitle} (${r.meanScore ?? "?"}/100) - ${r.genres.slice(0, 3).join(", ")}`); // top 3 genres only
147
147
  }
148
148
  }
149
+ // Cover image
150
+ const cover = m.coverImage?.extraLarge ?? m.coverImage?.large;
151
+ if (cover)
152
+ lines.push("", `Cover: ${cover}`);
153
+ // Trailer
154
+ const tUrl = trailerUrl(m.trailer);
155
+ if (tUrl)
156
+ lines.push(`Trailer: ${tUrl}`);
149
157
  lines.push("", `AniList: ${m.siteUrl}`);
150
158
  return lines.join("\n");
151
159
  }
@@ -68,6 +68,9 @@ export function registerWriteTools(server) {
68
68
  progress: args.progress,
69
69
  status: args.status ?? "CURRENT",
70
70
  };
71
+ if (args.volumeProgress !== undefined) {
72
+ variables.progressVolumes = args.volumeProgress;
73
+ }
71
74
  const data = await anilistClient.query(SAVE_MEDIA_LIST_ENTRY_MUTATION, variables, { cache: null });
72
75
  const viewerName = await getViewerName();
73
76
  anilistClient.invalidateUser(viewerName);
@@ -82,10 +85,13 @@ export function registerWriteTools(server) {
82
85
  timestamp: Date.now(),
83
86
  description: `Set progress to ${args.progress} on media ${args.mediaId}`,
84
87
  });
88
+ const volStr = entry.progressVolumes > 0
89
+ ? ` | Volumes: ${entry.progressVolumes}`
90
+ : "";
85
91
  return [
86
92
  `Progress updated.`,
87
93
  `Status: ${entry.status}`,
88
- `Progress: ${entry.progress}`,
94
+ `Progress: ${entry.progress}${volStr}`,
89
95
  `Entry ID: ${entry.id}`,
90
96
  undoHint(before),
91
97
  ].join("\n");
@@ -287,6 +293,7 @@ export function registerWriteTools(server) {
287
293
  status: op.before.status,
288
294
  scoreRaw: op.before.score * 10,
289
295
  progress: op.before.progress,
296
+ progressVolumes: op.before.progressVolumes,
290
297
  }, { cache: null });
291
298
  const viewerName = await getViewerName();
292
299
  anilistClient.invalidateUser(viewerName);
@@ -308,6 +315,7 @@ export function registerWriteTools(server) {
308
315
  status: op.before.status,
309
316
  scoreRaw: op.before.score * 10,
310
317
  progress: op.before.progress,
318
+ progressVolumes: op.before.progressVolumes,
311
319
  }, { cache: null });
312
320
  const viewerName = await getViewerName();
313
321
  anilistClient.invalidateUser(viewerName);
@@ -324,6 +332,7 @@ export function registerWriteTools(server) {
324
332
  status: item.before.status,
325
333
  scoreRaw: item.before.score * 10,
326
334
  progress: item.before.progress,
335
+ progressVolumes: item.before.progressVolumes,
327
336
  }, { cache: null });
328
337
  restored++;
329
338
  }
@@ -378,7 +387,9 @@ export function registerWriteTools(server) {
378
387
  requireAuth();
379
388
  const variables = { [FAVOURITE_VAR_MAP[args.type]]: args.id };
380
389
  const data = await anilistClient.query(TOGGLE_FAVOURITE_MUTATION, variables, { cache: null });
381
- anilistClient.invalidateUser(await getViewerName());
390
+ const viewerName = await getViewerName();
391
+ anilistClient.invalidateUser(viewerName);
392
+ invalidateUserProfiles(viewerName);
382
393
  // Check if entity is now in favourites (added) or absent (removed)
383
394
  const field = FAVOURITE_FIELD_MAP[args.type];
384
395
  const isFavourited = data.ToggleFavourite[field].nodes.some((n) => n.id === args.id);
@@ -410,7 +421,9 @@ export function registerWriteTools(server) {
410
421
  try {
411
422
  requireAuth();
412
423
  const data = await anilistClient.query(SAVE_TEXT_ACTIVITY_MUTATION, { text: args.text }, { cache: null });
413
- anilistClient.invalidateUser(await getViewerName());
424
+ const viewerName = await getViewerName();
425
+ anilistClient.invalidateUser(viewerName);
426
+ invalidateUserProfiles(viewerName);
414
427
  const activity = data.SaveTextActivity;
415
428
  const dateStr = new Date(activity.createdAt * 1000).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
416
429
  return [
@@ -560,6 +573,7 @@ export function registerWriteTools(server) {
560
573
  status: e.status,
561
574
  score: e.score,
562
575
  progress: e.progress,
576
+ progressVolumes: e.progressVolumes,
563
577
  notes: e.notes,
564
578
  private: false,
565
579
  };
package/dist/types.d.ts CHANGED
@@ -44,7 +44,13 @@ export interface AniListMedia {
44
44
  isAdult: boolean;
45
45
  coverImage: {
46
46
  large: string | null;
47
+ extraLarge: string | null;
47
48
  };
49
+ trailer: {
50
+ id: string;
51
+ site: string;
52
+ thumbnail: string;
53
+ } | null;
48
54
  siteUrl: string;
49
55
  description: string | null;
50
56
  }
@@ -238,6 +244,27 @@ export interface AiringScheduleResponse {
238
244
  siteUrl: string;
239
245
  };
240
246
  }
247
+ /** Batch airing response for currently watching tracker */
248
+ export interface BatchAiringResponse {
249
+ Page: {
250
+ media: Array<{
251
+ id: number;
252
+ title: {
253
+ romaji: string | null;
254
+ english: string | null;
255
+ native: string | null;
256
+ };
257
+ format: string | null;
258
+ episodes: number | null;
259
+ nextAiringEpisode: {
260
+ episode: number;
261
+ airingAt: number;
262
+ timeUntilAiring: number;
263
+ } | null;
264
+ siteUrl: string;
265
+ }>;
266
+ };
267
+ }
241
268
  /** Character search results */
242
269
  export interface CharacterSearchResponse {
243
270
  Page: {
@@ -290,6 +317,7 @@ export interface MediaListEntryResponse {
290
317
  status: string;
291
318
  score: number;
292
319
  progress: number;
320
+ progressVolumes: number;
293
321
  notes: string | null;
294
322
  private: boolean;
295
323
  } | null;
@@ -302,6 +330,7 @@ export interface SaveMediaListEntryResponse {
302
330
  status: string;
303
331
  score: number;
304
332
  progress: number;
333
+ progressVolumes: number;
305
334
  };
306
335
  }
307
336
  /** Response from deleting a list entry */
@@ -315,6 +344,7 @@ export interface AniListMediaListEntry {
315
344
  id: number;
316
345
  score: number;
317
346
  progress: number;
347
+ progressVolumes: number;
318
348
  status: string;
319
349
  updatedAt: number;
320
350
  startedAt: AniListDate;
package/dist/utils.d.ts CHANGED
@@ -16,6 +16,8 @@ export declare function throwToolError(error: unknown, action: string): never;
16
16
  export declare function paginationFooter(page: number, limit: number, total: number, hasNextPage: boolean): string;
17
17
  /** Format a media entry as a compact multi-line summary */
18
18
  export declare function formatMediaSummary(media: AniListMedia): string;
19
+ /** Construct full trailer URL from site + video ID */
20
+ export declare function trailerUrl(trailer: AniListMedia["trailer"]): string | null;
19
21
  /** Detect score format from env override or API fallback */
20
22
  export declare function detectScoreFormat(fetchFormat: () => Promise<ScoreFormat>): Promise<ScoreFormat>;
21
23
  /** Fetch score format for a user (by username) or the authenticated viewer */
package/dist/utils.js CHANGED
@@ -129,9 +129,27 @@ export function formatMediaSummary(media) {
129
129
  lines.push(` Length: ${length}`);
130
130
  if (studios)
131
131
  lines.push(` Studio: ${studios}`);
132
+ // Best available cover image
133
+ const cover = media.coverImage?.extraLarge ?? media.coverImage?.large;
134
+ if (cover)
135
+ lines.push(` Cover: ${cover}`);
136
+ // Trailer link
137
+ const trailer = trailerUrl(media.trailer);
138
+ if (trailer)
139
+ lines.push(` Trailer: ${trailer}`);
132
140
  lines.push(` URL: ${media.siteUrl}`);
133
141
  return lines.join("\n");
134
142
  }
143
+ /** Construct full trailer URL from site + video ID */
144
+ export function trailerUrl(trailer) {
145
+ if (!trailer)
146
+ return null;
147
+ if (trailer.site === "youtube")
148
+ return `https://youtube.com/watch?v=${trailer.id}`;
149
+ if (trailer.site === "dailymotion")
150
+ return `https://dailymotion.com/video/${trailer.id}`;
151
+ return null;
152
+ }
135
153
  /** Detect score format from env override or API fallback */
136
154
  export async function detectScoreFormat(fetchFormat) {
137
155
  const override = process.env.ANILIST_SCORE_FORMAT;
package/manifest.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": "0.3",
3
3
  "name": "ani-mcp",
4
- "version": "0.8.3",
4
+ "version": "0.9.0",
5
5
  "display_name": "AniList MCP",
6
6
  "description": "A smart MCP server for AniList that gets your anime/manga taste - not just API calls.",
7
7
  "author": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ani-mcp",
3
3
  "mcpName": "io.github.gavxm/ani-mcp",
4
- "version": "0.8.3",
4
+ "version": "0.9.0",
5
5
  "description": "A smart [MCP](https://modelcontextprotocol.io) server for [AniList](https://anilist.co) that gets your anime/manga taste - not just API calls.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/gavxm/ani-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.8.3",
9
+ "version": "0.9.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "ani-mcp",
14
- "version": "0.8.3",
14
+ "version": "0.9.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },